From e645995a4a6d1d512744c386c0aa7068445f8b93 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 15 Mar 2026 15:40:50 +0100 Subject: [PATCH] fix(ai): fix anthropic oauth manual callback and refresh flow closes #2169 --- packages/ai/src/utils/oauth/anthropic.ts | 7 +- packages/ai/test/anthropic-oauth.test.ts | 99 ++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 packages/ai/test/anthropic-oauth.test.ts diff --git a/packages/ai/src/utils/oauth/anthropic.ts b/packages/ai/src/utils/oauth/anthropic.ts index 41846c12..687cfa3c 100644 --- a/packages/ai/src/utils/oauth/anthropic.ts +++ b/packages/ai/src/utils/oauth/anthropic.ts @@ -27,7 +27,6 @@ const decode = (s: string) => atob(s); const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl"); const AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; const TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; -const MANUAL_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback"; const CALLBACK_HOST = "127.0.0.1"; const CALLBACK_PORT = 53692; const CALLBACK_PATH = "/callback"; @@ -302,7 +301,6 @@ export async function loginAnthropic(options: { } code = parsed.code; state = parsed.state ?? verifier; - redirectUriForExchange = MANUAL_REDIRECT_URI; } if (!code) { @@ -317,7 +315,6 @@ export async function loginAnthropic(options: { } code = parsed.code; state = parsed.state ?? verifier; - redirectUriForExchange = MANUAL_REDIRECT_URI; } } } else { @@ -332,7 +329,7 @@ export async function loginAnthropic(options: { if (!code) { const input = await options.onPrompt({ message: "Paste the authorization code or full redirect URL:", - placeholder: MANUAL_REDIRECT_URI, + placeholder: REDIRECT_URI, }); const parsed = parseAuthorizationInput(input); if (parsed.state && parsed.state !== verifier) { @@ -340,7 +337,6 @@ export async function loginAnthropic(options: { } code = parsed.code; state = parsed.state ?? verifier; - redirectUriForExchange = MANUAL_REDIRECT_URI; } if (!code) { @@ -368,7 +364,6 @@ export async function refreshAnthropicToken(refreshToken: string): Promise { + if (typeof init?.body !== "string") { + throw new Error(`Expected string request body, got ${typeof init?.body}`); + } + return JSON.parse(init.body) as Record; +} + +describe.sequential("Anthropic OAuth", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps the localhost redirect_uri for manual callback login", async () => { + let authUrl = ""; + const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise => { + expect(getUrl(input)).toBe("https://platform.claude.com/v1/oauth/token"); + expect(init?.method).toBe("POST"); + const body = getJsonBody(init); + expect(body.grant_type).toBe("authorization_code"); + expect(body.code).toBe("manual-code"); + expect(body.redirect_uri).toBe("http://localhost:53692/callback"); + return jsonResponse({ + access_token: "access-token", + refresh_token: "refresh-token", + expires_in: 3600, + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const credentials = await loginAnthropic({ + onAuth: (info) => { + authUrl = info.url; + }, + onPrompt: async () => "", + onManualCodeInput: async () => { + const url = new URL(authUrl); + const state = url.searchParams.get("state"); + const redirectUri = url.searchParams.get("redirect_uri"); + if (!state || !redirectUri) { + throw new Error("Missing OAuth state or redirect_uri in auth URL"); + } + return `${redirectUri}?code=manual-code&state=${state}`; + }, + }); + + expect(credentials.access).toBe("access-token"); + expect(credentials.refresh).toBe("refresh-token"); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("omits scope from refresh token requests", async () => { + const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise => { + expect(getUrl(input)).toBe("https://platform.claude.com/v1/oauth/token"); + expect(init?.method).toBe("POST"); + const body = getJsonBody(init); + expect(body.grant_type).toBe("refresh_token"); + expect(body.client_id).toBeTruthy(); + expect(body.refresh_token).toBe("refresh-token"); + expect(body).not.toHaveProperty("scope"); + return jsonResponse({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + expires_in: 3600, + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const credentials = await refreshAnthropicToken("refresh-token"); + + expect(credentials.access).toBe("new-access-token"); + expect(credentials.refresh).toBe("new-refresh-token"); + expect(fetchMock).toHaveBeenCalledOnce(); + }); +});