diff --git a/.eslintignore b/.eslintignore index c9977b236..6b6829707 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,5 +1,6 @@ /test/support/xhr /test/app/public +/test/apps/app/public node_modules /build/dist /build/lib diff --git a/CHANGELOG.md b/CHANGELOG.md index 509e2e881..05d435903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 6.7.0 + +## Features + +- [#1197](https://github.com/okta/okta-auth-js/pull/1197) + - Changes implementation of `SyncStorageService` using `broadcast-channel` instead of using `StorageEvent`. Supports `localStorage` and `cookie` storage. + - Adds `LeaderElectionService` as separate service + - Fixes error `Channel is closed` while stopping leader election + ## 6.6.1 ### Fixes diff --git a/README.md b/README.md index 1d1ed6ecc..5f441b47f 100644 --- a/README.md +++ b/README.md @@ -234,12 +234,12 @@ var authClient = new OktaAuth(config); ### Running as a service -By default, creating a new instance of `OktaAuth` will not create any asynchronous side-effects. However, certain features such as [token auto renew](#autorenew), [token auto remove](#autoremove) and [cross-tab synchronization](#syncstorage) require `OktaAuth` to be running as a service. This means timeouts are set in the background which will continue working until the service is stopped. To start the `OktaAuth` service, simply call the `start` method. To terminate all background processes, call `stop`. See [Service Configuration](#services) for more info. +By default, creating a new instance of `OktaAuth` will not create any asynchronous side-effects. However, certain features such as [token auto renew](#autorenew), [token auto remove](#autoremove) and [cross-tab synchronization](#syncstorage) require `OktaAuth` to be running as a service. This means timeouts are set in the background which will continue working until the service is stopped. To start the `OktaAuth` service, simply call the `start` method right after creation and before calling other methods like [handleLoginRedirect](#handleloginredirecttokens). To terminate all background processes, call `stop`. See [Service Configuration](#services) for more info. ```javascript var authClient = new OktaAuth(config); - authClient.start(); // start the service - authClient.stop(); // stop the service + await authClient.start(); // start the service + await authClient.stop(); // stop the service ``` Starting the service will also call [authStateManager.updateAuthState](#authstatemanagerupdateauthstate). @@ -829,7 +829,7 @@ When `tokenManager.autoRenew` is `true` both renew strategies are enabled. To di By default, the library will attempt to remove expired tokens when `autoRemove` is `true`. If you wish to disable auto removal of tokens, set `autoRemove` to `false`. #### `syncStorage` -Automatically syncs tokens across browser tabs when token storage is `localStorage`. To disable this behavior, set `syncStorage` to false. +Automatically syncs tokens across browser tabs when token storage is `localStorage` or `cookie`. To disable this behavior, set `syncStorage` to false. This is accomplished by selecting a single tab to handle the network requests to refresh the tokens and broadcasting to the other tabs. This is done to avoid all tabs sending refresh requests simultaneously, which can cause rate limiting/throttling issues. @@ -903,11 +903,15 @@ This is accomplished by selecting a single tab to handle the network requests to ### `start()` +> :hourglass: async + Starts the `OktaAuth` service. See [running as a service](#running-as-a-service) for more details. ### `stop()` -Starts the `OktaAuth` service. See [running as a service](#running-as-a-service) for more details. +> :hourglass: async + +Stops the `OktaAuth` service. See [running as a service](#running-as-a-service) for more details. ### `signIn(options)` diff --git a/jest.server.js b/jest.server.js index 96f88339d..487927685 100644 --- a/jest.server.js +++ b/jest.server.js @@ -26,6 +26,7 @@ const config = Object.assign({}, baseConfig, { 'oidc/renewTokens.ts', 'TokenManager/browser', 'SyncStorageService', + 'LeaderElectionService', 'ServiceManager' ]) }); diff --git a/lib/AuthStateManager.ts b/lib/AuthStateManager.ts index acf656aa4..35e16cd40 100644 --- a/lib/AuthStateManager.ts +++ b/lib/AuthStateManager.ts @@ -15,10 +15,9 @@ // Do not use this type in code, so it won't be emitted in the declaration output import PCancelable from 'p-cancelable'; import { AuthSdkError } from './errors'; -import { AuthState, AuthStateLogOptions } from './types'; +import { AuthState, AuthStateLogOptions, EVENT_ADDED, EVENT_REMOVED } from './types'; import { OktaAuth } from '.'; import { getConsole } from './util'; -import { EVENT_ADDED, EVENT_REMOVED } from './TokenManager'; import PromiseQueue from './PromiseQueue'; export const INITIAL_AUTH_STATE = null; diff --git a/lib/OktaAuth.ts b/lib/OktaAuth.ts index 99111267b..fec5e5719 100644 --- a/lib/OktaAuth.ts +++ b/lib/OktaAuth.ts @@ -367,17 +367,22 @@ class OktaAuth implements OktaAuthInterface, SigninAPI, SignoutAPI { // AuthStateManager this.authStateManager = new AuthStateManager(this); + // Enable `syncStorage` only if token storage is shared across tabs (type is `localStorage` or `cookie`) + if (!this.tokenManager.hasSharedStorage()) { + args.services = { ...args.services, syncStorage: false }; + } + // ServiceManager this.serviceManager = new ServiceManager(this, args.services); } async start() { + await this.serviceManager.start(); // TODO: review tokenManager.start this.tokenManager.start(); if (!this.token.isLoginRedirect()) { - this.authStateManager.updateAuthState(); + await this.authStateManager.updateAuthState(); } - await this.serviceManager.start(); } async stop() { diff --git a/lib/SavedObject.ts b/lib/SavedObject.ts index 9058eff6e..f183c6463 100644 --- a/lib/SavedObject.ts +++ b/lib/SavedObject.ts @@ -52,6 +52,11 @@ export default class SavedObject implements StorageProvider { // StorageProvider interface // + isSharedStorage() { + return typeof localStorage !== 'undefined' && this.storageProvider === localStorage as any + || !!this.storageProvider.isSharedStorage?.(); + } + getStorage() { var storageString = this.storageProvider.getItem(this.storageName); storageString = storageString || '{}'; diff --git a/lib/ServiceManager.ts b/lib/ServiceManager.ts index 0c1892eff..aeece01dc 100644 --- a/lib/ServiceManager.ts +++ b/lib/ServiceManager.ts @@ -17,23 +17,20 @@ import { ServiceManagerOptions } from './types'; import { OktaAuth } from '.'; -import { - BroadcastChannel, - createLeaderElection, - LeaderElector -} from 'broadcast-channel'; -import { AutoRenewService, SyncStorageService } from './services'; -import { isBrowser } from './features'; +import { AutoRenewService, SyncStorageService, LeaderElectionService } from './services'; +import { removeNils } from './util'; + +const AUTO_RENEW = 'autoRenew'; +const SYNC_STORAGE = 'syncStorage'; +const LEADER_ELECTION = 'leaderElection'; export class ServiceManager implements ServiceManagerInterface { private sdk: OktaAuth; private options: ServiceManagerOptions; private services: Map; - private channel?: BroadcastChannel; - private elector?: LeaderElector; private started: boolean; - private static knownServices = ['autoRenew', 'syncStorage']; + private static knownServices = [AUTO_RENEW, SYNC_STORAGE, LEADER_ELECTION]; private static defaultOptions = { autoRenew: true, @@ -43,19 +40,23 @@ export class ServiceManager implements ServiceManagerInterface { constructor(sdk: OktaAuth, options: ServiceManagerOptions = {}) { this.sdk = sdk; + this.onLeader = this.onLeader.bind(this); // TODO: backwards compatibility, remove in next major version - OKTA-473815 const { autoRenew, autoRemove, syncStorage } = sdk.tokenManager.getOptions(); + options.electionChannelName = options.electionChannelName || options.broadcastChannelName; this.options = Object.assign({}, ServiceManager.defaultOptions, - { autoRenew, autoRemove, syncStorage }, - options + { autoRenew, autoRemove, syncStorage }, + { + electionChannelName: `${sdk.options.clientId}-election`, + syncChannelName: `${sdk.options.clientId}-sync`, + }, + removeNils(options) ); this.started = false; this.services = new Map(); - this.onLeaderDuplicate = this.onLeaderDuplicate.bind(this); - this.onLeader = this.onLeader.bind(this); ServiceManager.knownServices.forEach(name => { const svc = this.createService(name); @@ -65,47 +66,31 @@ export class ServiceManager implements ServiceManagerInterface { }); } - public static canUseLeaderElection() { - return isBrowser(); - } - - private onLeader() { + private async onLeader() { if (this.started) { // Start services that requires leadership - this.startServices(); + await this.startServices(); } } - private onLeaderDuplicate() { - } - isLeader() { - return !!this.elector?.isLeader; - } - - hasLeader() { - return this.elector?.hasLeader; + return (this.getService(LEADER_ELECTION) as LeaderElectionService)?.isLeader(); } isLeaderRequired() { - return [...this.services.values()].some(srv => srv.requiresLeadership()); + return [...this.services.values()].some(srv => srv.canStart() && srv.requiresLeadership()); } async start() { if (this.started) { return; // noop if services have already started } - // only start election if a leader is required - if (this.isLeaderRequired()) { - await this.startElector(); - } - this.startServices(); + await this.startServices(); this.started = true; } async stop() { - await this.stopElector(); - this.stopServices(); + await this.stopServices(); this.started = false; } @@ -113,54 +98,44 @@ export class ServiceManager implements ServiceManagerInterface { return this.services.get(name); } - private startServices() { - for (const srv of this.services.values()) { - const canStart = srv.canStart() && !srv.isStarted() && (srv.requiresLeadership() ? this.isLeader() : true); - if (canStart) { - srv.start(); + private async startServices() { + for (const [name, srv] of this.services.entries()) { + if (this.canStartService(name, srv)) { + await srv.start(); } } } - private stopServices() { + private async stopServices() { for (const srv of this.services.values()) { - srv.stop(); - } - } - - private async startElector() { - await this.stopElector(); - if (ServiceManager.canUseLeaderElection()) { - if (!this.channel) { - const { broadcastChannelName } = this.options; - this.channel = new BroadcastChannel(broadcastChannelName as string); - } - if (!this.elector) { - this.elector = createLeaderElection(this.channel); - this.elector.onduplicate = this.onLeaderDuplicate; - this.elector.awaitLeadership().then(this.onLeader); - } + await srv.stop(); } } - private async stopElector() { - if (this.elector) { - await this.elector?.die(); - this.elector = undefined; - await this.channel?.close(); - this.channel = undefined; + // eslint-disable-next-line complexity + private canStartService(name: string, srv: ServiceInterface): boolean { + let canStart = srv.canStart() && !srv.isStarted(); + // only start election if a leader is required + if (name === LEADER_ELECTION) { + canStart &&= this.isLeaderRequired(); + } else if (srv.requiresLeadership()) { + canStart &&= this.isLeader(); } + return canStart; } private createService(name: string): ServiceInterface { const tokenManager = this.sdk.tokenManager; - let service: ServiceInterface | undefined; + let service: ServiceInterface; switch (name) { - case 'autoRenew': + case LEADER_ELECTION: + service = new LeaderElectionService({...this.options, onLeader: this.onLeader}); + break; + case AUTO_RENEW: service = new AutoRenewService(tokenManager, {...this.options}); break; - case 'syncStorage': + case SYNC_STORAGE: service = new SyncStorageService(tokenManager, {...this.options}); break; default: diff --git a/lib/TokenManager.ts b/lib/TokenManager.ts index c1e092ace..b1152e6e4 100644 --- a/lib/TokenManager.ts +++ b/lib/TokenManager.ts @@ -29,12 +29,22 @@ import { OktaAuthInterface, StorageProvider, TokenManagerErrorEventHandler, + TokenManagerSetStorageEventHandler, + TokenManagerRenewEventHandler, TokenManagerEventHandler, TokenManagerInterface, RefreshToken, AccessTokenCallback, IDTokenCallback, - RefreshTokenCallback + RefreshTokenCallback, + EVENT_RENEWED, + EVENT_ADDED, + EVENT_ERROR, + EVENT_EXPIRED, + EVENT_REMOVED, + EVENT_SET_STORAGE, + TokenManagerAnyEventHandler, + TokenManagerAnyEvent } from './types'; import { REFRESH_TOKEN_STORAGE_KEY, TOKEN_STORAGE_NAME } from './constants'; @@ -47,14 +57,8 @@ const DEFAULT_OPTIONS = { clearPendingRemoveTokens: true, storage: undefined, // will use value from storageManager config expireEarlySeconds: 30, - storageKey: TOKEN_STORAGE_NAME, - _storageEventDelay: 0 + storageKey: TOKEN_STORAGE_NAME }; -export const EVENT_EXPIRED = 'expired'; -export const EVENT_RENEWED = 'renewed'; -export const EVENT_ADDED = 'added'; -export const EVENT_REMOVED = 'removed'; -export const EVENT_ERROR = 'error'; interface TokenManagerState { expireTimeouts: Record; @@ -74,8 +78,31 @@ export class TokenManager implements TokenManagerInterface { private state: TokenManagerState; private options: TokenManagerOptions; - on: (event: string, handler: TokenManagerErrorEventHandler | TokenManagerEventHandler, context?: object) => void; - off: (event: string, handler?: TokenManagerErrorEventHandler | TokenManagerEventHandler) => void; + on(event: typeof EVENT_RENEWED, handler: TokenManagerRenewEventHandler, context?: object): void; + on(event: typeof EVENT_ERROR, handler: TokenManagerErrorEventHandler, context?: object): void; + on(event: typeof EVENT_SET_STORAGE, handler: TokenManagerSetStorageEventHandler, context?: object): void; + on(event: typeof EVENT_EXPIRED | typeof EVENT_ADDED | typeof EVENT_REMOVED, + handler: TokenManagerEventHandler, context?: object): void; + on(event: TokenManagerAnyEvent, handler: TokenManagerAnyEventHandler, context?: object): void { + if (context) { + this.emitter.on(event, handler, context); + } else { + this.emitter.on(event, handler); + } + } + + off(event: typeof EVENT_RENEWED, handler?: TokenManagerRenewEventHandler): void; + off(event: typeof EVENT_ERROR, handler?: TokenManagerErrorEventHandler): void; + off(event: typeof EVENT_SET_STORAGE, handler?: TokenManagerSetStorageEventHandler): void; + off(event: typeof EVENT_EXPIRED | typeof EVENT_ADDED | typeof EVENT_REMOVED, + handler?: TokenManagerEventHandler): void; + off(event: TokenManagerAnyEvent, handler?: TokenManagerAnyEventHandler): void { + if (handler) { + this.emitter.off(event, handler); + } else { + this.emitter.off(event); + } + } // eslint-disable-next-line complexity constructor(sdk: OktaAuthInterface, options: TokenManagerOptions = {}) { @@ -86,9 +113,6 @@ export class TokenManager implements TokenManagerInterface { } options = Object.assign({}, DEFAULT_OPTIONS, removeNils(options)); - if (isIE11OrLess()) { - options._storageEventDelay = options._storageEventDelay || 1000; - } if (!isLocalhost()) { options.expireEarlySeconds = DEFAULT_OPTIONS.expireEarlySeconds; } @@ -109,9 +133,10 @@ export class TokenManager implements TokenManagerInterface { this.storage = sdk.storageManager.getTokenStorage({...storageOptions, useSeparateCookies: true}); this.clock = SdkClock.create(/* sdk, options */); this.state = defaultState(); + } - this.on = this.emitter.on.bind(this.emitter); - this.off = this.emitter.off.bind(this.emitter); + hasSharedStorage() { + return this.storage.isSharedStorage(); } start() { @@ -160,25 +185,6 @@ export class TokenManager implements TokenManagerInterface { this.emitter.emit(EVENT_ERROR, error); } - emitEventsForCrossTabsStorageUpdate(newValue, oldValue) { - const oldTokens = this.getTokensFromStorageValue(oldValue); - const newTokens = this.getTokensFromStorageValue(newValue); - Object.keys(newTokens).forEach(key => { - const oldToken = oldTokens[key]; - const newToken = newTokens[key]; - if (JSON.stringify(oldToken) !== JSON.stringify(newToken)) { - this.emitAdded(key, newToken); - } - }); - Object.keys(oldTokens).forEach(key => { - const oldToken = oldTokens[key]; - const newToken = newTokens[key]; - if (!newToken) { - this.emitRemoved(key, oldToken); - } - }); - } - clearExpireEventTimeout(key) { clearTimeout(this.state.expireTimeouts[key] as any); delete this.state.expireTimeouts[key]; @@ -238,6 +244,7 @@ export class TokenManager implements TokenManagerInterface { validateToken(token); tokenStorage[key] = token; this.storage.setStorage(tokenStorage); + this.emitSetStorageEvent(); this.emitAdded(key, token); this.setExpireEventTimeout(key, token); } @@ -295,6 +302,19 @@ export class TokenManager implements TokenManagerInterface { throw new AuthSdkError('Unknown token type'); } + // for synchronization of LocalStorage cross tabs for IE11 + private emitSetStorageEvent() { + if (isIE11OrLess()) { + const storage = this.storage.getStorage(); + this.emitter.emit(EVENT_SET_STORAGE, storage); + } + } + + // used in `SyncStorageService` for synchronization of LocalStorage cross tabs for IE11 + public getStorage() { + return this.storage; + } + setTokens( tokens: Tokens, // TODO: callbacks can be removed in the next major version OKTA-407224 @@ -350,7 +370,8 @@ export class TokenManager implements TokenManagerInterface { return storage; }, {}); this.storage.setStorage(storage); - + this.emitSetStorageEvent(); + // emit event and start expiration timer types.forEach(type => { const newToken = tokens[type]; @@ -377,6 +398,7 @@ export class TokenManager implements TokenManagerInterface { var removedToken = tokenStorage[key]; delete tokenStorage[key]; this.storage.setStorage(tokenStorage); + this.emitSetStorageEvent(); this.emitRemoved(key, removedToken); } @@ -435,27 +457,31 @@ export class TokenManager implements TokenManagerInterface { } clear() { + const tokens = this.getTokensSync(); this.clearExpireEventTimeoutAll(); this.storage.clearStorage(); + this.emitSetStorageEvent(); + + Object.keys(tokens).forEach(key => { + this.emitRemoved(key, tokens[key]); + }); } clearPendingRemoveTokens() { - const tokens = this.getTokensSync(); - Object.keys(tokens).forEach(key => { - if (tokens[key].pendingRemove) { - this.remove(key); + const tokenStorage = this.storage.getStorage(); + const removedTokens = {}; + Object.keys(tokenStorage).forEach(key => { + if (tokenStorage[key].pendingRemove) { + removedTokens[key] = tokenStorage[key]; + delete tokenStorage[key]; } }); - } - - getTokensFromStorageValue(value) { - let tokens; - try { - tokens = JSON.parse(value) || {}; - } catch (e) { - tokens = {}; - } - return tokens; + this.storage.setStorage(tokenStorage); + this.emitSetStorageEvent(); + Object.keys(removedTokens).forEach(key => { + this.clearExpireEventTimeout(key); + this.emitRemoved(key, removedTokens[key]); + }); } updateRefreshToken(token: RefreshToken) { @@ -466,6 +492,7 @@ export class TokenManager implements TokenManagerInterface { validateToken(token); tokenStorage[key] = token; this.storage.setStorage(tokenStorage); + this.emitSetStorageEvent(); } removeRefreshToken () { diff --git a/lib/browser/browserStorage.ts b/lib/browser/browserStorage.ts index 6a763edd4..a949d7c67 100644 --- a/lib/browser/browserStorage.ts +++ b/lib/browser/browserStorage.ts @@ -25,6 +25,7 @@ import { CookieStorage } from '../types'; import { warn } from '../util'; +import { isIE11OrLess } from '../features'; // Building this as an object allows us to mock the functions in our tests var storageUtil: BrowserStorageUtil = { @@ -123,6 +124,11 @@ var storageUtil: BrowserStorageUtil = { }, getLocalStorage: function() { + // Workaound for synchronization issue of LocalStorage cross tabs in IE11 + if (isIE11OrLess() && !window.onstorage) { + window.onstorage = function() {}; + } + return localStorage; }, @@ -150,7 +156,8 @@ var storageUtil: BrowserStorageUtil = { }, removeItem: (key) => { this.storage.delete(key); - } + }, + isSharedStorage: () => true }; if (!options!.useSeparateCookies) { @@ -191,7 +198,8 @@ var storageUtil: BrowserStorageUtil = { Object.keys(existingValues).forEach(k => { storage.removeItem(key + '_' + k); }); - } + }, + isSharedStorage: () => true }; }, @@ -204,7 +212,8 @@ var storageUtil: BrowserStorageUtil = { }, setItem: (key, value) => { this.inMemoryStore[key] = value; - } + }, + isSharedStorage: () => false }; }, diff --git a/lib/server/serverStorage.ts b/lib/server/serverStorage.ts index 442654884..bd8fef364 100644 --- a/lib/server/serverStorage.ts +++ b/lib/server/serverStorage.ts @@ -99,7 +99,8 @@ class ServerStorage implements StorageUtil { getItem: this.nodeCache.get, setItem: (key, value) => { this.nodeCache.set(key, value, '2200-01-01T00:00:00.000Z'); - } + }, + isSharedStorage: () => true }; } } diff --git a/lib/services/AutoRenewService.ts b/lib/services/AutoRenewService.ts index b1efef9ac..351167d3b 100644 --- a/lib/services/AutoRenewService.ts +++ b/lib/services/AutoRenewService.ts @@ -11,9 +11,9 @@ */ -import { TokenManager, EVENT_EXPIRED } from '../TokenManager'; +import { TokenManager } from '../TokenManager'; import { AuthSdkError } from '../errors'; -import { ServiceInterface, ServiceManagerOptions } from '../types'; +import { ServiceInterface, ServiceManagerOptions, EVENT_EXPIRED } from '../types'; import { isBrowser } from '../features'; export class AutoRenewService implements ServiceInterface { @@ -63,15 +63,15 @@ export class AutoRenewService implements ServiceInterface { return (!!this.options.autoRenew || !!this.options.autoRemove); } - start() { + async start() { if (this.canStart()) { - this.stop(); + await this.stop(); this.tokenManager.on(EVENT_EXPIRED, this.onTokenExpiredHandler); this.started = true; } } - stop() { + async stop() { if (this.started) { this.tokenManager.off(EVENT_EXPIRED, this.onTokenExpiredHandler); this.renewTimeQueue = []; diff --git a/lib/services/LeaderElectionService.ts b/lib/services/LeaderElectionService.ts new file mode 100644 index 000000000..f7f8bd8ca --- /dev/null +++ b/lib/services/LeaderElectionService.ts @@ -0,0 +1,94 @@ +/*! + * Copyright (c) 2015-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + + +import { ServiceInterface, ServiceManagerOptions } from '../types'; +import { + BroadcastChannel, + createLeaderElection, + LeaderElector +} from 'broadcast-channel'; +import { isBrowser } from '../features'; + +declare type OnLeaderHandler = (() => Promise); +declare type ServiceOptions = ServiceManagerOptions & { + onLeader?: OnLeaderHandler; +}; + +export class LeaderElectionService implements ServiceInterface { + private options: ServiceOptions; + private channel?: BroadcastChannel; + private elector?: LeaderElector; + private started = false; + + constructor(options: ServiceOptions = {}) { + this.options = options; + this.onLeaderDuplicate = this.onLeaderDuplicate.bind(this); + this.onLeader = this.onLeader.bind(this); + } + + private onLeaderDuplicate() { + } + + private async onLeader() { + await this.options.onLeader?.(); + } + + isLeader() { + return !!this.elector?.isLeader; + } + + hasLeader() { + return !!this.elector?.hasLeader; + } + + async start() { + await this.stop(); + if (this.canStart()) { + const { electionChannelName } = this.options; + this.channel = new BroadcastChannel(electionChannelName as string); + this.elector = createLeaderElection(this.channel); + this.elector.onduplicate = this.onLeaderDuplicate; + this.elector.awaitLeadership().then(this.onLeader); + this.started = true; + } + } + + async stop() { + if (this.started) { + if (this.elector) { + await this.elector.die(); + this.elector = undefined; + } + if (this.channel) { + // Workaround to fix error `Failed to execute 'postMessage' on 'BroadcastChannel': Channel is closed` + (this.channel as any).postInternal = () => Promise.resolve(); + await this.channel.close(); + this.channel = undefined; + } + this.started = false; + } + } + + requiresLeadership() { + return false; + } + + isStarted() { + return this.started; + } + + canStart() { + return isBrowser(); + } + +} diff --git a/lib/services/SyncStorageService.ts b/lib/services/SyncStorageService.ts index 4671d593b..10007fa36 100644 --- a/lib/services/SyncStorageService.ts +++ b/lib/services/SyncStorageService.ts @@ -10,48 +10,36 @@ * See the License for the specific language governing permissions and limitations under the License. */ - -/* global window */ import { TokenManager } from '../TokenManager'; +import { BroadcastChannel } from 'broadcast-channel'; import { isBrowser } from '../features'; -import { ServiceManagerOptions, ServiceInterface } from '../types'; - +import { + ServiceManagerOptions, ServiceInterface, Token, Tokens, + EVENT_ADDED, EVENT_REMOVED, EVENT_RENEWED, EVENT_SET_STORAGE +} from '../types'; +export type SyncMessage = { + type: string; + key?: string; + token?: Token; + oldToken?: Token; + storage?: Tokens; +}; export class SyncStorageService implements ServiceInterface { private tokenManager: TokenManager; private options: ServiceManagerOptions; - private syncTimeout: unknown; + private channel?: BroadcastChannel; private started = false; + private enablePostMessage = true; constructor(tokenManager: TokenManager, options: ServiceManagerOptions = {}) { this.tokenManager = tokenManager; this.options = options; - this.storageListener = this.storageListener.bind(this); - } - - // Sync authState cross multiple tabs when localStorage is used as the storageProvider - // A StorageEvent is sent to a window when a storage area it has access to is changed - // within the context of another document. - // https://developer.mozilla.org/en-US/docs/Web/API/StorageEvent - private storageListener({ key, newValue, oldValue }: StorageEvent) { - const opts = this.tokenManager.getOptions(); - - const handleCrossTabsStorageChange = () => { - this.tokenManager.resetExpireEventTimeoutAll(); - this.tokenManager.emitEventsForCrossTabsStorageUpdate(newValue, oldValue); - }; - - // Skip if: - // not from localStorage.clear (event.key is null) - // event.key is not the storageKey - // oldValue === newValue - if (key && (key !== opts.storageKey || newValue === oldValue)) { - return; - } - - // LocalStorage cross tabs update is not synced in IE, set a 1s timer by default to read latest value - // https://stackoverflow.com/questions/24077117/localstorage-in-win8-1-ie11-does-not-synchronize - this.syncTimeout = setTimeout(() => handleCrossTabsStorageChange(), opts._storageEventDelay); + this.onTokenAddedHandler = this.onTokenAddedHandler.bind(this); + this.onTokenRemovedHandler = this.onTokenRemovedHandler.bind(this); + this.onTokenRenewedHandler = this.onTokenRenewedHandler.bind(this); + this.onSetStorageHandler = this.onSetStorageHandler.bind(this); + this.onSyncMessageHandler = this.onSyncMessageHandler.bind(this); } requiresLeadership() { @@ -66,20 +54,103 @@ export class SyncStorageService implements ServiceInterface { return !!this.options.syncStorage && isBrowser(); } - start() { + async start() { if (this.canStart()) { - this.stop(); - window.addEventListener('storage', this.storageListener); + await this.stop(); + const { syncChannelName } = this.options; + this.channel = new BroadcastChannel(syncChannelName as string); + this.tokenManager.on(EVENT_ADDED, this.onTokenAddedHandler); + this.tokenManager.on(EVENT_REMOVED, this.onTokenRemovedHandler); + this.tokenManager.on(EVENT_RENEWED, this.onTokenRenewedHandler); + this.tokenManager.on(EVENT_SET_STORAGE, this.onSetStorageHandler); + this.channel.addEventListener('message', this.onSyncMessageHandler); this.started = true; } } - stop() { + async stop() { if (this.started) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - window.removeEventListener('storage', this.storageListener!); - clearTimeout(this.syncTimeout as any); + this.tokenManager.off(EVENT_ADDED, this.onTokenAddedHandler); + this.tokenManager.off(EVENT_REMOVED, this.onTokenRemovedHandler); + this.tokenManager.off(EVENT_RENEWED, this.onTokenRenewedHandler); + this.tokenManager.off(EVENT_SET_STORAGE, this.onSetStorageHandler); + this.channel?.removeEventListener('message', this.onSyncMessageHandler); + await this.channel?.close(); + this.channel = undefined; this.started = false; } } + + private onTokenAddedHandler(key: string, token: Token) { + if (!this.enablePostMessage) { + return; + } + this.channel?.postMessage({ + type: EVENT_ADDED, + key, + token + }); + } + + private onTokenRemovedHandler(key: string, token: Token) { + if (!this.enablePostMessage) { + return; + } + this.channel?.postMessage({ + type: EVENT_REMOVED, + key, + token + }); + } + + private onTokenRenewedHandler(key: string, token: Token, oldToken?: Token) { + if (!this.enablePostMessage) { + return; + } + this.channel?.postMessage({ + type: EVENT_RENEWED, + key, + token, + oldToken + }); + } + + private onSetStorageHandler(storage: Tokens) { + this.channel?.postMessage({ + type: EVENT_SET_STORAGE, + storage + }); + } + + /* eslint-disable complexity */ + private onSyncMessageHandler(msg: SyncMessage) { + // Notes: + // 1. Using `enablePostMessage` flag here to prevent sync message loop. + // If this flag is on, tokenManager event handlers do not post sync message. + // 2. IE11 has known issue with synchronization of LocalStorage cross tabs. + // One workaround is to set empty event handler for `window.onstorage`. + // But it's not 100% working, sometimes you still get old value from LocalStorage. + // Better approch is to explicitly udpate LocalStorage with `setStorage`. + + this.enablePostMessage = false; + switch (msg.type) { + case EVENT_SET_STORAGE: + this.tokenManager.getStorage().setStorage(msg.storage); + break; + case EVENT_ADDED: + this.tokenManager.emitAdded(msg.key, msg.token); + this.tokenManager.setExpireEventTimeout(msg.key, msg.token); + break; + case EVENT_REMOVED: + this.tokenManager.clearExpireEventTimeout(msg.key); + this.tokenManager.emitRemoved(msg.key, msg.token); + break; + case EVENT_RENEWED: + this.tokenManager.emitRenewed(msg.key, msg.token, msg.oldToken); + break; + default: + break; + } + this.enablePostMessage = true; + } } \ No newline at end of file diff --git a/lib/services/index.ts b/lib/services/index.ts index 86d1e587d..3deffbcbb 100644 --- a/lib/services/index.ts +++ b/lib/services/index.ts @@ -13,3 +13,4 @@ export * from './AutoRenewService'; export * from './SyncStorageService'; +export * from './LeaderElectionService'; diff --git a/lib/types/OktaAuthOptions.ts b/lib/types/OktaAuthOptions.ts index ccd6ad602..11b614d13 100644 --- a/lib/types/OktaAuthOptions.ts +++ b/lib/types/OktaAuthOptions.ts @@ -34,7 +34,6 @@ export interface TokenManagerOptions { storageKey?: string; expireEarlySeconds?: number; syncStorage?: boolean; - _storageEventDelay?: number; } export interface CustomUrls { diff --git a/lib/types/Service.ts b/lib/types/Service.ts index cb6b630cc..a58245d35 100644 --- a/lib/types/Service.ts +++ b/lib/types/Service.ts @@ -1,7 +1,7 @@ // only add methods needed internally export interface ServiceInterface { - start(): void; - stop(): void; + start(): Promise; + stop(): Promise; isStarted(): boolean; canStart(): boolean; requiresLeadership(): boolean; @@ -21,10 +21,14 @@ export interface AutoRenewServiceOptions { export interface SyncStorageServiceOptions { syncStorage?: boolean; + syncChannelName?: string; +} + +export interface LeaderElectionServiceOptions { + electionChannelName?: string; + // TODO: remove in next major version - OKTA-473815 + broadcastChannelName?: string; } export type ServiceManagerOptions = AutoRenewServiceOptions & - SyncStorageServiceOptions & - { - broadcastChannelName?: string; - }; + SyncStorageServiceOptions & LeaderElectionServiceOptions; diff --git a/lib/types/Storage.ts b/lib/types/Storage.ts index 89cfaacd5..d984b13ee 100644 --- a/lib/types/Storage.ts +++ b/lib/types/Storage.ts @@ -26,6 +26,7 @@ export interface SimpleStorage { getItem(key: string): any; setItem(key: string, value: any): void; removeItem?: (key: string) => void; + isSharedStorage?(): boolean; } export interface StorageProvider extends SimpleStorage { @@ -33,6 +34,7 @@ export interface StorageProvider extends SimpleStorage { getStorage(): any; clearStorage(key?: string): void; updateStorage(key: string, value: any): void; + isSharedStorage(): boolean; } // will be removed in next version. OKTA-362589 diff --git a/lib/types/TokenManager.ts b/lib/types/TokenManager.ts index 45a483cb2..ad39185d0 100644 --- a/lib/types/TokenManager.ts +++ b/lib/types/TokenManager.ts @@ -9,18 +9,37 @@ export interface TokenManagerError { tokenKey: string; } -export declare type TokenManagerErrorEventHandler = (error: TokenManagerError) => void; -export declare type TokenManagerEventHandler = (key: string, token: Token, oldtoken?: Token) => void; - - export declare type AccessTokenCallback = (key: string, token: AccessToken) => void; export declare type IDTokenCallback = (key: string, token: IDToken) => void; export declare type RefreshTokenCallback = (key: string, token: RefreshToken) => void; +export const EVENT_EXPIRED = 'expired'; +export const EVENT_RENEWED = 'renewed'; +export const EVENT_ADDED = 'added'; +export const EVENT_REMOVED = 'removed'; +export const EVENT_ERROR = 'error'; +export const EVENT_SET_STORAGE = 'set_storage'; + +export declare type TokenManagerErrorEventHandler = (error: TokenManagerError) => void; +export declare type TokenManagerEventHandler = (key: string, token: Token) => void; +export declare type TokenManagerRenewEventHandler = (key: string, token: Token, oldtoken: Token) => void; +export declare type TokenManagerSetStorageEventHandler = (storage: Tokens) => void; + +export declare type TokenManagerAnyEventHandler = TokenManagerErrorEventHandler | TokenManagerRenewEventHandler | TokenManagerSetStorageEventHandler | TokenManagerEventHandler; +export declare type TokenManagerAnyEvent = typeof EVENT_RENEWED | typeof EVENT_ERROR | typeof EVENT_SET_STORAGE | typeof EVENT_EXPIRED | typeof EVENT_ADDED | typeof EVENT_REMOVED; + // only add methods needed internally export interface TokenManagerInterface { - on: (event: string, handler: TokenManagerErrorEventHandler | TokenManagerEventHandler, context?: object) => void; - off: (event: string, handler?: TokenManagerErrorEventHandler | TokenManagerEventHandler) => void; + on(event: typeof EVENT_RENEWED, handler: TokenManagerRenewEventHandler, context?: object): void; + on(event: typeof EVENT_ERROR, handler: TokenManagerErrorEventHandler, context?: object): void; + on(event: typeof EVENT_SET_STORAGE, handler: TokenManagerSetStorageEventHandler, context?: object): void; + on(event: typeof EVENT_EXPIRED | typeof EVENT_ADDED | typeof EVENT_REMOVED, handler: TokenManagerEventHandler, context?: object): void; + + off(event: typeof EVENT_RENEWED, handler?: TokenManagerRenewEventHandler): void; + off(event: typeof EVENT_ERROR, handler?: TokenManagerErrorEventHandler): void; + off(event: typeof EVENT_SET_STORAGE, handler?: TokenManagerSetStorageEventHandler): void; + off(event: typeof EVENT_EXPIRED | typeof EVENT_ADDED | typeof EVENT_REMOVED, handler?: TokenManagerEventHandler): void; + getTokensSync(): Tokens; setTokens({ accessToken, idToken, refreshToken }: Tokens, accessTokenCb?: AccessTokenCallback, idTokenCb?: IDTokenCallback, refreshTokenCb?: RefreshTokenCallback): void; getStorageKeyByType(type: TokenType): string; diff --git a/polyfill/index.js b/polyfill/index.js index 3151df733..c5cefd500 100644 --- a/polyfill/index.js +++ b/polyfill/index.js @@ -13,11 +13,18 @@ // Polyfills objects needed to support IE 11+ require('core-js/features/object/assign'); +require('core-js/features/object/keys'); require('core-js/features/object/values'); require('core-js/features/object/from-entries'); +require('core-js/features/object/entries'); +require('core-js/features/object/iterate-entries'); +require('core-js/features/object/iterate-keys'); +require('core-js/features/object/iterate-values'); +require('core-js/features/symbol/iterator'); require('core-js/es/promise'); require('core-js/es/typed-array/uint8-array'); require('core-js/features/array/from'); +require('core-js/features/array/includes'); require('core-js/web/url'); require('webcrypto-shim'); diff --git a/samples/generated/static-spa/public/app.js b/samples/generated/static-spa/public/app.js index 37c493407..d8649380c 100644 --- a/samples/generated/static-spa/public/app.js +++ b/samples/generated/static-spa/public/app.js @@ -505,7 +505,9 @@ function showRedirectButton() { function logout(e) { e.preventDefault(); - appState = {}; + appState = { + signedOut: true + }; // Normally tokens are cleared after redirect. For in-memory storage we should clear before. const clearTokensBeforeRedirect = config.storage === 'memory'; authClient.signOut({ clearTokensBeforeRedirect }); @@ -555,6 +557,11 @@ function shouldRedirectToGetTokens(authState) { return false; } + // Don't acquire tokens during signing out + if (appState.signedOut) { + return false; + } + // Call Okta to get tokens. Okta will redirect back to this app // The callback is handled by `handleLoginRedirect` which will call `renderApp` again return true; diff --git a/samples/generated/webpack-spa/src/index.js b/samples/generated/webpack-spa/src/index.js index 34713b3c2..d5ccb0f3f 100644 --- a/samples/generated/webpack-spa/src/index.js +++ b/samples/generated/webpack-spa/src/index.js @@ -504,7 +504,9 @@ function showRedirectButton() { function logout(e) { e.preventDefault(); - appState = {}; + appState = { + signedOut: true + }; // Normally tokens are cleared after redirect. For in-memory storage we should clear before. const clearTokensBeforeRedirect = config.storage === 'memory'; authClient.signOut({ clearTokensBeforeRedirect }); @@ -554,6 +556,11 @@ function shouldRedirectToGetTokens(authState) { return false; } + // Don't acquire tokens during signing out + if (appState.signedOut) { + return false; + } + // Call Okta to get tokens. Okta will redirect back to this app // The callback is handled by `handleLoginRedirect` which will call `renderApp` again return true; diff --git a/samples/templates/partials/spa/app.js b/samples/templates/partials/spa/app.js index 682dcccab..fc4c85698 100644 --- a/samples/templates/partials/spa/app.js +++ b/samples/templates/partials/spa/app.js @@ -290,7 +290,9 @@ function showRedirectButton() { function logout(e) { e.preventDefault(); - appState = {}; + appState = { + signedOut: true + }; // Normally tokens are cleared after redirect. For in-memory storage we should clear before. const clearTokensBeforeRedirect = config.storage === 'memory'; authClient.signOut({ clearTokensBeforeRedirect }); diff --git a/samples/templates/partials/spa/authMethod/redirect.js b/samples/templates/partials/spa/authMethod/redirect.js index ad79c0fdb..69b3cd34e 100644 --- a/samples/templates/partials/spa/authMethod/redirect.js +++ b/samples/templates/partials/spa/authMethod/redirect.js @@ -20,6 +20,11 @@ function shouldRedirectToGetTokens(authState) { return false; } + // Don't acquire tokens during signing out + if (appState.signedOut) { + return false; + } + // Call Okta to get tokens. Okta will redirect back to this app // The callback is handled by `handleLoginRedirect` which will call `renderApp` again return true; diff --git a/test/apps/app/src/form.ts b/test/apps/app/src/form.ts index 2e34195f0..aa19106cb 100644 --- a/test/apps/app/src/form.ts +++ b/test/apps/app/src/form.ts @@ -18,6 +18,7 @@ import { flattenConfig, Config, clearStorage } from './config'; import { FormDataEvent } from './types'; import { htmlString, makeClickHandler } from './util'; import { DEFAULT_CROSS_TABS_COUNT } from './config'; +import { OktaAuth } from '@okta/okta-auth-js'; const id = 'config-form'; const Form = ` @@ -204,24 +205,48 @@ export function updateForm(origConfig: Config): void { // Keeps us in the same tab export function onSubmitForm(event: Event): void { event.preventDefault(); - // eslint-disable-next-line no-new - new FormData(document.getElementById(id) as HTMLFormElement); // will fire formdata event + const form = document.getElementById(id) as HTMLFormElement; + if (OktaAuth.features.isIE11OrLess()) { + submitFormData(formDataObject(form)); + } else { + // eslint-disable-next-line no-new + new FormData(form); // formdata event will be fired automatically + } } // Take the data from the form and update query parameters on the current page export function onFormData(event: FormDataEvent): void { - const formData = event.formData; const params: any = {}; formData.forEach((value, key) => { params[key] = value; }); + submitFormData(params); +} + +function formDataObject(form: HTMLFormElement) { + const params: any = {}; + Array.prototype.slice.call(form.elements).forEach(function (field: any) { + if (!field.name || field.disabled) { + return; + } + if (['reset', 'submit', 'button'].indexOf(field.type) != -1) { + return; + } + if (['checkbox', 'radio'].indexOf(field.type) != -1 && !field.checked) { + return; + } + params[field.name] = field.value; + }); + return params; +} + +function submitFormData(params: any) { const query = '?' + Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent(v as string)}`).join('&'); const newUri = window.location.origin + '/' + query; window.location.replace(newUri); } - export function hideConfig(): void { const configArea = document.getElementById('config-dump'); configArea.style.display = 'none'; diff --git a/test/apps/app/src/testApp.ts b/test/apps/app/src/testApp.ts index c0a67ec33..6fbf57510 100644 --- a/test/apps/app/src/testApp.ts +++ b/test/apps/app/src/testApp.ts @@ -281,10 +281,17 @@ class TestApp { } subscribeToTokenEvents(): void { - ['expired', 'renewed', 'added', 'removed'].forEach(event => { - this.oktaAuth.tokenManager.on(event, (arg1: unknown, arg2?: unknown) => { - console.log(`TokenManager::${event}`, arg1, arg2); - }); + this.oktaAuth.tokenManager.on('added', (arg1: unknown) => { + console.log(`TokenManager::added`, arg1); + }); + this.oktaAuth.tokenManager.on('removed', (arg1: unknown) => { + console.log(`TokenManager::removed`, arg1); + }); + this.oktaAuth.tokenManager.on('expired', (arg1: unknown) => { + console.log(`TokenManager::expired`, arg1); + }); + this.oktaAuth.tokenManager.on('renewed', (arg1: unknown, arg2: unknown) => { + console.log(`TokenManager::renewed`, arg1, arg2); }); this.oktaAuth.tokenManager.on('error', (err: unknown) => { console.log('TokenManager::error', err); @@ -881,7 +888,7 @@ class TestApp { } appHTML(props: Tokens): string { - if (window.location.pathname.includes('/protected')) { + if (window.location.pathname.indexOf('/protected') != -1) { return this.appProtectedHTML(); } diff --git a/test/apps/app/src/window.ts b/test/apps/app/src/window.ts index 07d8b40d9..35751cf90 100644 --- a/test/apps/app/src/window.ts +++ b/test/apps/app/src/window.ts @@ -136,7 +136,7 @@ Object.assign(window, { syncStorage: config?.tokenManager?.syncStorage, }; config.services = { - broadcastChannelName: config.clientId + '_crossTabTest' + electionChannelName: config.clientId + '_crossTabTest' }; config.isTokenRenewPage = true; diff --git a/test/e2e/specs/authRequired.js b/test/e2e/specs/authRequired.js index c0dc7057e..d3fddbb95 100644 --- a/test/e2e/specs/authRequired.js +++ b/test/e2e/specs/authRequired.js @@ -42,6 +42,7 @@ describe('auth required', () => { await openPKCE({}, true); await switchToSecondWindow(); await TestApp.waitForLogoutBtn(); + await TestApp.startService(); await TestApp.logoutRedirect(); await TestApp.assertLoggedOut(); await browser.closeWindow(); diff --git a/test/spec/AuthStateManager.js b/test/spec/AuthStateManager.js index 4c64104b8..ecc2cde3f 100644 --- a/test/spec/AuthStateManager.js +++ b/test/spec/AuthStateManager.js @@ -10,12 +10,12 @@ * See the License for the specific language governing permissions and limitations under the License. */ - -/* global window, StorageEvent */ +/* global window */ import Emitter from 'tiny-emitter'; import { AuthStateManager, INITIAL_AUTH_STATE } from '../../lib/AuthStateManager'; import { AuthSdkError } from '../../lib/errors'; +import { BroadcastChannel } from 'broadcast-channel'; import { OktaAuth } from '@okta/okta-auth-js'; import tokens from '@okta/test.support/tokens'; import util from '@okta/test.support/util'; @@ -28,7 +28,10 @@ function createAuth() { redirectUri: 'https://example.com/redirect', tokenManager: { autoRenew: false, - autoRemove: false, + autoRemove: false + }, + services: { + syncChannelName: 'syncChannel' } }); } @@ -131,20 +134,18 @@ describe('AuthStateManager', () => { } it('should only trigger authStateManager.updateAuthState once when localStorage changed from other dom', async () => { util.disableLeaderElection(); - jest.useFakeTimers(); const auth = createAuth(); auth.authStateManager.updateAuthState = jest.fn(); auth.tokenManager.start(); // uses TokenService / crossTabs await auth.serviceManager.start(); - // simulate localStorage change from other dom context - window.dispatchEvent(new StorageEvent('storage', { - key: 'okta-token-storage', - newValue: '{"idToken": "fake_id_token"}', - oldValue: '{}' - })); - jest.runAllTimers(); + // simulate change from other dom context + const channel = new BroadcastChannel('syncChannel'); + await channel.postMessage({ + type: 'added', + key: 'idToken', + token: tokens.standardIdToken2Parsed + }); expect(auth.authStateManager.updateAuthState).toHaveBeenCalledTimes(1); - jest.useRealTimers(); auth.tokenManager.stop(); await auth.serviceManager.stop(); }); diff --git a/test/spec/OktaAuth/api.ts b/test/spec/OktaAuth/api.ts index 7872cd03e..71fd4e8f8 100644 --- a/test/spec/OktaAuth/api.ts +++ b/test/spec/OktaAuth/api.ts @@ -47,20 +47,20 @@ describe('OktaAuth (api)', function() { }); describe('start', () => { - it('starts the token service', () => { + it('starts the token service', async () => { jest.spyOn(auth.tokenManager, 'start'); - auth.start(); + await auth.start(); expect(auth.tokenManager.start).toHaveBeenCalled(); }); - it('updates auth state', () => { + it('updates auth state', async () => { jest.spyOn(auth.authStateManager, 'updateAuthState'); - auth.start(); + await auth.start(); expect(auth.authStateManager.updateAuthState).toHaveBeenCalled(); }); - it('should not update auth state during login redirect', () => { + it('should not update auth state during login redirect', async () => { jest.spyOn(auth.authStateManager, 'updateAuthState'); jest.spyOn(auth.token, 'isLoginRedirect').mockReturnValue(true); - auth.start(); + await auth.start(); expect(auth.authStateManager.updateAuthState).not.toHaveBeenCalled(); }); }); diff --git a/test/spec/ServiceManager.ts b/test/spec/ServiceManager.ts index 148ac68e5..ccd43fc62 100644 --- a/test/spec/ServiceManager.ts +++ b/test/spec/ServiceManager.ts @@ -14,20 +14,31 @@ import { OktaAuth } from '@okta/okta-auth-js'; import util from '@okta/test.support/util'; -jest.mock('broadcast-channel', () => { - const actual = jest.requireActual('broadcast-channel'); - class FakeBroadcastChannel { - async close() {} + +jest.mock('../../lib/services/LeaderElectionService', () => { + class FakeLeaderElectionService { + private _isLeader = false; + private started = false; + private options; + constructor(options = {}) { + this.options = options; + } + canStart() { return true; } + requiresLeadership() { return false; } + isStarted() { return this.started; } + async start() { this.started = true; } + async stop() { this.started = false; } + isLeader() { return this._isLeader; } + _setLeader() { this._isLeader = true; } + async onLeader() { + await (this.options as any).onLeader?.(); + } } return { - createLeaderElection: actual.createLeaderElection, - BroadcastChannel: FakeBroadcastChannel + LeaderElectionService: FakeLeaderElectionService, }; }); -const mocked = { - broadcastChannel: require('broadcast-channel'), -}; function createAuth(options) { options = options || {}; @@ -41,6 +52,7 @@ function createAuth(options) { syncStorage: options.tokenManager.syncStorage || false, autoRenew: options.tokenManager.autoRenew || false, autoRemove: options.tokenManager.autoRemove || false, + ...options.tokenManager }, services: options.services }); @@ -54,80 +66,130 @@ describe('ServiceManager', () => { jest.useRealTimers(); }); - it('starts syncStorage service for every tab, autoRenew service for leader tab (for syncStorage == true)', async () => { - const options = { tokenManager: { syncStorage: true, autoRenew: true } }; - let client1 = createAuth(options); - let client2 = createAuth(options); - util.disableLeaderElection(); - jest.spyOn(client1.serviceManager, 'isLeader').mockReturnValue(true); - jest.spyOn(client2.serviceManager, 'isLeader').mockReturnValue(false); - await client1.serviceManager.start(); - await client2.serviceManager.start(); - expect(client1.serviceManager.getService('autoRenew')?.isStarted()).toBeTruthy(); - expect(client2.serviceManager.getService('autoRenew')?.isStarted()).toBeFalsy(); - expect(client1.serviceManager.getService('syncStorage')?.isStarted()).toBeTruthy(); - expect(client2.serviceManager.getService('syncStorage')?.isStarted()).toBeTruthy(); - await client1.serviceManager.stop(); - await client2.serviceManager.stop(); - }); - - it('starts autoRenew service for every tab (for syncStorage == false)', async () => { - const options = { tokenManager: { syncStorage: false, autoRenew: true } }; - let client1 = createAuth(options); - let client2 = createAuth(options); - util.disableLeaderElection(); - jest.spyOn(client1.serviceManager, 'isLeader').mockReturnValue(true); - jest.spyOn(client2.serviceManager, 'isLeader').mockReturnValue(false); - await client1.serviceManager.start(); - await client2.serviceManager.start(); - expect(client1.serviceManager.getService('autoRenew')?.isStarted()).toBeTruthy(); - expect(client2.serviceManager.getService('autoRenew')?.isStarted()).toBeTruthy(); - expect(client1.serviceManager.getService('syncStorage')?.isStarted()).toBeFalsy(); - expect(client2.serviceManager.getService('syncStorage')?.isStarted()).toBeFalsy(); - await client1.serviceManager.stop(); - await client2.serviceManager.stop(); + describe('syncStorage', () => { + it('allows syncStorage for storage type "cookie"', async () => { + const options = { tokenManager: { syncStorage: true, storage: 'cookie' } }; + util.disableLeaderElection(); + const client = createAuth(options); + await client.serviceManager.start(); + expect(client.serviceManager.getService('syncStorage')?.isStarted()).toBeTruthy(); + await client.serviceManager.stop(); + }); + + it('allows syncStorage for storage type "localStorage"', async () => { + const options = { tokenManager: { syncStorage: true, storage: 'localStorage' } }; + util.disableLeaderElection(); + const client = createAuth(options); + await client.serviceManager.start(); + expect(client.serviceManager.getService('syncStorage')?.isStarted()).toBeTruthy(); + await client.serviceManager.stop(); + }); + + it('NOT allows syncStorage for storage type "sessionStorage"', async () => { + const options = { tokenManager: { syncStorage: true, storage: 'sessionStorage' } }; + util.disableLeaderElection(); + const client = createAuth(options); + await client.serviceManager.start(); + expect(client.serviceManager.getService('syncStorage')?.isStarted()).toBeFalsy(); + await client.serviceManager.stop(); + }); + + it('NOT allows syncStorage for storage type "memory"', async () => { + const options = { tokenManager: { syncStorage: true, storage: 'memory' } }; + util.disableLeaderElection(); + const client = createAuth(options); + await client.serviceManager.start(); + expect(client.serviceManager.getService('syncStorage')?.isStarted()).toBeFalsy(); + await client.serviceManager.stop(); + }); }); - it('starts no services for syncStorage == false and autoRenew == false', async () => { - const options = { tokenManager: { syncStorage: false, autoRenew: false } }; - let client1 = createAuth(options); - let client2 = createAuth(options); - util.disableLeaderElection(); - jest.spyOn(client1.serviceManager, 'isLeader').mockReturnValue(true); - jest.spyOn(client2.serviceManager, 'isLeader').mockReturnValue(false); - await client1.serviceManager.start(); - await client2.serviceManager.start(); - expect(client1.serviceManager.getService('autoRenew')?.isStarted()).toBeFalsy(); - expect(client2.serviceManager.getService('autoRenew')?.isStarted()).toBeFalsy(); - expect(client1.serviceManager.getService('syncStorage')?.isStarted()).toBeFalsy(); - expect(client2.serviceManager.getService('syncStorage')?.isStarted()).toBeFalsy(); - await client1.serviceManager.stop(); - await client2.serviceManager.stop(); + describe('leaderElection', () => { + it('doesn\'t start leaderElection service if other services don\'t require leadership', async () => { + const options = { tokenManager: { syncStorage: false, autoRenew: true } }; + const client = createAuth(options); + await client.serviceManager.start(); + expect(client.serviceManager.isLeaderRequired()).toBeFalsy(); + expect(client.serviceManager.getService('leaderElection')?.isStarted()).toBeFalsy(); + await client.serviceManager.stop(); + }); + + it('starts leaderElection service if any service (autoRenew) requires leadership', async () => { + const options = { tokenManager: { syncStorage: true, autoRenew: true } }; + const client = createAuth(options); + await client.serviceManager.start(); + expect(client.serviceManager.isLeaderRequired()).toBeTruthy(); + expect(client.serviceManager.getService('leaderElection')?.isStarted()).toBeTruthy(); + await client.serviceManager.stop(); + }); }); - it('starts autoRenew service after becoming leader (for syncStorage == true)', async () => { - // Become leader in 100ms - const mockedElector = { - isLeader: false, - awaitLeadership: () => new Promise(resolve => { - setTimeout(() => { - mockedElector.isLeader = true; - resolve(); - }, 100); - }) as Promise, - die: () => Promise.resolve(undefined), - }; - - const options = { tokenManager: { syncStorage: true, autoRenew: true } }; - let client = createAuth(options); - jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(mockedElector); - await client.serviceManager.start(); - expect(client.serviceManager.getService('autoRenew')?.isStarted()).toBeFalsy(); - expect(client.serviceManager.getService('syncStorage')?.isStarted()).toBeTruthy(); - jest.runAllTimers(); - await Promise.resolve(); - expect(client.serviceManager.getService('autoRenew')?.isStarted()).toBeTruthy(); - await client.serviceManager.stop(); + describe('autoRenew', () => { + it('starts syncStorage service for every tab, autoRenew service for leader tab (for syncStorage == true)', async () => { + const options = { tokenManager: { syncStorage: true, autoRenew: true } }; + const client1 = createAuth(options); + const client2 = createAuth(options); + util.disableLeaderElection(); + jest.spyOn(client1.serviceManager, 'isLeader').mockReturnValue(true); + jest.spyOn(client2.serviceManager, 'isLeader').mockReturnValue(false); + await client1.serviceManager.start(); + await client2.serviceManager.start(); + expect(client1.serviceManager.getService('autoRenew')?.isStarted()).toBeTruthy(); + expect(client2.serviceManager.getService('autoRenew')?.isStarted()).toBeFalsy(); + expect(client1.serviceManager.getService('syncStorage')?.isStarted()).toBeTruthy(); + expect(client2.serviceManager.getService('syncStorage')?.isStarted()).toBeTruthy(); + await client1.serviceManager.stop(); + await client2.serviceManager.stop(); + }); + + it('starts autoRenew service for every tab (for syncStorage == false)', async () => { + const options = { tokenManager: { syncStorage: false, autoRenew: true } }; + const client1 = createAuth(options); + const client2 = createAuth(options); + util.disableLeaderElection(); + jest.spyOn(client1.serviceManager, 'isLeader').mockReturnValue(true); + jest.spyOn(client2.serviceManager, 'isLeader').mockReturnValue(false); + await client1.serviceManager.start(); + await client2.serviceManager.start(); + expect(client1.serviceManager.getService('autoRenew')?.isStarted()).toBeTruthy(); + expect(client2.serviceManager.getService('autoRenew')?.isStarted()).toBeTruthy(); + expect(client1.serviceManager.getService('syncStorage')?.isStarted()).toBeFalsy(); + expect(client2.serviceManager.getService('syncStorage')?.isStarted()).toBeFalsy(); + await client1.serviceManager.stop(); + await client2.serviceManager.stop(); + }); + + it('starts no services for syncStorage == false and autoRenew == false', async () => { + const options = { tokenManager: { syncStorage: false, autoRenew: false } }; + const client1 = createAuth(options); + const client2 = createAuth(options); + util.disableLeaderElection(); + jest.spyOn(client1.serviceManager, 'isLeader').mockReturnValue(true); + jest.spyOn(client2.serviceManager, 'isLeader').mockReturnValue(false); + await client1.serviceManager.start(); + await client2.serviceManager.start(); + expect(client1.serviceManager.getService('autoRenew')?.isStarted()).toBeFalsy(); + expect(client2.serviceManager.getService('autoRenew')?.isStarted()).toBeFalsy(); + expect(client1.serviceManager.getService('syncStorage')?.isStarted()).toBeFalsy(); + expect(client2.serviceManager.getService('syncStorage')?.isStarted()).toBeFalsy(); + await client1.serviceManager.stop(); + await client2.serviceManager.stop(); + }); + + it('starts autoRenew service after becoming leader (for syncStorage == true)', async () => { + const options = { tokenManager: { syncStorage: true, autoRenew: true } }; + const client = createAuth(options); + await client.serviceManager.start(); + expect(client.serviceManager.isLeader()).toBeFalsy(); + expect(client.serviceManager.getService('autoRenew')?.isStarted()).toBeFalsy(); + expect(client.serviceManager.getService('syncStorage')?.isStarted()).toBeTruthy(); + expect(client.serviceManager.getService('leaderElection')?.isStarted()).toBeTruthy(); + (client.serviceManager.getService('leaderElection') as any)?._setLeader(); + await (client.serviceManager.getService('leaderElection') as any)?.onLeader(); + expect(client.serviceManager.isLeader()).toBeTruthy(); + expect(client.serviceManager.getService('autoRenew')?.isStarted()).toBeTruthy(); + await client.serviceManager.stop(); + }); }); it('can restart', async () => { @@ -143,6 +205,32 @@ describe('ServiceManager', () => { await client.serviceManager.stop(); }); + it('sets default channel names', () => { + const client = createAuth({}); + const serviceManagerOptions = (client.serviceManager as any).options; + expect(serviceManagerOptions.electionChannelName).toEqual('NPSfOkH5eZrTy8PMDlvx-election'); + expect(serviceManagerOptions.syncChannelName).toEqual('NPSfOkH5eZrTy8PMDlvx-sync'); + }); + + it('can set channel name for leader election with `services.electionChannelName`', () => { + const options = { + services: { electionChannelName: 'test-election-channel' } + }; + const client = createAuth(options); + const serviceManagerOptions = (client.serviceManager as any).options; + expect(serviceManagerOptions.electionChannelName).toEqual('test-election-channel'); + }); + + it('can set channel name for sync service with `services.syncChannelName`', () => { + const options = { + services: { syncChannelName: 'test-sync-channel' } + }; + const client = createAuth(options); + const serviceManagerOptions = (client.serviceManager as any).options; + expect(serviceManagerOptions.syncChannelName).toEqual('test-sync-channel'); + }); + + // TODO: remove in next major version - OKTA-473815 describe('Backwards Compatibility', () => { it('`services` will supersede `tokenManager` configurations', async () => { const options = { @@ -154,6 +242,15 @@ describe('ServiceManager', () => { await client.serviceManager.start(); expect(client.serviceManager.getService('autoRenew')?.isStarted()).toBeFalsy(); }); + + it('`services` supports `broadcastChannelName` as old name for `electionChannelName`', () => { + const options = { + services: { broadcastChannelName: 'test-channel' } + }; + const client = createAuth(options); + const serviceManagerOptions = (client.serviceManager as any).options; + expect(serviceManagerOptions.electionChannelName).toEqual('test-channel'); + }); }); }); diff --git a/test/spec/TokenManager/core.ts b/test/spec/TokenManager/core.ts index 194288d1c..503f57772 100644 --- a/test/spec/TokenManager/core.ts +++ b/test/spec/TokenManager/core.ts @@ -684,23 +684,26 @@ describe('TokenManager', function() { describe('clearPendingRemoveTokens', () => { it('clears pending remove tokens', () => { + const tokenStorage = { + idToken: { ...tokens.standardIdTokenParsed, pendingRemove: true }, + accessToken: { ...tokens.standardAccessTokenParsed, pendingRemove: true } + }; const storageProvider = { - getItem: jest.fn().mockReturnValue(JSON.stringify({ - idToken: { ...tokens.standardIdTokenParsed, pendingRemove: true }, - accessToken: { ...tokens.standardAccessTokenParsed, pendingRemove: true } - })), - setItem: jest.fn() + getItem: jest.fn().mockReturnValue(JSON.stringify(tokenStorage)), + setItem: jest.fn(), }; setupSync({ tokenManager: { storage: storageProvider } }); - jest.spyOn(client.tokenManager, 'remove'); + jest.spyOn(client.tokenManager, 'emitRemoved'); + jest.spyOn(storageProvider, 'setItem'); client.tokenManager.clearPendingRemoveTokens(); - expect(client.tokenManager.remove).toHaveBeenCalledTimes(2); - expect(client.tokenManager.remove).toHaveBeenNthCalledWith(1, 'idToken'); - expect(client.tokenManager.remove).toHaveBeenNthCalledWith(2, 'accessToken'); + expect(storageProvider.setItem).toHaveBeenNthCalledWith(1, 'okta-token-storage', '{}'); + expect(client.tokenManager.emitRemoved).toHaveBeenCalledTimes(2); + expect(client.tokenManager.emitRemoved).toHaveBeenNthCalledWith(1, 'idToken', tokenStorage.idToken); + expect(client.tokenManager.emitRemoved).toHaveBeenNthCalledWith(2, 'accessToken', tokenStorage.accessToken); }); }); diff --git a/test/spec/services/LeaderElectionService.ts b/test/spec/services/LeaderElectionService.ts new file mode 100644 index 000000000..9d2800662 --- /dev/null +++ b/test/spec/services/LeaderElectionService.ts @@ -0,0 +1,150 @@ +/*! + * Copyright (c) 2015-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + + +import { LeaderElectionService } from '../../../lib/services/LeaderElectionService'; + +jest.mock('broadcast-channel', () => { + const actual = jest.requireActual('broadcast-channel'); + class FakeBroadcastChannel { + async close() {} + } + return { + createLeaderElection: actual.createLeaderElection, + BroadcastChannel: FakeBroadcastChannel + }; +}); + +const mocked = { + broadcastChannel: require('broadcast-channel'), +}; + +describe('LeaderElectionService', () => { + let service: LeaderElectionService | null; + beforeEach(function() { + jest.useFakeTimers(); + service = null; + }); + afterEach(() => { + jest.useRealTimers(); + if (service) { + service.stop(); + } + }); + + function createService(options?) { + service = new LeaderElectionService({ + ...options, + electionChannelName: 'electionChannel' + }); + return service; + } + + function createElectorWithLeadership() { + return { + isLeader: true, + awaitLeadership: jest.fn().mockReturnValue(new Promise(() => {})), + die: jest.fn(), + }; + } + + // Become leader in 100ms + function createElectorWithDelayedLeadership() { + const mockedElector = { + isLeader: false, + awaitLeadership: () => new Promise(resolve => { + setTimeout(() => { + mockedElector.isLeader = true; + resolve(); + }, 100); + }) as Promise, + die: () => Promise.resolve(undefined), + }; + return mockedElector; + } + + + describe('start', () => { + it('creates elector and awaits leadership', async () => { + const elector = createElectorWithLeadership(); + jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(elector); + const service = createService(); + await service.start(); + expect(service.isStarted()).toBeTruthy(); + expect((service as any).elector).toStrictEqual(elector); + expect(elector.awaitLeadership).toHaveBeenCalledTimes(1); + }); + + it('stops service if already started', async () => { + const elector = createElectorWithLeadership(); + jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(elector); + const service = createService(); + await service.start(); + await service.start(); + expect(service.isStarted()).toBeTruthy(); + expect(elector.die).toHaveBeenCalledTimes(1); + }); + }); + + describe('stop', () => { + it('should kill elector', async () => { + const elector = createElectorWithLeadership(); + jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(elector); + const service = createService(); + await service.start(); + await service.stop(); + expect(service.isStarted()).toBeFalsy(); + expect(elector.die).toHaveBeenCalledTimes(1); + }); + + it('can be called twice without error', async () => { + const elector = createElectorWithLeadership(); + jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(elector); + const service = createService(); + await service.start(); + expect(service.isStarted()).toBeTruthy(); + await Promise.race([ + service.stop(), + service.stop() + ]); + expect(service.isStarted()).toBeFalsy(); + }); + }); + + describe('isLeader', () => { + it('returns true if current tab is elected as leader', async () => { + const elector = createElectorWithLeadership(); + jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(elector); + const service = createService(); + expect(service.isLeader()).toBeFalsy(); + await service.start(); + expect(service.isLeader()).toBeTruthy(); + }); + }); + + describe('options.onLeader', () => { + it('is called after obtaining leadership', async () => { + const onLeader = jest.fn(); + const elector = createElectorWithDelayedLeadership(); + jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(elector); + const service = createService({ onLeader }); + await service.start(); + await Promise.resolve(); + expect(onLeader).toHaveBeenCalledTimes(0); + expect(service.isLeader()).toBeFalsy(); + jest.runAllTimers(); + await Promise.resolve(); + expect(onLeader).toHaveBeenCalledTimes(1); + expect(service.isLeader()).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/test/spec/services/SyncStorageService.ts b/test/spec/services/SyncStorageService.ts index 6578de6e5..5653e28d0 100644 --- a/test/spec/services/SyncStorageService.ts +++ b/test/spec/services/SyncStorageService.ts @@ -12,195 +12,322 @@ /* eslint-disable max-statements */ -/* global window, localStorage, StorageEvent */ +import tokens from '@okta/test.support/tokens'; import { TokenManager } from '../../../lib/TokenManager'; import { SyncStorageService } from '../../../lib/services/SyncStorageService'; import * as features from '../../../lib/features'; +import { BroadcastChannel } from 'broadcast-channel'; const Emitter = require('tiny-emitter'); describe('SyncStorageService', () => { let sdkMock; - let instance; - let syncInstance; + let tokenManager; + let channel; + let service; + let storage; + let tokenStorage; beforeEach(function() { - jest.useFakeTimers(); - instance = null; + tokenManager = null; + channel = null; + service = null; const emitter = new Emitter(); + storage = { + idToken: tokens.standardIdTokenParsed + }; + tokenStorage = { + getStorage: jest.fn().mockImplementation(() => storage), + setStorage: jest.fn().mockImplementation((newStorage) => { + storage = newStorage; + }), + clearStorage: jest.fn().mockImplementation(() => { + storage = {}; + }), + }; sdkMock = { options: {}, storageManager: { - getTokenStorage: jest.fn().mockReturnValue({ - getStorage: jest.fn().mockReturnValue({}) - }), + getTokenStorage: jest.fn().mockReturnValue(tokenStorage), getOptionsForSection: jest.fn().mockReturnValue({}) }, emitter }; - jest.spyOn(features, 'isIE11OrLess').mockReturnValue(false); jest.spyOn(features, 'isLocalhost').mockReturnValue(true); }); afterEach(() => { - jest.useRealTimers(); - if (instance) { - instance.stop(); + if (tokenManager) { + tokenManager.stop(); + } + if (service) { + service.stop(); } - if (syncInstance) { - syncInstance.stop(); + if (channel) { + channel.close(); } }); - function createInstance(options?) { - instance = new TokenManager(sdkMock, options); - instance.start(); - syncInstance = new SyncStorageService(instance, instance.getOptions()); - syncInstance.start(); - return instance; + async function createInstance(options?) { + tokenManager = new TokenManager(sdkMock, options); + tokenManager.start(); + service = new SyncStorageService(tokenManager, { + ...tokenManager.getOptions(), + syncChannelName: 'syncChannel' + }); + await service.start(); + // Create another channel with same name for communication + channel = new BroadcastChannel('syncChannel'); + return tokenManager; } - it('should emit events and reset timeouts when storage event happen with token storage key', () => { - createInstance(); - instance.resetExpireEventTimeoutAll = jest.fn(); - instance.emitEventsForCrossTabsStorageUpdate = jest.fn(); - window.dispatchEvent(new StorageEvent('storage', { - key: 'okta-token-storage', - newValue: 'fake_new_value', - oldValue: 'fake_old_value' - })); - jest.runAllTimers(); - expect(instance.resetExpireEventTimeoutAll).toHaveBeenCalled(); - expect(instance.emitEventsForCrossTabsStorageUpdate).toHaveBeenCalledWith('fake_new_value', 'fake_old_value'); - }); - it('should set options._storageEventDelay default to 1000 in isIE11OrLess env', () => { - jest.spyOn(features, 'isIE11OrLess').mockReturnValue(true); - createInstance(); - expect(instance.getOptions()._storageEventDelay).toBe(1000); - }); - it('should use options._storageEventDelay from passed options', () => { - createInstance({ _storageEventDelay: 100 }); - expect(instance.getOptions()._storageEventDelay).toBe(100); - }); - it('should use options._storageEventDelay from passed options in isIE11OrLess env', () => { - jest.spyOn(features, 'isIE11OrLess').mockReturnValue(true); - createInstance({ _storageEventDelay: 100 }); - expect(instance.getOptions()._storageEventDelay).toBe(100); - }); - it('should handle storage change based on _storageEventDelay option', () => { - jest.spyOn(window, 'setTimeout'); - createInstance({ _storageEventDelay: 500 }); - instance.resetExpireEventTimeoutAll = jest.fn(); - instance.emitEventsForCrossTabsStorageUpdate = jest.fn(); - window.dispatchEvent(new StorageEvent('storage', { - key: 'okta-token-storage', - newValue: 'fake_new_value', - oldValue: 'fake_old_value' - })); - expect(window.setTimeout).toHaveBeenCalledWith(expect.any(Function), 500); - jest.runAllTimers(); - expect(instance.resetExpireEventTimeoutAll).toHaveBeenCalled(); - expect(instance.emitEventsForCrossTabsStorageUpdate).toHaveBeenCalledWith('fake_new_value', 'fake_old_value'); - }); - it('should emit events and reset timeouts when localStorage.clear() has been called from other tabs', () => { - createInstance(); - instance.resetExpireEventTimeoutAll = jest.fn(); - instance.emitEventsForCrossTabsStorageUpdate = jest.fn(); - // simulate localStorage.clear() - window.dispatchEvent(new StorageEvent('storage', { - key: null, - newValue: null, - oldValue: null - })); - jest.runAllTimers(); - expect(instance.resetExpireEventTimeoutAll).toHaveBeenCalled(); - expect(instance.emitEventsForCrossTabsStorageUpdate).toHaveBeenCalledWith(null, null); - }); - it('should not call localStorage.setItem when token storage changed', () => { - createInstance(); - // https://github.com/facebook/jest/issues/6798#issuecomment-440988627 - jest.spyOn(window.localStorage.__proto__, 'setItem'); - window.dispatchEvent(new StorageEvent('storage', { - key: 'okta-token-storage', - newValue: 'fake_new_value', - oldValue: 'fake_old_value' - })); - expect(localStorage.setItem).not.toHaveBeenCalled(); - }); - it('should not emit events or reset timeouts if the key is not token storage key', () => { - createInstance(); - instance.resetExpireEventTimeoutAll = jest.fn(); - instance.emitEventsForCrossTabsStorageUpdate = jest.fn(); - window.dispatchEvent(new StorageEvent('storage', { - key: 'fake-key', - newValue: 'fake_new_value', - oldValue: 'fake_old_value' - })); - expect(instance.resetExpireEventTimeoutAll).not.toHaveBeenCalled(); - expect(instance.emitEventsForCrossTabsStorageUpdate).not.toHaveBeenCalled(); + describe('start', () => { + it('stops service if already started, closes and recreates channel', async () => { + await createInstance(); + const oldChannel = (service as any).channel; + jest.spyOn(oldChannel, 'close'); + await service.start(); // restart + const newChannel = (service as any).channel; + expect(service.isStarted()).toBeTruthy(); + expect(oldChannel.close).toHaveBeenCalledTimes(1); + expect(newChannel).not.toStrictEqual(oldChannel); + }); }); - it('should not emit events or reset timeouts if oldValue === newValue', () => { - createInstance(); - instance.resetExpireEventTimeoutAll = jest.fn(); - instance.emitEventsForCrossTabsStorageUpdate = jest.fn(); - window.dispatchEvent(new StorageEvent('storage', { - key: 'okta-token-storage', - newValue: 'fake_unchanged_value', - oldValue: 'fake_unchanged_value' - })); - expect(instance.resetExpireEventTimeoutAll).not.toHaveBeenCalled(); - expect(instance.emitEventsForCrossTabsStorageUpdate).not.toHaveBeenCalled(); + + describe('stop', () => { + it('can be called twice without error', async () => { + await createInstance(); + await Promise.race([ + service.stop(), + service.stop() + ]); + expect(service.isStarted()).toBeFalsy(); + expect((service as any).channel).not.toBeDefined(); + }); }); - - describe('_emitEventsForCrossTabsStorageUpdate', () => { - it('should emit "added" event if new token is added', () => { - createInstance(); - const newValue = '{"idToken": "fake-idToken"}'; - const oldValue = null; + + describe('handling sync messages', () => { + it('should emit "added" event if new token is added from another tab', async () => { + await createInstance(); jest.spyOn(sdkMock.emitter, 'emit'); - instance.emitEventsForCrossTabsStorageUpdate(newValue, oldValue); - expect(sdkMock.emitter.emit).toHaveBeenCalledWith('added', 'idToken', 'fake-idToken'); + await channel.postMessage({ + type: 'added', + key: 'idToken', + token: tokens.standardIdToken2Parsed + }); + expect(sdkMock.emitter.emit).toHaveBeenCalledWith('added', 'idToken', tokens.standardIdToken2Parsed); }); - it('should emit "added" event if token is changed', () => { - createInstance(); - const newValue = '{"idToken": "fake-idToken"}'; - const oldValue = '{"idToken": "old-fake-idToken"}'; + + it('should emit "removed" event if token is removed from another tab', async () => { + await createInstance(); jest.spyOn(sdkMock.emitter, 'emit'); - instance.emitEventsForCrossTabsStorageUpdate(newValue, oldValue); - expect(sdkMock.emitter.emit).toHaveBeenCalledWith('added', 'idToken', 'fake-idToken'); + await channel.postMessage({ + type: 'removed', + key: 'idToken', + token: tokens.standardIdTokenParsed + }); + expect(sdkMock.emitter.emit).toHaveBeenCalledWith('removed', 'idToken', tokens.standardIdTokenParsed); }); - it('should emit two "added" event if two token are added', () => { - createInstance(); - const newValue = '{"idToken": "fake-idToken", "accessToken": "fake-accessToken"}'; - const oldValue = null; - jest.spyOn(sdkMock.emitter, 'emit'); - instance.emitEventsForCrossTabsStorageUpdate(newValue, oldValue); - expect(sdkMock.emitter.emit).toHaveBeenNthCalledWith(1, 'added', 'idToken', 'fake-idToken'); - expect(sdkMock.emitter.emit).toHaveBeenNthCalledWith(2, 'added', 'accessToken', 'fake-accessToken'); - }); - it('should not emit "added" event if oldToken equal to newToken', () => { - createInstance(); - const newValue = '{"idToken": "fake-idToken"}'; - const oldValue = '{"idToken": "fake-idToken"}'; + + it('should emit "renewed" event if token is chnaged from another tab', async () => { + await createInstance(); jest.spyOn(sdkMock.emitter, 'emit'); - instance.emitEventsForCrossTabsStorageUpdate(newValue, oldValue); - expect(sdkMock.emitter.emit).not.toHaveBeenCalled(); + await channel.postMessage({ + type: 'renewed', + key: 'idToken', + token: tokens.standardIdToken2Parsed, + oldToken: tokens.standardIdTokenParsed + }); + expect(sdkMock.emitter.emit).toHaveBeenCalledWith('renewed', 'idToken', tokens.standardIdToken2Parsed, tokens.standardIdTokenParsed); }); - it('should emit "removed" event if token is removed', () => { - createInstance(); - const newValue = null; - const oldValue = '{"idToken": "old-fake-idToken"}'; - jest.spyOn(sdkMock.emitter, 'emit'); - instance.emitEventsForCrossTabsStorageUpdate(newValue, oldValue); - expect(sdkMock.emitter.emit).toHaveBeenCalledWith('removed', 'idToken', 'old-fake-idToken'); + + it('should not post sync message to other tabs', async () => { + await createInstance(); + const serviceChannel = (service as any).channel; + jest.spyOn(serviceChannel, 'postMessage'); + await channel.postMessage({ + type: 'removed', + key: 'idToken', + token: tokens.standardIdTokenParsed + }); + expect(serviceChannel.postMessage).toHaveBeenCalledTimes(0); }); - it('should emit two "removed" event if two token are removed', () => { - createInstance(); - const newValue = null; - const oldValue = '{"idToken": "fake-idToken", "accessToken": "fake-accessToken"}'; - jest.spyOn(sdkMock.emitter, 'emit'); - instance.emitEventsForCrossTabsStorageUpdate(newValue, oldValue); - expect(sdkMock.emitter.emit).toHaveBeenNthCalledWith(1, 'removed', 'idToken', 'fake-idToken'); - expect(sdkMock.emitter.emit).toHaveBeenNthCalledWith(2, 'removed', 'accessToken', 'fake-accessToken'); + }); + + describe('posting sync messages', () => { + it('should post "added" sync message when new token is added', async () => { + await createInstance(); + const serviceChannel = (service as any).channel; + jest.spyOn(serviceChannel, 'postMessage'); + tokenManager.add('idToken', tokens.standardIdToken2Parsed); + expect(serviceChannel.postMessage).toHaveBeenCalledWith({ + type: 'added', + key: 'idToken', + token: tokens.standardIdToken2Parsed + }); + }); + + it('should post "removed" sync message when token is removed', async () => { + await createInstance(); + const serviceChannel = (service as any).channel; + jest.spyOn(serviceChannel, 'postMessage'); + tokenManager.remove('idToken'); + expect(serviceChannel.postMessage).toHaveBeenCalledWith({ + type: 'removed', + key: 'idToken', + token: tokens.standardIdTokenParsed + }); + }); + + it('should post "removed", "added", "renewed" sync messages when token is changed', async () => { + await createInstance(); + const serviceChannel = (service as any).channel; + jest.spyOn(serviceChannel, 'postMessage'); + tokenManager.setTokens({ + idToken: tokens.standardIdToken2Parsed + }); + expect(serviceChannel.postMessage).toHaveBeenNthCalledWith(1, { + type: 'removed', + key: 'idToken', + token: tokens.standardIdTokenParsed + }); + expect(serviceChannel.postMessage).toHaveBeenNthCalledWith(2, { + type: 'added', + key: 'idToken', + token: tokens.standardIdToken2Parsed + }); + expect(serviceChannel.postMessage).toHaveBeenNthCalledWith(3, { + type: 'renewed', + key: 'idToken', + token: tokens.standardIdToken2Parsed, + oldToken: tokens.standardIdTokenParsed + }); + }); + + it('should post "remove" events when token storage is cleared', async () => { + await createInstance(); + const serviceChannel = (service as any).channel; + jest.spyOn(serviceChannel, 'postMessage'); + tokenManager.clear(); + expect(serviceChannel.postMessage).toHaveBeenCalledTimes(1); + expect(serviceChannel.postMessage).toHaveBeenNthCalledWith(1, { + type: 'removed', + key: 'idToken', + token: tokens.standardIdTokenParsed + }); + }); + + it('should not post "set_storage" event on storage change (for non-IE)', async () => { + await createInstance(); + const serviceChannel = (service as any).channel; + jest.spyOn(serviceChannel, 'postMessage'); + tokenManager.add('idToken', tokens.standardIdTokenParsed); + expect(serviceChannel.postMessage).toHaveBeenCalledTimes(1); // only "added" }); }); + + describe('IE11', () => { + beforeEach(function() { + jest.spyOn(features, 'isIE11OrLess').mockReturnValue(true); + }); + + it('should post "set_storage" event when new token is added', async () => { + await createInstance(); + const serviceChannel = (service as any).channel; + jest.spyOn(serviceChannel, 'postMessage'); + tokenManager.add('idToken', tokens.standardIdToken2Parsed); + expect(serviceChannel.postMessage).toHaveBeenCalledTimes(2); + expect(serviceChannel.postMessage).toHaveBeenNthCalledWith(1, { + type: 'set_storage', + storage: { + idToken: tokens.standardIdToken2Parsed + }, + }); + expect(serviceChannel.postMessage).toHaveBeenNthCalledWith(2, { + type: 'added', + key: 'idToken', + token: tokens.standardIdToken2Parsed + }); + }); + + it('should post "set_storage" event when token is removed', async () => { + await createInstance(); + const serviceChannel = (service as any).channel; + jest.spyOn(serviceChannel, 'postMessage'); + tokenManager.remove('idToken'); + expect(serviceChannel.postMessage).toHaveBeenCalledTimes(2); + expect(serviceChannel.postMessage).toHaveBeenNthCalledWith(1, { + type: 'set_storage', + storage: { + }, + }); + expect(serviceChannel.postMessage).toHaveBeenNthCalledWith(2, { + type: 'removed', + key: 'idToken', + token: tokens.standardIdTokenParsed + }); + }); + + it('should post "set_storage" event when token storage is cleared', async () => { + await createInstance(); + const serviceChannel = (service as any).channel; + jest.spyOn(serviceChannel, 'postMessage'); + tokenManager.clear(); + expect(serviceChannel.postMessage).toHaveBeenCalledTimes(2); + expect(serviceChannel.postMessage).toHaveBeenNthCalledWith(1, { + type: 'set_storage', + storage: { + }, + }); + expect(serviceChannel.postMessage).toHaveBeenNthCalledWith(2, { + type: 'removed', + key: 'idToken', + token: tokens.standardIdTokenParsed + }); + }); + + it('should post "set_storage" event when token is chnaged', async () => { + await createInstance(); + const serviceChannel = (service as any).channel; + jest.spyOn(serviceChannel, 'postMessage'); + tokenManager.setTokens({ + idToken: tokens.standardIdToken2Parsed + }); + expect(serviceChannel.postMessage).toHaveBeenCalledTimes(4); + expect(serviceChannel.postMessage).toHaveBeenNthCalledWith(1, { + type: 'set_storage', + storage: { + idToken: tokens.standardIdToken2Parsed + }, + }); + expect(serviceChannel.postMessage).toHaveBeenNthCalledWith(2, { + type: 'removed', + key: 'idToken', + token: tokens.standardIdTokenParsed + }); + expect(serviceChannel.postMessage).toHaveBeenNthCalledWith(3, { + type: 'added', + key: 'idToken', + token: tokens.standardIdToken2Parsed + }); + expect(serviceChannel.postMessage).toHaveBeenNthCalledWith(4, { + type: 'renewed', + key: 'idToken', + token: tokens.standardIdToken2Parsed, + oldToken: tokens.standardIdTokenParsed + }); + }); + + it('should update storage excplicitly on "set_storage" event', async () => { + await createInstance(); + const newStorage = { + idToken: tokens.standardIdToken2Parsed + }; + await channel.postMessage({ + type: 'set_storage', + storage: newStorage, + }); + expect(storage).toEqual(newStorage); + }); + }); + }); \ No newline at end of file diff --git a/test/support/jest/jest.setup.js b/test/support/jest/jest.setup.js index 5abf6c1ac..50373db98 100644 --- a/test/support/jest/jest.setup.js +++ b/test/support/jest/jest.setup.js @@ -29,3 +29,7 @@ global.TextEncoder = TextEncoder; // Suppress warning messages global.console.warn = function() {}; + +// broadcast-channel should not detect node environment +// https://github.com/pubkey/broadcast-channel/blob/master/src/util.js#L61 +process[Symbol.toStringTag] = 'Process'; diff --git a/test/support/util.js b/test/support/util.js index 516aa3db2..c893f09b1 100644 --- a/test/support/util.js +++ b/test/support/util.js @@ -459,7 +459,7 @@ util.assertAuthSdkError = function (err, message) { }; util.disableLeaderElection = function() { - jest.spyOn(ServiceManager, 'canUseLeaderElection').mockReturnValue(false); + jest.spyOn(ServiceManager.prototype, 'isLeaderRequired').mockReturnValue(false); }; util.mockLeader = function() {