From c2c9b2af66f064b382019a78502c2eb0dfe9efa1 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Fri, 27 Feb 2026 11:30:50 -0800 Subject: [PATCH] fix(auth): make OAuth device flow polling cancellable via Ctrl+C Add AbortSignal support to pollForToken (GitHub) and pollDeviceToken (OpenAI) using an abortable sleep that clears its timer immediately on abort. Wire an AbortController into the TUI login handlers, triggered by the readline SIGINT event, so Ctrl+C exits the wait loop cleanly instead of hanging until the device code expires. Co-Authored-By: Claude Sonnet 4.6 --- src/auth/github.ts | 21 ++++++++++++++++++--- src/auth/openai.ts | 21 ++++++++++++++++++--- src/frontends/tui/minimal.ts | 36 ++++++++++++++++++++++++++++-------- 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/auth/github.ts b/src/auth/github.ts index 7e6528b..c14d47e 100644 --- a/src/auth/github.ts +++ b/src/auth/github.ts @@ -7,6 +7,20 @@ const DEVICE_CODE_URL = 'https://github.com/login/device/code'; const TOKEN_URL = 'https://github.com/login/oauth/access_token'; const POLLING_SAFETY_MARGIN_MS = 3000; +function abortableSleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error('Cancelled')); + return; + } + const timer = setTimeout(resolve, ms); + signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(new Error('Cancelled')); + }, { once: true }); + }); +} + const AUTH_DIR = resolve(homedir(), '.config/flynn'); const AUTH_FILE = resolve(AUTH_DIR, 'auth.json'); @@ -49,11 +63,11 @@ export async function requestDeviceCode(): Promise { * Poll GitHub for an access token after the user has entered the device code. * Blocks until the user authorizes or the code expires. */ -export async function pollForToken(deviceCode: string, interval: number): Promise { +export async function pollForToken(deviceCode: string, interval: number, signal?: AbortSignal): Promise { let currentInterval = interval; while (true) { - await new Promise(r => setTimeout(r, currentInterval * 1000 + POLLING_SAFETY_MARGIN_MS)); + await abortableSleep(currentInterval * 1000 + POLLING_SAFETY_MARGIN_MS, signal); const response = await fetch(TOKEN_URL, { method: 'POST', @@ -153,12 +167,13 @@ export function getGitHubToken(): string | null { */ export async function loginGitHub( onPrompt: (userCode: string, verificationUri: string) => void, + signal?: AbortSignal, ): Promise { const deviceCode = await requestDeviceCode(); onPrompt(deviceCode.user_code, deviceCode.verification_uri); - const token = await pollForToken(deviceCode.device_code, deviceCode.interval); + const token = await pollForToken(deviceCode.device_code, deviceCode.interval, signal); storeToken(token); return token; } diff --git a/src/auth/openai.ts b/src/auth/openai.ts index f403d2c..9e480ef 100644 --- a/src/auth/openai.ts +++ b/src/auth/openai.ts @@ -12,6 +12,20 @@ const TOKEN_URL = `${ISSUER}/oauth/token`; const POLLING_SAFETY_MARGIN_MS = 3000; const REFRESH_SAFETY_MARGIN_MS = 30_000; +function abortableSleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error('Cancelled')); + return; + } + const timer = setTimeout(resolve, ms); + signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(new Error('Cancelled')); + }, { once: true }); + }); +} + const AUTH_DIR = resolve(homedir(), '.config/flynn'); const AUTH_FILE = resolve(AUTH_DIR, 'auth.json'); @@ -256,9 +270,9 @@ async function requestDeviceAuth(): Promise { return response.json() as Promise; } -async function pollDeviceToken(deviceAuthId: string, userCode: string, intervalMs: number): Promise { +async function pollDeviceToken(deviceAuthId: string, userCode: string, intervalMs: number, signal?: AbortSignal): Promise { while (true) { - await new Promise(r => setTimeout(r, intervalMs + POLLING_SAFETY_MARGIN_MS)); + await abortableSleep(intervalMs + POLLING_SAFETY_MARGIN_MS, signal); const response = await fetch(DEVICE_TOKEN_URL, { method: 'POST', @@ -365,13 +379,14 @@ export async function ensureValidOpenAIAuth(): Promise { */ export async function loginOpenAI( onPrompt: (userCode: string, verificationUri: string) => void, + signal?: AbortSignal, ): Promise { const device = await requestDeviceAuth(); const intervalMs = Math.max(parseInt(device.interval) || 5, 1) * 1000; onPrompt(device.user_code, DEVICE_URL); - const deviceToken = await pollDeviceToken(device.device_auth_id, device.user_code, intervalMs); + const deviceToken = await pollDeviceToken(device.device_auth_id, device.user_code, intervalMs, signal); const tokens = await exchangeAuthorizationCode(deviceToken.authorization_code, deviceToken.code_verifier); const expiresAt = Date.now() + (tokens.expires_in ?? 3600) * 1000; diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index b131a8b..79d985c 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -1142,19 +1142,29 @@ export class MinimalTui { if (target === 'github') { console.log(`${colors.gray}Starting GitHub OAuth device flow...${colors.reset}`); + const controller = new AbortController(); + const onSigint = () => controller.abort(); + this.rl?.once('SIGINT', onSigint); + try { await loginGitHub((userCode, verificationUri) => { console.log(''); console.log(`${colors.gray}Please visit:${colors.reset} ${verificationUri}`); console.log(`${colors.gray}and enter code:${colors.reset} ${userCode}`); console.log(''); - console.log(`${colors.gray}Waiting for authorization...${colors.reset}`); - }); + console.log(`${colors.gray}Waiting for authorization... (Ctrl+C to cancel)${colors.reset}`); + }, controller.signal); console.log(`${colors.gray}GitHub authentication successful! Token stored.${colors.reset}\n`); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(`${colors.gray}GitHub login failed:${colors.reset} ${message}\n`); + if (controller.signal.aborted) { + console.log(`${colors.gray}GitHub login cancelled.${colors.reset}\n`); + } else { + const message = error instanceof Error ? error.message : String(error); + console.log(`${colors.gray}GitHub login failed:${colors.reset} ${message}\n`); + } + } finally { + this.rl?.removeListener('SIGINT', onSigint); } return; @@ -1222,6 +1232,10 @@ export class MinimalTui { console.log(`${colors.gray}Starting OpenAI OAuth device flow...${colors.reset}`); + const controller = new AbortController(); + const onSigint = () => controller.abort(); + this.rl?.once('SIGINT', onSigint); + let credentialStored = false; try { await loginOpenAI((userCode, verificationUri) => { @@ -1229,14 +1243,20 @@ export class MinimalTui { console.log(`${colors.gray}Please visit:${colors.reset} ${verificationUri}`); console.log(`${colors.gray}and enter code:${colors.reset} ${userCode}`); console.log(''); - console.log(`${colors.gray}Waiting for authorization...${colors.reset}`); - }); + console.log(`${colors.gray}Waiting for authorization... (Ctrl+C to cancel)${colors.reset}`); + }, controller.signal); console.log(`${colors.gray}OpenAI authentication successful! Token stored.${colors.reset}\n`); credentialStored = true; } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(`${colors.gray}OpenAI login failed:${colors.reset} ${message}\n`); + if (controller.signal.aborted) { + console.log(`${colors.gray}OpenAI login cancelled.${colors.reset}\n`); + } else { + const message = error instanceof Error ? error.message : String(error); + console.log(`${colors.gray}OpenAI login failed:${colors.reset} ${message}\n`); + } + } finally { + this.rl?.removeListener('SIGINT', onSigint); } // Offer to set auth_mode if config is available