mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
65117e31f2
commit
7a11fe1c72
2 changed files with 132 additions and 4 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue