-
Notifications
You must be signed in to change notification settings - Fork 261
Expand file tree
/
Copy pathdevice-authorization.ts
More file actions
214 lines (189 loc) · 7.81 KB
/
Copy pathdevice-authorization.ts
File metadata and controls
214 lines (189 loc) · 7.81 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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
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 {isCloudEnvironment} from '../../../public/node/context/local.js'
import {isCI, openURL} from '../../../public/node/system.js'
import {isTTY, keypress} from '../../../public/node/ui.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 tty = isTTY()
const waitingMessage = () => {
outputInfo('Waiting for authentication to complete. Keep this command running.')
if (!tty) {
outputInfo(
'If you are an agent, show the URL and code to the user, ask them to complete login, then continue after this command finishes.',
)
}
}
const manualLoginMessage = () => {
outputInfo(outputContent`👉 Open this link to start the auth process: ${linkToken}`)
waitingMessage()
}
if (isCloudEnvironment() || !tty) {
manualLoginMessage()
} else {
outputInfo('👉 Press any key to open the login page on your browser')
await keypress()
const opened = await openURL(jsonResult.verification_uri_complete)
if (opened) {
outputInfo(outputContent`Opened link to start the auth process: ${linkToken}`)
waitingMessage()
} else {
manualLoginMessage()
}
}
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': {
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 {
return Object.entries(queryParams)
.map(([key, value]) => value && `${key}=${value}`)
.filter((hasValue) => Boolean(hasValue))
.join('&')
}
/**
* 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`
}