feat(auth): implement Anthropic OAuth PKCE browser flow
Add openBrowser, exchangeCodeForToken, and loginAnthropicOAuth to src/auth/anthropic.ts, completing the full PKCE OAuth flow. Includes 5 new tests covering happy path, state mismatch, 403 subscription error, 500 error, and missing access_token cases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -145,3 +145,122 @@ describe('startCallbackServer', () => {
|
||||
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 });
|
||||
}, 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, 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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user