diff --git a/docs/plans/2026-02-26-anthropic-oauth-browser-flow-design.md b/docs/plans/2026-02-26-anthropic-oauth-browser-flow-design.md new file mode 100644 index 0000000..8bcb8ad --- /dev/null +++ b/docs/plans/2026-02-26-anthropic-oauth-browser-flow-design.md @@ -0,0 +1,179 @@ +# Anthropic OAuth Browser Flow — Design + +**Goal:** Replace the manual auth-token paste in `/login anthropic` (option 2) and `flynn anthropic-auth --token` with a PKCE OAuth2 browser flow that obtains the token automatically via a local HTTP callback server. + +**Date:** 2026-02-26 + +--- + +## OAuth Endpoints + +| Field | Value | +|-------|-------| +| Authorization URL | `https://claude.ai/oauth/authorize` | +| Token URL | `https://console.anthropic.com/v1/oauth/token` | +| Client ID | `9d1c250a-e61b-44d9-88ed-5944d1962f5e` | +| Scopes | `user:inference user:profile` | +| PKCE method | S256 (SHA256) | + +> **Note:** These endpoints and the client ID belong to Claude Code CLI. Anthropic has restricted third-party use of Pro subscription tokens as of early 2026; the flow may only complete for users with an active Claude Pro/Max session in the browser. + +--- + +## Architecture + +### Flow + +1. Generate PKCE `code_verifier` (32 random bytes, base64url-encoded) and `code_challenge` (SHA256 of verifier, base64url-encoded). +2. Generate random `state` (16 bytes, hex) for CSRF protection. +3. Bind a one-shot HTTP server to `127.0.0.1:0` (OS picks a free port). Read the actual port from the server address. +4. Build the authorization URL with all required params and call `onOpen(url)`. +5. `onOpen` opens the browser using `child_process` + platform command (`xdg-open` / `open` / `start`). If launch fails, print the URL and instruct the user to open it manually — the server keeps running. +6. Wait for the browser to redirect to `http://127.0.0.1:{port}/callback?code=...&state=...`. Server validates `state`, responds with an HTML "Authentication complete — you can close this tab." page, then closes. +7. Exchange `code` + `code_verifier` at the token URL via POST. +8. Store the returned `access_token` as the `auth_token` field in `~/.config/flynn/auth.json`. + +**Timeout:** 5 minutes. If the server receives no callback, it closes and throws `Error('Anthropic OAuth timed out after 5 minutes.')`. + +--- + +## Components + +### `src/auth/anthropic.ts` — new additions + +```typescript +// PKCE +function generateCodeVerifier(): string + // crypto.randomBytes(32).toString('base64url') + +function generateCodeChallenge(verifier: string): string + // createHash('sha256').update(verifier).digest('base64url') + +// One-shot callback server +// Resolves with { code, state } when redirect arrives, rejects on timeout +function startCallbackServer(port: number, timeoutMs: number): Promise<{ code: string; state: string }> + +// Token exchange +async function exchangeCodeForToken( + code: string, + codeVerifier: string, + redirectUri: string, +): Promise // returns access_token + +// Full public flow +export async function loginAnthropicOAuth( + onOpen: (url: string) => void, +): Promise +// Orchestrates all steps, calls storeAnthropicAuthToken() before returning +``` + +### `src/frontends/tui/minimal.ts` — Anthropic branch change + +Option 2 ("Paste auth token") becomes "Browser OAuth": + +``` +Anthropic login: + 1) Paste API key + 2) Browser OAuth (Claude Pro/Max) +``` + +Option 2 flow: +```typescript +console.log(`${colors.gray}Starting Anthropic browser OAuth...${colors.reset}`); +await loginAnthropicOAuth((url) => { + openBrowser(url); // platform child_process call + console.log(`${colors.gray}Opening browser. If it didn't open, visit:${colors.reset}`); + console.log(url); + console.log(`${colors.gray}Waiting for authentication...${colors.reset}`); +}); +console.log(`${colors.gray}Anthropic auth token stored.${colors.reset}\n`); +``` + +The `credentialStored` flag pattern (already present) applies here too. + +### `src/cli/anthropic-auth.ts` — new `--browser` flag + +Add alongside the existing `--token` flag: + +``` +--browser Obtain auth token via browser OAuth flow (Claude Pro/Max) +``` + +```typescript +if (mode === 'browser') { + console.log('Starting Anthropic browser OAuth...'); + await loginAnthropicOAuth((url) => { + openBrowser(url); + console.log(`If browser didn't open, visit:\n${url}`); + console.log('Waiting for authentication...'); + }); + console.log('Anthropic auth token stored in ~/.config/flynn/auth.json'); +} +``` + +`parseAnthropicAuthMode` gains a third value: `'api' | 'token' | 'browser'`. + +### Browser opening helper (inline, no new dependency) + +```typescript +function openBrowser(url: string): void { + const cmd = process.platform === 'win32' ? 'start' + : process.platform === 'darwin' ? 'open' + : 'xdg-open'; + spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref(); +} +``` + +--- + +## Error Handling + +| Scenario | Behaviour | +|----------|-----------| +| Port already in use | `127.0.0.1:0` avoids this — OS picks free port | +| Browser open fails | Print URL, keep server running, user opens manually | +| State mismatch | Throw `Error('OAuth state mismatch — possible CSRF attack.')` | +| Timeout (5 min) | Throw `Error('Anthropic OAuth timed out after 5 minutes.')` | +| Token exchange non-2xx | Throw `Error('Token exchange failed (${status}): ${body}')` | +| 403 from Anthropic | Surface message: "Anthropic OAuth requires an active Claude Pro/Max subscription." | + +--- + +## Testing + +### `src/auth/anthropic.test.ts` — new tests + +- `generateCodeVerifier()` returns 43-char base64url string (no padding, URL-safe chars only) +- `generateCodeChallenge(verifier)` returns correct SHA256 base64url digest +- `loginAnthropicOAuth()` with mocked `http.createServer` + mocked `fetch`: + - Verifies auth URL contains correct `client_id`, `scope`, `code_challenge_method=S256`, `redirect_uri`, `state` + - Verifies token exchange POST body contains `code`, `code_verifier`, `redirect_uri` + - Verifies `storeAnthropicAuthToken` called with returned `access_token` +- State mismatch → throws +- Timeout (use fake timers) → throws +- Token exchange 403 → throws with subscription message +- Token exchange other non-2xx → throws with status + body + +### `src/cli/anthropic-auth.test.ts` — new tests + +- `--browser` flag triggers `loginAnthropicOAuth` (mock it) +- `--browser` with existing token: prompts for confirmation before re-authenticating +- Existing `--token` and default API key tests unchanged + +### `src/frontends/tui/minimal.test.ts` + +- Anthropic option 2: mock `loginAnthropicOAuth`, verify it's called +- `credentialStored` flag: verify auth_mode prompt only appears after successful OAuth + +--- + +## Files Changed + +| File | Change | +|------|--------| +| `src/auth/anthropic.ts` | Add PKCE helpers, callback server, token exchange, `loginAnthropicOAuth` | +| `src/auth/anthropic.test.ts` | New tests for all new functions | +| `src/frontends/tui/minimal.ts` | Replace option 2 paste with browser OAuth | +| `src/frontends/tui/minimal.test.ts` | Update/add Anthropic login tests | +| `src/cli/anthropic-auth.ts` | Add `--browser` flag, `browser` auth mode | +| `src/cli/anthropic-auth.test.ts` | Tests for `--browser` flag |