-
Notifications
You must be signed in to change notification settings - Fork 262
Expand file tree
/
Copy pathexchange.ts
More file actions
287 lines (256 loc) · 10.6 KB
/
Copy pathexchange.ts
File metadata and controls
287 lines (256 loc) · 10.6 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
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
import {ApplicationToken, IdentityToken} from './schema.js'
import {applicationId, clientId as getIdentityClientId} from './identity.js'
import {tokenExchangeScopes} from './scopes.js'
import {API} from '../api.js'
import {identityFqdn} from '../../../public/node/context/fqdn.js'
import {shopifyFetch} from '../../../public/node/http.js'
import {err, ok, Result} from '../../../public/node/result.js'
import {AbortError, BugError, ExtendableError} from '../../../public/node/error.js'
import {setLastSeenAuthMethod, setLastSeenUserIdAfterAuth} from '../session.js'
import {nonRandomUUID} from '../../../public/node/crypto.js'
import * as jose from 'jose'
export class InvalidGrantError extends ExtendableError {}
export class InvalidRequestError extends ExtendableError {}
class InvalidTargetError extends AbortError {}
export interface ExchangeScopes {
admin: string[]
partners: string[]
storefront: string[]
businessPlatform: string[]
appManagement: string[]
}
/**
* Given an identity token, request an application token.
* @param identityToken - access token obtained in a previous step
* @param store - the store to use, only needed for admin API
* @returns An array with the application access tokens.
*/
export async function exchangeAccessForApplicationTokens(
identityToken: IdentityToken,
scopes: ExchangeScopes,
store?: string,
): Promise<Record<string, ApplicationToken>> {
const token = identityToken.accessToken
const [partners, storefront, businessPlatform, admin, appManagement] = await Promise.all([
requestAppToken('partners', token, scopes.partners),
requestAppToken('storefront-renderer', token, scopes.storefront),
requestAppToken('business-platform', token, scopes.businessPlatform),
store ? requestAppToken('admin', token, scopes.admin, store) : {},
requestAppToken('app-management', token, scopes.appManagement),
])
return {
...partners,
...storefront,
...businessPlatform,
...admin,
...appManagement,
}
}
/**
* Given an expired access token, refresh it to get a new one.
*/
export async function refreshAccessToken(currentToken: IdentityToken): Promise<IdentityToken> {
const clientId = getIdentityClientId()
const params = {
grant_type: 'refresh_token',
access_token: currentToken.accessToken,
refresh_token: currentToken.refreshToken,
client_id: clientId,
}
const tokenResult = await tokenRequest(params)
const value = tokenResult.mapError(tokenRequestErrorHandler).valueOrBug()
return buildIdentityToken(value, currentToken.userId, currentToken.alias)
}
/**
* Given a custom app automation token passed as ENV variable, request a valid API access token.
* @param apiName - The API to exchange for the access token
* @param token - The app automation token passed as ENV variable `SHOPIFY_APP_AUTOMATION_TOKEN`
* @param scopes - The scopes to request with the access token
* @returns An instance with the application access tokens.
*/
async function exchangeAppAutomationTokenForAccessToken(
apiName: API,
token: string,
scopes: string[],
): Promise<{accessToken: string; userId: string}> {
const appId = applicationId(apiName)
try {
const newToken = await requestAppToken(apiName, token, scopes)
const accessToken = newToken[appId]!.accessToken
const userId = nonRandomUUID(token)
setLastSeenUserIdAfterAuth(userId)
setLastSeenAuthMethod('partners_token')
return {accessToken, userId}
} catch (error) {
const prettyName = apiName.replace(/-/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase())
throw new AbortError(
`The custom token provided can't be used for the ${prettyName} API.`,
'Ensure the token is correct and not expired.',
)
}
}
/**
* Given a custom app automation token passed as ENV variable, request a valid Partners API token.
* This token does not accept extra scopes, just the cli one.
* @param token - The app automation token passed as ENV variable `SHOPIFY_APP_AUTOMATION_TOKEN`
* @returns An instance with the application access tokens.
*/
export async function exchangeCustomPartnerToken(token: string): Promise<{accessToken: string; userId: string}> {
return exchangeAppAutomationTokenForAccessToken('partners', token, tokenExchangeScopes('partners'))
}
/**
* Given a custom app automation token passed as ENV variable, request a valid App Management API token.
* @param token - The app automation token passed as ENV variable `SHOPIFY_APP_AUTOMATION_TOKEN`
* @returns An instance with the application access tokens.
*/
export async function exchangeAppAutomationTokenForAppManagementAccessToken(
token: string,
): Promise<{accessToken: string; userId: string}> {
return exchangeAppAutomationTokenForAccessToken('app-management', token, tokenExchangeScopes('app-management'))
}
/**
* Given a custom app automation token passed as ENV variable, request a valid Business Platform API token.
* @param token - The app automation token passed as ENV variable `SHOPIFY_APP_AUTOMATION_TOKEN`
* @returns An instance with the application access tokens.
*/
export async function exchangeAppAutomationTokenForBusinessPlatformAccessToken(
token: string,
): Promise<{accessToken: string; userId: string}> {
return exchangeAppAutomationTokenForAccessToken('business-platform', token, tokenExchangeScopes('business-platform'))
}
type IdentityDeviceError = 'authorization_pending' | 'access_denied' | 'expired_token' | 'slow_down' | 'unknown_failure'
/**
* Given a deviceCode obtained after starting a device identity flow, request an identity token.
* @param deviceCode - The device code obtained after starting a device identity flow
* @param scopes - The scopes to request
* @returns An instance with the identity access tokens.
*/
export async function exchangeDeviceCodeForAccessToken(
deviceCode: string,
): Promise<Result<IdentityToken, IdentityDeviceError>> {
const clientId = await getIdentityClientId()
const params = {
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: deviceCode,
client_id: clientId,
}
const tokenResult = await tokenRequest(params)
if (tokenResult.isErr()) {
return err(tokenResult.error.error as IdentityDeviceError)
}
const identityToken = buildIdentityToken(tokenResult.value)
return ok(identityToken)
}
export async function requestAppToken(
api: API,
token: string,
scopes: string[] = [],
store?: string,
): Promise<Record<string, ApplicationToken>> {
const appId = applicationId(api)
const clientId = await getIdentityClientId()
const params = {
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
requested_token_type: 'urn:ietf:params:oauth:token-type:access_token',
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
client_id: clientId,
audience: appId,
scope: scopes.join(' '),
subject_token: token,
...(api === 'admin' && {destination: `https://${store}/admin`, store}),
}
let identifier = appId
if (api === 'admin' && store) {
identifier = `${store}-${appId}`
}
const tokenResult = await tokenRequest(params)
const value = tokenResult.mapError(tokenRequestErrorHandler).valueOrBug()
const appToken = buildApplicationToken(value)
return {[identifier]: appToken}
}
interface TokenRequestResult {
access_token: string
expires_in: number
refresh_token: string
scope: string
id_token?: string
}
function tokenRequestErrorHandler({error, store}: {error: string; store?: string}) {
const invalidTargetErrorMessage = `You are not authorized to use the CLI to develop in the provided store${
store ? `: ${store}` : '.'
}`
if (error === 'invalid_grant') {
// There's an scenario when Identity returns "invalid_grant" when trying to refresh the token
// using a valid refresh token. When that happens, we take the user through the authentication flow.
return new InvalidGrantError()
}
if (error === 'invalid_request') {
// There's an scenario when Identity returns "invalid_request" when exchanging an identity token.
// This means the token is invalid. We clear the session and throw an error to let the caller know.
return new InvalidRequestError()
}
if (error === 'invalid_target') {
return new InvalidTargetError(invalidTargetErrorMessage, '', [
'Ensure you have logged in to the store using the Shopify admin at least once.',
'Ensure you are the store owner, or have a staff account if you are attempting to log in to a dev store.',
'Ensure you are using the permanent store domain, not a vanity domain.',
])
}
// eslint-disable-next-line @shopify/cli/no-error-factory-functions
return new AbortError(error)
}
async function tokenRequest(
params: Record<string, string>,
): Promise<Result<TokenRequestResult, {error: string; store?: string}>> {
const fqdn = await identityFqdn()
const url = `https://${fqdn}/oauth/token`
// Send OAuth parameters in the request body (per RFC 6749) rather than the
// URL query string. This prevents sensitive credentials (subject_token,
// refresh_token, device_code, client_id, etc.) from being written to
// verbose CLI debug output or otherwise leaking through URLs.
const body = new URLSearchParams(Object.entries(params)).toString()
const res = await shopifyFetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body,
})
try {
const responseText = await res.text()
const payload = JSON.parse(responseText)
if (res.ok) return ok(payload)
return err({error: payload.error, store: params.store})
} catch (error) {
if (error instanceof SyntaxError) {
throw new AbortError(
`Received invalid response from authentication service (HTTP ${res.status}).`,
'The response could not be parsed as JSON. The service may be temporarily unavailable. Please try again.',
)
}
throw error
}
}
function buildIdentityToken(
result: TokenRequestResult,
existingUserId?: string,
existingAlias?: string,
): IdentityToken {
const userId = existingUserId ?? (result.id_token ? jose.decodeJwt(result.id_token).sub! : undefined)
if (!userId) {
throw new BugError('Error setting userId for session. No id_token or pre-existing user ID provided.')
}
return {
accessToken: result.access_token,
refreshToken: result.refresh_token,
expiresAt: new Date(Date.now() + result.expires_in * 1000),
scopes: result.scope.split(' '),
userId,
alias: existingAlias,
}
}
function buildApplicationToken(result: TokenRequestResult): ApplicationToken {
return {
accessToken: result.access_token,
expiresAt: new Date(Date.now() + result.expires_in * 1000),
scopes: result.scope.split(' '),
}
}