Building a Purple Experience
...
SSO-Manager
Files
2 min
file structure default/storefront/assets/scripts/custom/ ├── custom js # main entry point import init js here └── sso manager/ ├── config js # configuration file ├── sso manager js # core ssomanager class └── init js # extended class with lifecycle hooks files config js / configuration for sso manager @type {config} / export const config = { type "oauth", // note currently unused in the class implementation withpostmessage false, externalstate { login \["logged in", "true"], logout \["logged in", "false"], purchase \["purchase", "true"], token \["transfertoken"] }, urlparamkeys { returnurl "returnto", loginviaurl "external login", checkoutviaurl "external checkout", logoutviaurl "external logout", }, postmessage { validorigins \[ "https //dev hz de", "https //web purplemanager com/heidenheimer staging", "resource //dynamic", ], }, targeturlparams {}, returnurlparams {}, removeurlparams \["jwt"], loginurl "https //checkout stage hz de/dispatch", logouturl "https //checkout stage hz de/logout", checkouturl "https //checkout stage hz de/start", }; / type externalstatekey = string; // url parameter key type externalstatevalue = string | undefined; // url parameter value type externalstate = \[externalstatekey, externalstatevalue]; // missing externalstatevalue causes checks for key only type config = { type 'oauth' | 'transfertoken'; // which sso procedure is active withpostmessage? boolean; // enable triggering login/logout via post message? externalstate { login externalstate; // external login state logout externalstate; // external logout state purchase externalstate; // external purchase action token externalstate; // transfertoken, usually declared without externalstatevalue } urlparamkeys { returnurl string; // under which url parameter expects the external api the return url loginviaurl "external login"; // url param which triggers the login, can receive a string as value which will be used as targeturl checkoutviaurl "external checkout"; // url param which triggers the checkout, can receive a string as value which will be used as targeturl logoutviaurl "external logout"; // url param which triggers the logout } postmessage { validorigins string\[]; // array of origins which are allowed to trigger ssomanager login/logout via post message } targeturlparams record\<string, string | number | boolean>; // additional params we need/want to add to the target url returnurlparams record\<string, string | number | boolean>; // additional params we need/want to add to the return url removeurlparams string\[], // additional url params we want to remove upon login/logout (eg to avoid endless login loop) loginurl string; // url to be called to login externally when none is given in the login call logouturl string; // url to be called to logout (always internally) checkouturl string; // url to be called to checkout externally when none is given in the checkout call } / sso manager js import { config as ssomanagerconfig } from ' /config'; / ssomanager class handles external authentication flows for both web and app platforms supports oauth and token based authentication, with optional postmessage integration for iframe communication this is a singleton class only one instance can exist at a time features login/logout via url parameters login/logout via postmessage (for iframe integration) automatic state detection and synchronization platform specific handling (web vs app) configurable return urls and url parameters @class / export const ssomanager = class { static instance = null; isweb; config = ssomanagerconfig; customreturnurl = undefined; / initialize ssomanager instance sets up authentication flow based on configuration and initializes postmessage listener if enabled singleton pattern returns existing instance if one already exists @constructor / constructor() { // return existing instance if it exists if (ssomanager instance) { return ssomanager instance; } if (!this config) { console error('no ssomanagerconfig found '); // todo validation of config? return; } // store instance ssomanager instance = this; this init(); if (this config withpostmessage) { this addpostmessage(); } } / get the singleton instance creates a new instance if none exists @returns {ssomanager} the singleton instance / static getinstance() { if (!ssomanager instance) { ssomanager instance = new ssomanager(); } return ssomanager instance; } / reset the singleton instance useful for testing or reinitialization @returns {void} / static resetinstance() { ssomanager instance = null; } / initialize the authentication flow detects platform (web/app) exposes public methods checks url parameters for login/logout triggers handles return from external sso provider (web only) type init = async () => void; / init = async () => { console log('ssomanager init config', this config); const metadata = await purple metadata getmetadata(); this isweb = metadata platform === 'web'; const url = new url(window\ location href); const params = new urlsearchparams(url search); const { loginviaurl, checkoutviaurl, logoutviaurl } = this config? urlparamkeys ?? {}; // trigger login via url param (eg by app menu) if (params has(loginviaurl)) { this utils clearurlparams(); this login(url searchparams get(loginviaurl)); } // trigger checkout via url param (eg by app menu) else if (params has(checkoutviaurl)) { this utils clearurlparams(); this checkout(url searchparams get(checkoutviaurl)); } // trigger logout via url param (eg by app menu) else if (params has(logoutviaurl)) { this utils clearurlparams(); this logout(); } // do web specific stuff upon init else if (this isweb) { this initweb(params); } }; / handle web specific initialization upon initial page render, we might be returning from an external sso provider this triggers the login/logout flow which decides if we need to login/logout type initweb = (params urlsearchparams) => void; / initweb = (params) => { console debug('ssomanager initweb params', params); const urlparams = object fromentries(params); const sanitizedurlparams = this utils sanitizeurlparams(urlparams) console debug('ssomanager initweb sanitizedurlparams', sanitizedurlparams); this finishloginout(sanitizedurlparams); } / activate login/checkout/logout flow via postmessage used for paywalls in iframes and cross origin communication type addpostmessage = () => void; @example // post message structure type postmessage = { method 'login' | 'checkout' | 'logout'; targeturl? string; } // usage examples const postmessage = {method 'login', targeturl 'https //www sso provider com/login'}; const postmessage = {method 'login'}; // targeturl optional, uses config const postmessage = {method 'checkout', targeturl 'https //www sso provider com/checkout'}; const postmessage = {method 'checkout'}; // targeturl optional, uses config const postmessage = {method 'logout'}; // logout always ignores targeturl window\ postmessage(postmessage) / addpostmessage = () => { window\ addeventlistener( "message", (event) => { console debug('ssomanager addpostmessage event data', event data); const receivedurl = new url(event origin); const receivedorigin = receivedurl origin; // build list of valid origins with special handling for non standard protocols const validoriginchecks = (this config postmessage? validorigins ?? \[]) map(configuredorigin => { const configurl = new url(configuredorigin); return { origin configurl origin, protocol configurl protocol, hostname configurl hostname, }; }); // check if received origin is valid // for standard origins (http/https), compare origin property // for non standard protocols with origin="null", compare protocol+hostname const isvalidorigin = validoriginchecks some(valid => { if (valid origin !== "null" && receivedorigin !== "null") { // standard origin comparison (http/https) return valid origin === receivedorigin; } else if (valid origin === "null" && receivedorigin === "null") { // non standard protocol comparison (resource //, etc ) return valid protocol === receivedurl protocol && valid hostname === receivedurl hostname; } return false; }); if (!isvalidorigin) { console debug('ssomanager addpostmessage origin not in validorigins ', receivedorigin); return; } console debug('ssomanager addpostmessage ', receivedorigin, event data); if (event data method === 'login') { this login(event data targeturl); } else if (event data method === 'checkout') { this checkout(event data targeturl); } else if (event data method === 'logout') { this logout(); } }, false, ); } / check if a given key/value pair matches externalstate config returns true if the value matches, undefined if no decision can be made type checkexternalstate = (key string, value? string) => boolean | undefined; / checkexternalstate(key, value) { console debug('ssomanager checkexternalstate', key, value); // no value no decision if (value == null) { return; } // if the given key is not configured no decision if (!this config externalstate\[key]? \[0]) { console error(`no configuration found for ssomanager config externalstate\[${key}]`); return; } // if we leave the second entry in externalstate\[key] config empty means any value is valid if (!this config externalstate\[key]\[1]) { return true } // check if the value matches the expected value return this config externalstate\[key]\[1] === value; } / decide if we need to (re )login in the app according to information we got from sso provider type loginrequired = ({ token? boolean; login? boolean; purchase? boolean; }) => promise\<boolean>; / loginrequired = async ({ token, login, purchase }) => { console debug('ssomanager loginrequired', { token, login, purchase }); // when token or purchase we have to (re )login regardless the local login state if (purchase || token) { console debug('ssomanager loginrequired purchase || token'); return true; } if (!login) { return false; } // otherwise we check if we are logged in externally const userdata = await purple entitlement getuserdata(); return !userdata accesstoken; } / decide if we need to logout in the app according to information we got from sso provider type logoutrequired = ({ logout? boolean | undefined; }) => promise\<boolean>; / logoutrequired = async ({ logout }) => { console debug('ssomanager logoutrequired', { logout }); if (!logout) return false; const userdata = await purple entitlement getuserdata(); return !!userdata accesstoken; } / start authentication process in native apps uses purple app performauthentication to handle the oauth flow type appauthentication = (targeturl string) => void / appauthentication = (targeturl) => { purple app performauthentication({ url targeturl, // use whatever key is defined as returnurl key callbackparamname this config? urlparamkeys? returnurl, }) then(returneddata => { const sanitizedurlparams = this utils sanitizeurlparams(returneddata values) console debug('ssomanager appauthentication sanitizedurlparams', returneddata, sanitizedurlparams); this finishloginout(sanitizedurlparams); }) catch(error => this loginerror(error)); } / trigger the login flow @param {string} \[targeturl] sso login url uses config loginurl if omitted @param {object} \[options] additional options @param {array<\[string, string|number]>} \[options params] url parameters to add as \[key, value] pairs @param {string} \[options returnurl] custom return url for this specific login call @returns {void} / login = (targeturl, options = {}) => { console debug('ssomanager login targeturl', targeturl); if (!targeturl) { targeturl = this config loginurl; } if (!targeturl) { console error('ssomanager login no targeturl found!'); return; } const { params, returnurl } = options; // sets a customreturnurl which will be used later // only useful for web if (returnurl) { this customreturnurl = returnurl; } const url = new url(targeturl); if (array isarray(params)) { params map( params => { url searchparams append( params); }) } console debug('ssomanager login url', url, url tostring()); if (this isweb) { this loginweb(url tostring()); } else { this loginapp(url tostring()); } }; / trigger the checkout flow same signature as login() @param {string} \[targeturl] sso checkout url uses config checkouturl if omitted @param {object} \[options] additional options (same as login) @param {array<\[string, string|number]>} \[options params] url parameters to add @param {string} \[options returnurl] custom return url @returns {void} / checkout = (targeturl, options = {}) => { console debug('ssomanager checkout targeturl', targeturl); if (!targeturl) { targeturl = this config checkouturl; } if (!targeturl) { console error('ssomanager checkout no targeturl found!'); return; } this login(targeturl, options); } / handle login flow for native apps @param {string} targeturl the sso url to authenticate against @returns {void} / loginapp = (targeturl) => { console debug('ssomanager loginapp targeturl', targeturl); this appauthentication(this utils handletargeturl(targeturl, false)); } / handle login flow for web platform redirects browser to sso provider @param {string} targeturl the sso url to authenticate against @returns {void} / loginweb = (targeturl) => { console debug('ssomanager loginweb targeturl', targeturl); targeturl = this utils handletargeturl(targeturl); console debug('ssomanager loginweb targeturl after handletargeturl', targeturl); window\ location assign(targeturl); } / trigger the logout flow @returns {void} / logout = () => { console debug('ssomanager logout'); if (!this config? logouturl) { this logouterror('no ssomanager config logouturl provided'); return; } if (this isweb) { this logoutweb(); } else { this logoutapp(); } } / handle logout flow for native apps @returns {void} / logoutapp = () => { console debug('ssomanager logoutapp'); this appauthentication(this config logouturl); } / handle logout flow for web platform redirects browser to sso provider logout url @returns {void} / logoutweb = () => { console debug('ssomanager logoutweb'); const url = new url(this config logouturl); url searchparams append(this config? urlparamkeys? returnurl, window\ location href); const targeturl = url tostring(); console debug('ssomanager logoutweb targeturl', targeturl); window\ location assign(targeturl); } / complete the login/logout flow based on external state parameters validates url parameters against config and determines if login or logout is required type sanitizedurlparams = { token? string; login? string; purchase? string; logout? string; } type finishloginout = async (sanitizedurlparams) => promise\<void> / finishloginout = async ({ token, login, logout, purchase }) => { // check if the url param values matches the related configuration const externalstate = { (token ? { token this checkexternalstate('token', token) } {}), (login ? { login this checkexternalstate('login', login) } {}), (purchase ? { purchase this checkexternalstate('purchase', purchase) } {}), (logout ? { logout this checkexternalstate('logout', logout) } {}), } console debug('ssomanager finishloginout externalstate', externalstate); // we got everything we needed from the url this utils clearurlparams(); // check if (re )login is required if (await this loginrequired(externalstate)) { console debug('ssomanager finishloginout loginrequired'); void this finishlogin({ token }); } // check if logout is required else if (await this logoutrequired(externalstate)) { console debug('ssomanager finishloginout logoutrequired'); this finishlogout(); } } / complete the login process logs out existing session if needed, then logs in with external token type finishlogin = async ({ token? string | undefined | null }) => promise\<void> / finishlogin = async ({ token }) => { // logout before logging in since we cannot "re login" const userdata = await purple entitlement getuserdata(); if (userdata accesstoken) { await purple entitlement logout(); } let state; if (this isweb) { // in apps irrelevant state = { redirecturl this utils getstrippedurl() tostring(), } } purple entitlement login({ externaltoken token, (state ? { state } {}) }) then(() => { if (this isweb) { void this onlogin web(); } else { void this onlogin app(); } }) catch(error => this loginerror(error)); } / complete the logout process calls purple entitlement logout and triggers onlogout hooks type finishlogout = () => void / finishlogout = () => { purple entitlement logout() then(() => { if (this isweb) { void this onlogout web(); } else { void this onlogout app(); } }) catch(error => this logouterror(error)); } / handle login error type loginerror = (error error) => void / loginerror = (error) => { console error('login failed', error); } / handle logout error type logouterror = (error error) => void / logouterror = (error) => { console error('logout failed', error); } / finish login by executing methods that might be different by project/environment reload the whole page so everything can properly update type onlogin = { app () => promise\<void>; web () => promise\<void>; } / onlogin = { app async () => { if (this onlogin? app) { await this onlogin app(); } this utils reload(); }, web async () => { if (this onlogin? web) { await this onlogin web(); } this utils reload(); } } / finish logout by executing methods that might be different by project/environment reload the whole page so everything can properly update type onlogout = { app () => promise\<void>; web () => promise\<void>; } / onlogout = { app async () => { if (this onlogout? app) { await this onlogout app(); } this utils reload(); }, web async () => { if (this onlogout? web) { await this onlogout web(); } this utils reload(); } } / get and reset the custom return url we want to reset the customreturnurl variable to undefined as soon as we use it so it cannot linger and pollute later login attempts type customreturnurl = string | undefined @returns {string | undefined} the custom return url if set, undefined otherwise / get customreturnurl() { const temp = this customreturnurl; this customreturnurl = undefined; return temp; } / provides utility functions for url handling and application state management / utils = { / handle target url by adding configured params and optionally the return url type handletargeturl = (targeturl string, withreturnurl? boolean) => string / handletargeturl (targeturl, withreturnurl = true) => { console debug('ssomanager utils handletargeturl targeturl', targeturl); const url = new url(targeturl); // add additional params defined in config to targeturl object entries(this config targeturlparams ?? {}) foreach((\[key, value]) => { url searchparams append(key, value); }) // adds return url as param to the targeturl using the urlparamkeys returnurl from config if (withreturnurl) { // the return url must be added as last param // otherwise later params might considered to belong to the return url const returnurl = this utils getreturnurl(); url searchparams append(this config? urlparamkeys? returnurl, returnurl); } console debug('ssomanager utils handletargeturl returned url', url tostring()); return url tostring(); }, / reload application by either reloading or replacing current location with a different one necessary because window\ location replace(newlocation) does not work when current and new location are identical type reload = () => void / reload () => { const url = this utils getstrippedurl(); const newlocation = url tostring(); console debug('ssomanager utils reload currentlocation', window\ location href); console debug('ssomanager utils reload newlocation', newlocation); if (window\ location href === newlocation) { window\ location reload(); } else { window\ location replace(newlocation); } }, / create the url we want to return to when coming back from sso provider only works for web (app is handled internally) uses customreturnurl if set adds additional parameters from config returnurlparams if present type getreturnurl = () => string / getreturnurl () => { const returnurl = new url(this customreturnurl ?? window\ location href); object entries(this config returnurlparams ?? {}) foreach((\[key, value]) => { returnurl searchparams append(key, value); }) console debug('ssomanager utils getreturnurl returnurl tostring()', returnurl tostring()); return returnurl tostring(); }, / create a url from the current url without login/logout relevant url params or manually declared ones in config removeurlparams type getstrippedurl = () => url / getstrippedurl () => { const { token, login, logout, purchase } = this config? externalstate ?? {}; console debug('ssomanager utils getstrippedurl { token, login, logout, purchase }', { token, login, logout, purchase }); const url = new url(window\ location href); \[ (this config? removeurlparams ?? \[]), `${this config? urlparamkeys? loginviaurl}`, `${this config? urlparamkeys? checkoutviaurl}`, `${this config? urlparamkeys? logoutviaurl}`, token\[0], login\[0], logout\[0], purchase\[0], ] foreach(param => { url searchparams delete(param) }); return url; }, / replace the current url with a clean one without any data regarding login/logout uses getstrippedurl() to create the clean url does not reload the application type clearurlparams = () => void / clearurlparams () => { const url = this utils getstrippedurl(); window\ history replacestate(null, '', url tostring()); }, / remap url parameters to avoid always working with the keys we defined in config externalstate converts external parameter names to normalized internal keys (token, login, purchase, logout) type paramsobject = record\<string, string | undefined | null> type normalizedparamkey = 'token' | 'login' | 'purchase' | 'logout' type sanitizedurlparams = { token? string; login? string; purchase? string; logout? string; } type sanitizeurlparams = (paramsobject paramsobject) => sanitizedurlparams / sanitizeurlparams (paramsobject) => { console debug('ssomanager utils sanitizeurlparams paramsobject', paramsobject); if (!paramsobject) return {}; const sanitizeurlparam = (key) => { const urlparamkey = this config? externalstate? \[key]? \[0]; if (paramsobject hasownproperty(urlparamkey)) { return { \[key] paramsobject\[urlparamkey] } } return {}; } return { sanitizeurlparam('token'), sanitizeurlparam('login'), sanitizeurlparam('purchase'), sanitizeurlparam('logout'), } } } }; init js import { ssomanager } from ' /sso manager'; import { extendstorefronthook } from " /utils/extend storefront hook"; / extended ssomanager class with project specific login/logout hooks @extends ssomanager / class extendedssomanager extends ssomanager { / hooks to execute project specific logic after successful login @type {{ app () => promise\<void>, web () => promise\<void> }} / onlogin = { app async () => { // do some project/environment specific stuff right after login in app }, web async () => { // do some project/environment specific stuff right after login in web } } / hooks to execute project specific logic after successful logout @type {{ app () => promise\<void>, web () => promise\<void> }} / onlogout = { app async () => { // do some project/environment specific stuff right after logout in app }, web async () => { // do some project/environment specific stuff right after logout in web } } } / global singleton instance of extendedssomanager @type {extendedssomanager | null} / export let ssomanager = null; / initialize sso manager automatically when purple service is ready creates the singleton instance of extendedssomanager on purple service initialization this ensures sso functionality is available as soon as the application is ready / extendstorefronthook('onpurpleserviceinit', () => { if (!ssomanager) { ssomanager = new extendedssomanager(); } });