-
Notifications
You must be signed in to change notification settings - Fork 21
/
slasHelper.ts
439 lines (400 loc) · 16.5 KB
/
slasHelper.ts
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
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
/*
* Copyright (c) 2022, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import {customRandom, urlAlphabet} from 'nanoid';
import seedrandom, {PRNG} from 'seedrandom';
import {isBrowser} from './environment';
import {
ShopperLogin,
TokenRequest,
TokenResponse,
} from '../../lib/shopperLogin';
import ResponseError from '../responseError';
export const stringToBase64 = isBrowser
? btoa
: (unencoded: string): string => Buffer.from(unencoded).toString('base64');
/**
* Parse out the code and usid from a redirect url
* @param urlString A url that contains `code` and `usid` query parameters, typically returned when calling a Shopper Login endpoint
* @returns An object containing the code and usid.
*/
export const getCodeAndUsidFromUrl = (
urlString: string
): {code: string; usid: string} => {
const url = new URL(urlString);
const urlParams = new URLSearchParams(url.search);
const usid = urlParams.get('usid') ?? '';
const code = urlParams.get('code') ?? '';
return {
code,
usid,
};
};
/**
* Adds entropy to nanoid() using seedrandom to ensure that the code_challenge sent to SCAPI by Google's crawler browser is unique.
* Solves the issue with Google's crawler getting the same result from nanoid() in two different runs, which results in the same PKCE code_challenge being used twice.
*/
const nanoid = (): string => {
const rng: PRNG = seedrandom(String(+new Date()), {entropy: true});
return customRandom(urlAlphabet, 128, size =>
new Uint8Array(size).map(() => 256 * rng())
)();
};
/**
* Creates a random string to use as a code verifier. This code is created by the client and sent with both the authorization request (as a code challenge) and the token request.
* @returns code verifier
*/
export const createCodeVerifier = (): string => nanoid();
/**
* Encodes a code verifier to a code challenge to send to the authorization endpoint
* @param codeVerifier random string to use as a code verifier
* @returns code challenge
*/
export const generateCodeChallenge = async (
codeVerifier: string
): Promise<string> => {
const urlSafe = (input: string) =>
input.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
let challenge = '';
// Cannot easily test browser functions. Integration test runs in the jsdom test environment which can only mimic certain browser functionality
// The window.crypto check is to see if code is being executed in the jsdom test environment or an actual browser to allow our test to successfully run
/* istanbul ignore next */
if (isBrowser && window.crypto) {
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await window.crypto.subtle.digest('SHA-256', data);
const base64Digest = btoa(String.fromCharCode(...new Uint8Array(digest)));
challenge = urlSafe(base64Digest);
} else {
const crypto = await import('crypto');
challenge = urlSafe(
crypto.default.createHash('sha256').update(codeVerifier).digest('base64')
);
}
/* istanbul ignore next */
if (challenge.length === 0) {
throw new Error('Problem generating code challenge');
}
return challenge;
};
/**
* Wrapper for the authorization endpoint. For federated login (3rd party IDP non-guest), the caller should redirect the user to the url in the url field of the returned object. The url will be the login page for the 3rd party IDP and the user will be sent to the redirectURI on success. Guest sessions return the code and usid directly with no need to redirect.
* @param slasClient a configured instance of the ShopperLogin SDK client
* @param codeVerifier - random string created by client app to use as a secret in the request
* @param parameters - Request parameters used by the `authorizeCustomer` endpoint.
* @param parameters.redirectURI - the location the client will be returned to after successful login with 3rd party IDP. Must be registered in SLAS.
* @param parameters.hint? - optional string to hint at a particular IDP. Guest sessions are created by setting this to 'guest'
* @param parameters.usid? - optional saved SLAS user id to link the new session to a previous session
* @returns login url, user id and authorization code if available
*/
export async function authorize(
slasClient: ShopperLogin<{
shortCode: string;
organizationId: string;
clientId: string;
siteId: string;
}>,
codeVerifier: string,
parameters: {
redirectURI: string;
hint?: string;
usid?: string;
}
): Promise<{code: string; url: string; usid: string}> {
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Create a copy to override specific fetchOptions
const slasClientCopy = new ShopperLogin(slasClient.clientConfig);
// set manual redirect on server since node allows access to the location
// header and it skips the extra call. In the browser, only the default
// follow setting allows us to get the url.
/* istanbul ignore next */
slasClientCopy.clientConfig.fetchOptions = {
...slasClient.clientConfig.fetchOptions,
redirect: isBrowser ? 'follow' : 'manual',
};
const options = {
parameters: {
client_id: slasClient.clientConfig.parameters.clientId,
channel_id: slasClient.clientConfig.parameters.siteId,
code_challenge: codeChallenge,
...(parameters.hint && {hint: parameters.hint}),
organizationId: slasClient.clientConfig.parameters.organizationId,
redirect_uri: parameters.redirectURI,
response_type: 'code',
...(parameters.usid && {usid: parameters.usid}),
},
};
const response = await slasClientCopy.authorizeCustomer(options, true);
const redirectUrlString = response.headers?.get('location') || response.url;
const redirectUrl = new URL(redirectUrlString);
const searchParams = Object.fromEntries(redirectUrl.searchParams.entries());
// url is a read only property we unfortunately cannot mock out using nock
// meaning redirectUrl will not have a falsy value for unit tests
/* istanbul ignore next */
if (response.status >= 400 || searchParams.error) {
throw new ResponseError(response);
}
return {url: redirectUrlString, ...getCodeAndUsidFromUrl(redirectUrlString)};
}
/**
* A single function to execute the ShopperLogin Private Client Guest Login as described in the [API documentation](https://developer.salesforce.com/docs/commerce/commerce-api/guide/slas-private-client.html).
* **Note**: this func can run on client side. Only use this one when the slas client secret is secured.
* @param slasClient - a configured instance of the ShopperLogin SDK client
* @param credentials - client secret used for authentication
* @param credentials.clientSecret - secret associated with client ID
* @param parameters - parameters to pass in the API calls.
* @param parameters.usid? - Unique Shopper Identifier to enable personalization.
* @param parameters.dnt? - Optional parameter to enable Do Not Track (DNT) for the user.
* @returns TokenResponse
*/
export async function loginGuestUserPrivate(
slasClient: ShopperLogin<{
shortCode: string;
organizationId: string;
clientId: string;
siteId: string;
}>,
parameters: {
usid?: string;
dnt?: boolean;
},
credentials: {
clientSecret: string;
}
): Promise<TokenResponse> {
if (!slasClient.clientConfig.parameters.siteId) {
throw new Error(
'Required argument channel_id is not provided through clientConfig.parameters.siteId'
);
}
const authorization = `Basic ${stringToBase64(
`${slasClient.clientConfig.parameters.clientId}:${credentials.clientSecret}`
)}`;
const options = {
headers: {
Authorization: authorization,
},
body: {
grant_type: 'client_credentials',
channel_id: slasClient.clientConfig.parameters.siteId,
...(parameters.usid && {usid: parameters.usid}),
...(parameters.dnt !== undefined && {dnt: parameters.dnt.toString()}),
},
};
return slasClient.getAccessToken(options);
}
/**
* A single function to execute the ShopperLogin Public Client Guest Login with proof key for code exchange flow as described in the [API documentation](https://developer.salesforce.com/docs/commerce/commerce-api/references?meta=shopper-login:Summary).
* @param slasClient a configured instance of the ShopperLogin SDK client.
* @param parameters - parameters to pass in the API calls.
* @param parameters.redirectURI - Per OAuth standard, a valid app route. Must be listed in your SLAS configuration. On server, this will not be actually called. On browser, this will be called, but ignored.
* @param parameters.usid? - Unique Shopper Identifier to enable personalization.
* @param parameters.dnt? - Optional parameter to enable Do Not Track (DNT) for the user.
* @returns TokenResponse
*/
export async function loginGuestUser(
slasClient: ShopperLogin<{
shortCode: string;
organizationId: string;
clientId: string;
siteId: string;
}>,
parameters: {
redirectURI: string;
usid?: string;
dnt?: boolean;
}
): Promise<TokenResponse> {
const codeVerifier = createCodeVerifier();
const authResponse = await authorize(slasClient, codeVerifier, {
redirectURI: parameters.redirectURI,
hint: 'guest',
...(parameters.usid && {usid: parameters.usid}),
});
const tokenBody: TokenRequest = {
client_id: slasClient.clientConfig.parameters.clientId,
channel_id: slasClient.clientConfig.parameters.siteId,
code: authResponse.code,
code_verifier: codeVerifier,
grant_type: 'authorization_code_pkce',
redirect_uri: parameters.redirectURI,
usid: authResponse.usid,
...(parameters.dnt !== undefined && {dnt: parameters.dnt.toString()}),
};
return slasClient.getAccessToken({body: tokenBody});
}
/**
* A single function to execute the ShopperLogin Public Client Registered User B2C Login with proof key for code exchange flow as described in the [API documentation](https://developer.salesforce.com/docs/commerce/commerce-api/references?meta=shopper-login:Summary).
* **Note**: this func can run on client side. Only use private slas when the slas client secret is secured.
* @param slasClient a configured instance of the ShopperLogin SDK client.
* @param credentials - the id and password and clientSecret (if applicable) to login with.
* @param credentials.username - the id of the user to login with.
* @param credentials.password - the password of the user to login with.
* @param credentials.clientSecret? - secret associated with client ID
* @param parameters - parameters to pass in the API calls.
* @param parameters.redirectURI - Per OAuth standard, a valid app route. Must be listed in your SLAS configuration. On server, this will not be actually called. On browser, this will be called, but ignored.
* @param parameters.usid? - Unique Shopper Identifier to enable personalization.
* @param parameters.dnt? - Optional parameter to enable Do Not Track (DNT) for the user.
* @returns TokenResponse
*/
export async function loginRegisteredUserB2C(
slasClient: ShopperLogin<{
shortCode: string;
organizationId: string;
clientId: string;
siteId: string;
}>,
credentials: {
username: string;
password: string;
clientSecret?: string;
},
parameters: {
redirectURI: string;
usid?: string;
dnt?: boolean;
}
): Promise<TokenResponse> {
const codeVerifier = createCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Create a copy to override specific fetchOptions
const slasClientCopy = new ShopperLogin(slasClient.clientConfig);
// set manual redirect on server since node allows access to the location
// header and it skips the extra call. In the browser, only the default
// follow setting allows us to get the url.
/* istanbul ignore next */
slasClientCopy.clientConfig.fetchOptions = {
...slasClient.clientConfig.fetchOptions,
redirect: isBrowser ? 'follow' : 'manual',
};
const authorization = `Basic ${stringToBase64(
`${credentials.username}:${credentials.password}`
)}`;
const options = {
headers: {
Authorization: authorization,
},
parameters: {
organizationId: slasClient.clientConfig.parameters.organizationId,
},
body: {
redirect_uri: parameters.redirectURI,
client_id: slasClient.clientConfig.parameters.clientId,
code_challenge: codeChallenge,
channel_id: slasClient.clientConfig.parameters.siteId,
...(parameters.usid && {usid: parameters.usid}),
},
};
const response = await slasClientCopy.authenticateCustomer(options, true);
const redirectUrlString = response.headers?.get('location') || response.url;
const redirectUrl = new URL(redirectUrlString);
const searchParams = Object.fromEntries(redirectUrl.searchParams.entries());
if (response.status >= 400 || searchParams.error) {
throw new ResponseError(response);
}
const authResponse = getCodeAndUsidFromUrl(redirectUrlString);
const tokenBody = {
client_id: slasClient.clientConfig.parameters.clientId,
channel_id: slasClient.clientConfig.parameters.siteId,
code: authResponse.code,
code_verifier: codeVerifier,
grant_type: 'authorization_code_pkce',
organizationId: slasClient.clientConfig.parameters.organizationId,
redirect_uri: parameters.redirectURI,
usid: authResponse.usid,
...(parameters.dnt !== undefined && {dnt: parameters.dnt.toString()}),
};
// using slas private client
if (credentials.clientSecret) {
const authHeaderIdSecret = `Basic ${stringToBase64(
`${slasClient.clientConfig.parameters.clientId}:${credentials.clientSecret}`
)}`;
const optionsToken = {
headers: {
Authorization: authHeaderIdSecret,
},
body: tokenBody,
};
return slasClient.getAccessToken(optionsToken);
}
// default is to use slas public client
return slasClient.getAccessToken({body: tokenBody});
}
/**
* Exchange a refresh token for a new access token.
* **Note**: this func can run on client side. Only use private slas when the slas client secret is secured.
* @param slasClient a configured instance of the ShopperLogin SDK client.
* @param parameters - parameters to pass in the API calls.
* @param parameters.refreshToken - a valid refresh token to exchange for a new access token (and refresh token).
* @param credentials - the clientSecret (if applicable) to login with.
* @param credentials.clientSecret - secret associated with client ID
* @returns TokenResponse
*/
export function refreshAccessToken(
slasClient: ShopperLogin<{
shortCode: string;
organizationId: string;
clientId: string;
siteId: string;
}>,
parameters: {
refreshToken: string;
dnt?: boolean;
},
credentials?: {clientSecret?: string}
): Promise<TokenResponse> {
const body = {
grant_type: 'refresh_token',
refresh_token: parameters.refreshToken,
client_id: slasClient.clientConfig.parameters.clientId,
channel_id: slasClient.clientConfig.parameters.siteId,
...(parameters.dnt !== undefined && {dnt: parameters.dnt.toString()}),
};
if (credentials && credentials.clientSecret) {
const authorization = `Basic ${stringToBase64(
`${slasClient.clientConfig.parameters.clientId}:${credentials.clientSecret}`
)}`;
const options = {
headers: {
Authorization: authorization,
},
body,
};
return slasClient.getAccessToken(options);
}
return slasClient.getAccessToken({body});
}
/**
* Logout a shopper. The shoppers access token and refresh token will be revoked and if the shopper authenticated with ECOM the OCAPI JWT will also be revoked.
* @param slasClient a configured instance of the ShopperLogin SDK client.
* @param parameters - parameters to pass in the API calls.
* @param parameters.accessToken - a valid access token to exchange for a new access token (and refresh token).
* @param parameters.refreshToken - a valid refresh token to exchange for a new access token (and refresh token).
* @returns TokenResponse
*/
export function logout(
slasClient: ShopperLogin<{
shortCode: string;
organizationId: string;
clientId: string;
siteId: string;
}>,
parameters: {
accessToken: string;
refreshToken: string;
}
): Promise<TokenResponse> {
return slasClient.logoutCustomer({
headers: {
Authorization: `Bearer ${parameters.accessToken}`,
},
parameters: {
refresh_token: parameters.refreshToken,
client_id: slasClient.clientConfig.parameters.clientId,
channel_id: slasClient.clientConfig.parameters.siteId,
},
});
}