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 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-02-27 11:30:50 -08:00
parent 7988d662e8
commit c2c9b2af66
3 changed files with 64 additions and 14 deletions
+18 -3
View File
@@ -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<void> {
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<DeviceCodeResponse> {
* 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<string> {
export async function pollForToken(deviceCode: string, interval: number, signal?: AbortSignal): Promise<string> {
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<string> {
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;
}
+18 -3
View File
@@ -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<void> {
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<DeviceAuthResponse> {
return response.json() as Promise<DeviceAuthResponse>;
}
async function pollDeviceToken(deviceAuthId: string, userCode: string, intervalMs: number): Promise<DeviceTokenResponse> {
async function pollDeviceToken(deviceAuthId: string, userCode: string, intervalMs: number, signal?: AbortSignal): Promise<DeviceTokenResponse> {
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<OpenAIOAuthInfo> {
*/
export async function loginOpenAI(
onPrompt: (userCode: string, verificationUri: string) => void,
signal?: AbortSignal,
): Promise<OpenAIOAuthInfo> {
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;
+28 -8
View File
@@ -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