Skip to content

Instantly share code, notes, and snippets.

@shethj
Last active March 26, 2024 18:47
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save shethj/0e5b069aa5b5779351c826d65dcd2de3 to your computer and use it in GitHub Desktop.
Save shethj/0e5b069aa5b5779351c826d65dcd2de3 to your computer and use it in GitHub Desktop.

Sample Auth.js to support hybrid deployment

This gist includes sample code which supports hybrid deployment in PWA Kit retail react app. The major changes are in auth.js where the authentication module supports storing auth tokens as cookies and login(..) function calls the /sessions OCAPI endpoint to bridge sessions between PWA Kit and SFRA sites.

If you generated a PWA Kit project prior to v2.7.1, you need to adopt the changes from this file in your project.

Code diffs are available in the PR: https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1159/files

/*
* 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
*/
/* eslint-disable no-unused-vars */
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 {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'
/**
* An object containing the customer's login credentials.
* @typedef {Object} CustomerCredentials
* @property {string} credentials.email
* @property {string} credentials.password
*/
/**
* Salesforce Customer object.
* {@link https://salesforcecommercecloud.github.io/commerce-sdk-isomorphic/modules/shoppercustomers.html#customer}}
* @typedef {Object} Customer
*/
/**
* A class that provides auth functionality for the retail react app.
*/
const slasCallbackEndpoint = '/callback'
class Auth {
constructor(api) {
this._api = api
this._config = api._config
this._onClient = typeof window !== 'undefined'
this._storageCopy = this._onClient ? new LocalStorage() : new Map()
// To store tokens as cookies
// change the next line to
this._storage = this._onClient ? new CookieStorage() : new Map()
// this._storage = this._onClient ? new LocalStorage() : new Map()
const configOid = api._config.parameters.organizationId
if (!this.oid) {
this.oid = configOid
}
if (this.oid !== configOid) {
this._clearAuth()
this.oid = configOid
}
this.login = this.login.bind(this)
this.logout = this.logout.bind(this)
}
/**
* Enum for user types
* @enum {string}
*/
static USER_TYPE = {
REGISTERED: 'registered',
GUEST: 'guest'
}
/**
* Returns the api client configuration
* @returns {boolean}
*/
get pendingLogin() {
return this._pendingLogin
}
get authToken() {
return this._storage.get(tokenStorageKey)
}
set authToken(token) {
this._storage.set(tokenStorageKey, token)
}
get userType() {
return this._storage.get(refreshTokenRegisteredStorageKey)
? Auth.USER_TYPE.REGISTERED
: Auth.USER_TYPE.GUEST
}
get refreshToken() {
const storageKey =
this.userType === Auth.USER_TYPE.REGISTERED
? refreshTokenRegisteredStorageKey
: refreshTokenGuestStorageKey
return this._storage.get(storageKey)
}
get usid() {
return this._storage.get(usidStorageKey)
}
set usid(usid) {
this._storage.set(usidStorageKey, usid)
}
get cid() {
return this._storage.get(cidStorageKey)
}
set cid(cid) {
this._storage.set(cidStorageKey, cid)
}
get encUserId() {
return this._storage.get(encUserIdStorageKey)
}
set encUserId(encUserId) {
this._storage.set(encUserIdStorageKey, encUserId)
}
get oid() {
return this._storage.get(oidStorageKey)
}
set oid(oid) {
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)
}
/**
* Called with the details from the redirect page that _loginWithCredentials returns
* I think it's best we leave it to developers on how and where to call from
* @param {{grantType, code, usid, codeVerifier, redirectUri}} requestDetails - The cutomerId of customer to get.
*/
async getLoggedInToken(requestDetails) {
const data = new URLSearchParams()
const {grantType, code, usid, codeVerifier, redirectUri} = requestDetails
data.append('code', code)
data.append('grant_type', grantType)
data.append('usid', usid)
data.append('code_verifier', codeVerifier)
data.append('client_id', this._config.parameters.clientId)
data.append('redirect_uri', redirectUri)
const options = {
headers: {
'Content-Type': `application/x-www-form-urlencoded`
},
body: data
}
const response = await this._api.shopperLogin.getAccessToken(options)
// Check for error response before handling the token
if (response.status_code) {
throw new HTTPError(response.status_code, response.message)
}
this._handleShopperLoginTokenResponse(response)
return response
}
/**
* Make a post request to the OCAPI /session endpoint to bridge the session.
*
* The HTTP response contains a set-cookie header which sets the dwsid session cookie.
* This cookie is used on SFRA, and it allows shoppers to navigate between SFRA and
* this PWA site seamlessly; this is often used to enable hybrid deployment.
*
* (Note: this method is client side only, b/c MRT doesn't support set-cookie header right now)
*
* @returns {Promise}
*/
createOCAPISession() {
return fetch(
`${getAppOrigin()}/mobify/proxy/ocapi/s/${
this._config.parameters.siteId
}/dw/shop/v22_8/sessions`,
{
method: 'POST',
headers: {
Authorization: this.authToken
}
}
)
}
/**
* Authorizes the customer as a registered or guest user.
* @param {CustomerCredentials} [credentials]
* @returns {Promise}
*/
async login(credentials) {
// Calling login while its already pending will return a reference
// to the existing promise.
if (this._pendingLogin) {
return this._pendingLogin
}
let retries = 0
const startLoginFlow = () => {
let authorizationMethod = '_loginAsGuest'
if (credentials) {
authorizationMethod = '_loginWithCredentials'
} else if (this.isTokenValid) {
authorizationMethod = '_reuseCurrentLogin'
} else if (this.refreshToken) {
authorizationMethod = '_refreshAccessToken'
}
return this[authorizationMethod](credentials)
.then((result) => {
// Uncomment the following line for phased launch
this._onClient && this.createOCAPISession()
return result
})
.catch((error) => {
const retryErrors = [INVALID_TOKEN, EXPIRED_TOKEN]
if (retries === 0 && retryErrors.includes(error.message)) {
retries = 1 // we only retry once
this._clearAuth()
return startLoginFlow()
}
throw error
})
}
this._pendingLogin = startLoginFlow().finally(() => {
// When the promise is resolved, we need to remove the reference so
// that subsequent calls to `login` can proceed.
this._pendingLogin = undefined
})
return this._pendingLogin
}
/**
* Clears the stored auth token and optionally logs back in as guest.
* @param {boolean} [shouldLoginAsGuest=true] - Indicates if we should automatically log back in as a guest
* @returns {(Promise<Customer>|undefined)}
*/
async logout(shouldLoginAsGuest = true) {
const options = {
parameters: {
refresh_token: this.refreshToken,
client_id: this._config.parameters.clientId,
channel_id: this._config.parameters.siteId
}
}
await this._api.shopperLogin.logoutCustomer(options, true)
await this._clearAuth()
if (shouldLoginAsGuest) {
return this.login()
}
}
/**
* Handles Response from ShopperLogin GetAccessToken, calls the getCustomer method and removes the PCKE code verifier from session storage
* @private
* @param {object} tokenResponse - access_token,id_token,refresh_token, expires_in,token_type, usid, customer_id, enc_user_id, idp_access_token
*/
_handleShopperLoginTokenResponse(tokenResponse) {
const {access_token, refresh_token, customer_id, usid, enc_user_id, id_token} =
tokenResponse
this.authToken = `Bearer ${access_token}`
this.usid = usid
this.cid = customer_id
// we use id_token to distinguish guest and registered users
if (id_token.length > 0) {
this.encUserId = enc_user_id
this._saveRefreshToken(refresh_token, Auth.USER_TYPE.REGISTERED)
} else {
this._saveRefreshToken(refresh_token, Auth.USER_TYPE.GUEST)
}
if (this._onClient) {
sessionStorage.removeItem('codeVerifier')
}
}
async _reuseCurrentLogin() {
// we're reusing the same token so we just need to return the customer object already associated with the token
const customer = {
authType: this.userType,
customerId: this.cid
}
return customer
}
/**
* Begins oAuth PCKE Flow
* @param {{email, password}}} credentials - User Credentials.
* @returns {object} - a skeleton registered customer object that can be used to retrieve a complete customer object
*/
async _loginWithCredentials(credentials) {
const codeVerifier = createCodeVerifier()
const codeChallenge = await generateCodeChallenge(codeVerifier)
sessionStorage.setItem('codeVerifier', codeVerifier)
const authorization = `Basic ${btoa(`${credentials.email}:${credentials.password}`)}`
const options = {
headers: {
Authorization: authorization,
'Content-Type': `application/x-www-form-urlencoded`
},
body: {
redirect_uri: `${getAppOrigin()}${slasCallbackEndpoint}`,
client_id: this._config.parameters.clientId,
code_challenge: codeChallenge,
channel_id: this._config.parameters.siteId,
usid: this.usid // mergeBasket API requires guest usid to be sent in the authToken
}
}
const response = await this._api.shopperLogin.authenticateCustomer(options, true)
if (response.status >= 400) {
const json = await response.json()
throw new HTTPError(response.status, json.message)
}
const tokenBody = createGetTokenBody(
response.url,
`${getAppOrigin()}${slasCallbackEndpoint}`,
window.sessionStorage.getItem('codeVerifier')
)
const {customer_id} = await this.getLoggedInToken(tokenBody)
const customer = {
customerId: customer_id,
authType: Auth.USER_TYPE.REGISTERED
}
return customer
}
/**
* Begins oAuth PCKE Flow for guest
* @returns {object} - a guest customer object
*/
async _loginAsGuest() {
const codeVerifier = createCodeVerifier()
const codeChallenge = await generateCodeChallenge(codeVerifier)
if (this._onClient) {
sessionStorage.setItem('codeVerifier', codeVerifier)
}
const options = {
headers: {
Authorization: '',
'Content-Type': `application/x-www-form-urlencoded`
},
parameters: {
redirect_uri: `${getAppOrigin()}${slasCallbackEndpoint}`,
client_id: this._config.parameters.clientId,
code_challenge: codeChallenge,
response_type: 'code',
hint: 'guest'
}
}
const response = await this._api.shopperLogin.authorizeCustomer(options, true)
if (response.status >= 400) {
let text = await response.text()
let errorMessage = text
try {
const data = JSON.parse(text)
if (data.message) {
errorMessage = data.message
}
} catch {} // eslint-disable-line no-empty
throw new HTTPError(response.status, errorMessage)
}
const tokenBody = createGetTokenBody(
response.url,
`${getAppOrigin()}${slasCallbackEndpoint}`,
this._onClient ? window.sessionStorage.getItem('codeVerifier') : codeVerifier
)
const {customer_id} = await this.getLoggedInToken(tokenBody)
// A guest customerId will never return a customer from the customer endpoint
const customer = {
authType: Auth.USER_TYPE.GUEST,
customerId: customer_id
}
return customer
}
/**
* Creates a guest session
* @private
* @returns {*} - The response to be passed back to original caller.
*/
async _createGuestSession() {
const loginType = 'guest'
const options = {
body: {
type: loginType
}
}
const rawResponse = await this._api.shopperCustomers.authorizeCustomer(options, true)
return rawResponse
}
/**
* Refreshes Logged In Token
* @private
* @returns {<Promise>} - Handle Shopper Login Promise
*/
async _refreshAccessToken() {
const data = new URLSearchParams()
data.append('grant_type', 'refresh_token')
data.append('refresh_token', this.refreshToken)
data.append('client_id', this._config.parameters.clientId)
const options = {
headers: {
'Content-Type': `application/x-www-form-urlencoded`
},
body: data
}
const response = await this._api.shopperLogin.getAccessToken(options)
// Check for error response before handling the token
if (response.status_code) {
throw new HTTPError(response.status_code, response.message)
}
this._handleShopperLoginTokenResponse(response)
const {id_token, enc_user_id, customer_id} = response
let customer = {
authType: Auth.USER_TYPE.GUEST,
customerId: customer_id
}
// Determining if registered customer or guest
if (id_token.length > 0 && enc_user_id.length > 0) {
customer.authType = Auth.USER_TYPE.REGISTERED
}
return customer
}
/**
* Removes the stored auth token.
* @private
*/
_clearAuth() {
this._storage.delete(tokenStorageKey)
this._storage.delete(refreshTokenRegisteredStorageKey)
this._storage.delete(refreshTokenGuestStorageKey)
this._storage.delete(usidStorageKey)
this._storage.delete(cidStorageKey)
this._storage.delete(encUserIdStorageKey)
this._storage.delete(dwSessionIdKey)
}
}
export default Auth
class Storage {
set(key, value, options) {}
get(key) {}
delete(key) {}
}
class CookieStorage extends Storage {
constructor(...args) {
super(args)
if (typeof document === 'undefined') {
throw new Error('CookieStorage is not avaliable on the current environment.')
}
}
set(key, value, options) {
Cookies.set(key, value, {secure: true, ...options})
}
get(key) {
return Cookies.get(key)
}
delete(key) {
Cookies.remove(key)
}
}
class LocalStorage extends Storage {
constructor(...args) {
super(args)
if (typeof window === 'undefined') {
throw new Error('LocalStorage is not avaliable on the current environment.')
}
}
set(key, value) {
window.localStorage.setItem(key, value)
}
get(key) {
return window.localStorage.getItem(key)
}
delete(key) {
window.localStorage.removeItem(key)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment