Skip to content

Commit 1154090

Browse files
authored
fix: prevent workos auth login from hanging indefinitely (#139)
* 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`. * 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 * chore: formatting
1 parent 4a18de1 commit 1154090

2 files changed

Lines changed: 73 additions & 159 deletions

File tree

src/commands/login.ts

Lines changed: 41 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -11,66 +11,7 @@ import type { CliConfig } from '../lib/config-store.js';
1111
import { formatWorkOSCommand } from '../utils/command-invocation.js';
1212
import { autoInstallSkills } from './install-skill.js';
1313
import { isJsonMode } from '../utils/output.js';
14-
15-
/**
16-
* Parse JWT payload
17-
*/
18-
function parseJwt(token: string): Record<string, unknown> | null {
19-
try {
20-
const parts = token.split('.');
21-
if (parts.length !== 3) return null;
22-
return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
23-
} catch {
24-
return null;
25-
}
26-
}
27-
28-
/**
29-
* Extract expiry time from JWT token
30-
*/
31-
function getJwtExpiry(token: string): number | null {
32-
const payload = parseJwt(token);
33-
if (!payload || typeof payload.exp !== 'number') return null;
34-
return payload.exp * 1000;
35-
}
36-
37-
const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
38-
39-
/**
40-
* Get Connect OAuth endpoints from AuthKit domain
41-
*/
42-
function getConnectEndpoints() {
43-
const domain = getAuthkitDomain();
44-
return {
45-
deviceAuthorization: `${domain}/oauth2/device_authorization`,
46-
token: `${domain}/oauth2/token`,
47-
};
48-
}
49-
50-
interface DeviceAuthResponse {
51-
device_code: string;
52-
user_code: string;
53-
verification_uri: string;
54-
verification_uri_complete: string;
55-
expires_in: number;
56-
interval: number;
57-
}
58-
59-
interface ConnectTokenResponse {
60-
access_token: string;
61-
id_token: string;
62-
token_type: string;
63-
expires_in: number;
64-
refresh_token?: string;
65-
}
66-
67-
interface AuthErrorResponse {
68-
error: string;
69-
}
70-
71-
function sleep(ms: number): Promise<void> {
72-
return new Promise((resolve) => setTimeout(resolve, ms));
73-
}
14+
import { requestDeviceCode, pollForToken, DeviceAuthTimeoutError } from '../lib/device-auth.js';
7415

7516
/**
7617
* Best-effort skill install after a successful auth-login.
@@ -169,29 +110,19 @@ export async function runLogin(): Promise<void> {
169110
}
170111
}
171112

172-
clack.log.step('Starting authentication...');
173-
174-
const endpoints = getConnectEndpoints();
113+
const authkitDomain = getAuthkitDomain();
175114

176-
const authResponse = await fetch(endpoints.deviceAuthorization, {
177-
method: 'POST',
178-
headers: {
179-
'Content-Type': 'application/x-www-form-urlencoded',
180-
},
181-
body: new URLSearchParams({
182-
client_id: clientId,
183-
scope: 'openid email staging-environment:credentials:read offline_access',
184-
}),
185-
});
115+
clack.log.step('Starting authentication...');
186116

187-
if (!authResponse.ok) {
188-
clack.log.error(`Failed to start authentication: ${authResponse.status}`);
117+
let deviceAuth;
118+
try {
119+
deviceAuth = await requestDeviceCode({ clientId, authkitDomain });
120+
} catch (error) {
121+
const msg = error instanceof Error ? error.message : String(error);
122+
clack.log.error(`Failed to start authentication: ${msg}`);
189123
process.exit(1);
190124
}
191125

192-
const deviceAuth = (await authResponse.json()) as DeviceAuthResponse;
193-
const pollIntervalMs = (deviceAuth.interval || 5) * 1000;
194-
195126
clack.log.info(`\nOpen this URL in your browser:\n`);
196127
console.log(` ${deviceAuth.verification_uri}`);
197128
console.log(`\nEnter code: ${deviceAuth.user_code}\n`);
@@ -206,84 +137,44 @@ export async function runLogin(): Promise<void> {
206137
const spinner = clack.spinner();
207138
spinner.start('Waiting for authentication...');
208139

209-
const startTime = Date.now();
210-
let currentInterval = pollIntervalMs;
211-
212-
while (Date.now() - startTime < POLL_TIMEOUT_MS) {
213-
await sleep(currentInterval);
214-
215-
try {
216-
const tokenResponse = await fetch(endpoints.token, {
217-
method: 'POST',
218-
headers: {
219-
'Content-Type': 'application/x-www-form-urlencoded',
220-
},
221-
body: new URLSearchParams({
222-
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
223-
device_code: deviceAuth.device_code,
224-
client_id: clientId,
225-
}),
226-
});
227-
228-
const data = await tokenResponse.json();
229-
230-
if (tokenResponse.ok) {
231-
const result = data as ConnectTokenResponse;
232-
233-
// Parse user info from id_token JWT
234-
const idTokenPayload = parseJwt(result.id_token);
235-
const userId = (idTokenPayload?.sub as string) || 'unknown';
236-
const email = (idTokenPayload?.email as string) || undefined;
237-
238-
// Extract actual expiry from access token JWT, fallback to response or 15 min
239-
const jwtExpiry = getJwtExpiry(result.access_token);
240-
const expiresAt =
241-
jwtExpiry ?? (result.expires_in ? Date.now() + result.expires_in * 1000 : Date.now() + 15 * 60 * 1000);
242-
243-
const expiresInSec = Math.round((expiresAt - Date.now()) / 1000);
244-
245-
saveCredentials({
246-
accessToken: result.access_token,
247-
expiresAt,
248-
userId,
249-
email,
250-
refreshToken: result.refresh_token,
251-
});
140+
try {
141+
const result = await pollForToken(deviceAuth.device_code, {
142+
clientId,
143+
authkitDomain,
144+
interval: deviceAuth.interval,
145+
});
252146

253-
spinner.stop('Authentication successful!');
254-
clack.log.success(`Logged in as ${email || userId}`);
255-
clack.log.info(`Token expires in ${expiresInSec} seconds`);
147+
const expiresInSec = Math.round((result.expiresAt - Date.now()) / 1000);
256148

257-
// Auto-provision staging environment
258-
const provisioned = await provisionStagingEnvironment(result.access_token);
259-
if (provisioned) {
260-
clack.log.success('Staging environment configured automatically');
261-
} else {
262-
clack.log.info(chalk.dim('Run `workos env add` to configure an environment manually'));
263-
}
149+
saveCredentials({
150+
accessToken: result.accessToken,
151+
expiresAt: result.expiresAt,
152+
userId: result.userId,
153+
email: result.email,
154+
refreshToken: result.refreshToken,
155+
});
264156

265-
// Best-effort skill install. Wrapped helper guarantees login never
266-
// fails on skill errors.
267-
await installSkillsAfterLogin();
268-
return;
269-
}
157+
spinner.stop('Authentication successful!');
158+
clack.log.success(`Logged in as ${result.email || result.userId}`);
159+
clack.log.info(`Token expires in ${expiresInSec} seconds`);
270160

271-
const errorData = data as AuthErrorResponse;
272-
if (errorData.error === 'authorization_pending') continue;
273-
if (errorData.error === 'slow_down') {
274-
currentInterval += 5000;
275-
continue;
276-
}
161+
const provisioned = await provisionStagingEnvironment(result.accessToken);
162+
if (provisioned) {
163+
clack.log.success('Staging environment configured automatically');
164+
} else {
165+
clack.log.info(chalk.dim('Run `workos env add` to configure an environment manually'));
166+
}
277167

168+
await installSkillsAfterLogin();
169+
} catch (error) {
170+
if (error instanceof DeviceAuthTimeoutError) {
171+
spinner.stop('Authentication timed out');
172+
clack.log.error('Authentication timed out. Please try again.');
173+
} else {
278174
spinner.stop('Authentication failed');
279-
clack.log.error(`Authentication error: ${errorData.error}`);
280-
process.exit(1);
281-
} catch {
282-
continue;
175+
const msg = error instanceof Error ? error.message : String(error);
176+
clack.log.error(`Authentication error: ${msg}`);
283177
}
178+
process.exit(1);
284179
}
285-
286-
spinner.stop('Authentication timed out');
287-
clack.log.error('Authentication timed out. Please try again.');
288-
process.exit(1);
289180
}

src/lib/device-auth.ts

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ export class DeviceAuthError extends Error {
5454
}
5555
}
5656

57+
export class DeviceAuthTimeoutError extends DeviceAuthError {
58+
constructor(message: string) {
59+
super(message);
60+
this.name = 'DeviceAuthTimeoutError';
61+
}
62+
}
63+
5764
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
5865
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
5966
const POLL_REQUEST_TIMEOUT_MS = 30_000;
@@ -94,14 +101,30 @@ export async function requestDeviceCode(options: DeviceAuthOptions): Promise<Dev
94101
const url = `${options.authkitDomain}/oauth2/device_authorization`;
95102

96103
logInfo('[device-auth] Requesting device code from:', url);
97-
const res = await fetch(url, {
98-
method: 'POST',
99-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
100-
body: new URLSearchParams({
101-
client_id: options.clientId,
102-
scope: scopes.join(' '),
103-
}),
104-
});
104+
const controller = new AbortController();
105+
const timeout = setTimeout(() => controller.abort(), POLL_REQUEST_TIMEOUT_MS);
106+
let res: Response;
107+
try {
108+
res = await fetch(url, {
109+
method: 'POST',
110+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
111+
body: new URLSearchParams({
112+
client_id: options.clientId,
113+
scope: scopes.join(' '),
114+
}),
115+
signal: controller.signal,
116+
});
117+
} catch (error) {
118+
clearTimeout(timeout);
119+
if (error instanceof DOMException && error.name === 'AbortError') {
120+
throw new DeviceAuthTimeoutError('Device authorization request timed out');
121+
}
122+
throw new DeviceAuthError(
123+
`Device authorization request failed: ${error instanceof Error ? error.message : String(error)}`,
124+
);
125+
} finally {
126+
clearTimeout(timeout);
127+
}
105128

106129
logInfo('[device-auth] Device code response status:', res.status);
107130
if (!res.ok) {
@@ -196,7 +219,7 @@ export async function pollForToken(
196219
}
197220

198221
logError('[device-auth] Authentication timed out, last poll:', lastPollSummary);
199-
throw new DeviceAuthError(
222+
throw new DeviceAuthTimeoutError(
200223
`Authentication timed out after ${Math.round(timeoutMs / 1000)} seconds (last token response: ${lastPollSummary})`,
201224
);
202225
}

0 commit comments

Comments
 (0)