Skip to content

Commit 744f9e4

Browse files
shethjkevinxh
andauthored
[Hybrid] PWA Kit should have a mechanism for replacing the access token when a SFRA login state is changed (#1171)
* Implement mechanism to store refresh token copy and compare with sfra * Update tests and mocks for util function to check SFRA login state * Fix linting issues * FIx param types for util functionn * Rename old isTokenValid to isTokenExpired * Remove expiry for refresh_token in localstorage * Update packages/template-retail-react-app/app/commerce-api/utils.js Co-authored-by: Kevin He <kevin.he@salesforce.com> * fix test * Fix linting on use-auth-modal.test.js * Update hasSFRAStateChanged logic to compare keys and values * Fix linting --------- Co-authored-by: Kevin He <kevin.he@salesforce.com>
1 parent 942acd2 commit 744f9e4

File tree

11 files changed

+151
-46
lines changed

11 files changed

+151
-46
lines changed

packages/template-retail-react-app/app/commerce-api/auth.js

+33-15
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,20 @@
99
import {getAppOrigin} from 'pwa-kit-react-sdk/utils/url'
1010
import {HTTPError} from 'pwa-kit-react-sdk/ssr/universal/errors'
1111
import {createCodeVerifier, generateCodeChallenge} from './pkce'
12-
import {isTokenValid, createGetTokenBody} from './utils'
12+
import {isTokenExpired, createGetTokenBody, hasSFRAAuthStateChanged} from './utils'
13+
import {
14+
usidStorageKey,
15+
cidStorageKey,
16+
encUserIdStorageKey,
17+
tokenStorageKey,
18+
refreshTokenRegisteredStorageKey,
19+
refreshTokenGuestStorageKey,
20+
oidStorageKey,
21+
dwSessionIdKey,
22+
REFRESH_TOKEN_COOKIE_AGE,
23+
EXPIRED_TOKEN,
24+
INVALID_TOKEN
25+
} from './constants'
1326
import fetch from 'cross-fetch'
1427
import Cookies from 'js-cookie'
1528

@@ -26,19 +39,6 @@ import Cookies from 'js-cookie'
2639
* @typedef {Object} Customer
2740
*/
2841

29-
const usidStorageKey = 'usid'
30-
const cidStorageKey = 'cid'
31-
const encUserIdStorageKey = 'enc-user-id'
32-
const tokenStorageKey = 'token'
33-
const refreshTokenRegisteredStorageKey = 'cc-nx'
34-
const refreshTokenGuestStorageKey = 'cc-nx-g'
35-
const oidStorageKey = 'oid'
36-
const dwSessionIdKey = 'dwsid'
37-
const REFRESH_TOKEN_COOKIE_AGE = 90 // 90 days. This value matches SLAS cartridge.
38-
39-
const EXPIRED_TOKEN = 'EXPIRED_TOKEN'
40-
const INVALID_TOKEN = 'invalid refresh_token'
41-
4242
/**
4343
* A class that provides auth functionality for the retail react app.
4444
*/
@@ -48,6 +48,7 @@ class Auth {
4848
this._api = api
4949
this._config = api._config
5050
this._onClient = typeof window !== 'undefined'
51+
this._storageCopy = this._onClient ? new LocalStorage() : new Map()
5152

5253
// To store tokens as cookies
5354
// change the next line to
@@ -139,23 +140,40 @@ class Auth {
139140
this._storage.set(oidStorageKey, oid)
140141
}
141142

143+
get isTokenValid() {
144+
return (
145+
!isTokenExpired(this.authToken) &&
146+
!hasSFRAAuthStateChanged(this._storage, this._storageCopy)
147+
)
148+
}
149+
142150
/**
143151
* Save refresh token in designated storage.
144152
*
145153
* @param {string} token The refresh token.
146154
* @param {USER_TYPE} type Type of the user.
147155
*/
148156
_saveRefreshToken(token, type) {
157+
/**
158+
* For hybrid deployments, We store a copy of the refresh_token
159+
* to update access_token whenever customer auth state changes on SFRA.
160+
*/
149161
if (type === Auth.USER_TYPE.REGISTERED) {
150162
this._storage.set(refreshTokenRegisteredStorageKey, token, {
151163
expires: REFRESH_TOKEN_COOKIE_AGE
152164
})
153165
this._storage.delete(refreshTokenGuestStorageKey)
166+
167+
this._storageCopy.set(refreshTokenRegisteredStorageKey, token)
168+
this._storageCopy.delete(refreshTokenGuestStorageKey)
154169
return
155170
}
156171

157172
this._storage.set(refreshTokenGuestStorageKey, token, {expires: REFRESH_TOKEN_COOKIE_AGE})
158173
this._storage.delete(refreshTokenRegisteredStorageKey)
174+
175+
this._storageCopy.set(refreshTokenGuestStorageKey, token)
176+
this._storageCopy.delete(refreshTokenRegisteredStorageKey)
159177
}
160178

161179
/**
@@ -230,7 +248,7 @@ class Auth {
230248
let authorizationMethod = '_loginAsGuest'
231249
if (credentials) {
232250
authorizationMethod = '_loginWithCredentials'
233-
} else if (isTokenValid(this.authToken)) {
251+
} else if (this.isTokenValid) {
234252
authorizationMethod = '_reuseCurrentLogin'
235253
} else if (this.refreshToken) {
236254
authorizationMethod = '_refreshAccessToken'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* Copyright (c) 2021, salesforce.com, inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
export const usidStorageKey = 'usid'
9+
export const cidStorageKey = 'cid'
10+
export const encUserIdStorageKey = 'enc-user-id'
11+
export const tokenStorageKey = 'token'
12+
export const refreshTokenRegisteredStorageKey = 'cc-nx'
13+
export const refreshTokenGuestStorageKey = 'cc-nx-g'
14+
export const oidStorageKey = 'oid'
15+
export const dwSessionIdKey = 'dwsid'
16+
export const REFRESH_TOKEN_COOKIE_AGE = 90 // 90 days. This value matches SLAS cartridge.
17+
export const EXPIRED_TOKEN = 'EXPIRED_TOKEN'
18+
export const INVALID_TOKEN = 'invalid refresh_token'

packages/template-retail-react-app/app/commerce-api/index.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import * as sdk from 'commerce-sdk-isomorphic'
1010
import {getAppOrigin} from 'pwa-kit-react-sdk/utils/url'
1111
import ShopperBaskets from './shopper-baskets'
1212
import OcapiShopperOrders from './ocapi-shopper-orders'
13-
import {getTenantId, isError, isTokenValid} from './utils'
13+
import {getTenantId, isError} from './utils'
1414
import Auth from './auth'
1515
import EinsteinAPI from './einstein'
1616

@@ -199,7 +199,7 @@ class CommerceAPI {
199199
// If the token is invalid (missing, past/nearing expiration), we issue
200200
// a login call, which will attempt to refresh the token or get a new
201201
// guest token. Once login is complete, we can proceed.
202-
if (!isTokenValid(this.auth.authToken)) {
202+
if (!this.auth.isTokenValid) {
203203
// NOTE: Login will update `this.auth.authToken` with a fresh token
204204
await this.auth.login()
205205
}

packages/template-retail-react-app/app/commerce-api/index.test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ describe('CommerceAPI', () => {
232232
})
233233
test('Use same customer if token is valid', async () => {
234234
const Utils = require('./utils')
235-
jest.spyOn(Utils, 'isTokenValid').mockReturnValue(true)
235+
jest.spyOn(Utils, 'isTokenExpired').mockReturnValue(false)
236236
const _CommerceAPI = require('./index').default
237237
const api = new _CommerceAPI(apiConfig)
238238

packages/template-retail-react-app/app/commerce-api/utils.js

+31-4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import jwtDecode from 'jwt-decode'
88
import {getAppOrigin} from 'pwa-kit-react-sdk/utils/url'
99
import {HTTPError} from 'pwa-kit-react-sdk/ssr/universal/errors'
10+
import {refreshTokenGuestStorageKey, refreshTokenRegisteredStorageKey} from './constants'
1011
import fetch from 'cross-fetch'
1112

1213
/**
@@ -16,18 +17,18 @@ import fetch from 'cross-fetch'
1617
* @param {string} token - The JWT bearer token to be inspected
1718
* @returns {boolean}
1819
*/
19-
export function isTokenValid(token) {
20+
export function isTokenExpired(token) {
2021
if (!token) {
21-
return false
22+
return true
2223
}
2324
const {exp, iat} = jwtDecode(token.replace('Bearer ', ''))
2425
const validTimeSeconds = exp - iat - 60
2526
const tokenAgeSeconds = Date.now() / 1000 - iat
2627
if (validTimeSeconds > tokenAgeSeconds) {
27-
return true
28+
return false
2829
}
2930

30-
return false
31+
return true
3132
}
3233

3334
// Returns fomrulated body for SopperLogin getToken endpoint
@@ -271,3 +272,29 @@ export const convertSnakeCaseToSentenceCase = (text) => {
271272
* Usually used as default for event handlers.
272273
*/
273274
export const noop = () => {}
275+
276+
/**
277+
* WARNING: This function is relevant to be used in Hybrid deployments only.
278+
* Compares the refresh_token keys for guest('cc-nx-g') and registered('cc-nx') login from the cookie received from SFRA with the copy stored in localstorage on PWA Kit
279+
* to determine if the login state of the shopper on SFRA site has changed. If the keys are different we return true considering the login state did change. If the keys are same,
280+
* we compare the values of the refresh_token to cover an edge case where the login state might have changed multiple times on SFRA and the eventual refresh_token key might be same
281+
* as that on PWA Kit which would incorrectly show both keys to be the same even though the sessions are different.
282+
* @param {Storage} storage Cookie storage on PWA Kit in hybrid deployment.
283+
* @param {LocalStorage} storageCopy Local storage holding the copy of the refresh_token in hybrid deployment.
284+
* @returns {boolean} true if the keys do not match (login state changed), false otherwise.
285+
*/
286+
export function hasSFRAAuthStateChanged(storage, storageCopy) {
287+
let refreshTokenKey =
288+
(storage.get(refreshTokenGuestStorageKey) && refreshTokenGuestStorageKey) ||
289+
(storage.get(refreshTokenRegisteredStorageKey) && refreshTokenRegisteredStorageKey)
290+
291+
let refreshTokenCopyKey =
292+
(storageCopy.get(refreshTokenGuestStorageKey) && refreshTokenGuestStorageKey) ||
293+
(storageCopy.get(refreshTokenRegisteredStorageKey) && refreshTokenRegisteredStorageKey)
294+
295+
if (refreshTokenKey !== refreshTokenCopyKey) {
296+
return true
297+
}
298+
299+
return storage.get(refreshTokenKey) !== storageCopy.get(refreshTokenCopyKey)
300+
}

packages/template-retail-react-app/app/commerce-api/utils.test.js

+41-10
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
import jwt from 'njwt'
88
import {
99
camelCaseKeysToUnderscore,
10-
isTokenValid,
10+
isTokenExpired,
1111
keysToCamel,
1212
convertSnakeCaseToSentenceCase,
13-
handleAsyncError
13+
handleAsyncError,
14+
hasSFRAAuthStateChanged
1415
} from './utils'
1516

1617
const createJwt = (secondsToExp) => {
@@ -26,20 +27,20 @@ jest.mock('./utils', () => {
2627
}
2728
})
2829

29-
describe('isTokenValid', () => {
30-
test('returns false when no token given', () => {
31-
expect(isTokenValid()).toBe(false)
30+
describe('isTokenExpired', () => {
31+
test('returns true when no token given', () => {
32+
expect(isTokenExpired()).toBe(true)
3233
})
3334

34-
test('returns true for valid token', () => {
35+
test('returns false for valid token', () => {
3536
const token = createJwt(600)
3637
const bearerToken = `Bearer ${token}`
37-
expect(isTokenValid(token)).toBe(true)
38-
expect(isTokenValid(bearerToken)).toBe(true)
38+
expect(isTokenExpired(token)).toBe(false)
39+
expect(isTokenExpired(bearerToken)).toBe(false)
3940
})
4041

41-
test('returns false if token expires within 60 econds', () => {
42-
expect(isTokenValid(createJwt(59))).toBe(false)
42+
test('returns true if token expires within 60 econds', () => {
43+
expect(isTokenExpired(createJwt(59))).toBe(true)
4344
})
4445
})
4546

@@ -244,3 +245,33 @@ describe('handleAsyncError', () => {
244245
expect(await handleAsyncError(func)()).toBe(1)
245246
})
246247
})
248+
249+
describe('hasSFRAAuthStateChanged', () => {
250+
test('returns true when refresh_token keys are different', () => {
251+
const storage = new Map()
252+
const storageCopy = new Map()
253+
254+
storage.set('cc-nx-g', 'testRefreshToken1')
255+
storageCopy.set('cc-nx', 'testRefreshToken2')
256+
257+
expect(hasSFRAAuthStateChanged(storage, storageCopy)).toBe(true)
258+
})
259+
test('returns false when refresh_token keys and values are the same', () => {
260+
const storage = new Map()
261+
const storageCopy = new Map()
262+
263+
storage.set('cc-nx', 'testRefreshToken1')
264+
storageCopy.set('cc-nx', 'testRefreshToken1')
265+
266+
expect(hasSFRAAuthStateChanged(storage, storageCopy)).toBe(false)
267+
})
268+
test('returns true when refresh_token keys are same but values are the different', () => {
269+
const storage = new Map()
270+
const storageCopy = new Map()
271+
272+
storage.set('cc-nx-g', 'testRefreshToken1')
273+
storageCopy.set('cc-nx-g', 'testRefreshToken2')
274+
275+
expect(hasSFRAAuthStateChanged(storage, storageCopy)).toBe(true)
276+
})
277+
})

packages/template-retail-react-app/app/hooks/use-auth-modal.test.js

+17-9
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ const mockLogin = jest.fn()
3838
jest.mock('../commerce-api/auth', () => {
3939
return jest.fn().mockImplementation(() => {
4040
return {
41-
login: mockLogin
41+
login: mockLogin,
42+
isTokenValid: true
4243
}
4344
})
4445
})
@@ -47,7 +48,8 @@ jest.mock('../commerce-api/utils', () => {
4748
const originalModule = jest.requireActual('../commerce-api/utils')
4849
return {
4950
...originalModule,
50-
isTokenValid: jest.fn().mockReturnValue(true),
51+
isTokenExpired: jest.fn().mockReturnValue(false),
52+
hasSFRAAuthStateChanged: jest.fn().mockReturnValue(false),
5153
createGetTokenBody: jest.fn().mockReturnValue({
5254
grantType: 'test',
5355
code: 'test',
@@ -137,10 +139,13 @@ test('Allows customer to sign in to their account', async () => {
137139
user.click(screen.getByText(/sign in/i))
138140

139141
// wait for successful toast to appear
140-
await waitFor(() => {
141-
expect(screen.getByText(/Welcome Tester/i)).toBeInTheDocument()
142-
expect(screen.getByText(/you're now signed in/i)).toBeInTheDocument()
143-
})
142+
await waitFor(
143+
() => {
144+
expect(screen.getByText(/Welcome Tester/i)).toBeInTheDocument()
145+
expect(screen.getByText(/you're now signed in/i)).toBeInTheDocument()
146+
},
147+
{timeout: 20000}
148+
)
144149
})
145150

146151
test('Renders error when given incorrect log in credentials', async () => {
@@ -233,7 +238,10 @@ test('Allows customer to create an account', async () => {
233238
user.paste(withinForm.getAllByLabelText(/password/i)[0], 'Password!1')
234239
user.click(withinForm.getByText(/create account/i))
235240

236-
await waitFor(() => {
237-
expect(screen.getAllByText(/welcome tester/i).length).toEqual(2)
238-
})
241+
await waitFor(
242+
() => {
243+
expect(screen.getAllByText(/customer@test.com/i).length).toEqual(1)
244+
},
245+
{timeout: 20000}
246+
)
239247
})

packages/template-retail-react-app/app/pages/checkout/index.test.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ jest.mock('../../commerce-api/utils', () => {
3838
const originalModule = jest.requireActual('../../commerce-api/utils')
3939
return {
4040
...originalModule,
41-
isTokenValid: jest.fn().mockReturnValue(true),
41+
isTokenExpired: jest.fn().mockReturnValue(false),
42+
hasSFRAAuthStateChanged: jest.fn().mockReturnValue(false),
4243
createGetTokenBody: jest.fn().mockReturnValue({
4344
grantType: 'test',
4445
code: 'test',

packages/template-retail-react-app/app/pages/login/index.test.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ jest.mock('../../commerce-api/utils', () => {
4343
const originalModule = jest.requireActual('../../commerce-api/utils')
4444
return {
4545
...originalModule,
46-
isTokenValid: jest.fn().mockReturnValue(true),
46+
isTokenExpired: jest.fn().mockReturnValue(false),
47+
hasSFRAAuthStateChanged: jest.fn().mockReturnValue(false),
4748
createGetTokenBody: jest.fn().mockReturnValue({
4849
grantType: 'test',
4950
code: 'test',

packages/template-retail-react-app/app/pages/registration/index.test.jsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ jest.mock('../../commerce-api/utils', () => {
4747
const originalModule = jest.requireActual('../../commerce-api/utils')
4848
return {
4949
...originalModule,
50-
isTokenValid: jest.fn().mockReturnValue(true),
50+
isTokenExpired: jest.fn().mockReturnValue(false),
51+
hasSFRAAuthStateChanged: jest.fn().mockReturnValue(false),
5152
createGetTokenBody: jest.fn().mockReturnValue({
5253
grantType: 'test',
5354
code: 'test',

packages/template-retail-react-app/jest-setup.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,12 @@ jest.mock('pwa-kit-runtime/utils/ssr-config', () => {
8484
}
8585
})
8686

87-
// Mock isTokenValid globally
87+
// Mock isTokenExpired globally
8888
jest.mock('./app/commerce-api/utils', () => {
8989
const originalModule = jest.requireActual('./app/commerce-api/utils')
9090
return {
9191
...originalModule,
92-
isTokenValid: jest.fn().mockReturnValue(true)
92+
isTokenExpired: jest.fn().mockReturnValue(false)
9393
}
9494
})
9595

0 commit comments

Comments
 (0)