From 22bb17d1064e3a98c3a671300a6c7cd77449d68a Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 30 Apr 2026 12:15:58 -0500 Subject: [PATCH 1/3] fix: prevent `workos auth login` from hanging indefinitely `login.ts` had its own inline device auth polling loop that lacked an AbortController on fetch requests. A hung TCP connection (proxy, captive portal, IPv6 sinkhole) would block `await fetch()` forever, defeating the 5-minute outer timeout. Refactored `runLogin` to use the shared `requestDeviceCode` and `pollForToken` from `device-auth.ts`, which already has a 30-second per-request abort timeout. Also added the same abort timeout to `requestDeviceCode` for the initial device code request. This removes ~100 lines of duplicated logic (JWT parsing, token response handling, sleep helper, endpoint construction, type definitions) that had diverged from `device-auth.ts`. --- src/commands/login.ts | 191 +++++++++-------------------------------- src/lib/device-auth.ts | 24 ++++-- 2 files changed, 57 insertions(+), 158 deletions(-) diff --git a/src/commands/login.ts b/src/commands/login.ts index 87edbd69..a64accac 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -11,66 +11,7 @@ import type { CliConfig } from '../lib/config-store.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; import { autoInstallSkills } from './install-skill.js'; import { isJsonMode } from '../utils/output.js'; - -/** - * Parse JWT payload - */ -function parseJwt(token: string): Record | null { - try { - const parts = token.split('.'); - if (parts.length !== 3) return null; - return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')); - } catch { - return null; - } -} - -/** - * Extract expiry time from JWT token - */ -function getJwtExpiry(token: string): number | null { - const payload = parseJwt(token); - if (!payload || typeof payload.exp !== 'number') return null; - return payload.exp * 1000; -} - -const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes - -/** - * Get Connect OAuth endpoints from AuthKit domain - */ -function getConnectEndpoints() { - const domain = getAuthkitDomain(); - return { - deviceAuthorization: `${domain}/oauth2/device_authorization`, - token: `${domain}/oauth2/token`, - }; -} - -interface DeviceAuthResponse { - device_code: string; - user_code: string; - verification_uri: string; - verification_uri_complete: string; - expires_in: number; - interval: number; -} - -interface ConnectTokenResponse { - access_token: string; - id_token: string; - token_type: string; - expires_in: number; - refresh_token?: string; -} - -interface AuthErrorResponse { - error: string; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +import { requestDeviceCode, pollForToken, DeviceAuthError } from '../lib/device-auth.js'; /** * Best-effort skill install after a successful auth-login. @@ -169,29 +110,19 @@ export async function runLogin(): Promise { } } - clack.log.step('Starting authentication...'); - - const endpoints = getConnectEndpoints(); + const authkitDomain = getAuthkitDomain(); - const authResponse = await fetch(endpoints.deviceAuthorization, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - client_id: clientId, - scope: 'openid email staging-environment:credentials:read offline_access', - }), - }); + clack.log.step('Starting authentication...'); - if (!authResponse.ok) { - clack.log.error(`Failed to start authentication: ${authResponse.status}`); + let deviceAuth; + try { + deviceAuth = await requestDeviceCode({ clientId, authkitDomain }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + clack.log.error(`Failed to start authentication: ${msg}`); process.exit(1); } - const deviceAuth = (await authResponse.json()) as DeviceAuthResponse; - const pollIntervalMs = (deviceAuth.interval || 5) * 1000; - clack.log.info(`\nOpen this URL in your browser:\n`); console.log(` ${deviceAuth.verification_uri}`); console.log(`\nEnter code: ${deviceAuth.user_code}\n`); @@ -206,84 +137,44 @@ export async function runLogin(): Promise { const spinner = clack.spinner(); spinner.start('Waiting for authentication...'); - const startTime = Date.now(); - let currentInterval = pollIntervalMs; - - while (Date.now() - startTime < POLL_TIMEOUT_MS) { - await sleep(currentInterval); - - try { - const tokenResponse = await fetch(endpoints.token, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - grant_type: 'urn:ietf:params:oauth:grant-type:device_code', - device_code: deviceAuth.device_code, - client_id: clientId, - }), - }); - - const data = await tokenResponse.json(); - - if (tokenResponse.ok) { - const result = data as ConnectTokenResponse; - - // Parse user info from id_token JWT - const idTokenPayload = parseJwt(result.id_token); - const userId = (idTokenPayload?.sub as string) || 'unknown'; - const email = (idTokenPayload?.email as string) || undefined; - - // Extract actual expiry from access token JWT, fallback to response or 15 min - const jwtExpiry = getJwtExpiry(result.access_token); - const expiresAt = - jwtExpiry ?? (result.expires_in ? Date.now() + result.expires_in * 1000 : Date.now() + 15 * 60 * 1000); - - const expiresInSec = Math.round((expiresAt - Date.now()) / 1000); - - saveCredentials({ - accessToken: result.access_token, - expiresAt, - userId, - email, - refreshToken: result.refresh_token, - }); + try { + const result = await pollForToken(deviceAuth.device_code, { + clientId, + authkitDomain, + interval: deviceAuth.interval, + }); - spinner.stop('Authentication successful!'); - clack.log.success(`Logged in as ${email || userId}`); - clack.log.info(`Token expires in ${expiresInSec} seconds`); + const expiresInSec = Math.round((result.expiresAt - Date.now()) / 1000); - // Auto-provision staging environment - const provisioned = await provisionStagingEnvironment(result.access_token); - if (provisioned) { - clack.log.success('Staging environment configured automatically'); - } else { - clack.log.info(chalk.dim('Run `workos env add` to configure an environment manually')); - } + saveCredentials({ + accessToken: result.accessToken, + expiresAt: result.expiresAt, + userId: result.userId, + email: result.email, + refreshToken: result.refreshToken, + }); - // Best-effort skill install. Wrapped helper guarantees login never - // fails on skill errors. - await installSkillsAfterLogin(); - return; - } + spinner.stop('Authentication successful!'); + clack.log.success(`Logged in as ${result.email || result.userId}`); + clack.log.info(`Token expires in ${expiresInSec} seconds`); - const errorData = data as AuthErrorResponse; - if (errorData.error === 'authorization_pending') continue; - if (errorData.error === 'slow_down') { - currentInterval += 5000; - continue; - } + const provisioned = await provisionStagingEnvironment(result.accessToken); + if (provisioned) { + clack.log.success('Staging environment configured automatically'); + } else { + clack.log.info(chalk.dim('Run `workos env add` to configure an environment manually')); + } + await installSkillsAfterLogin(); + } catch (error) { + if (error instanceof DeviceAuthError && error.message.includes('timed out')) { + spinner.stop('Authentication timed out'); + clack.log.error('Authentication timed out. Please try again.'); + } else { spinner.stop('Authentication failed'); - clack.log.error(`Authentication error: ${errorData.error}`); - process.exit(1); - } catch { - continue; + const msg = error instanceof Error ? error.message : String(error); + clack.log.error(`Authentication error: ${msg}`); } + process.exit(1); } - - spinner.stop('Authentication timed out'); - clack.log.error('Authentication timed out. Please try again.'); - process.exit(1); } diff --git a/src/lib/device-auth.ts b/src/lib/device-auth.ts index df296a47..4a0159d2 100644 --- a/src/lib/device-auth.ts +++ b/src/lib/device-auth.ts @@ -94,14 +94,22 @@ export async function requestDeviceCode(options: DeviceAuthOptions): Promise controller.abort(), POLL_REQUEST_TIMEOUT_MS); + let res: Response; + try { + res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: options.clientId, + scope: scopes.join(' '), + }), + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } logInfo('[device-auth] Device code response status:', res.status); if (!res.ok) { From fa685ec1fb7dec15d8d9cb2c566f900b4eb5a6c8 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 30 Apr 2026 12:26:52 -0500 Subject: [PATCH 2/3] fix: address PR review feedback for auth hang fix - Add DeviceAuthTimeoutError subclass so callers can use instanceof instead of fragile string matching on error messages - Wrap AbortError in requestDeviceCode with DeviceAuthTimeoutError instead of letting raw DOMException propagate - Also wrap other fetch errors in DeviceAuthError for consistency --- src/commands/login.ts | 4 ++-- src/lib/device-auth.ts | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/commands/login.ts b/src/commands/login.ts index a64accac..8c1a392e 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -11,7 +11,7 @@ import type { CliConfig } from '../lib/config-store.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; import { autoInstallSkills } from './install-skill.js'; import { isJsonMode } from '../utils/output.js'; -import { requestDeviceCode, pollForToken, DeviceAuthError } from '../lib/device-auth.js'; +import { requestDeviceCode, pollForToken, DeviceAuthTimeoutError } from '../lib/device-auth.js'; /** * Best-effort skill install after a successful auth-login. @@ -167,7 +167,7 @@ export async function runLogin(): Promise { await installSkillsAfterLogin(); } catch (error) { - if (error instanceof DeviceAuthError && error.message.includes('timed out')) { + if (error instanceof DeviceAuthTimeoutError) { spinner.stop('Authentication timed out'); clack.log.error('Authentication timed out. Please try again.'); } else { diff --git a/src/lib/device-auth.ts b/src/lib/device-auth.ts index 4a0159d2..81e99323 100644 --- a/src/lib/device-auth.ts +++ b/src/lib/device-auth.ts @@ -54,6 +54,13 @@ export class DeviceAuthError extends Error { } } +export class DeviceAuthTimeoutError extends DeviceAuthError { + constructor(message: string) { + super(message); + this.name = 'DeviceAuthTimeoutError'; + } +} + const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const DEFAULT_POLL_INTERVAL_SECONDS = 5; const POLL_REQUEST_TIMEOUT_MS = 30_000; @@ -107,6 +114,12 @@ export async function requestDeviceCode(options: DeviceAuthOptions): Promise Date: Thu, 30 Apr 2026 13:29:48 -0500 Subject: [PATCH 3/3] chore: formatting --- src/lib/device-auth.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/device-auth.ts b/src/lib/device-auth.ts index 81e99323..68e70d36 100644 --- a/src/lib/device-auth.ts +++ b/src/lib/device-auth.ts @@ -119,7 +119,9 @@ export async function requestDeviceCode(options: DeviceAuthOptions): Promise