a00451a690
Add AbortSignal support to startCallbackServer and loginAnthropicOAuth so that pressing Ctrl+C during the browser OAuth flow immediately closes the HTTP server and 5-minute timer instead of leaving the process hung. Wire up an AbortController in the TUI browser OAuth path so the cancel callback aborts the signal on Ctrl+C. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
297 lines
9.2 KiB
TypeScript
297 lines
9.2 KiB
TypeScript
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, or if signal is aborted.
|
|
*/
|
|
export function startCallbackServer(timeoutMs: number, signal?: AbortSignal): 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);
|
|
|
|
// Handle cancellation via AbortSignal
|
|
if (signal) {
|
|
if (signal.aborted) {
|
|
clearTimeout(timer);
|
|
try { server.close(); } catch { /* already closed */ }
|
|
rejectServer(new Error('Anthropic OAuth was cancelled.'));
|
|
return;
|
|
}
|
|
signal.addEventListener('abort', () => {
|
|
clearTimeout(timer);
|
|
try { server.close(); } catch { /* already closed */ }
|
|
rejectCb(new Error('Anthropic OAuth was cancelled.'));
|
|
}, { once: true });
|
|
}
|
|
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const port = (server.address() as AddressInfo).port;
|
|
resolveServer({ port, waitForCode });
|
|
});
|
|
|
|
server.on('error', rejectServer);
|
|
});
|
|
}
|
|
|
|
/** 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,
|
|
signal?: AbortSignal,
|
|
_startServer: (timeoutMs: number, signal?: AbortSignal) => Promise<CallbackServer> = startCallbackServer,
|
|
): Promise<string> {
|
|
const codeVerifier = generateCodeVerifier();
|
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
const state = randomBytes(16).toString('hex');
|
|
|
|
const { port, waitForCode } = await _startServer(OAUTH_TIMEOUT_MS, signal);
|
|
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;
|
|
}
|
|
|
|
export interface AnthropicAuthInfo {
|
|
/** Anthropic API key. */
|
|
api_key?: string;
|
|
/** Anthropic auth token (OAuth/token mode). */
|
|
auth_token?: string;
|
|
created_at: string;
|
|
}
|
|
|
|
interface AuthStore {
|
|
anthropic?: AnthropicAuthInfo;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
function safeJsonParse<T>(raw: string): T | null {
|
|
try {
|
|
return JSON.parse(raw) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readAuthStore(): AuthStore {
|
|
try {
|
|
const raw = readFileSync(AUTH_FILE, 'utf-8');
|
|
const parsed = safeJsonParse<AuthStore>(raw);
|
|
return parsed ?? {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function writeAuthStore(store: AuthStore): void {
|
|
mkdirSync(AUTH_DIR, { recursive: true });
|
|
writeFileSync(AUTH_FILE, JSON.stringify(store, null, 2) + '\n', 'utf-8');
|
|
chmodSync(AUTH_FILE, 0o600);
|
|
}
|
|
|
|
export function loadStoredAnthropicAuth(): AnthropicAuthInfo | null {
|
|
const store = readAuthStore();
|
|
return store.anthropic ?? null;
|
|
}
|
|
|
|
export function storeAnthropicAuth(apiKey: string): void {
|
|
const trimmed = apiKey.trim();
|
|
if (!trimmed) {
|
|
throw new Error('Anthropic API key is empty');
|
|
}
|
|
const store = readAuthStore();
|
|
store.anthropic = { ...store.anthropic, api_key: trimmed, created_at: new Date().toISOString() };
|
|
writeAuthStore(store);
|
|
}
|
|
|
|
export function loadStoredAnthropicAuthToken(): string | null {
|
|
return loadStoredAnthropicAuth()?.auth_token ?? null;
|
|
}
|
|
|
|
export function storeAnthropicAuthToken(token: string): void {
|
|
const trimmed = token.trim();
|
|
if (!trimmed) {
|
|
throw new Error('Anthropic auth token is empty');
|
|
}
|
|
const store = readAuthStore();
|
|
store.anthropic = { ...store.anthropic, auth_token: trimmed, created_at: new Date().toISOString() };
|
|
writeAuthStore(store);
|
|
}
|
|
|
|
export function clearAnthropicAuth(): void {
|
|
const store = readAuthStore();
|
|
delete store.anthropic;
|
|
writeAuthStore(store);
|
|
}
|
|
|
|
export function clearAnthropicAuthToken(): void {
|
|
const store = readAuthStore();
|
|
if (!store.anthropic) {
|
|
writeAuthStore(store);
|
|
return;
|
|
}
|
|
delete store.anthropic.auth_token;
|
|
if (!store.anthropic.api_key && !store.anthropic.auth_token) {
|
|
delete store.anthropic;
|
|
}
|
|
writeAuthStore(store);
|
|
}
|
|
|
|
/**
|
|
* Get an Anthropic API key from any available source.
|
|
* Priority: ANTHROPIC_API_KEY → stored auth.json.
|
|
*/
|
|
export function getAnthropicApiKey(): string | null {
|
|
return process.env.ANTHROPIC_API_KEY
|
|
?? loadStoredAnthropicAuth()?.api_key
|
|
?? null;
|
|
}
|
|
|
|
/**
|
|
* Get an Anthropic auth token from any available source.
|
|
* Priority: ANTHROPIC_AUTH_TOKEN → stored auth.json.
|
|
*/
|
|
export function getAnthropicAuthToken(): string | null {
|
|
return process.env.ANTHROPIC_AUTH_TOKEN
|
|
?? loadStoredAnthropicAuth()?.auth_token
|
|
?? null;
|
|
}
|