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>
276 lines
9.4 KiB
TypeScript
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/);
|
|
});
|
|
});
|