904 lines
30 KiB
Markdown
904 lines
30 KiB
Markdown
# Anthropic OAuth Browser Flow Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** Replace the manual auth-token paste with a PKCE OAuth2 browser flow that spins up a local HTTP callback server, opens the user's browser, and captures the Anthropic token automatically.
|
||
|
||
**Architecture:** New pure functions (`generateCodeVerifier`, `generateCodeChallenge`, `startCallbackServer`, `exchangeCodeForToken`, `loginAnthropicOAuth`) added to `src/auth/anthropic.ts`. TUI option 2 ("Paste auth token") replaced with "Browser OAuth" calling `loginAnthropicOAuth`. CLI `--browser` flag added alongside existing `--token`. No new npm dependencies — uses Node.js `crypto`, `http`, and `child_process` builtins.
|
||
|
||
**Tech Stack:** TypeScript, Node.js `crypto`/`http`/`child_process`, Vitest, existing `storeAnthropicAuthToken`.
|
||
|
||
---
|
||
|
||
### Task 1: PKCE helpers and one-shot callback server
|
||
|
||
**Files:**
|
||
- Modify: `src/auth/anthropic.ts`
|
||
- Modify: `src/auth/anthropic.test.ts`
|
||
|
||
**OAuth constants (add near top of `src/auth/anthropic.ts` after existing constants):**
|
||
|
||
```typescript
|
||
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; // 5 minutes
|
||
```
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
Add a new `describe('PKCE helpers', ...)` block in `src/auth/anthropic.test.ts`.
|
||
The test file uses `vi.resetModules()` in `beforeEach` and dynamic imports — add the new describe block at the bottom, after the existing describe, with its own `beforeEach` / `afterEach` for `HOME`:
|
||
|
||
```typescript
|
||
import { createHash, randomBytes } from 'crypto';
|
||
|
||
describe('PKCE helpers', () => {
|
||
it('generateCodeVerifier returns a 43-char base64url string', async () => {
|
||
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', async () => {
|
||
const { generateCodeVerifier, generateCodeChallenge } = await import('./anthropic.js');
|
||
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\-_]+$/);
|
||
});
|
||
});
|
||
```
|
||
|
||
**Step 2: Run to verify they fail**
|
||
|
||
```bash
|
||
cd /home/will/lab/flynn && pnpm test:run src/auth/anthropic.test.ts
|
||
```
|
||
|
||
Expected: FAIL — `generateCodeVerifier` not exported.
|
||
|
||
**Step 3: Implement the PKCE helpers in `src/auth/anthropic.ts`**
|
||
|
||
Add after the existing imports (add `createHash`, `randomBytes` to the node:crypto import):
|
||
|
||
```typescript
|
||
import { createHash, randomBytes } from 'crypto';
|
||
import { createServer, IncomingMessage, ServerResponse } from 'http';
|
||
import { AddressInfo } from 'net';
|
||
import { spawn } from 'child_process';
|
||
```
|
||
|
||
Add after the constants block (before `AnthropicAuthInfo` interface):
|
||
|
||
```typescript
|
||
/** 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');
|
||
}
|
||
```
|
||
|
||
**Step 4: Write the failing callback server test**
|
||
|
||
Add to the `describe('PKCE helpers', ...)` block:
|
||
|
||
```typescript
|
||
it('startCallbackServer resolves with code and state on redirect', async () => {
|
||
const { startCallbackServer } = await import('./anthropic.js');
|
||
const serverPromise = startCallbackServer(5000);
|
||
|
||
// Get the port from the returned object — we need to peek at it.
|
||
// startCallbackServer returns { port, waitForCode }
|
||
// (see implementation below for shape)
|
||
const { port, waitForCode } = await serverPromise;
|
||
|
||
// Simulate browser redirect
|
||
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).toContain('close this tab');
|
||
|
||
const { code, state } = await waitForCode;
|
||
expect(code).toBe('test-code');
|
||
expect(state).toBe('test-state');
|
||
});
|
||
|
||
it('startCallbackServer rejects on timeout', async () => {
|
||
const { startCallbackServer } = await import('./anthropic.js');
|
||
const { waitForCode } = await startCallbackServer(50); // 50ms timeout
|
||
await expect(waitForCode).rejects.toThrow('timed out');
|
||
});
|
||
```
|
||
|
||
**Step 5: Run to verify they fail**
|
||
|
||
```bash
|
||
cd /home/will/lab/flynn && pnpm test:run src/auth/anthropic.test.ts
|
||
```
|
||
|
||
Expected: FAIL — `startCallbackServer` not exported.
|
||
|
||
**Step 6: Implement `startCallbackServer`**
|
||
|
||
```typescript
|
||
export interface CallbackServer {
|
||
port: number;
|
||
waitForCode: Promise<{ code: string; state: string }>;
|
||
}
|
||
|
||
/**
|
||
* Start a one-shot HTTP server on a random port.
|
||
* Resolves immediately with { port, waitForCode }.
|
||
* waitForCode resolves when the browser hits /callback?code=...&state=...
|
||
* and rejects if timeoutMs elapses first.
|
||
*/
|
||
export function startCallbackServer(timeoutMs: number): Promise<CallbackServer> {
|
||
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(
|
||
'<!DOCTYPE html><html><body>' +
|
||
'<h2>Authentication complete</h2>' +
|
||
'<p>You can close this tab and return to Flynn.</p>' +
|
||
'</body></html>',
|
||
);
|
||
|
||
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);
|
||
});
|
||
}
|
||
```
|
||
|
||
**Step 7: Run tests to verify they pass**
|
||
|
||
```bash
|
||
cd /home/will/lab/flynn && pnpm test:run src/auth/anthropic.test.ts
|
||
```
|
||
|
||
Expected: all PASS.
|
||
|
||
**Step 8: Commit**
|
||
|
||
```bash
|
||
cd /home/will/lab/flynn && git add src/auth/anthropic.ts src/auth/anthropic.test.ts && git commit -m "feat(auth): add PKCE helpers and OAuth callback server for Anthropic"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Token exchange and `loginAnthropicOAuth`
|
||
|
||
**Files:**
|
||
- Modify: `src/auth/anthropic.ts`
|
||
- Modify: `src/auth/anthropic.test.ts`
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
Add a new `describe('loginAnthropicOAuth', ...)` block in `src/auth/anthropic.test.ts`.
|
||
This test needs to mock `fetch`, `startCallbackServer`, and `storeAnthropicAuthToken`.
|
||
Use `vi.hoisted` for mocks at module level (required for ESM).
|
||
|
||
Add at the top of the file (alongside the other `vi.hoisted` calls):
|
||
|
||
```typescript
|
||
const { mockFetch, mockStartCallbackServer } = vi.hoisted(() => ({
|
||
mockFetch: vi.fn(),
|
||
mockStartCallbackServer: vi.fn(),
|
||
}));
|
||
```
|
||
|
||
Then add a new describe block at the bottom:
|
||
|
||
```typescript
|
||
describe('loginAnthropicOAuth', () => {
|
||
beforeEach(() => {
|
||
vi.stubGlobal('fetch', mockFetch);
|
||
mockStartCallbackServer.mockReset();
|
||
mockFetch.mockReset();
|
||
vi.resetModules();
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('builds correct auth URL and exchanges code for token', async () => {
|
||
// Arrange: mock callback server to immediately return a code
|
||
const fakeCode = 'auth-code-abc';
|
||
const fakeState = 'state-xyz';
|
||
mockStartCallbackServer.mockResolvedValue({
|
||
port: 9999,
|
||
waitForCode: Promise.resolve({ code: fakeCode, state: fakeState }),
|
||
});
|
||
|
||
// Mock token exchange response
|
||
mockFetch.mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ access_token: 'tok-from-oauth' }),
|
||
});
|
||
|
||
const capturedUrls: string[] = [];
|
||
const { loginAnthropicOAuth } = await import('./anthropic.js');
|
||
|
||
// Use vi.spyOn to intercept startCallbackServer
|
||
// (injected via the module's own reference — see impl note below)
|
||
const token = await loginAnthropicOAuth((url) => capturedUrls.push(url));
|
||
|
||
// Auth URL assertions
|
||
expect(capturedUrls).toHaveLength(1);
|
||
const authUrl = new URL(capturedUrls[0]);
|
||
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');
|
||
|
||
// Token stored
|
||
expect(token).toBe('tok-from-oauth');
|
||
|
||
// Verify token exchange fetch call
|
||
expect(mockFetch).toHaveBeenCalledWith(
|
||
'https://console.anthropic.com/v1/oauth/token',
|
||
expect.objectContaining({ method: 'POST' }),
|
||
);
|
||
});
|
||
|
||
it('throws on state mismatch', async () => {
|
||
mockStartCallbackServer.mockResolvedValue({
|
||
port: 9999,
|
||
waitForCode: Promise.resolve({ code: 'code', state: 'WRONG' }),
|
||
});
|
||
|
||
const { loginAnthropicOAuth } = await import('./anthropic.js');
|
||
|
||
await expect(
|
||
loginAnthropicOAuth(() => undefined),
|
||
).rejects.toThrow('state mismatch');
|
||
});
|
||
|
||
it('throws with subscription message on 403', async () => {
|
||
mockStartCallbackServer.mockResolvedValue({
|
||
port: 9999,
|
||
waitForCode: Promise.resolve({ code: 'code', state: '__STATE__' }),
|
||
});
|
||
mockFetch.mockResolvedValue({
|
||
ok: false,
|
||
status: 403,
|
||
text: async () => 'Forbidden',
|
||
});
|
||
|
||
const { loginAnthropicOAuth } = await import('./anthropic.js');
|
||
|
||
await expect(
|
||
loginAnthropicOAuth(() => undefined),
|
||
).rejects.toThrow(/Pro\/Max subscription/);
|
||
});
|
||
|
||
it('throws with status on non-2xx token exchange', async () => {
|
||
mockStartCallbackServer.mockResolvedValue({
|
||
port: 9999,
|
||
waitForCode: Promise.resolve({ code: 'code', state: '__STATE__' }),
|
||
});
|
||
mockFetch.mockResolvedValue({
|
||
ok: false,
|
||
status: 500,
|
||
text: async () => 'Internal Server Error',
|
||
});
|
||
|
||
const { loginAnthropicOAuth } = await import('./anthropic.js');
|
||
|
||
await expect(
|
||
loginAnthropicOAuth(() => undefined),
|
||
).rejects.toThrow(/500/);
|
||
});
|
||
});
|
||
```
|
||
|
||
> **Implementation note on testability:** `loginAnthropicOAuth` calls `startCallbackServer` internally. To allow mocking in tests, we use the **same-module call** pattern: tests that need to control the callback server must mock `fetch` globally and intercept `startCallbackServer` via `vi.spyOn` on the module after import. Alternatively, `loginAnthropicOAuth` accepts an optional `_startServer` parameter (defaulting to the real `startCallbackServer`) for easy test injection. Use the optional injection pattern — it avoids complex spy setup.
|
||
|
||
Update the function signature accordingly:
|
||
```typescript
|
||
export async function loginAnthropicOAuth(
|
||
onOpen: (url: string) => void,
|
||
_startServer = startCallbackServer,
|
||
): Promise<string>
|
||
```
|
||
|
||
Also update the state assertion: when `loginAnthropicOAuth` generates `state` internally, the test mock that returns `state: '__STATE__'` needs to match. The simplest approach: generate state, pass it to `startCallbackServer` as part of the redirect URI validation. See implementation below — the state is generated before the server starts and checked after.
|
||
|
||
**Step 2: Run to verify they fail**
|
||
|
||
```bash
|
||
cd /home/will/lab/flynn && pnpm test:run src/auth/anthropic.test.ts
|
||
```
|
||
|
||
Expected: FAIL — `loginAnthropicOAuth` not exported.
|
||
|
||
**Step 3: Implement `openBrowser`, `exchangeCodeForToken`, and `loginAnthropicOAuth`**
|
||
|
||
Add to `src/auth/anthropic.ts`:
|
||
|
||
```typescript
|
||
/** 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<string> {
|
||
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<string, unknown>;
|
||
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<string> {
|
||
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;
|
||
}
|
||
```
|
||
|
||
> **Test note on state:** The tests that mock `_startServer` to return `state: '__STATE__'` will fail the state check because the internally generated state won't match. Fix: in those tests (state mismatch, 403, 500), provide a custom `_startServer` that also captures the generated state. The simplest approach: generate state externally and pass it in. BUT — that breaks encapsulation. Instead, make the state-checking tests use a spy approach:
|
||
|
||
Update the state-mismatch test to use a mock that returns the wrong state explicitly:
|
||
```typescript
|
||
// state mismatch test: mock returns a state that won't match the generated one
|
||
mockStartCallbackServer.mockResolvedValue({
|
||
port: 9999,
|
||
waitForCode: Promise.resolve({ code: 'code', state: 'DEFINITELY-WRONG-STATE' }),
|
||
});
|
||
```
|
||
|
||
For the 403 and 500 tests, we need the returned state to match. Use a custom `_startServer` that captures the state:
|
||
```typescript
|
||
// In the 403 test, pass a custom _startServer:
|
||
const customStartServer = async (_timeout: number) => {
|
||
// We don't know the state yet — use a trick: return a promise that resolves later
|
||
// Actually simplest: just test exchangeCodeForToken directly (separate test)
|
||
// and test loginAnthropicOAuth with a helper that bypasses state check
|
||
};
|
||
```
|
||
|
||
**Simpler approach:** Test `exchangeCodeForToken` directly (not through `loginAnthropicOAuth`) for the 403 and 500 cases. Only the state mismatch test needs `loginAnthropicOAuth`. The token exchange error tests should call `exchangeCodeForToken` directly:
|
||
|
||
```typescript
|
||
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/);
|
||
});
|
||
```
|
||
|
||
Rewrite the `loginAnthropicOAuth` tests accordingly (keep only: success path, state mismatch).
|
||
|
||
**Step 4: Run tests to verify they pass**
|
||
|
||
```bash
|
||
cd /home/will/lab/flynn && pnpm test:run src/auth/anthropic.test.ts
|
||
```
|
||
|
||
Expected: all PASS.
|
||
|
||
**Step 5: Run typecheck**
|
||
|
||
```bash
|
||
cd /home/will/lab/flynn && pnpm typecheck
|
||
```
|
||
|
||
Expected: no errors.
|
||
|
||
**Step 6: Commit**
|
||
|
||
```bash
|
||
cd /home/will/lab/flynn && git add src/auth/anthropic.ts src/auth/anthropic.test.ts && git commit -m "feat(auth): implement Anthropic OAuth PKCE browser flow"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Wire browser OAuth into TUI `/login anthropic` option 2
|
||
|
||
**Files:**
|
||
- Modify: `src/frontends/tui/minimal.ts`
|
||
- Modify: `src/frontends/tui/minimal.test.ts`
|
||
|
||
**Context:** The current Anthropic branch (around line 1255) offers:
|
||
```
|
||
1) Paste API key 2) Paste auth token
|
||
```
|
||
Option 2 needs to become "Browser OAuth". The existing paste-token block (lines 1264–1304) is replaced entirely.
|
||
|
||
**Step 1: Write the failing test**
|
||
|
||
In `src/frontends/tui/minimal.test.ts`, add at the top (with other `vi.hoisted` mocks):
|
||
|
||
```typescript
|
||
const { mockLoginAnthropicOAuth } = vi.hoisted(() => ({
|
||
mockLoginAnthropicOAuth: vi.fn(),
|
||
}));
|
||
```
|
||
|
||
Add to the `vi.mock` for `../../auth/index.js` (or wherever `loginAnthropicOAuth` is imported from in minimal.ts — it will be from `../../auth/anthropic.js`):
|
||
|
||
```typescript
|
||
vi.mock('../../auth/anthropic.js', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('../../auth/anthropic.js')>();
|
||
return {
|
||
...actual,
|
||
loginAnthropicOAuth: mockLoginAnthropicOAuth,
|
||
};
|
||
});
|
||
```
|
||
|
||
Add a test (check existing `minimal.login.test.ts` for pattern — the login tests may be in a separate file):
|
||
|
||
```typescript
|
||
describe('handleLoginCommand anthropic browser oauth', () => {
|
||
it('calls loginAnthropicOAuth when user selects option 2', async () => {
|
||
mockLoginAnthropicOAuth.mockResolvedValue('tok-from-browser');
|
||
// ... set up MinimalTui with a prompt mock that answers '2'
|
||
// ... trigger handleLoginCommand('anthropic')
|
||
// ... assert mockLoginAnthropicOAuth was called
|
||
// (follow existing login test patterns in the file)
|
||
});
|
||
});
|
||
```
|
||
|
||
**Note:** Check `src/frontends/tui/minimal.login.test.ts` — login tests may live there. Add the test in whichever file already tests `handleLoginCommand`.
|
||
|
||
**Step 2: Run to verify test fails**
|
||
|
||
```bash
|
||
cd /home/will/lab/flynn && pnpm test:run src/frontends/tui/minimal.login.test.ts
|
||
```
|
||
|
||
Expected: FAIL.
|
||
|
||
**Step 3: Update `minimal.ts` imports**
|
||
|
||
Find the import of `storeAnthropicAuth`, `storeAnthropicAuthToken`, etc. near the top of `minimal.ts`. Add `loginAnthropicOAuth` and `openBrowser` to that import:
|
||
|
||
```typescript
|
||
import {
|
||
// ... existing imports ...
|
||
loginAnthropicOAuth,
|
||
openBrowser,
|
||
} from '../../auth/anthropic.js';
|
||
```
|
||
|
||
**Step 4: Replace option 2 in the Anthropic branch**
|
||
|
||
Find and replace these lines in `handleLoginCommand` (around line 1256–1257):
|
||
|
||
```typescript
|
||
console.log(`${colors.gray}Anthropic login:${colors.reset}`);
|
||
console.log(`${colors.gray} 1) Paste API key 2) Paste auth token${colors.reset}`);
|
||
```
|
||
|
||
Change to:
|
||
|
||
```typescript
|
||
console.log(`${colors.gray}Anthropic login:${colors.reset}`);
|
||
console.log(`${colors.gray} 1) Paste API key 2) Browser OAuth (Claude Pro/Max)${colors.reset}`);
|
||
```
|
||
|
||
Replace the option 2 block (lines ~1264–1304, the "Paste auth token" path) with:
|
||
|
||
```typescript
|
||
// 2) Browser OAuth
|
||
if (choice === '2') {
|
||
if (hasToken) {
|
||
console.log(`${colors.gray}Anthropic auth token already exists.${colors.reset}`);
|
||
if (!await confirmReplace()) {
|
||
console.log(`${colors.gray}Cancelled.${colors.reset}\n`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
console.log(`${colors.gray}Starting Anthropic browser OAuth...${colors.reset}`);
|
||
|
||
let credentialStored = false;
|
||
try {
|
||
await loginAnthropicOAuth((url) => {
|
||
openBrowser(url);
|
||
console.log(`${colors.gray}Opening browser. If it didn't open, visit:${colors.reset}`);
|
||
console.log(url);
|
||
console.log(`${colors.gray}Waiting for authentication (up to 5 minutes)...${colors.reset}`);
|
||
});
|
||
console.log(`${colors.gray}Anthropic auth token stored in ~/.config/flynn/auth.json${colors.reset}\n`);
|
||
credentialStored = true;
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
console.log(`${colors.gray}Anthropic OAuth failed:${colors.reset} ${message}\n`);
|
||
}
|
||
|
||
// Offer to set auth_mode if config is available
|
||
if (credentialStored && this.config.currentConfig && this.config.configPath) {
|
||
const modeInput = (await this.prompt(
|
||
`${colors.orange}Set active auth mode?${colors.reset} ${colors.gray}[api_key/oauth/auto/skip] (default: skip):${colors.reset} `,
|
||
)).trim().toLowerCase();
|
||
if (modeInput === 'api_key' || modeInput === 'oauth' || modeInput === 'auto') {
|
||
const updated = applyAuthModeToConfig(this.config.currentConfig, 'anthropic', modeInput);
|
||
persistConfig(this.config.configPath, updated);
|
||
console.log(`${colors.gray}auth_mode set to ${modeInput}. Restart Flynn to apply.${colors.reset}\n`);
|
||
}
|
||
}
|
||
|
||
return;
|
||
}
|
||
```
|
||
|
||
**Step 5: Run tests**
|
||
|
||
```bash
|
||
cd /home/will/lab/flynn && pnpm test:run src/frontends/tui/minimal.login.test.ts
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
**Step 6: Run full suite and typecheck**
|
||
|
||
```bash
|
||
cd /home/will/lab/flynn && pnpm test:run && pnpm typecheck
|
||
```
|
||
|
||
**Step 7: Commit**
|
||
|
||
```bash
|
||
cd /home/will/lab/flynn && git add src/frontends/tui/minimal.ts src/frontends/tui/minimal.login.test.ts && git commit -m "feat(tui): replace Anthropic token paste with browser OAuth flow"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Add `--browser` flag to `flynn anthropic-auth` CLI
|
||
|
||
**Files:**
|
||
- Modify: `src/cli/anthropic-auth.ts`
|
||
- Modify: `src/cli/anthropic-auth.test.ts`
|
||
|
||
**Context:** `anthropic-auth.ts` currently has `type AnthropicAuthMode = 'api' | 'token'`. We add `'browser'`. The `--browser` flag is shorthand for `--mode browser` (mirrors `--token` → `--mode token`).
|
||
|
||
**Step 1: Write the failing tests**
|
||
|
||
Add to `src/cli/anthropic-auth.test.ts`:
|
||
|
||
```typescript
|
||
// Add to vi.hoisted at the top:
|
||
const { mockLoginAnthropicOAuth: mockCLILoginAnthropicOAuth, mockOpenBrowser } = vi.hoisted(() => ({
|
||
mockLoginAnthropicOAuth: vi.fn(),
|
||
mockOpenBrowser: vi.fn(),
|
||
}));
|
||
|
||
// Add to the vi.mock block (or add a new one):
|
||
vi.mock('../auth/anthropic.js', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('../auth/anthropic.js')>();
|
||
return {
|
||
...actual,
|
||
loginAnthropicOAuth: mockCLILoginAnthropicOAuth,
|
||
openBrowser: mockOpenBrowser,
|
||
};
|
||
});
|
||
```
|
||
|
||
Add test cases inside `describe('anthropic-auth command', ...)`:
|
||
|
||
```typescript
|
||
it('--browser triggers browser OAuth flow', async () => {
|
||
mockCLILoginAnthropicOAuth.mockResolvedValue('tok-browser');
|
||
mockLoadStoredAnthropicAuthToken.mockReturnValue(null);
|
||
|
||
const program = new Command();
|
||
const { registerAnthropicAuthCommand } = await import('./anthropic-auth.js');
|
||
registerAnthropicAuthCommand(program);
|
||
|
||
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||
|
||
await program.parseAsync(['node', 'test', 'anthropic-auth', '--browser']);
|
||
|
||
expect(mockCLILoginAnthropicOAuth).toHaveBeenCalled();
|
||
consoleLog.mockRestore();
|
||
});
|
||
|
||
it('--browser with existing token prompts for confirmation', async () => {
|
||
mockLoadStoredAnthropicAuthToken.mockReturnValue('existing-tok');
|
||
mockReadlineAnswers(['n']);
|
||
|
||
const program = new Command();
|
||
const { registerAnthropicAuthCommand } = await import('./anthropic-auth.js');
|
||
registerAnthropicAuthCommand(program);
|
||
|
||
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||
throw new Error(`EXIT:${code ?? 0}`);
|
||
}) as never);
|
||
|
||
await expect(program.parseAsync(['node', 'test', 'anthropic-auth', '--browser'])).rejects.toThrow('EXIT:0');
|
||
expect(mockCLILoginAnthropicOAuth).not.toHaveBeenCalled();
|
||
|
||
exitSpy.mockRestore();
|
||
consoleLog.mockRestore();
|
||
});
|
||
|
||
it('--mode browser triggers browser OAuth flow', async () => {
|
||
mockCLILoginAnthropicOAuth.mockResolvedValue('tok-mode-browser');
|
||
mockLoadStoredAnthropicAuthToken.mockReturnValue(null);
|
||
|
||
const program = new Command();
|
||
const { registerAnthropicAuthCommand } = await import('./anthropic-auth.js');
|
||
registerAnthropicAuthCommand(program);
|
||
|
||
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||
|
||
await program.parseAsync(['node', 'test', 'anthropic-auth', '--mode', 'browser']);
|
||
|
||
expect(mockCLILoginAnthropicOAuth).toHaveBeenCalled();
|
||
consoleLog.mockRestore();
|
||
});
|
||
|
||
it('--browser and --token conflict', async () => {
|
||
const program = new Command();
|
||
const { registerAnthropicAuthCommand } = await import('./anthropic-auth.js');
|
||
registerAnthropicAuthCommand(program);
|
||
|
||
await expect(
|
||
program.parseAsync(['node', 'test', 'anthropic-auth', '--browser', '--token']),
|
||
).rejects.toThrow(/Conflicting/);
|
||
});
|
||
```
|
||
|
||
**Step 2: Run to verify they fail**
|
||
|
||
```bash
|
||
cd /home/will/lab/flynn && pnpm test:run src/cli/anthropic-auth.test.ts
|
||
```
|
||
|
||
Expected: FAIL.
|
||
|
||
**Step 3: Update `anthropic-auth.ts`**
|
||
|
||
Add `loginAnthropicOAuth` and `openBrowser` imports from `'../auth/index.js'` (check what's exported from auth/index.ts — may need to add exports there too).
|
||
|
||
Change the type:
|
||
```typescript
|
||
type AnthropicAuthMode = 'api' | 'token' | 'browser';
|
||
```
|
||
|
||
Update `parseAnthropicAuthMode`:
|
||
```typescript
|
||
function parseAnthropicAuthMode(value: string): AnthropicAuthMode {
|
||
const mode = value.trim().toLowerCase();
|
||
if (mode === 'api' || mode === 'token' || mode === 'browser') {
|
||
return mode;
|
||
}
|
||
throw new Error(`Invalid mode "${value}". Expected: api, token, or browser.`);
|
||
}
|
||
```
|
||
|
||
Update `resolveAuthMode` to handle `--browser`:
|
||
```typescript
|
||
function resolveAuthMode(opts: { token?: boolean; browser?: boolean; mode?: AnthropicAuthMode }): AnthropicAuthMode {
|
||
if (opts.mode) {
|
||
if (opts.token && opts.mode !== 'token') {
|
||
throw new Error('Conflicting options: --token implies --mode token.');
|
||
}
|
||
if (opts.browser && opts.mode !== 'browser') {
|
||
throw new Error('Conflicting options: --browser implies --mode browser.');
|
||
}
|
||
return opts.mode;
|
||
}
|
||
if (opts.token && opts.browser) {
|
||
throw new Error('Conflicting options: --token and --browser cannot be used together.');
|
||
}
|
||
if (opts.browser) return 'browser';
|
||
if (opts.token) return 'token';
|
||
return 'api';
|
||
}
|
||
```
|
||
|
||
Add `--browser` option to the command:
|
||
```typescript
|
||
.option('--browser', 'Obtain auth token via browser OAuth flow (Claude Pro/Max)')
|
||
```
|
||
|
||
Add `browser` mode handler in the action:
|
||
```typescript
|
||
if (mode === 'browser') {
|
||
if (loadStoredAnthropicAuthToken()) {
|
||
console.log('Anthropic auth token already exists.');
|
||
const confirmed = await promptYesNo('Re-authenticate and replace it? (y/N): ');
|
||
if (!confirmed) {
|
||
console.log('Cancelled.');
|
||
process.exit(0);
|
||
}
|
||
}
|
||
|
||
console.log('Starting Anthropic browser OAuth...');
|
||
try {
|
||
await loginAnthropicOAuth((url) => {
|
||
openBrowser(url);
|
||
console.log(`If browser didn't open, visit:\n${url}`);
|
||
console.log('Waiting for authentication...');
|
||
});
|
||
console.log('');
|
||
console.log('Anthropic auth token stored in ~/.config/flynn/auth.json');
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
console.error(`Anthropic browser OAuth failed: ${message}`);
|
||
process.exit(1);
|
||
}
|
||
return;
|
||
}
|
||
```
|
||
|
||
Also check `src/auth/index.ts` — if `loginAnthropicOAuth` and `openBrowser` aren't re-exported there, add them.
|
||
|
||
**Step 4: Run tests**
|
||
|
||
```bash
|
||
cd /home/will/lab/flynn && pnpm test:run src/cli/anthropic-auth.test.ts
|
||
```
|
||
|
||
Expected: all PASS.
|
||
|
||
**Step 5: Run full suite and typecheck**
|
||
|
||
```bash
|
||
cd /home/will/lab/flynn && pnpm test:run && pnpm typecheck
|
||
```
|
||
|
||
**Step 6: Commit**
|
||
|
||
```bash
|
||
cd /home/will/lab/flynn && git add src/cli/anthropic-auth.ts src/cli/anthropic-auth.test.ts src/auth/index.ts && git commit -m "feat(cli): add --browser flag to anthropic-auth command"
|
||
```
|
||
|
||
---
|
||
|
||
### Verification
|
||
|
||
```bash
|
||
pnpm test:run # all pass
|
||
pnpm typecheck # no errors
|
||
pnpm lint # no new errors
|
||
```
|
||
|
||
Manual smoke test:
|
||
1. `pnpm tui` → `/login anthropic` → choose `2` → browser opens to `claude.ai/oauth/authorize`
|
||
2. `flynn anthropic-auth --browser` → same flow from CLI
|
||
3. `flynn anthropic-auth --browser --token` → error: conflicting options
|