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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user