feat(ai): add copy code login method to Anthropic OAuth (#10194)

Claude Code's OAuth client also accepts https://platform.claude.com/oauth/code/callback
as redirect_uri. That page shows the authorization code to copy, which works when
the browser runs on another machine than pi, where the localhost callback cannot load.
Anthropic login now asks for a method like OpenAI Codex login: browser (default)
or copy code (headless).
This commit is contained in:
Lucas Meijer 2026-09-30 23:17:13 +02:00 • committed by GitHub
parent 65117e31f2
commit 7a11fe1c72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 132 additions and 4 deletions

View file

@ -18,6 +18,9 @@ const CALLBACK_HOST = getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.
const CALLBACK_PORT = 53692;
const CALLBACK_PATH = "/callback";
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
const COPY_CODE_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback";
const ANTHROPIC_BROWSER_LOGIN_METHOD = "browser";
const ANTHROPIC_COPY_CODE_LOGIN_METHOD = "copy_code";
const SCOPES =
"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
@ -185,6 +188,43 @@ async function loginAnthropic(interaction: ProviderAuthInteraction): Promise<OAu
}
}
async function loginAnthropicCopyCode(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
const { verifier, challenge } = await generatePKCE();
const authParams = new URLSearchParams({
code: "true",
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: COPY_CODE_REDIRECT_URI,
scope: SCOPES,
code_challenge: challenge,
code_challenge_method: "S256",
state: verifier,
});
interaction.notify({
type: "auth_url",
url: `${AUTHORIZE_URL}?${authParams.toString()}`,
instructions: "Complete login in your browser, then copy the code Anthropic shows and paste it here.",
});
const input = await interaction.prompt({
type: "manual_code",
message: "Paste the code Anthropic shows after you sign in:",
placeholder: "code#state",
signal: interaction.signal,
});
const parsed = parseAuthorizationInput(input);
if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch");
if (!parsed.code) throw new Error("Missing authorization code");
interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." });
return await exchangeAuthorizationCode(
parsed.code,
parsed.state ?? verifier,
verifier,
COPY_CODE_REDIRECT_URI,
interaction.signal,
);
}
/**
* Refresh Anthropic OAuth token
*/
@ -229,7 +269,27 @@ async function refreshAnthropicToken(refreshToken: string, signal: AbortSignal):
export const anthropicOAuth: OAuthAuth = {
name: "Anthropic (Claude Pro/Max)",
isSubscription: true,
login: loginAnthropic,
async login(interaction) {
const method = await interaction.prompt({
type: "select",
message: "Select Anthropic login method:",
options: [
{ id: ANTHROPIC_BROWSER_LOGIN_METHOD, label: "Browser login (default)" },
{ id: ANTHROPIC_COPY_CODE_LOGIN_METHOD, label: "Copy code login (headless)" },
],
});
if (method === ANTHROPIC_COPY_CODE_LOGIN_METHOD) {
return loginAnthropicCopyCode(interaction);
}
if (method !== ANTHROPIC_BROWSER_LOGIN_METHOD) {
throw new Error(`Unknown Anthropic login method: ${method}`);
}
return loginAnthropic(interaction);
},
refresh: (credential, signal) => refreshAnthropicToken(credential.refresh, signal),
async toAuth(credential) {

View file

@ -62,6 +62,7 @@ describe.sequential("Anthropic OAuth", () => {
if (event.type === "auth_url") authUrl = event.url;
},
prompt: async (prompt) => {
if (prompt.type === "select") return "browser";
if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`);
const url = new URL(authUrl);
const state = url.searchParams.get("state");
@ -76,6 +77,70 @@ describe.sequential("Anthropic OAuth", () => {
expect(fetchMock).toHaveBeenCalledOnce();
});
it("offers browser login first and uses the selected Anthropic copy code flow", async () => {
const selectPrompts: Array<{
message: string;
options: readonly { id: string; label: string }[];
}> = [];
let authUrl = "";
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
expect(getUrl(input)).toBe("https://platform.claude.com/v1/oauth/token");
const body = getJsonBody(init);
expect(body.grant_type).toBe("authorization_code");
expect(body.code).toBe("copied-code");
expect(body.state).toBe(new URL(authUrl).searchParams.get("state"));
expect(body.redirect_uri).toBe("https://platform.claude.com/oauth/code/callback");
return jsonResponse({
access_token: "access-token",
refresh_token: "refresh-token",
expires_in: 3600,
});
});
vi.stubGlobal("fetch", fetchMock);
const credentials = await anthropicOAuth.login({
signal: neverAbortedSignal,
notify: (event) => {
if (event.type === "auth_url") authUrl = event.url;
},
prompt: async (prompt) => {
if (prompt.type === "select") {
selectPrompts.push(prompt);
return "copy_code";
}
if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`);
return `copied-code#${new URL(authUrl).searchParams.get("state")}`;
},
});
expect(credentials.access).toBe("access-token");
expect(credentials.refresh).toBe("refresh-token");
expect(new URL(authUrl).searchParams.get("redirect_uri")).toBe("https://platform.claude.com/oauth/code/callback");
expect(fetchMock).toHaveBeenCalledOnce();
expect(selectPrompts).toEqual([
{
type: "select",
message: "Select Anthropic login method:",
options: [
{ id: "browser", label: "Browser login (default)" },
{ id: "copy_code", label: "Copy code login (headless)" },
],
},
]);
});
it("cancels when Anthropic login method selection is cancelled", async () => {
await expect(
anthropicOAuth.login({
signal: neverAbortedSignal,
prompt: async () => {
throw new Error("Login cancelled");
},
notify: () => {},
}),
).rejects.toThrow("Login cancelled");
});
it("omits scope from refresh token requests", async () => {
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
expect(getUrl(input)).toBe("https://platform.claude.com/v1/oauth/token");
@ -127,6 +192,7 @@ describe.sequential("Anthropic OAuth", () => {
notify: (event) => events.push(event),
prompt: async (prompt) => {
prompts.push(prompt);
if (prompt.type === "select") return "browser";
if (prompt.type === "manual_code") {
manualSignal = prompt.signal;
return "the-code";
@ -164,9 +230,11 @@ describe.sequential("Anthropic OAuth", () => {
callbackPage = nativeFetch(`http://127.0.0.1:53692/callback?code=browser-code&state=${state}`);
},
prompt: (prompt) =>
new Promise((_, reject) => {
prompt.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
}),
prompt.type === "select"
? Promise.resolve("browser")
: new Promise((_, reject) => {
prompt.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
}),
});
expect(credential.access).toBe("access");