Skip to content

Instantly share code, notes, and snippets.

@kevinxh
Last active February 5, 2024 20:01
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kevinxh/a7404d8e1df8dcdd16ca7b6ab980f7e1 to your computer and use it in GitHub Desktop.
Save kevinxh/a7404d8e1df8dcdd16ca7b6ab980f7e1 to your computer and use it in GitHub Desktop.
Hybrid Deployment Support

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.

If you generated a PWA Kit project prior to v1.5, 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/429/files

Authentication flow diagram

PWA authentication flow

/*
* 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 {createGetTokenBody} from './utils'
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
*/
const usidStorageKey = 'usid'
const encUserIdStorageKey = 'enc-user-id'
const tokenStorageKey = 'token'
const refreshTokenStorageKey = '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.
/**
* A class that provides auth functionality for pwa.
*/
const slasCallbackEndpoint = '/callback'
class Auth {
constructor(api) {
this._api = api
this._config = api._config
this._onClient = typeof window !== 'undefined'
this._pendingAuth = undefined
this._customerId = undefined
this._storage = new CookieStorage()
const configOid = api._config.parameters.organizationId
this._oid = this._storage.get(oidStorageKey) || configOid
if (this._oid !== configOid) {
this._clearAuth()
this._saveOid(configOid)
} else {
this._authToken = this._storage.get(tokenStorageKey)
this._refreshToken =
this._storage.get(refreshTokenStorageKey) ||
this._storage.get(refreshTokenGuestStorageKey)
this._usid = this._storage.get(usidStorageKey)
this._encUserId = this._storage.get(encUserIdStorageKey)
}
this.login = this.login.bind(this)
this.logout = this.logout.bind(this)
}
/**
* Returns the api client configuration
* @returns {boolean}
*/
get pendingLogin() {
return this._pendingLogin
}
get authToken() {
return this._authToken
}
get refreshToken() {
return this._refreshToken
}
get usid() {
return this._usid
}
get encUserId() {
return this._encUserId
}
get oid() {
return this._oid
}
/**
* 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 site and it shoppers to navigate between SFRA site 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/v21_3/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._refreshToken) {
authorizationMethod = '_refreshAccessToken'
}
return this[authorizationMethod](credentials)
.catch((error) => {
if (retries === 0 && error.message === 'EXPIRED_TOKEN') {
retries = 1 // we only retry once
this._clearAuth()
return startLoginFlow()
}
throw error
})
.then((result) => {
this._onClient && this.createOCAPISession()
return result
})
}
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._customerId = customer_id
this._saveAccessToken(`Bearer ${access_token}`)
this._saveUsid(usid)
// we use id_token to distinguish guest and registered users
if (id_token.length > 0) {
this._saveEncUserId(enc_user_id)
this._saveRefreshToken(refresh_token, 'registered')
} else {
this._saveRefreshToken(refresh_token, 'guest')
}
if (this._onClient) {
sessionStorage.removeItem('codeVerifier')
}
}
/**
* 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`
},
parameters: {
redirect_uri: `${getAppOrigin()}${slasCallbackEndpoint}`,
client_id: this._config.parameters.clientId,
code_challenge: codeChallenge,
channel_id: this._config.parameters.siteId
}
}
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: '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: '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: 'guest',
customerId: customer_id
}
// Determining if registered customer or guest
if (id_token.length > 0 && enc_user_id.length > 0) {
customer.authType = 'registered'
}
return customer
}
/**
* Stores the given auth token.
* @private
* @param {string} token - A JWT auth token.
*/
_saveAccessToken(token) {
this._authToken = token
if (this._onClient) {
this._storage.set(tokenStorageKey, token)
}
}
/**
* Stores the given usid token.
* @private
* @param {string} usid - Unique shopper Id.
*/
_saveUsid(usid) {
this._usid = usid
if (this._onClient) {
this._storage.set(usidStorageKey, usid)
}
}
/**
* Stores the given enc_user_id token. enc = encoded
* @private
* @param {string} encUserId - Logged in Shopper reference for Einstein API.
*/
_saveEncUserId(encUserId) {
this._encUserId = encUserId
if (this._onClient) {
this._storage.set(encUserIdStorageKey, encUserId)
}
}
/**
* Stores the given oid token.
* @private
* @param {string} oid - Unique organization Id.
*/
_saveOid(oid) {
this._oid = oid
if (this._onClient) {
this._storage.set(oidStorageKey, oid)
}
}
/**
* Removes the stored auth token.
* @private
*/
_clearAuth() {
this._customerId = undefined
this._authToken = undefined
this._refreshToken = undefined
this._usid = undefined
this._encUserId = undefined
if (this._onClient) {
this._storage.remove(tokenStorageKey)
this._storage.remove(refreshTokenStorageKey)
this._storage.remove(refreshTokenGuestStorageKey)
this._storage.remove(usidStorageKey)
this._storage.remove(encUserIdStorageKey)
this._storage.remove(dwSessionIdKey)
}
}
/**
* Stores the given refresh token.
* @private
* @param {string} refreshToken - A JWT refresh token.
*/
_saveRefreshToken(refreshToken, type) {
this._refreshToken = refreshToken
const storeageKey =
type === 'registered' ? refreshTokenStorageKey : refreshTokenGuestStorageKey
if (this._onClient) {
this._storage.set(storeageKey, refreshToken, {expires: REFRESH_TOKEN_COOKIE_AGE})
}
}
}
export default Auth
class Storage {
set(key, value, options) {}
get(key) {}
remove(key) {}
}
class CookieStorage extends Storage {
constructor(...args) {
super(args)
this._avaliable = false
if (typeof document === 'undefined') {
console.warn('CookieStorage is not avaliable on the current environment.')
return
}
this._avaliable = true
}
set(key, value, options) {
this._avaliable && Cookies.set(key, value, {secure: true, ...options})
}
get(key) {
return this._avaliable ? Cookies.get(key) : undefined
}
remove(key) {
this._avaliable && Cookies.remove(key)
}
}
class LocalStorage extends Storage {
constructor(...args) {
super(args)
this._avaliable = false
if (typeof window === 'undefined') {
console.warn('LocalStorage is not avaliable on the current environment.')
return
}
this._avaliable = true
}
set(key, value) {
this._avaliable && window.localStorage.setItem(key, value)
}
get(key) {
return this._avaliable ? window.localStorage.getItem(key) : undefined
}
remove(key) {
this._avaliable && window.localStorage.removeItem(key)
}
}
/*
* 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
*/
import {nanoid} from 'nanoid'
import {encode as base64encode} from 'base64-arraybuffer'
// Server Side
const randomstring = require('randomstring')
// Globals
const isServer = typeof window === 'undefined'
/**
* Creates Code Verifier use for PKCE auth flow.
*
* @returns {String} The 128 character length code verifier.
*/
export const createCodeVerifier = () => {
return isServer ? randomstring.generate(128) : nanoid(128)
}
/**
* Creates Code Challenge based on Code Verifier
*
* @param {String} codeVerifier
* @returns {String}
*/
export const generateCodeChallenge = async (codeVerifier) => {
let base64Digest
if (isServer) {
await import('crypto').then((module) => {
const crypto = module.default
base64Digest = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64')
})
} else {
const encoder = new TextEncoder()
const data = encoder.encode(codeVerifier)
const digest = await window.crypto.subtle.digest('SHA-256', data)
base64Digest = base64encode(digest)
}
return base64Digest
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
export function createGetTokenBody(urlString, slasCallbackEndpoint, codeVerifier) {
const url = new URL(urlString)
const urlParams = new URLSearchParams(url.search)
const usid = urlParams.get('usid')
const code = urlParams.get('code')
return {
grantType: 'authorization_code_pkce',
code,
usid,
codeVerifier: codeVerifier,
redirectUri: slasCallbackEndpoint
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment