Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Hybrid] PWA Kit should have a mechanism for replacing the access token when a SFRA login state is changed #1171

Merged
merged 12 commits into from
May 5, 2023
48 changes: 33 additions & 15 deletions packages/template-retail-react-app/app/commerce-api/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,20 @@
import {getAppOrigin} from 'pwa-kit-react-sdk/utils/url'
import {HTTPError} from 'pwa-kit-react-sdk/ssr/universal/errors'
import {createCodeVerifier, generateCodeChallenge} from './pkce'
import {isTokenValid, createGetTokenBody} from './utils'
import {isTokenExpired, createGetTokenBody, hasSFRAAuthStateChanged} from './utils'
import {
usidStorageKey,
cidStorageKey,
encUserIdStorageKey,
tokenStorageKey,
refreshTokenRegisteredStorageKey,
refreshTokenGuestStorageKey,
oidStorageKey,
dwSessionIdKey,
REFRESH_TOKEN_COOKIE_AGE,
EXPIRED_TOKEN,
INVALID_TOKEN
} from './constants'
import fetch from 'cross-fetch'
import Cookies from 'js-cookie'

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

const usidStorageKey = 'usid'
const cidStorageKey = 'cid'
const encUserIdStorageKey = 'enc-user-id'
const tokenStorageKey = 'token'
const refreshTokenRegisteredStorageKey = 'cc-nx'
const refreshTokenGuestStorageKey = 'cc-nx-g'
const oidStorageKey = 'oid'
const dwSessionIdKey = 'dwsid'
const REFRESH_TOKEN_COOKIE_AGE = 90 // 90 days. This value matches SLAS cartridge.

const EXPIRED_TOKEN = 'EXPIRED_TOKEN'
const INVALID_TOKEN = 'invalid refresh_token'

/**
* A class that provides auth functionality for the retail react app.
*/
Expand All @@ -48,6 +48,7 @@ class Auth {
this._api = api
this._config = api._config
this._onClient = typeof window !== 'undefined'
this._storageCopy = this._onClient ? new LocalStorage() : new Map()
kevinxh marked this conversation as resolved.
Show resolved Hide resolved

// To store tokens as cookies
// change the next line to
Expand Down Expand Up @@ -139,23 +140,40 @@ class Auth {
this._storage.set(oidStorageKey, oid)
}

get isTokenValid() {
return (
!isTokenExpired(this.authToken) &&
!hasSFRAAuthStateChanged(this._storage, this._storageCopy)
)
}

/**
* Save refresh token in designated storage.
*
* @param {string} token The refresh token.
* @param {USER_TYPE} type Type of the user.
*/
_saveRefreshToken(token, type) {
/**
* For hybrid deployments, We store a copy of the refresh_token
* to update access_token whenever customer auth state changes on SFRA.
*/
if (type === Auth.USER_TYPE.REGISTERED) {
this._storage.set(refreshTokenRegisteredStorageKey, token, {
expires: REFRESH_TOKEN_COOKIE_AGE
})
this._storage.delete(refreshTokenGuestStorageKey)

this._storageCopy.set(refreshTokenRegisteredStorageKey, token)
this._storageCopy.delete(refreshTokenGuestStorageKey)
return
}

this._storage.set(refreshTokenGuestStorageKey, token, {expires: REFRESH_TOKEN_COOKIE_AGE})
this._storage.delete(refreshTokenRegisteredStorageKey)

this._storageCopy.set(refreshTokenGuestStorageKey, token)
this._storageCopy.delete(refreshTokenRegisteredStorageKey)
}

/**
Expand Down Expand Up @@ -230,7 +248,7 @@ class Auth {
let authorizationMethod = '_loginAsGuest'
if (credentials) {
authorizationMethod = '_loginWithCredentials'
} else if (isTokenValid(this.authToken)) {
} else if (this.isTokenValid) {
authorizationMethod = '_reuseCurrentLogin'
} else if (this.refreshToken) {
authorizationMethod = '_refreshAccessToken'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2021, salesforce.com, 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
*/

export const usidStorageKey = 'usid'
export const cidStorageKey = 'cid'
export const encUserIdStorageKey = 'enc-user-id'
export const tokenStorageKey = 'token'
export const refreshTokenRegisteredStorageKey = 'cc-nx'
export const refreshTokenGuestStorageKey = 'cc-nx-g'
export const oidStorageKey = 'oid'
export const dwSessionIdKey = 'dwsid'
export const REFRESH_TOKEN_COOKIE_AGE = 90 // 90 days. This value matches SLAS cartridge.
export const EXPIRED_TOKEN = 'EXPIRED_TOKEN'
export const INVALID_TOKEN = 'invalid refresh_token'
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import * as sdk from 'commerce-sdk-isomorphic'
import {getAppOrigin} from 'pwa-kit-react-sdk/utils/url'
import ShopperBaskets from './shopper-baskets'
import OcapiShopperOrders from './ocapi-shopper-orders'
import {getTenantId, isError, isTokenValid} from './utils'
import {getTenantId, isError} from './utils'
import Auth from './auth'
import EinsteinAPI from './einstein'

Expand Down Expand Up @@ -199,7 +199,7 @@ class CommerceAPI {
// If the token is invalid (missing, past/nearing expiration), we issue
// a login call, which will attempt to refresh the token or get a new
// guest token. Once login is complete, we can proceed.
if (!isTokenValid(this.auth.authToken)) {
if (!this.auth.isTokenValid) {
// NOTE: Login will update `this.auth.authToken` with a fresh token
vcua-mobify marked this conversation as resolved.
Show resolved Hide resolved
await this.auth.login()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ describe('CommerceAPI', () => {
})
test('Use same customer if token is valid', async () => {
const Utils = require('./utils')
jest.spyOn(Utils, 'isTokenValid').mockReturnValue(true)
jest.spyOn(Utils, 'isTokenExpired').mockReturnValue(false)
const _CommerceAPI = require('./index').default
const api = new _CommerceAPI(apiConfig)

Expand Down
35 changes: 31 additions & 4 deletions packages/template-retail-react-app/app/commerce-api/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import jwtDecode from 'jwt-decode'
import {getAppOrigin} from 'pwa-kit-react-sdk/utils/url'
import {HTTPError} from 'pwa-kit-react-sdk/ssr/universal/errors'
import {refreshTokenGuestStorageKey, refreshTokenRegisteredStorageKey} from './constants'
import fetch from 'cross-fetch'

/**
Expand All @@ -16,18 +17,18 @@ import fetch from 'cross-fetch'
* @param {string} token - The JWT bearer token to be inspected
* @returns {boolean}
*/
export function isTokenValid(token) {
export function isTokenExpired(token) {
if (!token) {
return false
return true
}
const {exp, iat} = jwtDecode(token.replace('Bearer ', ''))
const validTimeSeconds = exp - iat - 60
const tokenAgeSeconds = Date.now() / 1000 - iat
if (validTimeSeconds > tokenAgeSeconds) {
return true
return false
}

return false
return true
}

// Returns fomrulated body for SopperLogin getToken endpoint
Expand Down Expand Up @@ -271,3 +272,29 @@ export const convertSnakeCaseToSentenceCase = (text) => {
* Usually used as default for event handlers.
*/
export const noop = () => {}

/**
* WARNING: This function is relevant to be used in Hybrid deployments only.
* 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
* 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,
* 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
* as that on PWA Kit which would incorrectly show both keys to be the same even though the sessions are different.
* @param {Storage} storage Cookie storage on PWA Kit in hybrid deployment.
* @param {LocalStorage} storageCopy Local storage holding the copy of the refresh_token in hybrid deployment.
* @returns {boolean} true if the keys do not match (login state changed), false otherwise.
*/
export function hasSFRAAuthStateChanged(storage, storageCopy) {
let refreshTokenKey =
(storage.get(refreshTokenGuestStorageKey) && refreshTokenGuestStorageKey) ||
(storage.get(refreshTokenRegisteredStorageKey) && refreshTokenRegisteredStorageKey)

let refreshTokenCopyKey =
(storageCopy.get(refreshTokenGuestStorageKey) && refreshTokenGuestStorageKey) ||
(storageCopy.get(refreshTokenRegisteredStorageKey) && refreshTokenRegisteredStorageKey)

if (refreshTokenKey !== refreshTokenCopyKey) {
return true
}

return storage.get(refreshTokenKey) !== storageCopy.get(refreshTokenCopyKey)
}
51 changes: 41 additions & 10 deletions packages/template-retail-react-app/app/commerce-api/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
import jwt from 'njwt'
import {
camelCaseKeysToUnderscore,
isTokenValid,
isTokenExpired,
keysToCamel,
convertSnakeCaseToSentenceCase,
handleAsyncError
handleAsyncError,
hasSFRAAuthStateChanged
} from './utils'

const createJwt = (secondsToExp) => {
Expand All @@ -26,20 +27,20 @@ jest.mock('./utils', () => {
}
})

describe('isTokenValid', () => {
test('returns false when no token given', () => {
expect(isTokenValid()).toBe(false)
describe('isTokenExpired', () => {
test('returns true when no token given', () => {
expect(isTokenExpired()).toBe(true)
})

test('returns true for valid token', () => {
test('returns false for valid token', () => {
const token = createJwt(600)
const bearerToken = `Bearer ${token}`
expect(isTokenValid(token)).toBe(true)
expect(isTokenValid(bearerToken)).toBe(true)
expect(isTokenExpired(token)).toBe(false)
expect(isTokenExpired(bearerToken)).toBe(false)
})

test('returns false if token expires within 60 econds', () => {
expect(isTokenValid(createJwt(59))).toBe(false)
test('returns true if token expires within 60 econds', () => {
expect(isTokenExpired(createJwt(59))).toBe(true)
})
})

Expand Down Expand Up @@ -244,3 +245,33 @@ describe('handleAsyncError', () => {
expect(await handleAsyncError(func)()).toBe(1)
})
})

describe('hasSFRAAuthStateChanged', () => {
test('returns true when refresh_token keys are different', () => {
const storage = new Map()
const storageCopy = new Map()

storage.set('cc-nx-g', 'testRefreshToken1')
storageCopy.set('cc-nx', 'testRefreshToken2')

expect(hasSFRAAuthStateChanged(storage, storageCopy)).toBe(true)
})
test('returns false when refresh_token keys and values are the same', () => {
const storage = new Map()
const storageCopy = new Map()

storage.set('cc-nx', 'testRefreshToken1')
storageCopy.set('cc-nx', 'testRefreshToken1')

expect(hasSFRAAuthStateChanged(storage, storageCopy)).toBe(false)
})
test('returns true when refresh_token keys are same but values are the different', () => {
const storage = new Map()
const storageCopy = new Map()

storage.set('cc-nx-g', 'testRefreshToken1')
storageCopy.set('cc-nx-g', 'testRefreshToken2')

expect(hasSFRAAuthStateChanged(storage, storageCopy)).toBe(true)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ const mockLogin = jest.fn()
jest.mock('../commerce-api/auth', () => {
return jest.fn().mockImplementation(() => {
return {
login: mockLogin
login: mockLogin,
isTokenValid: true
}
})
})
Expand All @@ -47,7 +48,8 @@ jest.mock('../commerce-api/utils', () => {
const originalModule = jest.requireActual('../commerce-api/utils')
return {
...originalModule,
isTokenValid: jest.fn().mockReturnValue(true),
isTokenExpired: jest.fn().mockReturnValue(false),
hasSFRAAuthStateChanged: jest.fn().mockReturnValue(false),
createGetTokenBody: jest.fn().mockReturnValue({
grantType: 'test',
code: 'test',
Expand Down Expand Up @@ -137,10 +139,13 @@ test('Allows customer to sign in to their account', async () => {
user.click(screen.getByText(/sign in/i))

// wait for successful toast to appear
await waitFor(() => {
expect(screen.getByText(/Welcome Tester/i)).toBeInTheDocument()
expect(screen.getByText(/you're now signed in/i)).toBeInTheDocument()
})
await waitFor(
() => {
expect(screen.getByText(/Welcome Tester/i)).toBeInTheDocument()
expect(screen.getByText(/you're now signed in/i)).toBeInTheDocument()
},
{timeout: 20000}
)
})

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

await waitFor(() => {
expect(screen.getAllByText(/welcome tester/i).length).toEqual(2)
})
await waitFor(
() => {
expect(screen.getAllByText(/[email protected]/i).length).toEqual(1)
},
{timeout: 20000}
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ jest.mock('../../commerce-api/utils', () => {
const originalModule = jest.requireActual('../../commerce-api/utils')
return {
...originalModule,
isTokenValid: jest.fn().mockReturnValue(true),
isTokenExpired: jest.fn().mockReturnValue(false),
hasSFRAAuthStateChanged: jest.fn().mockReturnValue(false),
createGetTokenBody: jest.fn().mockReturnValue({
grantType: 'test',
code: 'test',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ jest.mock('../../commerce-api/utils', () => {
const originalModule = jest.requireActual('../../commerce-api/utils')
return {
...originalModule,
isTokenValid: jest.fn().mockReturnValue(true),
isTokenExpired: jest.fn().mockReturnValue(false),
hasSFRAAuthStateChanged: jest.fn().mockReturnValue(false),
createGetTokenBody: jest.fn().mockReturnValue({
grantType: 'test',
code: 'test',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ jest.mock('../../commerce-api/utils', () => {
const originalModule = jest.requireActual('../../commerce-api/utils')
return {
...originalModule,
isTokenValid: jest.fn().mockReturnValue(true),
isTokenExpired: jest.fn().mockReturnValue(false),
hasSFRAAuthStateChanged: jest.fn().mockReturnValue(false),
createGetTokenBody: jest.fn().mockReturnValue({
grantType: 'test',
code: 'test',
Expand Down
4 changes: 2 additions & 2 deletions packages/template-retail-react-app/jest-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,12 @@ jest.mock('pwa-kit-runtime/utils/ssr-config', () => {
}
})

// Mock isTokenValid globally
// Mock isTokenExpired globally
jest.mock('./app/commerce-api/utils', () => {
const originalModule = jest.requireActual('./app/commerce-api/utils')
return {
...originalModule,
isTokenValid: jest.fn().mockReturnValue(true)
isTokenExpired: jest.fn().mockReturnValue(false)
}
})

Expand Down