@@ -214,80 +214,115 @@ export async function startAuthorizationCodeFlow(
214214export async function startDeviceAuthorizationFlow (
215215 opts : DeviceAuthorizationFlowOptions = { } ,
216216) : Promise < OAuthTokens > {
217- const clientId = resolveClientId ( ) ;
218- const fetchImpl = opts . fetchImpl ?? fetch ;
219- const sleepImpl =
220- opts . sleepImpl ?? ( ( ms : number ) => new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ) ;
221- const now = opts . now ?? Date . now ;
222- const scope = opts . scope ?? DEFAULT_SCOPES ;
217+ const runtime : DeviceFlowRuntime = {
218+ clientId : resolveClientId ( ) ,
219+ fetchImpl : opts . fetchImpl ?? fetch ,
220+ sleepImpl :
221+ opts . sleepImpl ?? ( ( ms : number ) => new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ) ,
222+ now : opts . now ?? Date . now ,
223+ } ;
224+ const issuance = await requestDeviceAuthorization ( runtime , opts . scope ?? DEFAULT_SCOPES ) ;
225+ await opts . onChallenge ?.( {
226+ userCode : issuance . userCode ,
227+ verificationUri : issuance . verificationUri ,
228+ } ) ;
229+ return await pollDeviceToken ( runtime , issuance ) ;
230+ }
223231
224- let issuanceResponse : Response ;
232+ interface DeviceFlowRuntime {
233+ clientId : string ;
234+ fetchImpl : typeof fetch ;
235+ sleepImpl : ( ms : number ) => Promise < void > ;
236+ now : ( ) => number ;
237+ }
238+
239+ async function requestDeviceAuthorization (
240+ runtime : DeviceFlowRuntime ,
241+ scope : string ,
242+ ) : Promise < ParsedDeviceAuthorization > {
243+ let response : Response ;
225244 try {
226- issuanceResponse = await fetchImpl ( deviceAuthorizationEndpoint ( ) , {
245+ response = await runtime . fetchImpl ( deviceAuthorizationEndpoint ( ) , {
227246 method : "POST" ,
228247 headers : {
229248 "content-type" : "application/x-www-form-urlencoded" ,
230249 accept : "application/json" ,
231250 } ,
232- body : new URLSearchParams ( { client_id : clientId , scope } ) . toString ( ) ,
251+ body : new URLSearchParams ( { client_id : runtime . clientId , scope } ) . toString ( ) ,
233252 } ) ;
234253 } catch {
235254 throw ErrDeviceAuthFailed ( "could not reach the authorization server" ) ;
236255 }
237- if ( ! issuanceResponse . ok ) {
238- throw ErrDeviceAuthFailed ( `authorization server returned HTTP ${ issuanceResponse . status } ` ) ;
256+ if ( ! response . ok ) {
257+ throw ErrDeviceAuthFailed ( `authorization server returned HTTP ${ response . status } ` ) ;
239258 }
259+ return parseDeviceAuthorizationResponse ( await readJsonOrDeviceError ( response ) ) ;
260+ }
240261
241- const issuance = parseDeviceAuthorizationResponse ( await readJsonOrDeviceError ( issuanceResponse ) ) ;
242- await opts . onChallenge ?.( {
243- userCode : issuance . userCode ,
244- verificationUri : issuance . verificationUri ,
245- } ) ;
246-
247- const deadline = now ( ) + Math . min ( issuance . expiresIn , MAX_DEVICE_FLOW_SECONDS ) * 1000 ;
262+ async function pollDeviceToken (
263+ runtime : DeviceFlowRuntime ,
264+ issuance : ParsedDeviceAuthorization ,
265+ ) : Promise < OAuthTokens > {
266+ const deadline = runtime . now ( ) + Math . min ( issuance . expiresIn , MAX_DEVICE_FLOW_SECONDS ) * 1000 ;
248267 let intervalSeconds = issuance . interval ;
249- while ( now ( ) < deadline ) {
250- const remainingMs = deadline - now ( ) ;
268+ while ( runtime . now ( ) < deadline ) {
269+ const remainingMs = deadline - runtime . now ( ) ;
251270 if ( remainingMs <= 0 ) break ;
252- await sleepImpl ( Math . min ( intervalSeconds * 1000 , remainingMs ) ) ;
253-
254- let pollResponse : Response ;
255- try {
256- pollResponse = await fetchImpl ( tokenEndpoint ( ) , {
257- method : "POST" ,
258- headers : {
259- "content-type" : "application/x-www-form-urlencoded" ,
260- accept : "application/json" ,
261- } ,
262- body : new URLSearchParams ( {
263- grant_type : DEVICE_CODE_GRANT_TYPE ,
264- device_code : issuance . deviceCode ,
265- client_id : clientId ,
266- } ) . toString ( ) ,
267- } ) ;
268- } catch {
269- throw ErrDeviceAuthFailed ( "lost contact with the authorization server" ) ;
270- }
271-
272- if ( pollResponse . ok ) {
273- return parseTokenResponse ( await readJsonOrDeviceError ( pollResponse ) ) ;
274- }
271+ await runtime . sleepImpl ( Math . min ( intervalSeconds * 1000 , remainingMs ) ) ;
275272
276- const error = await readDeviceOAuthError ( pollResponse ) ;
277- if ( error === "authorization_pending" ) continue ;
278- if ( error === "slow_down" ) {
273+ const result = await evaluateDevicePollResponse (
274+ await requestDeviceToken ( runtime , issuance . deviceCode ) ,
275+ ) ;
276+ if ( result . tokens ) return result . tokens ;
277+ if ( result . slowDown ) {
279278 intervalSeconds = Math . min ( intervalSeconds + 5 , MAX_DEVICE_POLL_SECONDS ) ;
280- continue ;
281279 }
282- if ( error === "access_denied" ) {
280+ }
281+ throw ErrDeviceAuthFailed ( "the code expired" ) ;
282+ }
283+
284+ async function requestDeviceToken (
285+ runtime : DeviceFlowRuntime ,
286+ deviceCode : string ,
287+ ) : Promise < Response > {
288+ try {
289+ return await runtime . fetchImpl ( tokenEndpoint ( ) , {
290+ method : "POST" ,
291+ headers : {
292+ "content-type" : "application/x-www-form-urlencoded" ,
293+ accept : "application/json" ,
294+ } ,
295+ body : new URLSearchParams ( {
296+ grant_type : DEVICE_CODE_GRANT_TYPE ,
297+ device_code : deviceCode ,
298+ client_id : runtime . clientId ,
299+ } ) . toString ( ) ,
300+ } ) ;
301+ } catch {
302+ throw ErrDeviceAuthFailed ( "lost contact with the authorization server" ) ;
303+ }
304+ }
305+
306+ async function evaluateDevicePollResponse (
307+ response : Response ,
308+ ) : Promise < { tokens ?: OAuthTokens ; slowDown ?: boolean } > {
309+ if ( response . ok ) {
310+ return { tokens : parseTokenResponse ( await readJsonOrDeviceError ( response ) ) } ;
311+ }
312+
313+ const error = await readDeviceOAuthError ( response ) ;
314+ switch ( error ) {
315+ case "authorization_pending" :
316+ return { } ;
317+ case "slow_down" :
318+ return { slowDown : true } ;
319+ case "access_denied" :
283320 throw ErrDeviceAuthFailed ( "access was denied" ) ;
284- }
285- if ( error === "expired_token" ) {
321+ case "expired_token" :
286322 throw ErrDeviceAuthFailed ( "the code expired" ) ;
287- }
288- throw ErrDeviceAuthFailed ( `authorization server returned HTTP ${ pollResponse . status } ` ) ;
323+ default :
324+ throw ErrDeviceAuthFailed ( `authorization server returned HTTP ${ response . status } ` ) ;
289325 }
290- throw ErrDeviceAuthFailed ( "the code expired" ) ;
291326}
292327
293328export async function refreshTokens (
@@ -589,22 +624,22 @@ interface ParsedDeviceAuthorization {
589624}
590625
591626function parseDeviceAuthorizationResponse ( payload : unknown ) : ParsedDeviceAuthorization {
592- if ( ! payload || typeof payload !== "object" || Array . isArray ( payload ) ) {
593- throw ErrDeviceAuthFailed ( "authorization server returned an invalid response" ) ;
594- }
595- const data = payload as Record < string , unknown > ;
627+ const data = requireDeviceAuthorizationRecord ( payload ) ;
596628 const deviceCode = stringField ( data , "device_code" ) ;
597629 const userCode = stringField ( data , "user_code" ) ;
598630 const verificationUri = stringField ( data , "verification_uri" ) ;
599631 const expiresIn = strictNumericField ( data , "expires_in" ) ;
600632 const interval = strictNumericField ( data , "interval" ) ;
601- if ( ! deviceCode || ! isHeaderSafe ( deviceCode ) || ! userCode || ! verificationUri ) {
633+ if ( ! deviceCode || ! isHeaderSafe ( deviceCode ) ) {
634+ throw ErrDeviceAuthFailed ( "authorization server returned an invalid response" ) ;
635+ }
636+ if ( ! userCode || ! verificationUri ) {
602637 throw ErrDeviceAuthFailed ( "authorization server returned an invalid response" ) ;
603638 }
604639 if ( ! isSafeVerificationUri ( verificationUri ) ) {
605640 throw ErrDeviceAuthFailed ( "authorization server returned an unsafe verification URL" ) ;
606641 }
607- if ( ! expiresIn || expiresIn <= 0 || ! interval || interval <= 0 ) {
642+ if ( ! isPositiveNumber ( expiresIn ) || ! isPositiveNumber ( interval ) ) {
608643 throw ErrDeviceAuthFailed ( "authorization server returned invalid timing values" ) ;
609644 }
610645 return {
@@ -619,6 +654,17 @@ function parseDeviceAuthorizationResponse(payload: unknown): ParsedDeviceAuthori
619654 } ;
620655}
621656
657+ function requireDeviceAuthorizationRecord ( payload : unknown ) : Record < string , unknown > {
658+ if ( ! payload || typeof payload !== "object" || Array . isArray ( payload ) ) {
659+ throw ErrDeviceAuthFailed ( "authorization server returned an invalid response" ) ;
660+ }
661+ return payload as Record < string , unknown > ;
662+ }
663+
664+ function isPositiveNumber ( value : number | undefined ) : value is number {
665+ return value !== undefined && value > 0 ;
666+ }
667+
622668function isSafeVerificationUri ( value : string ) : boolean {
623669 try {
624670 const url = new URL ( value ) ;
0 commit comments