diff --git a/src/commands/login.ts b/src/commands/login.ts index 87edbd69..8c1a392e 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, DeviceAuthTimeoutError } 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 DeviceAuthTimeoutError) { + 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..68e70d36 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; @@ -94,14 +101,30 @@ 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, + }); + } catch (error) { + clearTimeout(timeout); + if (error instanceof DOMException && error.name === 'AbortError') { + throw new DeviceAuthTimeoutError('Device authorization request timed out'); + } + throw new DeviceAuthError( + `Device authorization request failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + clearTimeout(timeout); + } logInfo('[device-auth] Device code response status:', res.status); if (!res.ok) { @@ -196,7 +219,7 @@ export async function pollForToken( } logError('[device-auth] Authentication timed out, last poll:', lastPollSummary); - throw new DeviceAuthError( + throw new DeviceAuthTimeoutError( `Authentication timed out after ${Math.round(timeoutMs / 1000)} seconds (last token response: ${lastPollSummary})`, ); }