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:
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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