Files
flynn/src/auth/anthropic.test.ts
T
William Valentin a00451a690 fix(auth): cancel OAuth callback server when flow is aborted
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>
2026-02-26 11:51:27 -08:00

276 lines
9.4 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, statSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
describe('auth/anthropic', () => {
const originalHome = process.env.HOME;
const originalEnvKey = process.env.ANTHROPIC_API_KEY;
const originalEnvToken = process.env.ANTHROPIC_AUTH_TOKEN;
let homeDir: string;
beforeEach(() => {
homeDir = mkdtempSync(join(tmpdir(), 'flynn-auth-anthropic-'));
process.env.HOME = homeDir;
delete process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_AUTH_TOKEN;
vi.resetModules();
});
afterEach(() => {
process.env.HOME = originalHome;
if (originalEnvKey) {
process.env.ANTHROPIC_API_KEY = originalEnvKey;
} else {
delete process.env.ANTHROPIC_API_KEY;
}
if (originalEnvToken) {
process.env.ANTHROPIC_AUTH_TOKEN = originalEnvToken;
} else {
delete process.env.ANTHROPIC_AUTH_TOKEN;
}
});
it('stores, loads, and clears Anthropic API key', async () => {
const mod = await import('./anthropic.js');
expect(mod.loadStoredAnthropicAuth()).toBeNull();
mod.storeAnthropicAuth('sk-test');
expect(mod.loadStoredAnthropicAuth()?.api_key).toBe('sk-test');
const authFile = join(homeDir, '.config/flynn/auth.json');
const mode = statSync(authFile).mode & 0o777;
expect(mode).toBe(0o600);
mod.clearAnthropicAuth();
expect(mod.loadStoredAnthropicAuth()).toBeNull();
});
it('stores, loads, and clears Anthropic auth token', async () => {
const mod = await import('./anthropic.js');
expect(mod.loadStoredAnthropicAuthToken()).toBeNull();
mod.storeAnthropicAuthToken('tok-test');
expect(mod.loadStoredAnthropicAuthToken()).toBe('tok-test');
const authFile = join(homeDir, '.config/flynn/auth.json');
const mode = statSync(authFile).mode & 0o777;
expect(mode).toBe(0o600);
mod.clearAnthropicAuthToken();
expect(mod.loadStoredAnthropicAuthToken()).toBeNull();
});
it('getAnthropicApiKey prefers environment variable', async () => {
process.env.ANTHROPIC_API_KEY = 'sk-env';
const mod = await import('./anthropic.js');
expect(mod.getAnthropicApiKey()).toBe('sk-env');
});
it('getAnthropicAuthToken prefers environment variable', async () => {
process.env.ANTHROPIC_AUTH_TOKEN = 'tok-env';
const mod = await import('./anthropic.js');
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('startCallbackServer rejects when signal is aborted', async () => {
vi.resetModules();
const { startCallbackServer } = await import('./anthropic.js');
const controller = new AbortController();
const { waitForCode } = await startCallbackServer(5000, controller.signal);
controller.abort();
await expect(waitForCode).rejects.toThrow(/cancelled/);
});
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;
});
});
describe('loginAnthropicOAuth and exchangeCodeForToken', () => {
const originalHome = process.env.HOME;
let homeDir: string;
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
homeDir = mkdtempSync(join(tmpdir(), 'flynn-auth-oauth-'));
process.env.HOME = homeDir;
mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
vi.resetModules();
});
afterEach(() => {
process.env.HOME = originalHome;
vi.unstubAllGlobals();
});
it('loginAnthropicOAuth builds correct auth URL and exchanges code for token', async () => {
const fakeToken = 'tok-from-oauth';
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ access_token: fakeToken }),
});
const { loginAnthropicOAuth } = await import('./anthropic.js');
let capturedAuthUrl = '';
let resolveCb!: (v: { code: string; state: string }) => void;
// Custom _startServer that gives us control over waitForCode
const mockStartServer = async (_timeoutMs: number) => {
return {
port: 9999,
waitForCode: new Promise<{ code: string; state: string }>((res) => {
resolveCb = res;
}),
};
};
// Start the flow — onOpen fires after _startServer resolves and URL is built
const flowPromise = loginAnthropicOAuth((url) => {
capturedAuthUrl = url;
// Extract state from the URL and resolve waitForCode with matching state
const parsedUrl = new URL(url);
const state = parsedUrl.searchParams.get('state')!;
resolveCb({ code: 'auth-code-123', state });
}, undefined, mockStartServer);
const token = await flowPromise;
// Auth URL assertions
const authUrl = new URL(capturedAuthUrl);
expect(authUrl.hostname).toBe('claude.ai');
expect(authUrl.searchParams.get('client_id')).toBe('9d1c250a-e61b-44d9-88ed-5944d1962f5e');
expect(authUrl.searchParams.get('code_challenge_method')).toBe('S256');
expect(authUrl.searchParams.get('response_type')).toBe('code');
expect(authUrl.searchParams.get('scope')).toContain('user:inference');
expect(authUrl.searchParams.get('state')).toBeTruthy();
// Token stored and returned
expect(token).toBe(fakeToken);
// Verify token exchange fetch
expect(mockFetch).toHaveBeenCalledWith(
'https://console.anthropic.com/v1/oauth/token',
expect.objectContaining({ method: 'POST' }),
);
});
it('loginAnthropicOAuth throws on state mismatch', async () => {
const { loginAnthropicOAuth } = await import('./anthropic.js');
const mockStartServer = async (_timeoutMs: number) => ({
port: 9999,
waitForCode: Promise.resolve({ code: 'code', state: 'DEFINITELY-WRONG-STATE' }),
});
await expect(
loginAnthropicOAuth(() => undefined, undefined, mockStartServer),
).rejects.toThrow('state mismatch');
});
it('exchangeCodeForToken throws subscription message on 403', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 403,
text: async () => 'Forbidden',
});
const { exchangeCodeForToken } = await import('./anthropic.js');
await expect(
exchangeCodeForToken('code', 'verifier', 'http://127.0.0.1:1234/callback'),
).rejects.toThrow(/Pro\/Max subscription/);
});
it('exchangeCodeForToken throws with status on 500', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 500,
text: async () => 'Server error',
});
const { exchangeCodeForToken } = await import('./anthropic.js');
await expect(
exchangeCodeForToken('code', 'verifier', 'http://127.0.0.1:1234/callback'),
).rejects.toThrow(/500/);
});
it('exchangeCodeForToken throws when no access_token in response', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ something_else: 'x' }),
});
const { exchangeCodeForToken } = await import('./anthropic.js');
await expect(
exchangeCodeForToken('code', 'verifier', 'http://127.0.0.1:1234/callback'),
).rejects.toThrow(/no access_token/);
});
});