From 57f08e70055e795a0ccd310def325842fe5c570e Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 26 Feb 2026 10:52:28 -0800 Subject: [PATCH] feat(auth): implement Anthropic OAuth PKCE browser flow Add openBrowser, exchangeCodeForToken, and loginAnthropicOAuth to src/auth/anthropic.ts, completing the full PKCE OAuth flow. Includes 5 new tests covering happy path, state mismatch, 403 subscription error, 500 error, and missing access_token cases. Co-Authored-By: Claude Sonnet 4.6 --- src/auth/anthropic.test.ts | 119 +++++++++++++++++++++++++++++++++++++ src/auth/anthropic.ts | 86 +++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) diff --git a/src/auth/anthropic.test.ts b/src/auth/anthropic.test.ts index 00bd524..331e8b4 100644 --- a/src/auth/anthropic.test.ts +++ b/src/auth/anthropic.test.ts @@ -145,3 +145,122 @@ describe('startCallbackServer', () => { await waitForCode; }); }); + +describe('loginAnthropicOAuth and exchangeCodeForToken', () => { + const originalHome = process.env.HOME; + let homeDir: string; + let mockFetch: ReturnType; + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'flynn-auth-oauth-')); + process.env.HOME = homeDir; + mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + vi.resetModules(); + }); + + afterEach(() => { + process.env.HOME = originalHome; + vi.unstubAllGlobals(); + }); + + it('loginAnthropicOAuth builds correct auth URL and exchanges code for token', async () => { + const fakeToken = 'tok-from-oauth'; + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ access_token: fakeToken }), + }); + + const { loginAnthropicOAuth } = await import('./anthropic.js'); + + let capturedAuthUrl = ''; + let resolveCb!: (v: { code: string; state: string }) => void; + + // Custom _startServer that gives us control over waitForCode + const mockStartServer = async (_timeoutMs: number) => { + return { + port: 9999, + waitForCode: new Promise<{ code: string; state: string }>((res) => { + resolveCb = res; + }), + }; + }; + + // Start the flow — onOpen fires after _startServer resolves and URL is built + const flowPromise = loginAnthropicOAuth((url) => { + capturedAuthUrl = url; + // Extract state from the URL and resolve waitForCode with matching state + const parsedUrl = new URL(url); + const state = parsedUrl.searchParams.get('state')!; + resolveCb({ code: 'auth-code-123', state }); + }, mockStartServer); + + const token = await flowPromise; + + // Auth URL assertions + const authUrl = new URL(capturedAuthUrl); + expect(authUrl.hostname).toBe('claude.ai'); + expect(authUrl.searchParams.get('client_id')).toBe('9d1c250a-e61b-44d9-88ed-5944d1962f5e'); + expect(authUrl.searchParams.get('code_challenge_method')).toBe('S256'); + expect(authUrl.searchParams.get('response_type')).toBe('code'); + expect(authUrl.searchParams.get('scope')).toContain('user:inference'); + expect(authUrl.searchParams.get('state')).toBeTruthy(); + + // Token stored and returned + expect(token).toBe(fakeToken); + + // Verify token exchange fetch + expect(mockFetch).toHaveBeenCalledWith( + 'https://console.anthropic.com/v1/oauth/token', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('loginAnthropicOAuth throws on state mismatch', async () => { + const { loginAnthropicOAuth } = await import('./anthropic.js'); + + const mockStartServer = async (_timeoutMs: number) => ({ + port: 9999, + waitForCode: Promise.resolve({ code: 'code', state: 'DEFINITELY-WRONG-STATE' }), + }); + + await expect( + loginAnthropicOAuth(() => undefined, mockStartServer), + ).rejects.toThrow('state mismatch'); + }); + + it('exchangeCodeForToken throws subscription message on 403', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 403, + text: async () => 'Forbidden', + }); + const { exchangeCodeForToken } = await import('./anthropic.js'); + await expect( + exchangeCodeForToken('code', 'verifier', 'http://127.0.0.1:1234/callback'), + ).rejects.toThrow(/Pro\/Max subscription/); + }); + + it('exchangeCodeForToken throws with status on 500', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + text: async () => 'Server error', + }); + const { exchangeCodeForToken } = await import('./anthropic.js'); + await expect( + exchangeCodeForToken('code', 'verifier', 'http://127.0.0.1:1234/callback'), + ).rejects.toThrow(/500/); + }); + + it('exchangeCodeForToken throws when no access_token in response', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ something_else: 'x' }), + }); + const { exchangeCodeForToken } = await import('./anthropic.js'); + await expect( + exchangeCodeForToken('code', 'verifier', 'http://127.0.0.1:1234/callback'), + ).rejects.toThrow(/no access_token/); + }); +}); diff --git a/src/auth/anthropic.ts b/src/auth/anthropic.ts index c14dfb4..1bf4525 100644 --- a/src/auth/anthropic.ts +++ b/src/auth/anthropic.ts @@ -88,6 +88,92 @@ export function startCallbackServer(timeoutMs: number): Promise }); } +/** Open a URL in the user's default browser (platform-specific). Failures are silent. */ +export function openBrowser(url: string): void { + const cmd = process.platform === 'win32' ? 'start' + : process.platform === 'darwin' ? 'open' + : 'xdg-open'; + try { + spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref(); + } catch { + // Caller should already be printing the URL as fallback + } +} + +/** Exchange an authorization code for an Anthropic access token. */ +export async function exchangeCodeForToken( + code: string, + codeVerifier: string, + redirectUri: string, +): Promise { + const response = await fetch(ANTHROPIC_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: ANTHROPIC_CLIENT_ID, + code, + code_verifier: codeVerifier, + redirect_uri: redirectUri, + }).toString(), + }); + + if (!response.ok) { + const body = await response.text(); + if (response.status === 403) { + throw new Error( + `Anthropic OAuth requires an active Claude Pro/Max subscription (403): ${body}`, + ); + } + throw new Error(`Anthropic token exchange failed (${response.status}): ${body}`); + } + + const data = await response.json() as Record; + if (typeof data.access_token !== 'string') { + throw new Error('Anthropic token exchange returned no access_token'); + } + return data.access_token; +} + +/** + * Run the full Anthropic OAuth PKCE browser flow. + * Calls onOpen with the authorization URL (caller should open browser or print URL). + * Stores the resulting token via storeAnthropicAuthToken. + * Returns the access token. + */ +export async function loginAnthropicOAuth( + onOpen: (url: string) => void, + _startServer: typeof startCallbackServer = startCallbackServer, +): Promise { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + const state = randomBytes(16).toString('hex'); + + const { port, waitForCode } = await _startServer(OAUTH_TIMEOUT_MS); + const redirectUri = `http://127.0.0.1:${port}/callback`; + + const authUrl = new URL(ANTHROPIC_AUTH_URL); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('client_id', ANTHROPIC_CLIENT_ID); + authUrl.searchParams.set('redirect_uri', redirectUri); + authUrl.searchParams.set('scope', ANTHROPIC_OAUTH_SCOPES); + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + authUrl.searchParams.set('state', state); + + onOpen(authUrl.toString()); + + const { code, state: returnedState } = await waitForCode; + + if (returnedState !== state) { + throw new Error('Anthropic OAuth state mismatch — possible CSRF attack.'); + } + + const token = await exchangeCodeForToken(code, codeVerifier, redirectUri); + storeAnthropicAuthToken(token); + return token; +} + export interface AnthropicAuthInfo { /** Anthropic API key. */ api_key?: string;