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>
This commit is contained in:
William Valentin
2026-02-26 11:51:27 -08:00
parent dfc7fbe3b9
commit a00451a690
3 changed files with 37 additions and 7 deletions
+11 -2
View File
@@ -132,6 +132,15 @@ describe('startCallbackServer', () => {
await expect(waitForCode).rejects.toThrow(/timed out/i); 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 () => { it('returns 404 for non-callback paths', async () => {
vi.resetModules(); vi.resetModules();
const { startCallbackServer } = await import('./anthropic.js'); const { startCallbackServer } = await import('./anthropic.js');
@@ -193,7 +202,7 @@ describe('loginAnthropicOAuth and exchangeCodeForToken', () => {
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
const state = parsedUrl.searchParams.get('state')!; const state = parsedUrl.searchParams.get('state')!;
resolveCb({ code: 'auth-code-123', state }); resolveCb({ code: 'auth-code-123', state });
}, mockStartServer); }, undefined, mockStartServer);
const token = await flowPromise; const token = await flowPromise;
@@ -225,7 +234,7 @@ describe('loginAnthropicOAuth and exchangeCodeForToken', () => {
}); });
await expect( await expect(
loginAnthropicOAuth(() => undefined, mockStartServer), loginAnthropicOAuth(() => undefined, undefined, mockStartServer),
).rejects.toThrow('state mismatch'); ).rejects.toThrow('state mismatch');
}); });
+20 -4
View File
@@ -35,9 +35,9 @@ export interface CallbackServer {
* Start a one-shot HTTP server on a random port bound to 127.0.0.1. * Start a one-shot HTTP server on a random port bound to 127.0.0.1.
* Returns { port, waitForCode } immediately. * Returns { port, waitForCode } immediately.
* waitForCode resolves when the browser hits /callback?code=...&state=... * 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<CallbackServer> { export function startCallbackServer(timeoutMs: number, signal?: AbortSignal): Promise<CallbackServer> {
return new Promise((resolveServer, rejectServer) => { return new Promise((resolveServer, rejectServer) => {
let resolveCb!: (v: { code: string; state: string }) => void; let resolveCb!: (v: { code: string; state: string }) => void;
let rejectCb!: (e: Error) => void; let rejectCb!: (e: Error) => void;
@@ -79,6 +79,21 @@ export function startCallbackServer(timeoutMs: number): Promise<CallbackServer>
rejectCb(new Error('Anthropic OAuth timed out — browser flow was not completed.')); rejectCb(new Error('Anthropic OAuth timed out — browser flow was not completed.'));
}, timeoutMs); }, 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', () => { server.listen(0, '127.0.0.1', () => {
const port = (server.address() as AddressInfo).port; const port = (server.address() as AddressInfo).port;
resolveServer({ port, waitForCode }); resolveServer({ port, waitForCode });
@@ -143,13 +158,14 @@ export async function exchangeCodeForToken(
*/ */
export async function loginAnthropicOAuth( export async function loginAnthropicOAuth(
onOpen: (url: string) => void, onOpen: (url: string) => void,
_startServer: typeof startCallbackServer = startCallbackServer, signal?: AbortSignal,
_startServer: (timeoutMs: number, signal?: AbortSignal) => Promise<CallbackServer> = startCallbackServer,
): Promise<string> { ): Promise<string> {
const codeVerifier = generateCodeVerifier(); const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier); const codeChallenge = generateCodeChallenge(codeVerifier);
const state = randomBytes(16).toString('hex'); 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 redirectUri = `http://127.0.0.1:${port}/callback`;
const authUrl = new URL(ANTHROPIC_AUTH_URL); const authUrl = new URL(ANTHROPIC_AUTH_URL);
+6 -1
View File
@@ -1275,6 +1275,9 @@ export class MinimalTui {
console.log(`${colors.gray}Starting Anthropic browser OAuth...${colors.reset}`); console.log(`${colors.gray}Starting Anthropic browser OAuth...${colors.reset}`);
const abortController = new AbortController();
this.activeOperationCancel = () => abortController.abort();
let credentialStored = false; let credentialStored = false;
try { try {
await loginAnthropicOAuth((url) => { 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(`${colors.gray}Opening browser. If it didn't open, visit:${colors.reset}`);
console.log(url); console.log(url);
console.log(`${colors.gray}Waiting for authentication (up to 5 minutes)...${colors.reset}`); 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`); console.log(`${colors.gray}Anthropic auth token stored in ~/.config/flynn/auth.json${colors.reset}\n`);
credentialStored = true; credentialStored = true;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
console.log(`${colors.gray}Anthropic OAuth failed:${colors.reset} ${message}\n`); console.log(`${colors.gray}Anthropic OAuth failed:${colors.reset} ${message}\n`);
} finally {
this.activeOperationCancel = null;
} }
// Offer to set auth_mode if config is available // Offer to set auth_mode if config is available