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:
+18
-3
@@ -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 TOKEN_URL = 'https://github.com/login/oauth/access_token';
|
||||||
const POLLING_SAFETY_MARGIN_MS = 3000;
|
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_DIR = resolve(homedir(), '.config/flynn');
|
||||||
const AUTH_FILE = resolve(AUTH_DIR, 'auth.json');
|
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.
|
* Poll GitHub for an access token after the user has entered the device code.
|
||||||
* Blocks until the user authorizes or the code expires.
|
* 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;
|
let currentInterval = interval;
|
||||||
|
|
||||||
while (true) {
|
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, {
|
const response = await fetch(TOKEN_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -153,12 +167,13 @@ export function getGitHubToken(): string | null {
|
|||||||
*/
|
*/
|
||||||
export async function loginGitHub(
|
export async function loginGitHub(
|
||||||
onPrompt: (userCode: string, verificationUri: string) => void,
|
onPrompt: (userCode: string, verificationUri: string) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const deviceCode = await requestDeviceCode();
|
const deviceCode = await requestDeviceCode();
|
||||||
|
|
||||||
onPrompt(deviceCode.user_code, deviceCode.verification_uri);
|
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);
|
storeToken(token);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-3
@@ -12,6 +12,20 @@ const TOKEN_URL = `${ISSUER}/oauth/token`;
|
|||||||
const POLLING_SAFETY_MARGIN_MS = 3000;
|
const POLLING_SAFETY_MARGIN_MS = 3000;
|
||||||
const REFRESH_SAFETY_MARGIN_MS = 30_000;
|
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_DIR = resolve(homedir(), '.config/flynn');
|
||||||
const AUTH_FILE = resolve(AUTH_DIR, 'auth.json');
|
const AUTH_FILE = resolve(AUTH_DIR, 'auth.json');
|
||||||
|
|
||||||
@@ -256,9 +270,9 @@ async function requestDeviceAuth(): Promise<DeviceAuthResponse> {
|
|||||||
return response.json() as 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) {
|
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, {
|
const response = await fetch(DEVICE_TOKEN_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -365,13 +379,14 @@ export async function ensureValidOpenAIAuth(): Promise<OpenAIOAuthInfo> {
|
|||||||
*/
|
*/
|
||||||
export async function loginOpenAI(
|
export async function loginOpenAI(
|
||||||
onPrompt: (userCode: string, verificationUri: string) => void,
|
onPrompt: (userCode: string, verificationUri: string) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<OpenAIOAuthInfo> {
|
): Promise<OpenAIOAuthInfo> {
|
||||||
const device = await requestDeviceAuth();
|
const device = await requestDeviceAuth();
|
||||||
const intervalMs = Math.max(parseInt(device.interval) || 5, 1) * 1000;
|
const intervalMs = Math.max(parseInt(device.interval) || 5, 1) * 1000;
|
||||||
|
|
||||||
onPrompt(device.user_code, DEVICE_URL);
|
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 tokens = await exchangeAuthorizationCode(deviceToken.authorization_code, deviceToken.code_verifier);
|
||||||
|
|
||||||
const expiresAt = Date.now() + (tokens.expires_in ?? 3600) * 1000;
|
const expiresAt = Date.now() + (tokens.expires_in ?? 3600) * 1000;
|
||||||
|
|||||||
@@ -1142,19 +1142,29 @@ export class MinimalTui {
|
|||||||
if (target === 'github') {
|
if (target === 'github') {
|
||||||
console.log(`${colors.gray}Starting GitHub OAuth device flow...${colors.reset}`);
|
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 {
|
try {
|
||||||
await loginGitHub((userCode, verificationUri) => {
|
await loginGitHub((userCode, verificationUri) => {
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(`${colors.gray}Please visit:${colors.reset} ${verificationUri}`);
|
console.log(`${colors.gray}Please visit:${colors.reset} ${verificationUri}`);
|
||||||
console.log(`${colors.gray}and enter code:${colors.reset} ${userCode}`);
|
console.log(`${colors.gray}and enter code:${colors.reset} ${userCode}`);
|
||||||
console.log('');
|
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`);
|
console.log(`${colors.gray}GitHub authentication successful! Token stored.${colors.reset}\n`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
if (controller.signal.aborted) {
|
||||||
console.log(`${colors.gray}GitHub login failed:${colors.reset} ${message}\n`);
|
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;
|
return;
|
||||||
@@ -1222,6 +1232,10 @@ export class MinimalTui {
|
|||||||
|
|
||||||
console.log(`${colors.gray}Starting OpenAI OAuth device flow...${colors.reset}`);
|
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;
|
let credentialStored = false;
|
||||||
try {
|
try {
|
||||||
await loginOpenAI((userCode, verificationUri) => {
|
await loginOpenAI((userCode, verificationUri) => {
|
||||||
@@ -1229,14 +1243,20 @@ export class MinimalTui {
|
|||||||
console.log(`${colors.gray}Please visit:${colors.reset} ${verificationUri}`);
|
console.log(`${colors.gray}Please visit:${colors.reset} ${verificationUri}`);
|
||||||
console.log(`${colors.gray}and enter code:${colors.reset} ${userCode}`);
|
console.log(`${colors.gray}and enter code:${colors.reset} ${userCode}`);
|
||||||
console.log('');
|
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`);
|
console.log(`${colors.gray}OpenAI authentication successful! Token stored.${colors.reset}\n`);
|
||||||
credentialStored = true;
|
credentialStored = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
if (controller.signal.aborted) {
|
||||||
console.log(`${colors.gray}OpenAI login failed:${colors.reset} ${message}\n`);
|
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
|
// Offer to set auth_mode if config is available
|
||||||
|
|||||||
Reference in New Issue
Block a user