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, or if signal is aborted. */ export function startCallbackServer(timeoutMs: number, signal?: AbortSignal): 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); // Handle cancellation via AbortSignal if (signal) { if (signal.aborted) { clearTimeout(timer); try { server.close(); } catch { /* already closed */ } rejectServer(new Error('Anthropic OAuth was cancelled.')); return; } signal.addEventListener('abort', () => { clearTimeout(timer); try { server.close(); } catch { /* already closed */ } rejectCb(new Error('Anthropic OAuth was cancelled.')); }, { once: true }); } server.listen(0, '127.0.0.1', () => { const port = (server.address() as AddressInfo).port; resolveServer({ port, waitForCode }); }); server.on('error', rejectServer); }); } /** 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, signal?: AbortSignal, _startServer: (timeoutMs: number, signal?: AbortSignal) => Promise = startCallbackServer, ): Promise { const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); const state = randomBytes(16).toString('hex'); const { port, waitForCode } = await _startServer(OAUTH_TIMEOUT_MS, signal); 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; /** Anthropic auth token (OAuth/token mode). */ auth_token?: string; created_at: string; } interface AuthStore { anthropic?: AnthropicAuthInfo; [key: string]: unknown; } function safeJsonParse(raw: string): T | null { try { return JSON.parse(raw) as T; } catch { return null; } } function readAuthStore(): AuthStore { try { const raw = readFileSync(AUTH_FILE, 'utf-8'); const parsed = safeJsonParse(raw); return parsed ?? {}; } catch { return {}; } } function writeAuthStore(store: AuthStore): void { mkdirSync(AUTH_DIR, { recursive: true }); writeFileSync(AUTH_FILE, JSON.stringify(store, null, 2) + '\n', 'utf-8'); chmodSync(AUTH_FILE, 0o600); } export function loadStoredAnthropicAuth(): AnthropicAuthInfo | null { const store = readAuthStore(); return store.anthropic ?? null; } export function storeAnthropicAuth(apiKey: string): void { const trimmed = apiKey.trim(); if (!trimmed) { throw new Error('Anthropic API key is empty'); } const store = readAuthStore(); store.anthropic = { ...store.anthropic, api_key: trimmed, created_at: new Date().toISOString() }; writeAuthStore(store); } export function loadStoredAnthropicAuthToken(): string | null { return loadStoredAnthropicAuth()?.auth_token ?? null; } export function storeAnthropicAuthToken(token: string): void { const trimmed = token.trim(); if (!trimmed) { throw new Error('Anthropic auth token is empty'); } const store = readAuthStore(); store.anthropic = { ...store.anthropic, auth_token: trimmed, created_at: new Date().toISOString() }; writeAuthStore(store); } export function clearAnthropicAuth(): void { const store = readAuthStore(); delete store.anthropic; writeAuthStore(store); } export function clearAnthropicAuthToken(): void { const store = readAuthStore(); if (!store.anthropic) { writeAuthStore(store); return; } delete store.anthropic.auth_token; if (!store.anthropic.api_key && !store.anthropic.auth_token) { delete store.anthropic; } writeAuthStore(store); } /** * Get an Anthropic API key from any available source. * Priority: ANTHROPIC_API_KEY → stored auth.json. */ export function getAnthropicApiKey(): string | null { return process.env.ANTHROPIC_API_KEY ?? loadStoredAnthropicAuth()?.api_key ?? null; } /** * Get an Anthropic auth token from any available source. * Priority: ANTHROPIC_AUTH_TOKEN → stored auth.json. */ export function getAnthropicAuthToken(): string | null { return process.env.ANTHROPIC_AUTH_TOKEN ?? loadStoredAnthropicAuth()?.auth_token ?? null; }