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
+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;