-
Notifications
You must be signed in to change notification settings - Fork 274
Expand file tree
/
Copy pathdevice-authorization.ts
More file actions
188 lines (165 loc) · 7.12 KB
/
Copy pathdevice-authorization.ts
File metadata and controls
188 lines (165 loc) · 7.12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
import {clientId} from './identity.js'
import {exchangeDeviceCodeForAccessToken} from './exchange.js'
import {IdentityToken} from './schema.js'
import {identityFqdn} from '../../../public/node/context/fqdn.js'
import {shopifyFetch} from '../../../public/node/http.js'
import {outputContent, outputDebug, outputInfo, outputToken} from '../../../public/node/output.js'
import {AbortError, BugError} from '../../../public/node/error.js'
import {isCI, openURL} from '../../../public/node/system.js'
import {Response} from 'node-fetch'
export interface DeviceAuthorizationResponse {
deviceCode: string
userCode: string
verificationUri: string
expiresIn: number
verificationUriComplete?: string
interval?: number
}
/**
* Initiate a device authorization flow.
* This will return a DeviceAuthorizationResponse containing the URL where user
* should go to authorize the device without the need of a callback to the CLI.
*
* Also returns a `deviceCode` used for polling the token endpoint in the next step.
*
* @param scopes - The scopes to request
* @returns An object with the device authorization response.
*/
export async function requestDeviceAuthorization(scopes: string[]): Promise<DeviceAuthorizationResponse> {
const fqdn = await identityFqdn()
const identityClientId = clientId()
const queryParams = {client_id: identityClientId, scope: scopes.join(' ')}
const url = `https://${fqdn}/oauth/device_authorization`
const response = await shopifyFetch(url, {
method: 'POST',
headers: {'Content-type': 'application/x-www-form-urlencoded'},
body: convertRequestToParams(queryParams),
})
// First read the response body as text so we have it for debugging
let responseText: string
try {
responseText = await response.text()
} catch (error) {
throw new BugError(
`Failed to read response from authorization service (HTTP ${response.status}). Network or streaming error occurred.`,
'Check your network connection and try again.',
)
}
// Now try to parse the text as JSON
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let jsonResult: any
try {
jsonResult = JSON.parse(responseText)
} catch {
// JSON.parse failed, handle the parsing error
const errorMessage = buildAuthorizationParseErrorMessage(response, responseText)
throw new BugError(errorMessage)
}
outputDebug(outputContent`Received device authorization code: ${outputToken.json(jsonResult)}`)
if (!jsonResult.device_code || !jsonResult.verification_uri_complete) {
throw new BugError('Failed to start authorization process')
}
outputInfo('\nTo run this command, log in to Shopify.')
if (isCI()) {
throw new AbortError(
'Authorization is required to continue, but the current environment does not support interactive prompts.',
'To resolve this, specify credentials in your environment, or run the command in an interactive environment such as your local terminal.',
)
}
outputInfo(outputContent`User verification code: ${jsonResult.user_code}`)
const linkToken = outputToken.link(jsonResult.verification_uri_complete)
const opened = await openURL(jsonResult.verification_uri_complete)
if (opened) {
outputInfo(outputContent`Opened link to start the auth process: ${linkToken}`)
} else {
outputInfo(outputContent`👉 Open this link to start the auth process: ${linkToken}`)
}
return {
deviceCode: jsonResult.device_code,
userCode: jsonResult.user_code,
verificationUri: jsonResult.verification_uri,
expiresIn: jsonResult.expires_in,
verificationUriComplete: jsonResult.verification_uri_complete,
interval: jsonResult.interval,
}
}
/**
* Poll the Oauth token endpoint with the device code obtained from a DeviceAuthorizationResponse.
* The endpoint will return `authorization_pending` until the user completes the auth flow in the browser.
* Once the user completes the auth flow, the endpoint will return the identity token.
*
* Timeout for the polling is defined by the server and is around 600 seconds.
*
* @param code - The device code obtained after starting a device identity flow
* @param interval - The interval to poll the token endpoint
* @returns The identity token
*/
export async function pollForDeviceAuthorization(code: string, interval = 5): Promise<IdentityToken> {
let currentIntervalInSeconds = interval
return new Promise<IdentityToken>((resolve, reject) => {
const onPoll = async () => {
const result = await exchangeDeviceCodeForAccessToken(code)
if (!result.isErr()) {
resolve(result.value)
return
}
const error = result.error ?? 'unknown_failure'
outputDebug(outputContent`Polling for device authorization... status: ${error}`)
switch (error) {
case 'authorization_pending': {
startPolling()
return
}
case 'slow_down':
currentIntervalInSeconds += 5
startPolling()
return
case 'access_denied':
reject(new AbortError(`Device authorization failed: Access denied.`))
return
case 'expired_token':
reject(new AbortError(`Device authorization failed: Token expired. Please try again.`))
return
case 'unknown_failure':
default:
reject(new Error(`Device authorization failed: ${error}`))
}
}
const startPolling = () => {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
setTimeout(onPoll, currentIntervalInSeconds * 1000)
}
startPolling()
})
}
function convertRequestToParams(queryParams: {client_id: string; scope: string}): string {
// URLSearchParams applies form encoding so scope values cannot change the request structure.
return new URLSearchParams(Object.entries(queryParams).filter(([, value]) => Boolean(value))).toString()
}
/**
* Build a detailed error message for JSON parsing failures from the authorization service.
* Provides context-specific error messages based on response status and content.
*
* @param response - The HTTP response object
* @param responseText - The raw response body text
* @returns Detailed error message about the failure
*/
function buildAuthorizationParseErrorMessage(response: Response, responseText: string): string {
// Build helpful error message based on response status and content
let errorMessage = `Received invalid response from authorization service (HTTP ${response.status}).`
// Add status-based context
if (response.status >= 500) {
errorMessage += ' The service may be experiencing issues.'
} else if (response.status >= 400) {
errorMessage += ' The request may be malformed or unauthorized.'
}
// Add content-based context (check these regardless of status)
if (responseText.trim().startsWith('<!DOCTYPE') || responseText.trim().startsWith('<html')) {
errorMessage += ' Received HTML instead of JSON - the service endpoint may have changed.'
} else if (responseText.trim() === '') {
errorMessage += ' Received empty response body.'
} else {
errorMessage += ' Response could not be parsed as valid JSON.'
}
return `${errorMessage} If this issue persists, please contact support at https://help.shopify.com`
}