feat(auth): add PKCE helpers and OAuth callback server for Anthropic

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-02-26 10:32:13 -08:00
parent 6e63e00b84
commit 82f09422d6
2 changed files with 151 additions and 0 deletions
+68
View File
@@ -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;
});
});
+83
View File
@@ -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<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);
});
}
export interface AnthropicAuthInfo {
/** Anthropic API key. */
api_key?: string;