diff --git a/src/auth/anthropic.test.ts b/src/auth/anthropic.test.ts index 331e8b4..995731c 100644 --- a/src/auth/anthropic.test.ts +++ b/src/auth/anthropic.test.ts @@ -132,6 +132,15 @@ describe('startCallbackServer', () => { 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'); @@ -193,7 +202,7 @@ describe('loginAnthropicOAuth and exchangeCodeForToken', () => { const parsedUrl = new URL(url); const state = parsedUrl.searchParams.get('state')!; resolveCb({ code: 'auth-code-123', state }); - }, mockStartServer); + }, undefined, mockStartServer); const token = await flowPromise; @@ -225,7 +234,7 @@ describe('loginAnthropicOAuth and exchangeCodeForToken', () => { }); await expect( - loginAnthropicOAuth(() => undefined, mockStartServer), + loginAnthropicOAuth(() => undefined, undefined, mockStartServer), ).rejects.toThrow('state mismatch'); }); diff --git a/src/auth/anthropic.ts b/src/auth/anthropic.ts index 253c84c..ea13676 100644 --- a/src/auth/anthropic.ts +++ b/src/auth/anthropic.ts @@ -35,9 +35,9 @@ export interface CallbackServer { * 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. + * or rejects if timeoutMs elapses first, or if signal is aborted. */ -export function startCallbackServer(timeoutMs: number): Promise { +export function startCallbackServer(timeoutMs: number, signal?: AbortSignal): Promise { return new Promise((resolveServer, rejectServer) => { let resolveCb!: (v: { code: string; state: string }) => void; let rejectCb!: (e: Error) => void; @@ -79,6 +79,21 @@ export function startCallbackServer(timeoutMs: number): Promise rejectCb(new Error('Anthropic OAuth timed out — browser flow was not completed.')); }, timeoutMs); + // Handle cancellation via AbortSignal + if (signal) { + if (signal.aborted) { + clearTimeout(timer); + try { server.close(); } catch { /* already closed */ } + rejectServer(new Error('Anthropic OAuth was cancelled.')); + return; + } + signal.addEventListener('abort', () => { + clearTimeout(timer); + try { server.close(); } catch { /* already closed */ } + rejectCb(new Error('Anthropic OAuth was cancelled.')); + }, { once: true }); + } + server.listen(0, '127.0.0.1', () => { const port = (server.address() as AddressInfo).port; resolveServer({ port, waitForCode }); @@ -143,13 +158,14 @@ export async function exchangeCodeForToken( */ export async function loginAnthropicOAuth( onOpen: (url: string) => void, - _startServer: typeof startCallbackServer = startCallbackServer, + signal?: AbortSignal, + _startServer: (timeoutMs: number, signal?: AbortSignal) => Promise = startCallbackServer, ): Promise { const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); const state = randomBytes(16).toString('hex'); - const { port, waitForCode } = await _startServer(OAUTH_TIMEOUT_MS); + const { port, waitForCode } = await _startServer(OAUTH_TIMEOUT_MS, signal); const redirectUri = `http://127.0.0.1:${port}/callback`; const authUrl = new URL(ANTHROPIC_AUTH_URL); diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index c6f7942..b131a8b 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -1275,6 +1275,9 @@ export class MinimalTui { console.log(`${colors.gray}Starting Anthropic browser OAuth...${colors.reset}`); + const abortController = new AbortController(); + this.activeOperationCancel = () => abortController.abort(); + let credentialStored = false; try { await loginAnthropicOAuth((url) => { @@ -1282,12 +1285,14 @@ export class MinimalTui { console.log(`${colors.gray}Opening browser. If it didn't open, visit:${colors.reset}`); console.log(url); console.log(`${colors.gray}Waiting for authentication (up to 5 minutes)...${colors.reset}`); - }); + }, abortController.signal); console.log(`${colors.gray}Anthropic auth token stored in ~/.config/flynn/auth.json${colors.reset}\n`); credentialStored = true; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.log(`${colors.gray}Anthropic OAuth failed:${colors.reset} ${message}\n`); + } finally { + this.activeOperationCancel = null; } // Offer to set auth_mode if config is available