@@ -11,66 +11,7 @@ import type { CliConfig } from '../lib/config-store.js';
1111import { formatWorkOSCommand } from '../utils/command-invocation.js' ;
1212import { autoInstallSkills } from './install-skill.js' ;
1313import { 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}
0 commit comments