-
Notifications
You must be signed in to change notification settings - Fork 212
Expand file tree
/
Copy pathindex.ts
More file actions
1469 lines (1348 loc) · 55 KB
/
index.ts
File metadata and controls
1469 lines (1348 loc) · 55 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
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
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
* Copyright (c) 2023, 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 {
helpers,
ShopperLogin,
ShopperCustomers,
ShopperLoginTypes,
ShopperCustomersTypes,
FetchOptions
} from 'commerce-sdk-isomorphic'
import {jwtDecode, JwtPayload} from 'jwt-decode'
import {ApiClientConfigParams, Prettify, RemoveStringIndex} from '../hooks/types'
import {BaseStorage, LocalStorage, CookieStorage, MemoryStorage, StorageType} from './storage'
import {CustomerType} from '../hooks/useCustomerType'
import {
getParentOrigin,
isOriginTrusted,
onClient,
getDefaultCookieAttributes,
stringToBase64,
extractCustomParameters
} from '../utils'
import {
SLAS_SECRET_WARNING_MSG,
SLAS_SECRET_PLACEHOLDER,
SLAS_SECRET_OVERRIDE_MSG,
DNT_COOKIE_NAME,
DWSID_COOKIE_NAME,
SLAS_REFRESH_TOKEN_COOKIE_TTL_OVERRIDE_MSG
} from '../constant'
import {Logger} from '../types'
type TokenResponse = ShopperLoginTypes.TokenResponse
type TrustedAgentTokenRequest = ShopperLoginTypes.getTrustedAgentAccessTokenBodyType
type Helpers = typeof helpers
interface AuthConfig extends ApiClientConfigParams {
redirectURI: string
proxy: string
headers?: Record<string, string>
privateClientProxyEndpoint?: string
fetchOptions?: FetchOptions
fetchedToken?: string
enablePWAKitPrivateClient?: boolean
clientSecret?: string
silenceWarnings?: boolean
logger: Logger
defaultDnt?: boolean
passwordlessLoginCallbackURI?: string
refreshTokenRegisteredCookieTTL?: number
refreshTokenGuestCookieTTL?: number
hybridAuthEnabled?: boolean
}
interface JWTHeaders {
exp: number
iat: number
}
interface SlasJwtPayload extends JwtPayload {
sub: string
isb: string
dnt: string
}
type LoginRegisteredUserB2CCredentials = Parameters<Helpers['loginRegisteredUserB2C']>[0]
/**
* Body type for loginRegisteredUserB2C - aligns with register function pattern
*/
type LoginRegisteredUserB2CBody = {
username: string
password: string
customParameters?: helpers.CustomRequestBody
}
type LoginIDPUserParams = {
redirectURI?: string
code: string
usid?: string
}
type AuthorizeIDPParams = {
redirectURI: string
hint: string
usid?: string
[key: string]: any // Allow custom parameters
}
type AuthorizePasswordlessParams = {
callbackURI?: string
userid: string
mode?: 'email' | 'callback'
locale?: string
/** When true, SLAS will register the customer as part of the passwordless flow */
register_customer?: boolean | string
/** Optional registration details forwarded to SLAS when register_customer=true */
first_name?: string
last_name?: string
email?: string
phone_number?: string
}
type GetPasswordLessAccessTokenParams = {
pwdlessLoginToken: string
/** When true, SLAS will register the customer if not already registered */
register_customer?: boolean | string
}
/**
* The extended field is not from api response, we manually store the auth type,
* so we don't need to make another API call when we already have the data.
* Plus, the getCustomer endpoint only works for registered user, it returns a 404 for a guest user,
* and it's not easy to grab this info in user land, so we add it into the Auth object, and expose it via a hook
*/
export type AuthData = Prettify<
RemoveStringIndex<TokenResponse> & {
customer_type: CustomerType
idp_access_token: string
}
>
/** A shopper could be guest or registered, so we store the refresh tokens individually. */
type AuthDataKeys =
| Exclude<keyof AuthData, 'refresh_token'>
| 'refresh_token_guest'
| 'refresh_token_registered'
| 'access_token_sfra'
| typeof DNT_COOKIE_NAME
| typeof DWSID_COOKIE_NAME
| 'code_verifier'
| 'uido'
| 'idp_refresh_token'
| 'dnt'
type AuthDataMap = Record<
AuthDataKeys,
{
storageType: StorageType
key: string
callback?: (storage: BaseStorage) => void
}
>
type DntOptions = {
includeDefaults: boolean
}
const isParentTrusted = isOriginTrusted(getParentOrigin())
/**
* A map of the data that this auth module stores. This maps the name of the property to
* the storage type and the key when stored in that storage. You can also pass in a "callback"
* function to do extra operation after a property is set.
*/
const DATA_MAP: AuthDataMap = {
access_token: {
storageType: 'local',
key: 'access_token'
},
customer_id: {
storageType: 'local',
key: 'customer_id'
},
usid: {
storageType: 'cookie',
key: 'usid'
},
enc_user_id: {
storageType: 'local',
key: 'enc_user_id'
},
expires_in: {
storageType: 'local',
key: 'expires_in'
},
id_token: {
storageType: 'local',
key: 'id_token'
},
idp_access_token: {
storageType: 'local',
key: 'idp_access_token'
},
idp_refresh_token: {
storageType: 'local',
key: 'idp_refresh_token'
},
dnt: {
storageType: 'local',
key: 'dnt'
},
token_type: {
storageType: 'local',
key: 'token_type'
},
refresh_token_guest: {
storageType: 'cookie',
key: isParentTrusted ? 'cc-nx-g-iframe' : 'cc-nx-g',
callback: (store) => {
store.delete(isParentTrusted ? 'cc-nx-iframe' : 'cc-nx')
}
},
refresh_token_registered: {
storageType: 'cookie',
key: isParentTrusted ? 'cc-nx-iframe' : 'cc-nx',
callback: (store) => {
store.delete(isParentTrusted ? 'cc-nx-g-iframe' : 'cc-nx-g')
}
},
refresh_token_expires_in: {
storageType: 'local',
key: 'refresh_token_expires_in'
},
customer_type: {
storageType: 'local',
key: 'customer_type'
},
/*
* For Hybrid setups, we need a mechanism to inform PWA Kit whenever customer login state changes on SFRA.
* We do this by having SFRA store the access token in cookies. If these cookies are present, PWA
* compares the access token from the cookie with the one in local store. If the tokens are different,
* discard the access token in local store and replace it with the access token from the cookie.
*
* ECOM has a 1200 character limit on the values of cookies. The access token easily exceeds this amount
* so it sends the access token in chunks across several cookies.
*
* The JWT tends to come in at around 2250 characters so there's usually
* both a cc-at and cc-at_2.
*/
access_token_sfra: {
storageType: 'cookie',
key: 'cc-at'
},
[DNT_COOKIE_NAME]: {
storageType: 'cookie',
key: DNT_COOKIE_NAME
},
dwsid: {
storageType: 'cookie',
key: DWSID_COOKIE_NAME
},
code_verifier: {
storageType: 'local',
key: 'code_verifier'
},
uido: {
storageType: 'local',
key: 'uido'
}
}
export const DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL = 90 * 24 * 60 * 60
export const DEFAULT_SLAS_REFRESH_TOKEN_GUEST_TTL = 30 * 24 * 60 * 60
/**
* This class is used to handle shopper authentication.
* It is responsible for initializing shopper session, manage access
* and refresh tokens on server/browser environments. As well as providing
* a mechanism to queue network calls before having a valid access token.
*
* @Internal
*/
class Auth {
private client: ShopperLogin<ApiClientConfigParams>
private shopperCustomersClient: ShopperCustomers<ApiClientConfigParams>
private redirectURI: string
private pendingToken: Promise<TokenResponse> | undefined
private stores: Record<StorageType, BaseStorage>
private fetchedToken: string
private clientSecret: string
private silenceWarnings: boolean
private logger: Logger
private defaultDnt: boolean | undefined
private isPrivate: boolean
private passwordlessLoginCallbackURI: string
private refreshTokenRegisteredCookieTTL: number | undefined
private refreshTokenGuestCookieTTL: number | undefined
private refreshTrustedAgentHandler:
| ((loginId: string, usid: string, refresh: boolean) => Promise<TokenResponse>)
| undefined
private hybridAuthEnabled: boolean
constructor(config: AuthConfig) {
// Special proxy endpoint for injecting SLAS private client secret.
// We prioritize config.privateClientProxyEndpoint since that allows us to use the new envBasePath feature
this.client = new ShopperLogin({
proxy: config.enablePWAKitPrivateClient
? config.privateClientProxyEndpoint
: config.proxy,
headers: config.headers || {},
parameters: {
clientId: config.clientId,
organizationId: config.organizationId,
shortCode: config.shortCode,
siteId: config.siteId
},
throwOnBadResponse: true,
// We need to set credentials to 'same-origin' to allow cookies to be set.
// This is required as SLAS calls return a dwsid cookie for hybrid sites.
// The dwsid value is then passed to the SCAPI as a header maintain the server affinity.
fetchOptions: {
credentials: 'same-origin',
...config.fetchOptions
}
})
this.shopperCustomersClient = new ShopperCustomers({
proxy: config.proxy,
headers: config.headers || {},
parameters: {
clientId: config.clientId,
organizationId: config.organizationId,
shortCode: config.shortCode,
siteId: config.siteId
},
throwOnBadResponse: true,
fetchOptions: config.fetchOptions
})
const options = {
keySuffix: config.siteId,
// Setting this to true on the server allows us to reuse guest auth tokens across lambda runs
sharedContext: !onClient()
}
this.stores = {
cookie: onClient() ? new CookieStorage(options) : new MemoryStorage(options),
local: onClient() ? new LocalStorage(options) : new MemoryStorage(options),
memory: new MemoryStorage(options)
}
this.redirectURI = config.redirectURI
this.fetchedToken = config.fetchedToken || ''
this.logger = config.logger
this.defaultDnt = config.defaultDnt
this.refreshTokenRegisteredCookieTTL = config.refreshTokenRegisteredCookieTTL
this.refreshTokenGuestCookieTTL = config.refreshTokenGuestCookieTTL
/*
* There are 2 ways to enable SLAS private client mode.
* If enablePWAKitPrivateClient=true, we route SLAS calls to /mobify/slas/private
* and set an internal placeholder as the client secret. The proxy will override the placeholder
* with the actual client secret so any truthy value as the placeholder works here.
*
* If enablePWAKitPrivateClient=false and clientSecret is provided as a non-empty string,
* private client mode is enabled but we don't route calls to /mobify/slas/private
* This is how non-PWA Kit consumers of commerce-sdk-react can enable private client and set a secret
*
* If both enablePWAKitPrivateClient and clientSecret are truthy, enablePWAKitPrivateClient takes
* priority and we ignore whatever was set for clientSecret. This prints a warning about the clientSecret
* being ignored.
*
* If both enablePWAKitPrivateClient and clientSecret are falsy, we are in SLAS public client mode.
*/
if (config.enablePWAKitPrivateClient && config.clientSecret) {
this.logWarning(SLAS_SECRET_OVERRIDE_MSG)
}
this.clientSecret = config.enablePWAKitPrivateClient
? // PWA proxy is enabled, assume project is PWA and that the proxy will handle setting the secret
// We can pass any truthy value here to satisfy commerce-sdk-isomorphic requirements
SLAS_SECRET_PLACEHOLDER
: // We think there are users of Commerce SDK React and Commerce SDK isomorphic outside of PWA
// For these users to use a private client, they must have some way to set a client secret
// PWA users should not need to touch this.
config.clientSecret || ''
this.silenceWarnings = config.silenceWarnings || false
this.isPrivate = !!this.clientSecret
this.passwordlessLoginCallbackURI = config.passwordlessLoginCallbackURI || ''
this.hybridAuthEnabled = config.hybridAuthEnabled || false
}
get(name: AuthDataKeys) {
const {key, storageType} = DATA_MAP[name]
const storage = this.stores[storageType]
return storage.get(key)
}
private set(name: AuthDataKeys, value: string, options?: unknown) {
const {key, storageType} = DATA_MAP[name]
const storage = this.stores[storageType]
storage.set(key, value, options)
DATA_MAP[name].callback?.(storage)
}
private delete(name: AuthDataKeys) {
const {key, storageType} = DATA_MAP[name]
const storage = this.stores[storageType]
storage.delete(key)
}
/**
* Return the value of the DNT cookie or undefined if it is not set.
* The DNT cookie being undefined means that there is a necessity to
* get the user's input for consent tracking, but not that there is no
* DNT value to apply to analytics layers. DNT value will default to
* a certain value and this is reflected by effectiveDnt.
*
* If the cookie value is invalid, then it will be deleted in this function.
*
* If includeDefaults is true, then even if the cookie is not defined,
* defaultDnt will be returned, if it exists. If defaultDnt is not defined, then
* the SDK Default will return (false)
*/
getDnt(options?: DntOptions) {
const dntCookieVal = this.get(DNT_COOKIE_NAME)
let dntCookieStatus = undefined
const accessToken = this.getAccessToken()
let isInSync = true
if (accessToken) {
const {dnt} = this.parseSlasJWT(accessToken)
isInSync = dnt === dntCookieVal
}
if ((dntCookieVal !== '1' && dntCookieVal !== '0') || !isInSync) {
this.delete(DNT_COOKIE_NAME)
} else {
dntCookieStatus = Boolean(Number(dntCookieVal))
}
if (options?.includeDefaults) {
const defaultDnt = this.defaultDnt
let effectiveDnt
const dntCookie = dntCookieVal === '1' ? true : dntCookieVal === '0' ? false : undefined
if (dntCookie !== undefined) {
effectiveDnt = dntCookie
} else {
// If the cookie is not set, read the defaultDnt preference.
// If defaultDnt doesn't exist, default to false, following SLAS default for dnt
effectiveDnt = defaultDnt !== undefined ? defaultDnt : false
}
return effectiveDnt
}
return dntCookieStatus
}
async setDnt(preference: boolean | null) {
let dntCookieVal = String(Number(preference))
// Use defaultDNT if defined. If not, use SLAS default DNT
if (preference === null) {
dntCookieVal = this.defaultDnt ? String(Number(this.defaultDnt)) : '0'
}
// Set the cookie once to include dnt in the access token and then again to set the expiry time
this.set(DNT_COOKIE_NAME, dntCookieVal, {
...getDefaultCookieAttributes(),
secure: true
})
const accessToken = this.getAccessToken()
if (accessToken !== '') {
const {dnt} = this.parseSlasJWT(accessToken)
if (dnt !== dntCookieVal) {
await this.refreshAccessToken()
}
} else {
await this.refreshAccessToken()
}
if (preference !== null) {
const SECONDS_IN_DAY = 86400
this.set(DNT_COOKIE_NAME, dntCookieVal, {
...getDefaultCookieAttributes(),
secure: true,
expires: Number(this.get('refresh_token_expires_in')) / SECONDS_IN_DAY
})
}
}
private clearStorage() {
// Type assertion because Object.keys is silly and limited :(
const keys = Object.keys(DATA_MAP) as AuthDataKeys[]
keys.forEach((keyName) => {
const {key, storageType} = DATA_MAP[keyName]
const store = this.stores[storageType]
store.delete(key)
})
}
/**
* Every method in this class that returns a `TokenResponse` constructs it via this getter.
*/
private get data(): AuthData {
return {
access_token: this.get('access_token'),
customer_id: this.get('customer_id'),
enc_user_id: this.get('enc_user_id'),
expires_in: parseInt(this.get('expires_in')),
id_token: this.get('id_token'),
idp_access_token: this.get('idp_access_token'),
refresh_token: this.get('refresh_token_registered') || this.get('refresh_token_guest'),
token_type: this.get('token_type') as 'Bearer',
usid: this.get('usid'),
customer_type: this.get('customer_type') as CustomerType,
refresh_token_expires_in: Number(this.get('refresh_token_expires_in'))
}
}
/**
* Used to validate JWT token expiration.
*/
private isTokenExpired(token: string) {
const {exp, iat} = jwtDecode<JWTHeaders>(token.replace('Bearer ', ''))
const validTimeSeconds = exp - iat - 60
const tokenAgeSeconds = Date.now() / 1000 - iat
return validTimeSeconds <= tokenAgeSeconds
}
/**
* Returns the SLAS access token or an empty string if the access token
* is not found in local store or if SFRA wants PWA to trigger refresh token login.
*
* On PWA-only sites, this returns the access token from local storage.
* On Hybrid sites, this checks whether SFRA has sent an auth token via cookies.
* Returns an access token from SFRA if it exist.
* If not, the access token from local store is returned.
*
* This is only used within this Auth module since other modules consider the access
* token from this.get('access_token') to be the source of truth.
*
* @returns {string} access token
*/
private getAccessToken() {
let accessToken = this.get('access_token')
const sfraAuthToken = this.get('access_token_sfra')
if (sfraAuthToken) {
/*
* If SFRA sends 'refresh', we return an empty token here so PWA can trigger a login refresh
* This key is used when logout is triggered in SFRA but the redirect after logout
* sends the user to PWA.
*/
if (sfraAuthToken === 'refresh') {
this.set('access_token', '')
this.clearSFRAAuthToken()
return ''
}
const {isGuest, customerId, usid} = this.parseSlasJWT(sfraAuthToken)
/**
* This if block is only executed in a hybrid setup when the cc-at cookie is set.
* If the login state of the shopper changes on SFRA, the "refresh_token_expires_in"
* will change and the updated value is not propagated back to PWA Kit via cookies or cc-at token.
* This results in the "refresh_token_expires_in" to be incorrect so we can't read it from localStorage.
* We must instead read the login state by decoding the cc-at token and rely on the default values for the guest or registered user.
* This in worst cases will cause the usid cookie to expire a few hours after the refreshToken which should be acceptable given
* a few hours are insignificant compared tothe overall validty of the refreshToken.
*/
const refreshTokenExpiresIn = isGuest
? DEFAULT_SLAS_REFRESH_TOKEN_GUEST_TTL
: DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL
const refreshTokenTTLValue = this.getRefreshTokenCookieTTLValue(
refreshTokenExpiresIn,
isGuest
)
const expiresDate = this.convertSecondsToDate(refreshTokenTTLValue)
this.set('access_token', sfraAuthToken)
this.set('customer_id', customerId)
/**
* The usid cookie always set when session bridging in a hybrid setup. This makes resetting the usid
* cookie here redundant. However, if the usid cookie is not set, we can have a fallback to read the usid from the accesstoken and set it.
* Setting the usid cookie conditionally ensures the usid is always set and minimizes the discrepancy between usid cookie and refresh_token cookie expiration.
*/
const usidCookieValue = this.get('usid')
if (!usidCookieValue || usidCookieValue !== usid) {
this.set('usid', usid, {
expires: expiresDate
})
}
this.set('customer_type', isGuest ? 'guest' : 'registered')
accessToken = sfraAuthToken
// SFRA -> PWA access token cookie handoff is successful so we clear the SFRA made cookies.
// We don't want these cookies to persist and continue overriding what is in local store.
this.clearSFRAAuthToken()
}
return accessToken
}
/**
* For Hybrid storefronts ONLY!!!
* This method clears out SLAS access token generated in Plugin SLAS and passed in via "cc-at" cookie.
*
* In a hybrid setup, whenever any SLAS flow executes in Plugin SLAS and an access token is generated,
* the access token is sent over to PWA Kit using cc-at cookie.
*
* PWA Kit will check to see if cc-at cookie exists, if it does, the access token value in localStorage is updated
* with value from the cc-at cookie and is then used for all SCAPI requests made from PWA Kit. The cc-at cookie is then cleared.
*/
private clearSFRAAuthToken() {
const {key, storageType} = DATA_MAP['access_token_sfra']
const store = this.stores[storageType]
store.delete(key)
}
/**
* For Hybrid storefronts ONLY!!!
* This method clears the dwsid cookie from the browser.
* In a hybrid setup, dwsid points to an ECOM session and is passed between PWA Kit and SFRA/SG sites via "dwsid" cookie.
*
* Whenever a registered shopper logs in on PWA Kit, we must clear the dwsid cookie if one exists. When shopper navigates
* to SFRA as a logged-in shopper, ECOM notices a missing DWSID, generates a new DWSID and triggers the onSession hook which uses
* registered shopper refresh-token and restores session and basket on SFRA.
*/
private clearECOMSession() {
/**
* If `hybridAuthEnabled` is true, dwsid cookie must not be cleared.
* This makes sure the session-bridged dwsid, received from `/oauth2/token` call on shopper login
* is NOT cleared and can be used to maintain the server affinity.
*/
if (this.hybridAuthEnabled) {
return
}
const {key, storageType} = DATA_MAP[DWSID_COOKIE_NAME]
const store = this.stores[storageType]
store.delete(key)
}
/**
* Converts a duration in seconds to a Date object.
* This function takes a number representing seconds and returns a Date object
* for the current time plus the given duration.
*
* @param {number} seconds - The number of seconds to add to the current time.
* @returns {Date} A Date object for the expiration time.
*/
private convertSecondsToDate(seconds: number): Date {
if (typeof seconds !== 'number') {
throw new Error('The refresh_token_expires_in seconds parameter must be a number.')
}
return new Date(Date.now() + seconds * 1000)
}
/**
* Retrieves our refresh token cookie ttl value from the following sources in order:
* 1. Override value (if set)
* 2. SLAS response value (if set)
* 3. Default value (if no override or SLAS response value is set)
*/
private getRefreshTokenCookieTTLValue(
refreshTokenExpiresInSLASValue: number | undefined,
isGuest: boolean
): number {
const overrideValue = isGuest
? this.refreshTokenGuestCookieTTL
: this.refreshTokenRegisteredCookieTTL
const defaultValue = isGuest
? DEFAULT_SLAS_REFRESH_TOKEN_GUEST_TTL
: DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL
// Check if overrideValue is valid
// if not, log warning and fall back to responseValue or defaultValue
const isOverrideValid =
typeof overrideValue === 'number' && overrideValue > 0 && overrideValue <= defaultValue
if (!isOverrideValid && overrideValue !== undefined) {
this.logWarning(SLAS_REFRESH_TOKEN_COOKIE_TTL_OVERRIDE_MSG)
}
// Return the first valid value: overrideValue (if valid), responseValue, or defaultValue
return isOverrideValid ? overrideValue : refreshTokenExpiresInSLASValue || defaultValue
}
/**
* This method stores the TokenResponse object retrieved from SLAS, and
* store the data in storage.
*/
private handleTokenResponse(res: TokenResponse, isGuest: boolean) {
// Delete the SFRA auth token cookie if it exists
this.clearSFRAAuthToken()
this.set('access_token', res.access_token)
this.set('customer_id', res.customer_id)
this.set('enc_user_id', res.enc_user_id)
this.set('expires_in', `${res.expires_in}`)
this.set('id_token', res.id_token)
this.set('idp_access_token', res.idp_access_token)
this.set('token_type', res.token_type)
this.set('customer_type', isGuest ? 'guest' : 'registered')
const refreshTokenKey = isGuest ? 'refresh_token_guest' : 'refresh_token_registered'
const refreshTokenTTLValue = this.getRefreshTokenCookieTTLValue(
res.refresh_token_expires_in,
isGuest
)
if (res.access_token) {
const {uido} = this.parseSlasJWT(res.access_token)
this.set('uido', uido)
}
const expiresDate = this.convertSecondsToDate(refreshTokenTTLValue)
this.set('refresh_token_expires_in', refreshTokenTTLValue.toString())
this.set(refreshTokenKey, res.refresh_token, {
expires: expiresDate
})
this.set('usid', res.usid, {
expires: expiresDate
})
}
async refreshAccessToken() {
const dntPref = this.getDnt({includeDefaults: true})
const refreshTokenRegistered = this.get('refresh_token_registered')
const refreshTokenGuest = this.get('refresh_token_guest')
const refreshToken = refreshTokenRegistered || refreshTokenGuest
if (refreshToken) {
try {
return await this.queueRequest(
() =>
helpers.refreshAccessToken({
slasClient: this.client,
parameters: {
refreshToken,
dnt: dntPref
},
credentials: {
clientSecret: this.clientSecret
}
}),
!!refreshTokenGuest
)
} catch (error) {
// If the refresh token is invalid, we need to re-login the user
if (error instanceof Error && 'response' in error) {
// commerce-sdk-isomorphic throws a `ResponseError`, but doesn't export the class.
// We can't use `instanceof`, so instead we just check for the `response` property
// and assume it is a fetch Response.
const json = await (error['response'] as Response).json()
if (json.message === 'invalid refresh_token') {
// clean up storage and restart the login flow
this.clearStorage()
}
}
}
}
// refresh flow for TAOB
const accessToken = this.getAccessToken()
if (accessToken && this.isTokenExpired(accessToken)) {
try {
const {isGuest, usid, loginId, isAgent} = this.parseSlasJWT(accessToken)
if (isAgent) {
return await this.queueRequest(
() => this.refreshTrustedAgent(loginId, usid),
isGuest
)
}
} catch (e) {
/* catch invalid jwt */
}
}
// if a TAOB left a usid and it tries to
// use it, we will be stuck in a fail loop
let token
try {
token = await this.loginGuestUser()
} catch (e) {
this.clearStorage()
token = await this.loginGuestUser()
}
return token
}
/**
* This method queues the requests and handles the SLAS token response.
*
* It returns the queue.
*
* @Internal
*/
async queueRequest(fn: () => Promise<TokenResponse>, isGuest: boolean) {
const queue = this.pendingToken ?? Promise.resolve()
this.pendingToken = queue
.then(async () => {
const token = await fn()
this.handleTokenResponse(token, isGuest)
// Q: Why don't we just return token? Why re-construct the same object again?
// A: because a user could open multiple tabs and the data in memory could be out-dated
// We must always grab the data from the storage (cookie/localstorage) directly
return this.data
})
.finally(() => {
this.pendingToken = undefined
})
return await this.pendingToken
}
logWarning = (msg: string) => {
if (!this.silenceWarnings) {
this.logger.warn(msg)
}
}
/**
* This method extracts the status and message from a ResponseError that is returned
* by commerce-sdk-isomorphic.
*
* commerce-sdk-isomorphic throws a `ResponseError`, but doesn't export the class.
* We can't use `instanceof`, so instead we just check for the `response` property
* and assume it is a `ResponseError` if a response is present
*
* Once commerce-sdk-isomorphic exports `ResponseError` we can revisit if this method is
* still required.
*
* @returns {status_code, responseMessage} contained within the ResponseError
* @throws error if the error is not a ResponseError
* @Internal
*/
extractResponseError = async (error: Error) => {
// the regular error.message will return only the generic status code message
// ie. 'Bad Request' for 400. We need to drill specifically into the ResponseError
// to get a more descriptive error message from SLAS
if ('response' in error) {
const json = await (error['response'] as Response).json()
const status_code: string = json.status_code
const responseMessage: string = json.message
return {
status_code,
responseMessage
}
}
throw error
}
/**
* The ready function returns a promise that resolves with valid ShopperLogin
* token response.
*
* When this method is called for the very first time, it initializes the session
* by following the public client auth flow to get access token for the user.
* The flow:
* 1. If we have valid access token - use it
* 2. If we have valid refresh token - refresh token flow
* 3. If we have valid TAOB access token - refresh TAOB token flow
* 4. PKCE flow
*/
async ready() {
if (this.fetchedToken && this.fetchedToken !== '') {
const {isGuest, customerId, usid} = this.parseSlasJWT(this.fetchedToken)
/**
* If the login state of the shopper changes on SFRA, the "refresh_token_expires_in"
* will change and the updated value is not propagated back to PWA Kit via cookies or cc-at token.
* This results in the "refresh_token_expires_in" to be incorrect so we can't read it from localStorage.
* We must instead read the login state by decoding the cc-at token and rely on the default values for the guest or registered user.
* This in worst cases will cause the usid cookie to expire a few hours after the refreshToken which should be acceptable given
* a few hours are insignificant compared tothe overall validty of the refreshToken.
*/
const refreshTokenExpiresIn = isGuest
? DEFAULT_SLAS_REFRESH_TOKEN_GUEST_TTL
: DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL
const refreshTokenTTLValue = this.getRefreshTokenCookieTTLValue(
refreshTokenExpiresIn,
isGuest
)
const expiresDate = this.convertSecondsToDate(refreshTokenTTLValue)
this.set('access_token', this.fetchedToken)
this.set('customer_id', customerId)
/**
* The usid cookie always set when setting up auth in pure composable env or session bridging in a hybrid setup. This makes resetting the usid
* cookie here redundant. However, if the usid cookie is not set, we can have a fallback to read the usid from the accesstoken and set it.
* Setting the usid cookie conditionally ensures the usid is always set and minimizes the discrepancy between usid cookie and refresh_token cookie expiration.
*/
const usidCookieValue = this.get('usid')
if (!usidCookieValue || usidCookieValue !== usid) {
this.set('usid', usid, {
expires: expiresDate
})
}
this.set('customer_type', isGuest ? 'guest' : 'registered')
return this.data
}
if (this.pendingToken) {
return await this.pendingToken
}
const accessToken = this.getAccessToken()
if (accessToken && !this.isTokenExpired(accessToken)) {
return this.data
}
return await this.refreshAccessToken()
}
/**
* Creates a function that only executes after a session is initialized.
* @param fn Function that needs to wait until the session is initialized.
* @returns Wrapped function
*/
whenReady<Args extends unknown[], Data>(
fn: (...args: Args) => Promise<Data>
): (...args: Args) => Promise<Data> {
return async (...args) => {
await this.ready()
return await fn(...args)
}
}
/**
* A wrapper method for commerce-sdk-isomorphic helper: loginGuestUser.
*
*/
async loginGuestUser(parameters?: helpers.CustomQueryParameters) {
if (this.clientSecret && onClient() && this.clientSecret !== SLAS_SECRET_PLACEHOLDER) {
this.logWarning(SLAS_SECRET_WARNING_MSG)
}
const usid = this.get('usid')
const dntPref = this.getDnt({includeDefaults: true})
const isGuest = true
const guestPrivateArgs = {
slasClient: this.client,
parameters: {
dnt: dntPref,
...(usid && {usid})
},
credentials: {clientSecret: this.clientSecret}
} as const
const guestPublicArgs = {
slasClient: this.client,
parameters: {
redirectURI: this.redirectURI,
dnt: dntPref,
...(usid && {usid}),
// custom parameters are sent only into the /authorize endpoint.
...parameters
}
} as const
const callback = this.clientSecret
? () => helpers.loginGuestUserPrivate({...guestPrivateArgs})
: () => helpers.loginGuestUser({...guestPublicArgs})
try {
return await this.queueRequest(callback, isGuest)
} catch (error) {
// We catch the error here to do logging but we still need to
// throw an error to stop the login flow from continuing.
const {status_code, responseMessage} = await this.extractResponseError(error as Error)
this.logger.error(`${status_code} ${responseMessage}`)
throw new Error(
`New guest user could not be logged in. ${status_code} ${responseMessage}`
)
}
}
/**
* This is a wrapper method for ShopperCustomer API registerCustomer endpoint.
*
*/
async register(body: ShopperCustomersTypes.CustomerRegistration) {
const {customer, password, ...parameters} = body
const {login} = customer
const customParameters = extractCustomParameters(parameters)
// login is optional field from isomorphic library
// type CustomerRegistration
// here we had to guard it to avoid ts error
if (!login) {
throw new Error('Customer registration is missing login field.')
}
// The registerCustomer endpoint currently does not support custom parameters
// so we make sure not to send any custom params here
const res = await this.shopperCustomersClient.registerCustomer({
headers: {
authorization: `Bearer ${this.get('access_token')}`
},
body: {
customer,
password
}
})
await this.loginRegisteredUserB2C({
username: login,
password,
customParameters
})
return res
}
/**
* A wrapper method for commerce-sdk-isomorphic helper: loginRegisteredUserB2C.
*
* This method uses a body-based API similar to the register function for consistency.
* Supports custom parameters through the customParameters field.
*/
async loginRegisteredUserB2C(body: LoginRegisteredUserB2CBody) {