docs: design Anthropic OAuth browser flow (PKCE + local callback server)
This commit is contained in:
@@ -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<string> // returns access_token
|
||||
|
||||
// Full public flow
|
||||
export async function loginAnthropicOAuth(
|
||||
onOpen: (url: string) => void,
|
||||
): Promise<string>
|
||||
// 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 |
|
||||
Reference in New Issue
Block a user