diff --git a/src/auth/anthropic.test.ts b/src/auth/anthropic.test.ts index 95df656..00bd524 100644 --- a/src/auth/anthropic.test.ts +++ b/src/auth/anthropic.test.ts @@ -77,3 +77,71 @@ describe('auth/anthropic', () => { expect(mod.getAnthropicAuthToken()).toBe('tok-env'); }); }); + +describe('generateCodeVerifier and generateCodeChallenge', () => { + it('generateCodeVerifier returns a 43-char base64url string with no padding', async () => { + vi.resetModules(); + const { generateCodeVerifier } = await import('./anthropic.js'); + const v = generateCodeVerifier(); + expect(v).toMatch(/^[A-Za-z0-9_-]+$/); + expect(v.length).toBe(43); + expect(v).not.toContain('='); + }); + + it('generateCodeChallenge returns correct SHA256 base64url of the verifier', async () => { + vi.resetModules(); + const { generateCodeVerifier, generateCodeChallenge } = await import('./anthropic.js'); + const { createHash } = await import('crypto'); + const verifier = generateCodeVerifier(); + const challenge = generateCodeChallenge(verifier); + const expected = createHash('sha256').update(verifier).digest('base64url'); + expect(challenge).toBe(expected); + expect(challenge).not.toContain('='); + expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('generateCodeVerifier produces unique values', async () => { + vi.resetModules(); + const { generateCodeVerifier } = await import('./anthropic.js'); + const a = generateCodeVerifier(); + const b = generateCodeVerifier(); + expect(a).not.toBe(b); + }); +}); + +describe('startCallbackServer', () => { + it('resolves with code and state when browser redirects to /callback', async () => { + vi.resetModules(); + const { startCallbackServer } = await import('./anthropic.js'); + const { port, waitForCode } = await startCallbackServer(5000); + + const res = await fetch(`http://127.0.0.1:${port}/callback?code=test-code&state=test-state`); + expect(res.status).toBe(200); + const text = await res.text(); + expect(text.toLowerCase()).toContain('close this tab'); + + const { code, state } = await waitForCode; + expect(code).toBe('test-code'); + expect(state).toBe('test-state'); + }); + + it('rejects waitForCode on timeout', async () => { + vi.resetModules(); + const { startCallbackServer } = await import('./anthropic.js'); + const { waitForCode } = await startCallbackServer(50); + await expect(waitForCode).rejects.toThrow(/timed out/i); + }); + + it('returns 404 for non-callback paths', async () => { + vi.resetModules(); + const { startCallbackServer } = await import('./anthropic.js'); + const { port, waitForCode } = await startCallbackServer(5000); + + const res = await fetch(`http://127.0.0.1:${port}/other`); + expect(res.status).toBe(404); + + // Clean up: hit the real callback to resolve + await fetch(`http://127.0.0.1:${port}/callback?code=c&state=s`); + await waitForCode; + }); +}); diff --git a/src/auth/anthropic.ts b/src/auth/anthropic.ts index 851a213..c14dfb4 100644 --- a/src/auth/anthropic.ts +++ b/src/auth/anthropic.ts @@ -1,10 +1,93 @@ import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs'; import { resolve } from 'path'; import { homedir } from 'os'; +import { createHash, randomBytes } from 'crypto'; +import { createServer } from 'http'; +import type { IncomingMessage, ServerResponse } from 'http'; +import type { AddressInfo } from 'net'; +import { spawn } from 'child_process'; const AUTH_DIR = resolve(homedir(), '.config/flynn'); const AUTH_FILE = resolve(AUTH_DIR, 'auth.json'); +const ANTHROPIC_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; +const ANTHROPIC_AUTH_URL = 'https://claude.ai/oauth/authorize'; +const ANTHROPIC_TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token'; +const ANTHROPIC_OAUTH_SCOPES = 'user:inference user:profile'; +const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; + +/** Generate a PKCE code_verifier: 32 random bytes as base64url (43 chars, no padding). */ +export function generateCodeVerifier(): string { + return randomBytes(32).toString('base64url'); +} + +/** Generate a PKCE code_challenge: SHA256 of verifier, base64url-encoded (no padding). */ +export function generateCodeChallenge(verifier: string): string { + return createHash('sha256').update(verifier).digest('base64url'); +} + +export interface CallbackServer { + port: number; + waitForCode: Promise<{ code: string; state: string }>; +} + +/** + * Start a one-shot HTTP server on a random port bound to 127.0.0.1. + * Returns { port, waitForCode } immediately. + * waitForCode resolves when the browser hits /callback?code=...&state=... + * or rejects if timeoutMs elapses first. + */ +export function startCallbackServer(timeoutMs: number): Promise { + return new Promise((resolveServer, rejectServer) => { + let resolveCb!: (v: { code: string; state: string }) => void; + let rejectCb!: (e: Error) => void; + + const waitForCode = new Promise<{ code: string; state: string }>((res, rej) => { + resolveCb = res; + rejectCb = rej; + }); + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + if (url.pathname !== '/callback') { + res.writeHead(404).end('Not found'); + return; + } + + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + + if (!code || !state) { + res.writeHead(400).end('Missing code or state'); + return; + } + + res.writeHead(200, { 'Content-Type': 'text/html' }).end( + '' + + '

Authentication complete

' + + '

You can close this tab and return to Flynn.

' + + '', + ); + + server.close(); + clearTimeout(timer); + resolveCb({ code, state }); + }); + + const timer = setTimeout(() => { + server.close(); + rejectCb(new Error('Anthropic OAuth timed out — browser flow was not completed.')); + }, timeoutMs); + + server.listen(0, '127.0.0.1', () => { + const port = (server.address() as AddressInfo).port; + resolveServer({ port, waitForCode }); + }); + + server.on('error', rejectServer); + }); +} + export interface AnthropicAuthInfo { /** Anthropic API key. */ api_key?: string;