1- import { exchangeDeviceCodeForAccessToken } from './exchange.js'
2- import { IdentityToken } from './schema.js'
3- import { identityFqdn } from '../../../public/node/context/fqdn.js'
4- import { shopifyFetch } from '../../../public/node/http.js'
5- import { outputContent , outputDebug , outputInfo , outputToken } from '../../../public/node/output.js'
6- import { AbortError , BugError } from '../../../public/node/error.js'
7- import { isCloudEnvironment } from '../../../public/node/context/local.js'
8- import { isCI , openURL } from '../../../public/node/system.js'
9- import { isTTY , keypress } from '../../../public/node/ui.js'
10- import { getIdentityClient } from '../clients/identity/instance.js'
111import { Response } from 'node-fetch'
122
133export interface DeviceAuthorizationResponse {
@@ -19,145 +9,6 @@ export interface DeviceAuthorizationResponse {
199 interval ?: number
2010}
2111
22- /**
23- * Initiate a device authorization flow.
24- * This will return a DeviceAuthorizationResponse containing the URL where user
25- * should go to authorize the device without the need of a callback to the CLI.
26- *
27- * Also returns a `deviceCode` used for polling the token endpoint in the next step.
28- *
29- * @param scopes - The scopes to request
30- * @returns An object with the device authorization response.
31- */
32- export async function requestDeviceAuthorization ( scopes : string [ ] ) : Promise < DeviceAuthorizationResponse > {
33- const fqdn = await identityFqdn ( )
34- const identityClientId = getIdentityClient ( ) . clientId ( )
35- const queryParams = { client_id : identityClientId , scope : scopes . join ( ' ' ) }
36- const url = `https://${ fqdn } /oauth/device_authorization`
37-
38- const response = await shopifyFetch ( url , {
39- method : 'POST' ,
40- headers : { 'Content-type' : 'application/x-www-form-urlencoded' } ,
41- body : convertRequestToParams ( queryParams ) ,
42- } )
43-
44- // First read the response body as text so we have it for debugging
45- let responseText : string
46- try {
47- responseText = await response . text ( )
48- } catch ( error ) {
49- throw new BugError (
50- `Failed to read response from authorization service (HTTP ${ response . status } ). Network or streaming error occurred.` ,
51- 'Check your network connection and try again.' ,
52- )
53- }
54-
55- // Now try to parse the text as JSON
56- // eslint-disable-next-line @typescript-eslint/no-explicit-any
57- let jsonResult : any
58- try {
59- jsonResult = JSON . parse ( responseText )
60- } catch {
61- // JSON.parse failed, handle the parsing error
62- const errorMessage = buildAuthorizationParseErrorMessage ( response , responseText )
63- throw new BugError ( errorMessage )
64- }
65-
66- outputDebug ( outputContent `Received device authorization code: ${ outputToken . json ( jsonResult ) } ` )
67- if ( ! jsonResult . device_code || ! jsonResult . verification_uri_complete ) {
68- throw new BugError ( 'Failed to start authorization process' )
69- }
70-
71- outputInfo ( '\nTo run this command, log in to Shopify.' )
72-
73- if ( isCI ( ) ) {
74- throw new AbortError (
75- 'Authorization is required to continue, but the current environment does not support interactive prompts.' ,
76- 'To resolve this, specify credentials in your environment, or run the command in an interactive environment such as your local terminal.' ,
77- )
78- }
79-
80- outputInfo ( outputContent `User verification code: ${ jsonResult . user_code } ` )
81- const linkToken = outputToken . link ( jsonResult . verification_uri_complete )
82-
83- const cloudMessage = ( ) => {
84- outputInfo ( outputContent `👉 Open this link to start the auth process: ${ linkToken } ` )
85- }
86-
87- if ( isCloudEnvironment ( ) || ! isTTY ( ) ) {
88- cloudMessage ( )
89- } else {
90- outputInfo ( '👉 Press any key to open the login page on your browser' )
91- await keypress ( )
92- const opened = await openURL ( jsonResult . verification_uri_complete )
93- if ( opened ) {
94- outputInfo ( outputContent `Opened link to start the auth process: ${ linkToken } ` )
95- } else {
96- cloudMessage ( )
97- }
98- }
99-
100- return {
101- deviceCode : jsonResult . device_code ,
102- userCode : jsonResult . user_code ,
103- verificationUri : jsonResult . verification_uri ,
104- expiresIn : jsonResult . expires_in ,
105- verificationUriComplete : jsonResult . verification_uri_complete ,
106- interval : jsonResult . interval ,
107- }
108- }
109-
110- /**
111- * Poll the Oauth token endpoint with the device code obtained from a DeviceAuthorizationResponse.
112- * The endpoint will return `authorization_pending` until the user completes the auth flow in the browser.
113- * Once the user completes the auth flow, the endpoint will return the identity token.
114- *
115- * Timeout for the polling is defined by the server and is around 600 seconds.
116- *
117- * @param code - The device code obtained after starting a device identity flow
118- * @param interval - The interval to poll the token endpoint
119- * @returns The identity token
120- */
121- export async function pollForDeviceAuthorization ( code : string , interval = 5 ) : Promise < IdentityToken > {
122- let currentIntervalInSeconds = interval
123-
124- return new Promise < IdentityToken > ( ( resolve , reject ) => {
125- const onPoll = async ( ) => {
126- const result = await exchangeDeviceCodeForAccessToken ( code )
127- if ( ! result . isErr ( ) ) {
128- resolve ( result . value )
129- return
130- }
131-
132- const error = result . error ?? 'unknown_failure'
133-
134- outputDebug ( outputContent `Polling for device authorization... status: ${ error } ` )
135- switch ( error ) {
136- case 'authorization_pending' : {
137- startPolling ( )
138- return
139- }
140- case 'slow_down' :
141- currentIntervalInSeconds += 5
142- startPolling ( )
143- return
144- case 'access_denied' :
145- case 'expired_token' :
146- case 'unknown_failure' : {
147- reject ( new Error ( `Device authorization failed: ${ error } ` ) )
148- }
149- }
150- }
151-
152- const startPolling = ( ) => {
153- // eslint-disable-next-line @typescript-eslint/no-misused-promises
154- setTimeout ( onPoll , currentIntervalInSeconds * 1000 )
155- }
156-
157- startPolling ( )
158- } )
159- }
160-
16112export function convertRequestToParams ( queryParams : { client_id : string ; scope : string } ) : string {
16213 return Object . entries ( queryParams )
16314 . map ( ( [ key , value ] ) => value && `${ key } =${ value } ` )
0 commit comments