From 419224da67ca26e1c90d1cd705a409ce0c00ddca Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Mon, 25 Apr 2022 00:14:10 +0300 Subject: [PATCH 01/35] Added LeaderElectionService --- lib/ServiceManager.ts | 75 ++++++---------------- lib/services/LeaderElectionService.ts | 92 +++++++++++++++++++++++++++ lib/services/index.ts | 1 + lib/types/Service.ts | 9 +-- test/support/util.js | 2 +- 5 files changed, 119 insertions(+), 60 deletions(-) create mode 100644 lib/services/LeaderElectionService.ts diff --git a/lib/ServiceManager.ts b/lib/ServiceManager.ts index 0c1892eff..8b239a2e3 100644 --- a/lib/ServiceManager.ts +++ b/lib/ServiceManager.ts @@ -17,23 +17,15 @@ 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'; 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 = ['autoRenew', 'syncStorage', 'leaderElection']; private static defaultOptions = { autoRenew: true, @@ -43,19 +35,18 @@ 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(); this.options = Object.assign({}, ServiceManager.defaultOptions, - { autoRenew, autoRemove, syncStorage }, + { autoRenew, autoRemove, syncStorage, broadcastChannelName: sdk.options.clientId }, 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,10 +56,6 @@ export class ServiceManager implements ServiceManagerInterface { }); } - public static canUseLeaderElection() { - return isBrowser(); - } - private onLeader() { if (this.started) { // Start services that requires leadership @@ -76,15 +63,8 @@ export class ServiceManager implements ServiceManagerInterface { } } - private onLeaderDuplicate() { - } - isLeader() { - return !!this.elector?.isLeader; - } - - hasLeader() { - return this.elector?.hasLeader; + return (this.getService('leaderElection') as LeaderElectionService)?.isLeader(); } isLeaderRequired() { @@ -95,16 +75,11 @@ export class ServiceManager implements ServiceManagerInterface { 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(); this.started = true; } - async stop() { - await this.stopElector(); + stop() { this.stopServices(); this.started = false; } @@ -114,9 +89,8 @@ export class ServiceManager implements ServiceManagerInterface { } private startServices() { - for (const srv of this.services.values()) { - const canStart = srv.canStart() && !srv.isStarted() && (srv.requiresLeadership() ? this.isLeader() : true); - if (canStart) { + for (const [name, srv] of this.services.entries()) { + if (this.canStartService(name, srv)) { srv.start(); } } @@ -128,28 +102,16 @@ export class ServiceManager implements ServiceManagerInterface { } } - 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); - } - } - } - - 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 == 'leaderElection') { + canStart &&= this.isLeaderRequired(); + } else if (srv.requiresLeadership()) { + canStart &&= this.isLeader(); } + return canStart; } private createService(name: string): ServiceInterface { @@ -157,6 +119,9 @@ export class ServiceManager implements ServiceManagerInterface { let service: ServiceInterface | undefined; switch (name) { + case 'leaderElection': + service = new LeaderElectionService({...this.options, onLeader: this.onLeader}); + break; case 'autoRenew': service = new AutoRenewService(tokenManager, {...this.options}); break; diff --git a/lib/services/LeaderElectionService.ts b/lib/services/LeaderElectionService.ts new file mode 100644 index 000000000..cbd81433b --- /dev/null +++ b/lib/services/LeaderElectionService.ts @@ -0,0 +1,92 @@ +/*! + * 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'; + +type OnLeaderHandler = (() => void); +type Options = ServiceManagerOptions & { + onLeader?: OnLeaderHandler; +}; + +export class LeaderElectionService implements ServiceInterface { + private options: Options; + private channel?: BroadcastChannel; + private elector?: LeaderElector; + private started = false; + + constructor(options: Options = {}) { + this.options = options; + this.onLeaderDuplicate = this.onLeaderDuplicate.bind(this); + this.onLeader = this.onLeader.bind(this); + } + + private onLeaderDuplicate() { + } + + private onLeader() { + this.options.onLeader?.(); + } + + isLeader() { + return !!this.elector?.isLeader; + } + + hasLeader() { + return !!this.elector?.hasLeader; + } + + start() { + this.stop(); + if (this.canStart()) { + 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); + } + this.started = true; + } + } + + stop() { + if (this.started) { + this.elector?.die(); + this.elector = undefined; + this.channel?.close(); + this.channel = undefined; + this.started = false; + } + } + + requiresLeadership() { + return false; + } + + isStarted() { + return this.started; + } + + canStart() { + return isBrowser(); + } + +} 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/Service.ts b/lib/types/Service.ts index cb6b630cc..51dcab8d8 100644 --- a/lib/types/Service.ts +++ b/lib/types/Service.ts @@ -23,8 +23,9 @@ export interface SyncStorageServiceOptions { syncStorage?: boolean; } +export interface LeaderElectionServiceOptions { + broadcastChannelName?: string; +} + export type ServiceManagerOptions = AutoRenewServiceOptions & - SyncStorageServiceOptions & - { - broadcastChannelName?: string; - }; + SyncStorageServiceOptions & LeaderElectionServiceOptions; 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() { From d79566202d870072bbf64883c16d7b71f9a26b66 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Mon, 25 Apr 2022 00:14:39 +0300 Subject: [PATCH 02/35] broadcastChannelName -> electionChannelName --- lib/ServiceManager.ts | 2 +- lib/services/LeaderElectionService.ts | 4 ++-- lib/types/Service.ts | 2 +- test/apps/app/src/window.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/ServiceManager.ts b/lib/ServiceManager.ts index 8b239a2e3..ca2273928 100644 --- a/lib/ServiceManager.ts +++ b/lib/ServiceManager.ts @@ -41,7 +41,7 @@ export class ServiceManager implements ServiceManagerInterface { const { autoRenew, autoRemove, syncStorage } = sdk.tokenManager.getOptions(); this.options = Object.assign({}, ServiceManager.defaultOptions, - { autoRenew, autoRemove, syncStorage, broadcastChannelName: sdk.options.clientId }, + { autoRenew, autoRemove, syncStorage, electionChannelName: sdk.options.clientId }, options ); diff --git a/lib/services/LeaderElectionService.ts b/lib/services/LeaderElectionService.ts index cbd81433b..62a059ed6 100644 --- a/lib/services/LeaderElectionService.ts +++ b/lib/services/LeaderElectionService.ts @@ -55,8 +55,8 @@ export class LeaderElectionService implements ServiceInterface { this.stop(); if (this.canStart()) { if (!this.channel) { - const { broadcastChannelName } = this.options; - this.channel = new BroadcastChannel(broadcastChannelName as string); + const { electionChannelName } = this.options; + this.channel = new BroadcastChannel(electionChannelName as string); } if (!this.elector) { this.elector = createLeaderElection(this.channel); diff --git a/lib/types/Service.ts b/lib/types/Service.ts index 51dcab8d8..6f79b30d0 100644 --- a/lib/types/Service.ts +++ b/lib/types/Service.ts @@ -24,7 +24,7 @@ export interface SyncStorageServiceOptions { } export interface LeaderElectionServiceOptions { - broadcastChannelName?: string; + electionChannelName?: string; } export type ServiceManagerOptions = AutoRenewServiceOptions & 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; From 20c5d10e7a030fb1389b1b7740b21d0d09cbc12f Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Mon, 25 Apr 2022 15:48:42 +0300 Subject: [PATCH 03/35] New impl of SyncStorageService --- jest.server.js | 1 + lib/ServiceManager.ts | 24 ++- lib/TokenManager.ts | 37 +--- lib/services/LeaderElectionService.ts | 22 +-- lib/services/SyncStorageService.ts | 106 +++++++---- lib/types/OktaAuthOptions.ts | 1 - lib/types/Service.ts | 1 + test/spec/AuthStateManager.js | 27 +-- test/spec/ServiceManager.ts | 50 +++--- test/spec/services/LeaderElectionService.ts | 84 +++++++++ test/spec/services/SyncStorageService.ts | 189 +++++--------------- test/support/jest/jest.setup.js | 4 + 12 files changed, 269 insertions(+), 277 deletions(-) create mode 100644 test/spec/services/LeaderElectionService.ts 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/ServiceManager.ts b/lib/ServiceManager.ts index ca2273928..c0c11c707 100644 --- a/lib/ServiceManager.ts +++ b/lib/ServiceManager.ts @@ -19,13 +19,17 @@ import { import { OktaAuth } from '.'; import { AutoRenewService, SyncStorageService, LeaderElectionService } from './services'; +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 started: boolean; - private static knownServices = ['autoRenew', 'syncStorage', 'leaderElection']; + private static knownServices = [AUTO_RENEW, SYNC_STORAGE, LEADER_ELECTION]; private static defaultOptions = { autoRenew: true, @@ -41,7 +45,11 @@ export class ServiceManager implements ServiceManagerInterface { const { autoRenew, autoRemove, syncStorage } = sdk.tokenManager.getOptions(); this.options = Object.assign({}, ServiceManager.defaultOptions, - { autoRenew, autoRemove, syncStorage, electionChannelName: sdk.options.clientId }, + { autoRenew, autoRemove, syncStorage }, + { + electionChannelName: `${sdk.options.clientId}-election`, + syncChannelName: `${sdk.options.clientId}-sync`, + }, options ); @@ -64,11 +72,11 @@ export class ServiceManager implements ServiceManagerInterface { } isLeader() { - return (this.getService('leaderElection') as LeaderElectionService)?.isLeader(); + 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() { @@ -106,7 +114,7 @@ export class ServiceManager implements ServiceManagerInterface { private canStartService(name: string, srv: ServiceInterface): boolean { let canStart = srv.canStart() && !srv.isStarted(); // only start election if a leader is required - if (name == 'leaderElection') { + if (name === LEADER_ELECTION) { canStart &&= this.isLeaderRequired(); } else if (srv.requiresLeadership()) { canStart &&= this.isLeader(); @@ -119,13 +127,13 @@ export class ServiceManager implements ServiceManagerInterface { let service: ServiceInterface | undefined; switch (name) { - case 'leaderElection': + case LEADER_ELECTION: service = new LeaderElectionService({...this.options, onLeader: this.onLeader}); break; - case 'autoRenew': + 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..e478a21fc 100644 --- a/lib/TokenManager.ts +++ b/lib/TokenManager.ts @@ -13,7 +13,7 @@ import { removeNils, clone } from './util'; import { AuthSdkError } from './errors'; import { validateToken } from './oidc/util'; -import { isLocalhost, isIE11OrLess } from './features'; +import { isLocalhost } from './features'; import SdkClock from './clock'; import { EventEmitter, @@ -47,8 +47,7 @@ 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'; @@ -86,9 +85,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; } @@ -160,25 +156,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]; @@ -447,16 +424,6 @@ export class TokenManager implements TokenManagerInterface { } }); } - - getTokensFromStorageValue(value) { - let tokens; - try { - tokens = JSON.parse(value) || {}; - } catch (e) { - tokens = {}; - } - return tokens; - } updateRefreshToken(token: RefreshToken) { const key = this.getStorageKeyByType('refreshToken') || REFRESH_TOKEN_STORAGE_KEY; diff --git a/lib/services/LeaderElectionService.ts b/lib/services/LeaderElectionService.ts index 62a059ed6..cd4e1a567 100644 --- a/lib/services/LeaderElectionService.ts +++ b/lib/services/LeaderElectionService.ts @@ -19,18 +19,18 @@ import { } from 'broadcast-channel'; import { isBrowser } from '../features'; -type OnLeaderHandler = (() => void); -type Options = ServiceManagerOptions & { +declare type OnLeaderHandler = (() => void); +declare type ServiceOptions = ServiceManagerOptions & { onLeader?: OnLeaderHandler; }; export class LeaderElectionService implements ServiceInterface { - private options: Options; + private options: ServiceOptions; private channel?: BroadcastChannel; private elector?: LeaderElector; private started = false; - constructor(options: Options = {}) { + constructor(options: ServiceOptions = {}) { this.options = options; this.onLeaderDuplicate = this.onLeaderDuplicate.bind(this); this.onLeader = this.onLeader.bind(this); @@ -54,15 +54,11 @@ export class LeaderElectionService implements ServiceInterface { start() { this.stop(); if (this.canStart()) { - if (!this.channel) { - const { electionChannelName } = this.options; - this.channel = new BroadcastChannel(electionChannelName as string); - } - if (!this.elector) { - this.elector = createLeaderElection(this.channel); - this.elector.onduplicate = this.onLeaderDuplicate; - this.elector.awaitLeadership().then(this.onLeader); - } + 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; } } diff --git a/lib/services/SyncStorageService.ts b/lib/services/SyncStorageService.ts index 4671d593b..bcce79c54 100644 --- a/lib/services/SyncStorageService.ts +++ b/lib/services/SyncStorageService.ts @@ -10,48 +10,30 @@ * See the License for the specific language governing permissions and limitations under the License. */ - -/* global window */ -import { TokenManager } from '../TokenManager'; +import { TokenManager, EVENT_ADDED, EVENT_REMOVED, EVENT_RENEWED } from '../TokenManager'; +import { BroadcastChannel } from 'broadcast-channel'; import { isBrowser } from '../features'; -import { ServiceManagerOptions, ServiceInterface } from '../types'; - +import { ServiceManagerOptions, ServiceInterface, Token } from '../types'; +export type SyncMessage = { + type: string; + key: string; + token: Token; + oldToken?: Token; +}; export class SyncStorageService implements ServiceInterface { private tokenManager: TokenManager; private options: ServiceManagerOptions; - private syncTimeout: unknown; + private channel?: BroadcastChannel; private started = false; 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.onSyncMessageHandler = this.onSyncMessageHandler.bind(this); } requiresLeadership() { @@ -69,16 +51,68 @@ export class SyncStorageService implements ServiceInterface { start() { if (this.canStart()) { this.stop(); - window.addEventListener('storage', this.storageListener); + 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.channel.addEventListener('message', this.onSyncMessageHandler); this.started = true; } } + private onTokenAddedHandler(key: string, token: Token) { + this.channel?.postMessage({ + type: EVENT_ADDED, + key, + token + }); + } + + private onTokenRemovedHandler(key: string, token: Token) { + this.channel?.postMessage({ + type: EVENT_REMOVED, + key, + token + }); + } + + private onTokenRenewedHandler(key: string, token: Token, oldToken?: Token) { + this.channel?.postMessage({ + type: EVENT_RENEWED, + key, + token, + oldToken + }); + } + + private onSyncMessageHandler(msg: SyncMessage) { + switch (msg.type) { + 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.clearExpireEventTimeout(msg.key); + this.tokenManager.emitRenewed(msg.key, msg.token, msg.oldToken); + this.tokenManager.setExpireEventTimeout(msg.key, msg.token); + break; + default: + throw new Error(`Unknown message type ${msg.type}`); + } + } + 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.channel?.removeEventListener('message', this.onSyncMessageHandler); + this.channel?.close(); + this.channel = undefined; this.started = false; } } 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 6f79b30d0..142c9a017 100644 --- a/lib/types/Service.ts +++ b/lib/types/Service.ts @@ -21,6 +21,7 @@ export interface AutoRenewServiceOptions { export interface SyncStorageServiceOptions { syncStorage?: boolean; + syncChannelName?: string; } export interface LeaderElectionServiceOptions { diff --git a/test/spec/AuthStateManager.js b/test/spec/AuthStateManager.js index 4c64104b8..8d8d20b6e 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(); + auth.serviceManager.start(); + // simulate change from other dom context + const channel = new BroadcastChannel('syncChannel'); + await channel.postMessage({ + type: 'added', + key: 'idToken', + token: 'fake_id_token' + }); expect(auth.authStateManager.updateAuthState).toHaveBeenCalledTimes(1); - jest.useRealTimers(); auth.tokenManager.stop(); await auth.serviceManager.stop(); }); diff --git a/test/spec/ServiceManager.ts b/test/spec/ServiceManager.ts index 148ac68e5..54c590cd9 100644 --- a/test/spec/ServiceManager.ts +++ b/test/spec/ServiceManager.ts @@ -14,20 +14,30 @@ 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; } + isStarted() { return this.started; } + start() { this.started = true; } + stop() { this.started = false; } + isLeader() { return this._isLeader; } + _setLeader() { this._isLeader = true; } + public onLeader() { + (this.options as any).onLeader?.(); + } } return { - createLeaderElection: actual.createLeaderElection, - BroadcastChannel: FakeBroadcastChannel + LeaderElectionService: FakeLeaderElectionService, }; }); -const mocked = { - broadcastChannel: require('broadcast-channel'), -}; function createAuth(options) { options = options || {}; @@ -106,26 +116,16 @@ describe('ServiceManager', () => { }); 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(); + client.serviceManager.start(); + expect(client.serviceManager.isLeader()).toBeFalsy(); expect(client.serviceManager.getService('autoRenew')?.isStarted()).toBeFalsy(); expect(client.serviceManager.getService('syncStorage')?.isStarted()).toBeTruthy(); - jest.runAllTimers(); - await Promise.resolve(); + expect(client.serviceManager.getService('leaderElection')?.isStarted()).toBeTruthy(); + (client.serviceManager.getService('leaderElection') as any)?._setLeader(); + (client.serviceManager.getService('leaderElection') as any)?.onLeader(); + expect(client.serviceManager.isLeader()).toBeTruthy(); expect(client.serviceManager.getService('autoRenew')?.isStarted()).toBeTruthy(); await client.serviceManager.stop(); }); diff --git a/test/spec/services/LeaderElectionService.ts b/test/spec/services/LeaderElectionService.ts new file mode 100644 index 000000000..a516d3c81 --- /dev/null +++ b/test/spec/services/LeaderElectionService.ts @@ -0,0 +1,84 @@ +/*! + * 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'; +import { BroadcastChannel } from 'broadcast-channel'; + +jest.mock('broadcast-channel', () => { + const actual = jest.requireActual('broadcast-channel'); + class FakeBroadcastChannel { + close() {} + } + return { + createLeaderElection: actual.createLeaderElection, + BroadcastChannel: FakeBroadcastChannel + }; +}); + +const mocked = { + broadcastChannel: require('broadcast-channel'), +}; + +describe('LeaderElectionService', () => { + let channel; + let service; + beforeEach(function() { + jest.useFakeTimers(); + channel = null; + service = null; + }); + afterEach(() => { + jest.useRealTimers(); + if (service) { + service.stop(); + } + if (channel) { + channel.close(); + } + }); + + function createService(options?) { + service = new LeaderElectionService({ + ...options, + electionChannelName: 'electionChannel' + }); + service.start(); + channel = new BroadcastChannel('electionChannel'); + return service; + } + + it('can await leadership and then call onLeader', async () => { + // Become leader in 100ms + const mockedElector = { + isLeader: false, + awaitLeadership: () => new Promise(resolve => { + setTimeout(() => { + mockedElector.isLeader = true; + resolve(); + }, 100); + }) as Promise, + die: () => {}, + }; + + const onLeader = jest.fn(); + jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(mockedElector); + const service = createService({ onLeader }); + 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..8acb4140b 100644 --- a/test/spec/services/SyncStorageService.ts +++ b/test/spec/services/SyncStorageService.ts @@ -12,21 +12,23 @@ /* eslint-disable max-statements */ -/* global window, localStorage, StorageEvent */ 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 channel; + let service; beforeEach(function() { - jest.useFakeTimers(); instance = null; + channel = null; + service = null; const emitter = new Emitter(); sdkMock = { options: {}, @@ -38,169 +40,64 @@ describe('SyncStorageService', () => { }, emitter }; - jest.spyOn(features, 'isIE11OrLess').mockReturnValue(false); jest.spyOn(features, 'isLocalhost').mockReturnValue(true); }); afterEach(() => { - jest.useRealTimers(); if (instance) { instance.stop(); } - if (syncInstance) { - syncInstance.stop(); + if (service) { + service.stop(); + } + if (channel) { + channel.close(); } }); function createInstance(options?) { instance = new TokenManager(sdkMock, options); instance.start(); - syncInstance = new SyncStorageService(instance, instance.getOptions()); - syncInstance.start(); + service = new SyncStorageService(instance, { + ...instance.getOptions(), + syncChannelName: 'syncChannel' + }); + service.start(); + channel = new BroadcastChannel('syncChannel'); return instance; } - 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', () => { + it('should emit "added" event if new token is added', async () => { 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(); + jest.spyOn(sdkMock.emitter, 'emit'); + await channel.postMessage({ + type: 'added', + key: 'idToken', + token: 'fake-idToken' + }); + expect(sdkMock.emitter.emit).toHaveBeenCalledWith('added', 'idToken', 'fake-idToken'); }); - it('should not emit events or reset timeouts if the key is not token storage key', () => { + + it('should emit "renewed" event if token is changed', async () => { 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(); + jest.spyOn(sdkMock.emitter, 'emit'); + await channel.postMessage({ + type: 'renewed', + key: 'idToken', + token: 'fake-idToken', + oldToken: 'old-fake-idToken' + }); + expect(sdkMock.emitter.emit).toHaveBeenCalledWith('renewed', 'idToken', 'fake-idToken', 'old-fake-idToken'); }); - it('should not emit events or reset timeouts if oldValue === newValue', () => { + + it('should emit "removed" event if token is removed', async () => { 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('_emitEventsForCrossTabsStorageUpdate', () => { - it('should emit "added" event if new token is added', () => { - createInstance(); - const newValue = '{"idToken": "fake-idToken"}'; - const oldValue = null; - jest.spyOn(sdkMock.emitter, 'emit'); - instance.emitEventsForCrossTabsStorageUpdate(newValue, oldValue); - expect(sdkMock.emitter.emit).toHaveBeenCalledWith('added', 'idToken', 'fake-idToken'); - }); - it('should emit "added" event if token is changed', () => { - createInstance(); - const newValue = '{"idToken": "fake-idToken"}'; - const oldValue = '{"idToken": "old-fake-idToken"}'; - jest.spyOn(sdkMock.emitter, 'emit'); - instance.emitEventsForCrossTabsStorageUpdate(newValue, oldValue); - expect(sdkMock.emitter.emit).toHaveBeenCalledWith('added', 'idToken', 'fake-idToken'); - }); - 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"}'; - jest.spyOn(sdkMock.emitter, 'emit'); - instance.emitEventsForCrossTabsStorageUpdate(newValue, oldValue); - expect(sdkMock.emitter.emit).not.toHaveBeenCalled(); - }); - 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 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'); + jest.spyOn(sdkMock.emitter, 'emit'); + await channel.postMessage({ + type: 'removed', + key: 'idToken', + token: 'fake-idToken' }); + expect(sdkMock.emitter.emit).toHaveBeenCalledWith('removed', 'idToken', 'fake-idToken'); }); + }); \ 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'; From 0f461e8bfe60a705e2204d0b6c7b565bc310a61a Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Tue, 26 Apr 2022 20:05:19 +0300 Subject: [PATCH 04/35] fix message loop --- lib/services/SyncStorageService.ts | 39 +++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/lib/services/SyncStorageService.ts b/lib/services/SyncStorageService.ts index bcce79c54..bb168d2ad 100644 --- a/lib/services/SyncStorageService.ts +++ b/lib/services/SyncStorageService.ts @@ -26,6 +26,7 @@ export class SyncStorageService implements ServiceInterface { private options: ServiceManagerOptions; private channel?: BroadcastChannel; private started = false; + private enablePostMessage = true; constructor(tokenManager: TokenManager, options: ServiceManagerOptions = {}) { this.tokenManager = tokenManager; @@ -61,7 +62,22 @@ export class SyncStorageService implements ServiceInterface { } } + stop() { + if (this.started) { + this.tokenManager.off(EVENT_ADDED, this.onTokenAddedHandler); + this.tokenManager.off(EVENT_REMOVED, this.onTokenRemovedHandler); + this.tokenManager.off(EVENT_RENEWED, this.onTokenRenewedHandler); + this.channel?.removeEventListener('message', this.onSyncMessageHandler); + 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, @@ -70,6 +86,9 @@ export class SyncStorageService implements ServiceInterface { } private onTokenRemovedHandler(key: string, token: Token) { + if (!this.enablePostMessage) { + return; + } this.channel?.postMessage({ type: EVENT_REMOVED, key, @@ -78,6 +97,9 @@ export class SyncStorageService implements ServiceInterface { } private onTokenRenewedHandler(key: string, token: Token, oldToken?: Token) { + if (!this.enablePostMessage) { + return; + } this.channel?.postMessage({ type: EVENT_RENEWED, key, @@ -89,31 +111,26 @@ export class SyncStorageService implements ServiceInterface { private onSyncMessageHandler(msg: SyncMessage) { switch (msg.type) { case EVENT_ADDED: + this.enablePostMessage = false; this.tokenManager.emitAdded(msg.key, msg.token); this.tokenManager.setExpireEventTimeout(msg.key, msg.token); + this.enablePostMessage = true; break; case EVENT_REMOVED: + this.enablePostMessage = false; this.tokenManager.clearExpireEventTimeout(msg.key); this.tokenManager.emitRemoved(msg.key, msg.token); + this.enablePostMessage = true; break; case EVENT_RENEWED: + this.enablePostMessage = false; this.tokenManager.clearExpireEventTimeout(msg.key); this.tokenManager.emitRenewed(msg.key, msg.token, msg.oldToken); this.tokenManager.setExpireEventTimeout(msg.key, msg.token); + this.enablePostMessage = true; break; default: throw new Error(`Unknown message type ${msg.type}`); } } - - stop() { - if (this.started) { - this.tokenManager.off(EVENT_ADDED, this.onTokenAddedHandler); - this.tokenManager.off(EVENT_REMOVED, this.onTokenRemovedHandler); - this.channel?.removeEventListener('message', this.onSyncMessageHandler); - this.channel?.close(); - this.channel = undefined; - this.started = false; - } - } } \ No newline at end of file From 5991101bc08b096a675eb476878afe57269f9b04 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Tue, 26 Apr 2022 23:51:19 +0300 Subject: [PATCH 05/35] allow syncStorage for localStorage and cookie . . --- README.md | 2 +- lib/OktaAuth.ts | 8 ++++++++ lib/services/SyncStorageService.ts | 13 +++++++------ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1d1ed6ecc..85832e604 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/OktaAuth.ts b/lib/OktaAuth.ts index 99111267b..91189cef5 100644 --- a/lib/OktaAuth.ts +++ b/lib/OktaAuth.ts @@ -175,6 +175,14 @@ class OktaAuth implements OktaAuthInterface, SigninAPI, SignoutAPI { }, options.transactionManager)); this._oktaUserAgent = new OktaUserAgent(); + // Enable `syncStorage` only if token storage type is `localStorage` or `cookie` + const syncableStorageTypes = ['localStorage', 'cookie']; + const canSyncStorage = typeof args.tokenManager?.storage === 'string' + && syncableStorageTypes.includes(args.tokenManager.storage); + if (!canSyncStorage) { + args.services = { ...args.services, syncStorage: false }; + } + this.tx = { status: transactionStatus.bind(null, this), resume: resumeTransaction.bind(null, this), diff --git a/lib/services/SyncStorageService.ts b/lib/services/SyncStorageService.ts index bb168d2ad..de734049b 100644 --- a/lib/services/SyncStorageService.ts +++ b/lib/services/SyncStorageService.ts @@ -109,28 +109,29 @@ export class SyncStorageService implements ServiceInterface { } private onSyncMessageHandler(msg: SyncMessage) { + // Use `enablePostMessage` flag here to prevent sync message loop switch (msg.type) { case EVENT_ADDED: this.enablePostMessage = false; - this.tokenManager.emitAdded(msg.key, msg.token); - this.tokenManager.setExpireEventTimeout(msg.key, msg.token); + this.tokenManager.add(msg.key, msg.token); this.enablePostMessage = true; break; case EVENT_REMOVED: this.enablePostMessage = false; - this.tokenManager.clearExpireEventTimeout(msg.key); - this.tokenManager.emitRemoved(msg.key, msg.token); + this.tokenManager.remove(msg.key); this.enablePostMessage = true; break; case EVENT_RENEWED: this.enablePostMessage = false; - this.tokenManager.clearExpireEventTimeout(msg.key); + this.tokenManager.remove(msg.key); + this.tokenManager.add(msg.key, msg.token); this.tokenManager.emitRenewed(msg.key, msg.token, msg.oldToken); - this.tokenManager.setExpireEventTimeout(msg.key, msg.token); this.enablePostMessage = true; break; default: throw new Error(`Unknown message type ${msg.type}`); } + + } } \ No newline at end of file From bd6ac2da03a90a235b58d7a43e9ba22bb001b8fe Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Wed, 27 Apr 2022 00:10:40 +0300 Subject: [PATCH 06/35] . --- lib/OktaAuth.ts | 8 -------- lib/ServiceManager.ts | 8 ++++++++ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/OktaAuth.ts b/lib/OktaAuth.ts index 91189cef5..99111267b 100644 --- a/lib/OktaAuth.ts +++ b/lib/OktaAuth.ts @@ -175,14 +175,6 @@ class OktaAuth implements OktaAuthInterface, SigninAPI, SignoutAPI { }, options.transactionManager)); this._oktaUserAgent = new OktaUserAgent(); - // Enable `syncStorage` only if token storage type is `localStorage` or `cookie` - const syncableStorageTypes = ['localStorage', 'cookie']; - const canSyncStorage = typeof args.tokenManager?.storage === 'string' - && syncableStorageTypes.includes(args.tokenManager.storage); - if (!canSyncStorage) { - args.services = { ...args.services, syncStorage: false }; - } - this.tx = { status: transactionStatus.bind(null, this), resume: resumeTransaction.bind(null, this), diff --git a/lib/ServiceManager.ts b/lib/ServiceManager.ts index c0c11c707..4bb520eac 100644 --- a/lib/ServiceManager.ts +++ b/lib/ServiceManager.ts @@ -52,6 +52,14 @@ export class ServiceManager implements ServiceManagerInterface { }, options ); + // Enable `syncStorage` only if token storage type is `localStorage` or `cookie` + const syncableStorageTypes = ['localStorage', 'cookie']; + const storageType = sdk.tokenManager.getOptions().storage; + const canSyncStorage = typeof storageType === 'string' + && syncableStorageTypes.includes(storageType); + if (!canSyncStorage) { + this.options.syncStorage = false; + } this.started = false; this.services = new Map(); From e890015664baf9f3743c01feee15839cd23fc29e Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Wed, 27 Apr 2022 18:51:18 +0300 Subject: [PATCH 07/35] fixes --- lib/OktaAuth.ts | 5 +++++ lib/SavedObject.ts | 4 ++++ lib/ServiceManager.ts | 8 ------- lib/TokenManager.ts | 4 ++++ lib/browser/browserStorage.ts | 9 +++++--- lib/server/serverStorage.ts | 3 ++- lib/types/Storage.ts | 2 ++ test/spec/AuthStateManager.js | 2 +- test/spec/ServiceManager.ts | 1 + test/spec/services/SyncStorageService.ts | 28 +++++++++++++++--------- 10 files changed, 43 insertions(+), 23 deletions(-) diff --git a/lib/OktaAuth.ts b/lib/OktaAuth.ts index 99111267b..02d864d0c 100644 --- a/lib/OktaAuth.ts +++ b/lib/OktaAuth.ts @@ -367,6 +367,11 @@ 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); } diff --git a/lib/SavedObject.ts b/lib/SavedObject.ts index 9058eff6e..5643d2e6e 100644 --- a/lib/SavedObject.ts +++ b/lib/SavedObject.ts @@ -52,6 +52,10 @@ export default class SavedObject implements StorageProvider { // StorageProvider interface // + isSharedStorage() { + return 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 4bb520eac..c0c11c707 100644 --- a/lib/ServiceManager.ts +++ b/lib/ServiceManager.ts @@ -52,14 +52,6 @@ export class ServiceManager implements ServiceManagerInterface { }, options ); - // Enable `syncStorage` only if token storage type is `localStorage` or `cookie` - const syncableStorageTypes = ['localStorage', 'cookie']; - const storageType = sdk.tokenManager.getOptions().storage; - const canSyncStorage = typeof storageType === 'string' - && syncableStorageTypes.includes(storageType); - if (!canSyncStorage) { - this.options.syncStorage = false; - } this.started = false; this.services = new Map(); diff --git a/lib/TokenManager.ts b/lib/TokenManager.ts index e478a21fc..7611cab86 100644 --- a/lib/TokenManager.ts +++ b/lib/TokenManager.ts @@ -110,6 +110,10 @@ export class TokenManager implements TokenManagerInterface { this.off = this.emitter.off.bind(this.emitter); } + hasSharedStorage() { + return this.storage.isSharedStorage(); + } + start() { if (this.options.clearPendingRemoveTokens) { this.clearPendingRemoveTokens(); diff --git a/lib/browser/browserStorage.ts b/lib/browser/browserStorage.ts index 6a763edd4..9f909bab2 100644 --- a/lib/browser/browserStorage.ts +++ b/lib/browser/browserStorage.ts @@ -150,7 +150,8 @@ var storageUtil: BrowserStorageUtil = { }, removeItem: (key) => { this.storage.delete(key); - } + }, + isSharedStorage: () => true }; if (!options!.useSeparateCookies) { @@ -191,7 +192,8 @@ var storageUtil: BrowserStorageUtil = { Object.keys(existingValues).forEach(k => { storage.removeItem(key + '_' + k); }); - } + }, + isSharedStorage: () => true }; }, @@ -204,7 +206,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/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/test/spec/AuthStateManager.js b/test/spec/AuthStateManager.js index 8d8d20b6e..88c3b3446 100644 --- a/test/spec/AuthStateManager.js +++ b/test/spec/AuthStateManager.js @@ -143,7 +143,7 @@ describe('AuthStateManager', () => { await channel.postMessage({ type: 'added', key: 'idToken', - token: 'fake_id_token' + token: tokens.standardIdToken2Parsed }); expect(auth.authStateManager.updateAuthState).toHaveBeenCalledTimes(1); auth.tokenManager.stop(); diff --git a/test/spec/ServiceManager.ts b/test/spec/ServiceManager.ts index 54c590cd9..f2c7953ef 100644 --- a/test/spec/ServiceManager.ts +++ b/test/spec/ServiceManager.ts @@ -24,6 +24,7 @@ jest.mock('../../lib/services/LeaderElectionService', () => { this.options = options; } canStart() { return true; } + requiresLeadership() { return false; } isStarted() { return this.started; } start() { this.started = true; } stop() { this.started = false; } diff --git a/test/spec/services/SyncStorageService.ts b/test/spec/services/SyncStorageService.ts index 8acb4140b..b98859f8d 100644 --- a/test/spec/services/SyncStorageService.ts +++ b/test/spec/services/SyncStorageService.ts @@ -13,6 +13,7 @@ /* eslint-disable max-statements */ +import tokens from '@okta/test.support/tokens'; import { TokenManager } from '../../../lib/TokenManager'; import { SyncStorageService } from '../../../lib/services/SyncStorageService'; import * as features from '../../../lib/features'; @@ -25,17 +26,24 @@ describe('SyncStorageService', () => { let instance; let channel; let service; + let storage; + let tokenStorage; beforeEach(function() { instance = null; channel = null; service = null; const emitter = new Emitter(); + storage = { + idToken: tokens.standardIdTokenParsed + }; + tokenStorage = { + getStorage: jest.fn().mockImplementation(() => storage), + setStorage: jest.fn().mockImplementation(() => {}) + }; sdkMock = { options: {}, storageManager: { - getTokenStorage: jest.fn().mockReturnValue({ - getStorage: jest.fn().mockReturnValue({}) - }), + getTokenStorage: jest.fn().mockReturnValue(tokenStorage), getOptionsForSection: jest.fn().mockReturnValue({}) }, emitter @@ -72,9 +80,9 @@ describe('SyncStorageService', () => { await channel.postMessage({ type: 'added', key: 'idToken', - token: 'fake-idToken' + token: tokens.standardIdTokenParsed }); - expect(sdkMock.emitter.emit).toHaveBeenCalledWith('added', 'idToken', 'fake-idToken'); + expect(sdkMock.emitter.emit).toHaveBeenCalledWith('added', 'idToken', tokens.standardIdTokenParsed); }); it('should emit "renewed" event if token is changed', async () => { @@ -83,10 +91,10 @@ describe('SyncStorageService', () => { await channel.postMessage({ type: 'renewed', key: 'idToken', - token: 'fake-idToken', - oldToken: 'old-fake-idToken' + token: tokens.standardIdToken2Parsed, + oldToken: tokens.standardIdTokenParsed }); - expect(sdkMock.emitter.emit).toHaveBeenCalledWith('renewed', 'idToken', 'fake-idToken', 'old-fake-idToken'); + expect(sdkMock.emitter.emit).toHaveBeenCalledWith('renewed', 'idToken', tokens.standardIdToken2Parsed, tokens.standardIdTokenParsed); }); it('should emit "removed" event if token is removed', async () => { @@ -95,9 +103,9 @@ describe('SyncStorageService', () => { await channel.postMessage({ type: 'removed', key: 'idToken', - token: 'fake-idToken' + token: tokens.standardIdTokenParsed }); - expect(sdkMock.emitter.emit).toHaveBeenCalledWith('removed', 'idToken', 'fake-idToken'); + expect(sdkMock.emitter.emit).toHaveBeenCalledWith('removed', 'idToken', tokens.standardIdTokenParsed); }); }); \ No newline at end of file From ca08809860e6162a415f66d4a225b9348d50ef6c Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Wed, 27 Apr 2022 18:55:27 +0300 Subject: [PATCH 08/35] fix --- lib/SavedObject.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/SavedObject.ts b/lib/SavedObject.ts index 5643d2e6e..f183c6463 100644 --- a/lib/SavedObject.ts +++ b/lib/SavedObject.ts @@ -53,7 +53,8 @@ export default class SavedObject implements StorageProvider { // isSharedStorage() { - return this.storageProvider === localStorage as any || !!this.storageProvider.isSharedStorage?.(); + return typeof localStorage !== 'undefined' && this.storageProvider === localStorage as any + || !!this.storageProvider.isSharedStorage?.(); } getStorage() { From 095faf4c8d3ba4504903ea79e9d42c3981326955 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Mon, 2 May 2022 13:53:09 +0300 Subject: [PATCH 09/35] support IE11 --- lib/services/SyncStorageService.ts | 39 ++++++++++++++++++------------ test/apps/app/src/form.ts | 33 ++++++++++++++++++++++--- test/apps/app/src/testApp.ts | 2 +- 3 files changed, 54 insertions(+), 20 deletions(-) diff --git a/lib/services/SyncStorageService.ts b/lib/services/SyncStorageService.ts index de734049b..7324cfc86 100644 --- a/lib/services/SyncStorageService.ts +++ b/lib/services/SyncStorageService.ts @@ -12,7 +12,7 @@ import { TokenManager, EVENT_ADDED, EVENT_REMOVED, EVENT_RENEWED } from '../TokenManager'; import { BroadcastChannel } from 'broadcast-channel'; -import { isBrowser } from '../features'; +import { isBrowser, isIE11OrLess } from '../features'; import { ServiceManagerOptions, ServiceInterface, Token } from '../types'; export type SyncMessage = { @@ -109,29 +109,38 @@ export class SyncStorageService implements ServiceInterface { } private onSyncMessageHandler(msg: SyncMessage) { - // Use `enablePostMessage` flag here to prevent sync message loop + // 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 tokenManager's add/remove. + + this.enablePostMessage = false; switch (msg.type) { case EVENT_ADDED: - this.enablePostMessage = false; - this.tokenManager.add(msg.key, msg.token); - this.enablePostMessage = true; + if (!isIE11OrLess()) { + this.tokenManager.emitAdded(msg.key, msg.token); + this.tokenManager.setExpireEventTimeout(msg.key, msg.token); + } else { + this.tokenManager.add(msg.key, msg.token); + } break; case EVENT_REMOVED: - this.enablePostMessage = false; - this.tokenManager.remove(msg.key); - this.enablePostMessage = true; + if (!isIE11OrLess()) { + this.tokenManager.clearExpireEventTimeout(msg.key); + this.tokenManager.emitRemoved(msg.key, msg.token); + } else { + this.tokenManager.remove(msg.key); + } break; case EVENT_RENEWED: - this.enablePostMessage = false; - this.tokenManager.remove(msg.key); - this.tokenManager.add(msg.key, msg.token); this.tokenManager.emitRenewed(msg.key, msg.token, msg.oldToken); - this.enablePostMessage = true; break; default: - throw new Error(`Unknown message type ${msg.type}`); + break; } - - + this.enablePostMessage = true; } } \ No newline at end of file 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..98a314950 100644 --- a/test/apps/app/src/testApp.ts +++ b/test/apps/app/src/testApp.ts @@ -881,7 +881,7 @@ class TestApp { } appHTML(props: Tokens): string { - if (window.location.pathname.includes('/protected')) { + if (window.location.pathname.indexOf('/protected') != -1) { return this.appProtectedHTML(); } From 714ed25083f507bfcbfb5f9239dd70c4025b7524 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Mon, 2 May 2022 14:08:45 +0300 Subject: [PATCH 10/35] IE11 workaround --- lib/browser/browserStorage.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/browser/browserStorage.ts b/lib/browser/browserStorage.ts index 9f909bab2..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; }, From a4adcfef8420e531a7edc24137ac29445933738c Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Tue, 3 May 2022 15:54:48 +0300 Subject: [PATCH 11/35] Use event set_storage for IE11 --- lib/TokenManager.ts | 29 +++++++++++++++----- lib/services/SyncStorageService.ts | 43 +++++++++++++++++------------- lib/types/TokenManager.ts | 7 ++--- 3 files changed, 52 insertions(+), 27 deletions(-) diff --git a/lib/TokenManager.ts b/lib/TokenManager.ts index 7611cab86..7a685ea5c 100644 --- a/lib/TokenManager.ts +++ b/lib/TokenManager.ts @@ -13,7 +13,7 @@ import { removeNils, clone } from './util'; import { AuthSdkError } from './errors'; import { validateToken } from './oidc/util'; -import { isLocalhost } from './features'; +import { isLocalhost, isIE11OrLess } from './features'; import SdkClock from './clock'; import { EventEmitter, @@ -28,8 +28,7 @@ import { StorageType, OktaAuthInterface, StorageProvider, - TokenManagerErrorEventHandler, - TokenManagerEventHandler, + TokenManagerAnyEventHandler, TokenManagerInterface, RefreshToken, AccessTokenCallback, @@ -54,6 +53,7 @@ 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'; interface TokenManagerState { expireTimeouts: Record; @@ -73,8 +73,8 @@ 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: string, handler: TokenManagerAnyEventHandler, context?: object) => void; + off: (event: string, handler?: TokenManagerAnyEventHandler) => void; // eslint-disable-next-line complexity constructor(sdk: OktaAuthInterface, options: TokenManagerOptions = {}) { @@ -219,6 +219,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); } @@ -276,6 +277,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 @@ -331,7 +345,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]; @@ -358,6 +373,7 @@ export class TokenManager implements TokenManagerInterface { var removedToken = tokenStorage[key]; delete tokenStorage[key]; this.storage.setStorage(tokenStorage); + this.emitSetStorageEvent(); this.emitRemoved(key, removedToken); } @@ -437,6 +453,7 @@ export class TokenManager implements TokenManagerInterface { validateToken(token); tokenStorage[key] = token; this.storage.setStorage(tokenStorage); + this.emitSetStorageEvent(); } removeRefreshToken () { diff --git a/lib/services/SyncStorageService.ts b/lib/services/SyncStorageService.ts index 7324cfc86..5ef80cfaa 100644 --- a/lib/services/SyncStorageService.ts +++ b/lib/services/SyncStorageService.ts @@ -10,16 +10,17 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { TokenManager, EVENT_ADDED, EVENT_REMOVED, EVENT_RENEWED } from '../TokenManager'; +import { TokenManager, EVENT_ADDED, EVENT_REMOVED, EVENT_RENEWED, EVENT_SET_STORAGE } from '../TokenManager'; import { BroadcastChannel } from 'broadcast-channel'; -import { isBrowser, isIE11OrLess } from '../features'; -import { ServiceManagerOptions, ServiceInterface, Token } from '../types'; +import { isBrowser } from '../features'; +import { ServiceManagerOptions, ServiceInterface, Token, Tokens } from '../types'; export type SyncMessage = { type: string; - key: string; - token: Token; + key?: string; + token?: Token; oldToken?: Token; + storage?: Tokens; }; export class SyncStorageService implements ServiceInterface { private tokenManager: TokenManager; @@ -34,6 +35,7 @@ export class SyncStorageService implements ServiceInterface { 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); } @@ -57,6 +59,7 @@ export class SyncStorageService implements ServiceInterface { 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; } @@ -67,6 +70,7 @@ export class SyncStorageService implements ServiceInterface { 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); this.channel?.close(); this.channel = undefined; @@ -108,6 +112,14 @@ export class SyncStorageService implements ServiceInterface { }); } + 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. @@ -115,25 +127,20 @@ export class SyncStorageService implements ServiceInterface { // 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 tokenManager's add/remove. + // 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: - if (!isIE11OrLess()) { - this.tokenManager.emitAdded(msg.key, msg.token); - this.tokenManager.setExpireEventTimeout(msg.key, msg.token); - } else { - this.tokenManager.add(msg.key, msg.token); - } + this.tokenManager.emitAdded(msg.key, msg.token); + this.tokenManager.setExpireEventTimeout(msg.key, msg.token); break; case EVENT_REMOVED: - if (!isIE11OrLess()) { - this.tokenManager.clearExpireEventTimeout(msg.key); - this.tokenManager.emitRemoved(msg.key, msg.token); - } else { - this.tokenManager.remove(msg.key); - } + 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); diff --git a/lib/types/TokenManager.ts b/lib/types/TokenManager.ts index 45a483cb2..488311e31 100644 --- a/lib/types/TokenManager.ts +++ b/lib/types/TokenManager.ts @@ -11,7 +11,8 @@ export interface TokenManagerError { export declare type TokenManagerErrorEventHandler = (error: TokenManagerError) => void; export declare type TokenManagerEventHandler = (key: string, token: Token, oldtoken?: Token) => void; - +export declare type TokenManagerSetStorageEventHandler = (storage: Tokens) => void; +export declare type TokenManagerAnyEventHandler = TokenManagerEventHandler | TokenManagerErrorEventHandler | TokenManagerSetStorageEventHandler; export declare type AccessTokenCallback = (key: string, token: AccessToken) => void; export declare type IDTokenCallback = (key: string, token: IDToken) => void; @@ -19,8 +20,8 @@ export declare type RefreshTokenCallback = (key: string, token: RefreshToken) => // 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: string, handler: TokenManagerAnyEventHandler, context?: object) => void; + off: (event: string, handler?: TokenManagerAnyEventHandler) => void; getTokensSync(): Tokens; setTokens({ accessToken, idToken, refreshToken }: Tokens, accessTokenCb?: AccessTokenCallback, idTokenCb?: IDTokenCallback, refreshTokenCb?: RefreshTokenCallback): void; getStorageKeyByType(type: TokenType): string; From 5850351aee62c13477505b8ee89895d535b5dc94 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Tue, 3 May 2022 16:54:39 +0300 Subject: [PATCH 12/35] Added polyfills for IE11 --- polyfill/index.js | 7 +++++++ 1 file changed, 7 insertions(+) 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'); From 3453ebbc09b35bed5c103d9c1abca4b13ee5cea1 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Tue, 3 May 2022 19:46:09 +0300 Subject: [PATCH 13/35] lint fix --- .eslintignore | 1 + 1 file changed, 1 insertion(+) 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 From 85138a440c498dbdc412aed77927c7cd7a3c8db9 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Tue, 3 May 2022 23:47:58 +0300 Subject: [PATCH 14/35] fix Channel is closed . --- lib/services/LeaderElectionService.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/services/LeaderElectionService.ts b/lib/services/LeaderElectionService.ts index cd4e1a567..5ecaa2cc6 100644 --- a/lib/services/LeaderElectionService.ts +++ b/lib/services/LeaderElectionService.ts @@ -68,6 +68,8 @@ export class LeaderElectionService implements ServiceInterface { this.elector?.die(); this.elector = undefined; this.channel?.close(); + // Workaround to fix error `Failed to execute 'postMessage' on 'BroadcastChannel': Channel is closed` + (this.channel as any).postInternal = () => Promise.resolve(); this.channel = undefined; this.started = false; } From e442b2c67083bf43d93fcfd5f361b857cffe03df Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Wed, 4 May 2022 12:22:09 +0300 Subject: [PATCH 15/35] added tests LES --- lib/ServiceManager.ts | 2 +- test/spec/services/LeaderElectionService.ts | 80 +++++++++++++++++---- 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/lib/ServiceManager.ts b/lib/ServiceManager.ts index c0c11c707..734316f6c 100644 --- a/lib/ServiceManager.ts +++ b/lib/ServiceManager.ts @@ -125,7 +125,7 @@ export class ServiceManager implements ServiceManagerInterface { private createService(name: string): ServiceInterface { const tokenManager = this.sdk.tokenManager; - let service: ServiceInterface | undefined; + let service: ServiceInterface; switch (name) { case LEADER_ELECTION: service = new LeaderElectionService({...this.options, onLeader: this.onLeader}); diff --git a/test/spec/services/LeaderElectionService.ts b/test/spec/services/LeaderElectionService.ts index a516d3c81..183b84440 100644 --- a/test/spec/services/LeaderElectionService.ts +++ b/test/spec/services/LeaderElectionService.ts @@ -52,13 +52,20 @@ describe('LeaderElectionService', () => { ...options, electionChannelName: 'electionChannel' }); - service.start(); channel = new BroadcastChannel('electionChannel'); return service; } - it('can await leadership and then call onLeader', async () => { - // Become leader in 100ms + function createElectorWithLeadership() { + return { + isLeader: true, + awaitLeadership: () => new Promise(() => {}), + die: jest.fn(), + }; + } + + // Become leader in 100ms + function createElectorWithDelayedLeadership() { const mockedElector = { isLeader: false, awaitLeadership: () => new Promise(resolve => { @@ -69,16 +76,63 @@ describe('LeaderElectionService', () => { }) as Promise, die: () => {}, }; + return mockedElector; + } + + + describe('start', () => { + it('stops service if already started', async () => { + const elector = createElectorWithLeadership(); + jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(elector); + const service = createService(); + service.start(); + service.start(); + expect(service.isStarted()).toBeTruthy(); + expect(elector.die).toHaveBeenCalledTimes(1); + }); + }); - const onLeader = jest.fn(); - jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(mockedElector); - const service = createService({ onLeader }); - await Promise.resolve(); - expect(onLeader).toHaveBeenCalledTimes(0); - expect(service.isLeader()).toBeFalsy(); - jest.runAllTimers(); - await Promise.resolve(); - expect(onLeader).toHaveBeenCalledTimes(1); - expect(service.isLeader()).toBeTruthy(); + describe('stop', () => { + it('can be called twice without error', async () => { + const elector = createElectorWithLeadership(); + jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(elector); + const service = createService(); + service.start(); + expect(service.isStarted()).toBeTruthy(); + await Promise.race([ + service.stop(), + service.stop() + ]); + expect(service.isStarted()).toBeFalsy(); + expect(elector.die).toHaveBeenCalledTimes(1); + }); + }); + + describe('isLeader', () => { + it('returns true if elected as leader', () => { + const elector = createElectorWithLeadership(); + jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(elector); + const service = createService(); + expect(service.isLeader()).toBeFalsy(); + 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 }); + 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 From 34acc6fd13ea64ac68674618cf0ba61e5bfe815e Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Wed, 4 May 2022 13:04:46 +0300 Subject: [PATCH 16/35] added test for starting leaderElection service --- test/spec/ServiceManager.ts | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/test/spec/ServiceManager.ts b/test/spec/ServiceManager.ts index f2c7953ef..42360864e 100644 --- a/test/spec/ServiceManager.ts +++ b/test/spec/ServiceManager.ts @@ -65,10 +65,28 @@ describe('ServiceManager', () => { jest.useRealTimers(); }); - it('starts syncStorage service for every tab, autoRenew service for leader tab (for syncStorage == true)', async () => { + it('doesn\'t start leaderElection service if other services don\'t require leadership', () => { + const options = { tokenManager: { syncStorage: false, autoRenew: true } }; + const client = createAuth(options); + client.serviceManager.start(); + expect(client.serviceManager.isLeaderRequired()).toBeFalsy(); + expect(client.serviceManager.getService('leaderElection')?.isStarted()).toBeFalsy(); + client.serviceManager.stop(); + }); + + it('starts leaderElection service if any service (autoRenew) requires leadership', () => { + const options = { tokenManager: { syncStorage: true, autoRenew: true } }; + const client = createAuth(options); + client.serviceManager.start(); + expect(client.serviceManager.isLeaderRequired()).toBeTruthy(); + expect(client.serviceManager.getService('leaderElection')?.isStarted()).toBeTruthy(); + client.serviceManager.stop(); + }); + + it('starts syncStorage service for every tab, autoRenew service for leader tab (for syncStorage == true)', () => { const options = { tokenManager: { syncStorage: true, autoRenew: true } }; - let client1 = createAuth(options); - let client2 = createAuth(options); + const client1 = createAuth(options); + const client2 = createAuth(options); util.disableLeaderElection(); jest.spyOn(client1.serviceManager, 'isLeader').mockReturnValue(true); jest.spyOn(client2.serviceManager, 'isLeader').mockReturnValue(false); @@ -84,8 +102,8 @@ describe('ServiceManager', () => { 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); + const client1 = createAuth(options); + const client2 = createAuth(options); util.disableLeaderElection(); jest.spyOn(client1.serviceManager, 'isLeader').mockReturnValue(true); jest.spyOn(client2.serviceManager, 'isLeader').mockReturnValue(false); @@ -101,8 +119,8 @@ describe('ServiceManager', () => { 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); + const client1 = createAuth(options); + const client2 = createAuth(options); util.disableLeaderElection(); jest.spyOn(client1.serviceManager, 'isLeader').mockReturnValue(true); jest.spyOn(client2.serviceManager, 'isLeader').mockReturnValue(false); @@ -118,7 +136,7 @@ describe('ServiceManager', () => { it('starts autoRenew service after becoming leader (for syncStorage == true)', async () => { const options = { tokenManager: { syncStorage: true, autoRenew: true } }; - let client = createAuth(options); + const client = createAuth(options); client.serviceManager.start(); expect(client.serviceManager.isLeader()).toBeFalsy(); expect(client.serviceManager.getService('autoRenew')?.isStarted()).toBeFalsy(); From 246b2af0730ee398f652c5da030b0c2f9f3409db Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Wed, 4 May 2022 15:35:17 +0300 Subject: [PATCH 17/35] added tests for services --- test/spec/services/LeaderElectionService.ts | 31 ++-- test/spec/services/SyncStorageService.ts | 171 +++++++++++++++----- 2 files changed, 157 insertions(+), 45 deletions(-) diff --git a/test/spec/services/LeaderElectionService.ts b/test/spec/services/LeaderElectionService.ts index 183b84440..c34972548 100644 --- a/test/spec/services/LeaderElectionService.ts +++ b/test/spec/services/LeaderElectionService.ts @@ -12,7 +12,6 @@ import { LeaderElectionService } from '../../../lib/services/LeaderElectionService'; -import { BroadcastChannel } from 'broadcast-channel'; jest.mock('broadcast-channel', () => { const actual = jest.requireActual('broadcast-channel'); @@ -30,11 +29,9 @@ const mocked = { }; describe('LeaderElectionService', () => { - let channel; let service; beforeEach(function() { jest.useFakeTimers(); - channel = null; service = null; }); afterEach(() => { @@ -42,9 +39,6 @@ describe('LeaderElectionService', () => { if (service) { service.stop(); } - if (channel) { - channel.close(); - } }); function createService(options?) { @@ -52,14 +46,13 @@ describe('LeaderElectionService', () => { ...options, electionChannelName: 'electionChannel' }); - channel = new BroadcastChannel('electionChannel'); return service; } function createElectorWithLeadership() { return { isLeader: true, - awaitLeadership: () => new Promise(() => {}), + awaitLeadership: jest.fn().mockReturnValue(new Promise(() => {})), die: jest.fn(), }; } @@ -81,6 +74,16 @@ describe('LeaderElectionService', () => { describe('start', () => { + it('creates elector and awaits leadership', () => { + const elector = createElectorWithLeadership(); + jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(elector); + const service = createService(); + 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); @@ -93,6 +96,16 @@ describe('LeaderElectionService', () => { }); describe('stop', () => { + it('should kill elector', () => { + const elector = createElectorWithLeadership(); + jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(elector); + const service = createService(); + service.start(); + 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); @@ -109,7 +122,7 @@ describe('LeaderElectionService', () => { }); describe('isLeader', () => { - it('returns true if elected as leader', () => { + it('returns true if current tab is elected as leader', () => { const elector = createElectorWithLeadership(); jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(elector); const service = createService(); diff --git a/test/spec/services/SyncStorageService.ts b/test/spec/services/SyncStorageService.ts index b98859f8d..191b28483 100644 --- a/test/spec/services/SyncStorageService.ts +++ b/test/spec/services/SyncStorageService.ts @@ -23,13 +23,13 @@ const Emitter = require('tiny-emitter'); describe('SyncStorageService', () => { let sdkMock; - let instance; + let tokenManager; let channel; let service; let storage; let tokenStorage; beforeEach(function() { - instance = null; + tokenManager = null; channel = null; service = null; const emitter = new Emitter(); @@ -37,8 +37,10 @@ describe('SyncStorageService', () => { idToken: tokens.standardIdTokenParsed }; tokenStorage = { - getStorage: jest.fn().mockImplementation(() => storage), - setStorage: jest.fn().mockImplementation(() => {}) + getStorage: jest.fn().mockImplementation(() => storage), + setStorage: jest.fn().mockImplementation((newStorage) => { + storage = newStorage; + }) }; sdkMock = { options: {}, @@ -51,8 +53,8 @@ describe('SyncStorageService', () => { jest.spyOn(features, 'isLocalhost').mockReturnValue(true); }); afterEach(() => { - if (instance) { - instance.stop(); + if (tokenManager) { + tokenManager.stop(); } if (service) { service.stop(); @@ -63,49 +65,146 @@ describe('SyncStorageService', () => { }); function createInstance(options?) { - instance = new TokenManager(sdkMock, options); - instance.start(); - service = new SyncStorageService(instance, { - ...instance.getOptions(), + tokenManager = new TokenManager(sdkMock, options); + tokenManager.start(); + service = new SyncStorageService(tokenManager, { + ...tokenManager.getOptions(), syncChannelName: 'syncChannel' }); service.start(); + // Create another channel with same name for communication channel = new BroadcastChannel('syncChannel'); - return instance; + return tokenManager; } - it('should emit "added" event if new token is added', async () => { - createInstance(); - jest.spyOn(sdkMock.emitter, 'emit'); - await channel.postMessage({ - type: 'added', - key: 'idToken', - token: tokens.standardIdTokenParsed + describe('start', () => { + it('stops service if already started, closes and recreates channel', async () => { + createInstance(); + const oldChannel = (service as any).channel; + jest.spyOn(oldChannel, 'close'); + service.start(); // restart + const newChannel = (service as any).channel; + expect(service.isStarted()).toBeTruthy(); + expect(oldChannel.close).toHaveBeenCalledTimes(1); + expect(newChannel).not.toStrictEqual(oldChannel); }); - expect(sdkMock.emitter.emit).toHaveBeenCalledWith('added', 'idToken', tokens.standardIdTokenParsed); }); - it('should emit "renewed" event if token is changed', async () => { - createInstance(); - jest.spyOn(sdkMock.emitter, 'emit'); - await channel.postMessage({ - type: 'renewed', - key: 'idToken', - token: tokens.standardIdToken2Parsed, - oldToken: tokens.standardIdTokenParsed + describe('stop', () => { + it('can be called twice without error', async () => { + createInstance(); + const serviceChannel = (service as any).channel; + jest.spyOn(serviceChannel, 'close'); + await Promise.race([ + service.stop(), + service.stop() + ]); + expect(service.isStarted()).toBeFalsy(); + expect(serviceChannel.close).toHaveBeenCalledTimes(1); + expect((service as any).channel).not.toBeDefined(); }); - expect(sdkMock.emitter.emit).toHaveBeenCalledWith('renewed', 'idToken', tokens.standardIdToken2Parsed, tokens.standardIdTokenParsed); }); - it('should emit "removed" event if token is removed', async () => { - createInstance(); - jest.spyOn(sdkMock.emitter, 'emit'); - await channel.postMessage({ - type: 'removed', - key: 'idToken', - token: tokens.standardIdTokenParsed + describe('handling sync message', () => { + it('should emit "added" event if new token is added from another tab', async () => { + createInstance(); + jest.spyOn(sdkMock.emitter, 'emit'); + await channel.postMessage({ + type: 'added', + key: 'idToken', + token: tokens.standardIdToken2Parsed + }); + expect(sdkMock.emitter.emit).toHaveBeenCalledWith('added', 'idToken', tokens.standardIdToken2Parsed); + }); + + it('should emit "removed" event if new token is removed from another tab', async () => { + createInstance(); + jest.spyOn(sdkMock.emitter, 'emit'); + await channel.postMessage({ + type: 'removed', + key: 'idToken', + token: tokens.standardIdTokenParsed + }); + expect(sdkMock.emitter.emit).toHaveBeenCalledWith('removed', 'idToken', tokens.standardIdTokenParsed); + }); + + it('should emit "renewed" event if new token is chnaged from another tab', async () => { + createInstance(); + jest.spyOn(sdkMock.emitter, 'emit'); + 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 not post sync message to other tabs', async () => { + 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); + }); + }); + + describe('posting sync messages', () => { + it('should post "added" sync message', () => { + 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 not post set_storage event on storage change (for non-IE)', () => { + 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 on any storage change', () => { + createInstance(); + const serviceChannel = (service as any).channel; + jest.spyOn(serviceChannel, 'postMessage'); + tokenManager.add('idToken', tokens.standardIdToken2Parsed); + expect(serviceChannel.postMessage).toHaveBeenCalledTimes(2); // ""set_storage" + "added" + expect(serviceChannel.postMessage).toHaveBeenNthCalledWith(1, { + type: 'set_storage', + storage: { + idToken: tokens.standardIdToken2Parsed + }, + }); + }); + + it('should update storage excplicitly on "set_storage" event', async () => { + createInstance(); + const newStorage = { + idToken: tokens.standardIdToken2Parsed + }; + await channel.postMessage({ + type: 'set_storage', + storage: newStorage, + }); + expect(storage).toEqual(newStorage); }); - expect(sdkMock.emitter.emit).toHaveBeenCalledWith('removed', 'idToken', tokens.standardIdTokenParsed); }); }); \ No newline at end of file From 34e6ff418d7bed1b3adc45eb20f21b26693b95f4 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Thu, 5 May 2022 18:39:52 +0300 Subject: [PATCH 18/35] added test --- test/spec/services/SyncStorageService.ts | 47 +++++++++++++++++++++--- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/test/spec/services/SyncStorageService.ts b/test/spec/services/SyncStorageService.ts index 191b28483..508214fc7 100644 --- a/test/spec/services/SyncStorageService.ts +++ b/test/spec/services/SyncStorageService.ts @@ -117,7 +117,7 @@ describe('SyncStorageService', () => { expect(sdkMock.emitter.emit).toHaveBeenCalledWith('added', 'idToken', tokens.standardIdToken2Parsed); }); - it('should emit "removed" event if new token is removed from another tab', async () => { + it('should emit "removed" event if token is removed from another tab', async () => { createInstance(); jest.spyOn(sdkMock.emitter, 'emit'); await channel.postMessage({ @@ -128,7 +128,7 @@ describe('SyncStorageService', () => { expect(sdkMock.emitter.emit).toHaveBeenCalledWith('removed', 'idToken', tokens.standardIdTokenParsed); }); - it('should emit "renewed" event if new token is chnaged from another tab', async () => { + it('should emit "renewed" event if token is chnaged from another tab', async () => { createInstance(); jest.spyOn(sdkMock.emitter, 'emit'); await channel.postMessage({ @@ -140,7 +140,7 @@ describe('SyncStorageService', () => { expect(sdkMock.emitter.emit).toHaveBeenCalledWith('renewed', 'idToken', tokens.standardIdToken2Parsed, tokens.standardIdTokenParsed); }); - it('should not post sync message to other tabs', async () => { + it('should not post "sync message" to other tabs', async () => { createInstance(); const serviceChannel = (service as any).channel; jest.spyOn(serviceChannel, 'postMessage'); @@ -154,7 +154,7 @@ describe('SyncStorageService', () => { }); describe('posting sync messages', () => { - it('should post "added" sync message', () => { + it('should post "added" sync message when new token is added', () => { createInstance(); const serviceChannel = (service as any).channel; jest.spyOn(serviceChannel, 'postMessage'); @@ -166,7 +166,44 @@ describe('SyncStorageService', () => { }); }); - it('should not post set_storage event on storage change (for non-IE)', () => { + it('should post "removed" sync message when token is removed', () => { + 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', () => { + 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 not post "set_storage" event on storage change (for non-IE)', () => { createInstance(); const serviceChannel = (service as any).channel; jest.spyOn(serviceChannel, 'postMessage'); From f9294ee769b9cdf1456b3feff6abd7d8e63bd1bb Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Thu, 5 May 2022 18:46:18 +0300 Subject: [PATCH 19/35] added tests --- test/spec/services/SyncStorageService.ts | 61 ++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/test/spec/services/SyncStorageService.ts b/test/spec/services/SyncStorageService.ts index 508214fc7..72c4d9ee0 100644 --- a/test/spec/services/SyncStorageService.ts +++ b/test/spec/services/SyncStorageService.ts @@ -105,7 +105,7 @@ describe('SyncStorageService', () => { }); }); - describe('handling sync message', () => { + describe('handling sync messages', () => { it('should emit "added" event if new token is added from another tab', async () => { createInstance(); jest.spyOn(sdkMock.emitter, 'emit'); @@ -217,18 +217,73 @@ describe('SyncStorageService', () => { jest.spyOn(features, 'isIE11OrLess').mockReturnValue(true); }); - it('should post "set_storage" event on any storage change', () => { + it('should post "set_storage" event when new token is added', () => { createInstance(); const serviceChannel = (service as any).channel; jest.spyOn(serviceChannel, 'postMessage'); tokenManager.add('idToken', tokens.standardIdToken2Parsed); - expect(serviceChannel.postMessage).toHaveBeenCalledTimes(2); // ""set_storage" + "added" + 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', () => { + 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 is chnaged', () => { + 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 () => { From b156e62fdc226d31b86a90e42a04dc1766548382 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Thu, 5 May 2022 19:15:30 +0300 Subject: [PATCH 20/35] added SM tests --- test/spec/ServiceManager.ts | 199 ++++++++++++++++++++++-------------- 1 file changed, 121 insertions(+), 78 deletions(-) diff --git a/test/spec/ServiceManager.ts b/test/spec/ServiceManager.ts index 42360864e..350e762ab 100644 --- a/test/spec/ServiceManager.ts +++ b/test/spec/ServiceManager.ts @@ -52,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 }); @@ -65,88 +66,130 @@ describe('ServiceManager', () => { jest.useRealTimers(); }); - it('doesn\'t start leaderElection service if other services don\'t require leadership', () => { - const options = { tokenManager: { syncStorage: false, autoRenew: true } }; - const client = createAuth(options); - client.serviceManager.start(); - expect(client.serviceManager.isLeaderRequired()).toBeFalsy(); - expect(client.serviceManager.getService('leaderElection')?.isStarted()).toBeFalsy(); - client.serviceManager.stop(); - }); - - it('starts leaderElection service if any service (autoRenew) requires leadership', () => { - const options = { tokenManager: { syncStorage: true, autoRenew: true } }; - const client = createAuth(options); - client.serviceManager.start(); - expect(client.serviceManager.isLeaderRequired()).toBeTruthy(); - expect(client.serviceManager.getService('leaderElection')?.isStarted()).toBeTruthy(); - client.serviceManager.stop(); - }); - - it('starts syncStorage service for every tab, autoRenew service for leader tab (for syncStorage == true)', () => { - 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(); + describe('syncStorage', () => { + it('allows syncStorage for storage type "cookie"', () => { + const options = { tokenManager: { syncStorage: true, storage: 'cookie' } }; + util.disableLeaderElection(); + const client = createAuth(options); + client.serviceManager.start(); + expect(client.serviceManager.getService('syncStorage')?.isStarted()).toBeTruthy(); + client.serviceManager.stop(); + }); + + it('allows syncStorage for storage type "localStorage"', () => { + const options = { tokenManager: { syncStorage: true, storage: 'localStorage' } }; + util.disableLeaderElection(); + const client = createAuth(options); + client.serviceManager.start(); + expect(client.serviceManager.getService('syncStorage')?.isStarted()).toBeTruthy(); + client.serviceManager.stop(); + }); + + it('NOT allows syncStorage for storage type "sessionStorage"', () => { + const options = { tokenManager: { syncStorage: true, storage: 'sessionStorage' } }; + util.disableLeaderElection(); + const client = createAuth(options); + client.serviceManager.start(); + expect(client.serviceManager.getService('syncStorage')?.isStarted()).toBeFalsy(); + client.serviceManager.stop(); + }); + + it('NOT allows syncStorage for storage type "memory"', () => { + const options = { tokenManager: { syncStorage: true, storage: 'memory' } }; + util.disableLeaderElection(); + const client = createAuth(options); + client.serviceManager.start(); + expect(client.serviceManager.getService('syncStorage')?.isStarted()).toBeFalsy(); + client.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(); + describe('leaderElection', () => { + it('doesn\'t start leaderElection service if other services don\'t require leadership', () => { + const options = { tokenManager: { syncStorage: false, autoRenew: true } }; + const client = createAuth(options); + client.serviceManager.start(); + expect(client.serviceManager.isLeaderRequired()).toBeFalsy(); + expect(client.serviceManager.getService('leaderElection')?.isStarted()).toBeFalsy(); + client.serviceManager.stop(); + }); + + it('starts leaderElection service if any service (autoRenew) requires leadership', () => { + const options = { tokenManager: { syncStorage: true, autoRenew: true } }; + const client = createAuth(options); + client.serviceManager.start(); + expect(client.serviceManager.isLeaderRequired()).toBeTruthy(); + expect(client.serviceManager.getService('leaderElection')?.isStarted()).toBeTruthy(); + client.serviceManager.stop(); + }); }); - it('starts autoRenew service after becoming leader (for syncStorage == true)', async () => { - const options = { tokenManager: { syncStorage: true, autoRenew: true } }; - const client = createAuth(options); - 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(); - (client.serviceManager.getService('leaderElection') as any)?.onLeader(); - expect(client.serviceManager.isLeader()).toBeTruthy(); - 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)', () => { + 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); + client1.serviceManager.start(); + 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(); + client1.serviceManager.stop(); + client2.serviceManager.stop(); + }); + + it('starts autoRenew service for every tab (for syncStorage == false)', () => { + 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); + client1.serviceManager.start(); + 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(); + client1.serviceManager.stop(); + client2.serviceManager.stop(); + }); + + it('starts no services for syncStorage == false and autoRenew == false', () => { + 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); + client1.serviceManager.start(); + 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(); + client1.serviceManager.stop(); + 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); + 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(); + (client.serviceManager.getService('leaderElection') as any)?.onLeader(); + expect(client.serviceManager.isLeader()).toBeTruthy(); + expect(client.serviceManager.getService('autoRenew')?.isStarted()).toBeTruthy(); + client.serviceManager.stop(); + }); }); it('can restart', async () => { From 353ba5e8ded81b8220a374c9c564e648527cda48 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Thu, 5 May 2022 22:02:22 +0300 Subject: [PATCH 21/35] fix: post set_storage event when storage is cleared on logout . . --- lib/TokenManager.ts | 6 ++++ test/spec/services/SyncStorageService.ts | 38 ++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/lib/TokenManager.ts b/lib/TokenManager.ts index 7a685ea5c..c95f56f80 100644 --- a/lib/TokenManager.ts +++ b/lib/TokenManager.ts @@ -432,8 +432,14 @@ 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() { diff --git a/test/spec/services/SyncStorageService.ts b/test/spec/services/SyncStorageService.ts index 72c4d9ee0..183e48c2c 100644 --- a/test/spec/services/SyncStorageService.ts +++ b/test/spec/services/SyncStorageService.ts @@ -40,7 +40,10 @@ describe('SyncStorageService', () => { getStorage: jest.fn().mockImplementation(() => storage), setStorage: jest.fn().mockImplementation((newStorage) => { storage = newStorage; - }) + }), + clearStorage: jest.fn().mockImplementation(() => { + storage = {}; + }), }; sdkMock = { options: {}, @@ -140,7 +143,7 @@ describe('SyncStorageService', () => { expect(sdkMock.emitter.emit).toHaveBeenCalledWith('renewed', 'idToken', tokens.standardIdToken2Parsed, tokens.standardIdTokenParsed); }); - it('should not post "sync message" to other tabs', async () => { + it('should not post sync message to other tabs', async () => { createInstance(); const serviceChannel = (service as any).channel; jest.spyOn(serviceChannel, 'postMessage'); @@ -203,6 +206,19 @@ describe('SyncStorageService', () => { }); }); + it('should post "remove" events when token storage is cleared', () => { + 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)', () => { createInstance(); const serviceChannel = (service as any).channel; @@ -254,6 +270,24 @@ describe('SyncStorageService', () => { }); }); + it('should post "set_storage" event when token storage is cleared', () => { + 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', () => { createInstance(); const serviceChannel = (service as any).channel; From 3949afb0284fceb5e2c0c55ee066dcf8331d5019 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Fri, 6 May 2022 16:51:34 +0300 Subject: [PATCH 22/35] fix: SM should be started before TM --- lib/OktaAuth.ts | 4 ++-- test/e2e/specs/authRequired.js | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/OktaAuth.ts b/lib/OktaAuth.ts index 02d864d0c..5bc047ec2 100644 --- a/lib/OktaAuth.ts +++ b/lib/OktaAuth.ts @@ -376,13 +376,13 @@ class OktaAuth implements OktaAuthInterface, SigninAPI, SignoutAPI { this.serviceManager = new ServiceManager(this, args.services); } - async start() { + start() { + this.serviceManager.start(); // TODO: review tokenManager.start this.tokenManager.start(); if (!this.token.isLoginRedirect()) { this.authStateManager.updateAuthState(); } - await this.serviceManager.start(); } async stop() { 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(); From 0cf1982bd42a17d7bf9ec8f0339f7e38cfbcc756 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Mon, 9 May 2022 14:55:38 +0300 Subject: [PATCH 23/35] fix test 'can use memory token storage' (OKTA-464122) --- samples/generated/static-spa/public/app.js | 9 ++++++++- samples/templates/partials/spa/app.js | 4 +++- samples/templates/partials/spa/authMethod/redirect.js | 5 +++++ 3 files changed, 16 insertions(+), 2 deletions(-) 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/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; From f8ecc6e85356bcd96abf43da3c0889a8178cc726 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Tue, 10 May 2022 21:15:03 +0300 Subject: [PATCH 24/35] Support broadcastChannelName as old name for electionChannelName (OKTA-473815) --- lib/ServiceManager.ts | 4 +++- lib/types/Service.ts | 2 ++ test/spec/ServiceManager.ts | 10 ++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/ServiceManager.ts b/lib/ServiceManager.ts index 734316f6c..b37b8a7d7 100644 --- a/lib/ServiceManager.ts +++ b/lib/ServiceManager.ts @@ -18,6 +18,7 @@ import { } from './types'; import { OktaAuth } from '.'; import { AutoRenewService, SyncStorageService, LeaderElectionService } from './services'; +import { removeNils } from './util'; const AUTO_RENEW = 'autoRenew'; const SYNC_STORAGE = 'syncStorage'; @@ -43,6 +44,7 @@ export class ServiceManager implements ServiceManagerInterface { // TODO: backwards compatibility, remove in next major version - OKTA-473815 const { autoRenew, autoRemove, syncStorage } = sdk.tokenManager.getOptions(); + options.electionChannelName = options.broadcastChannelName; this.options = Object.assign({}, ServiceManager.defaultOptions, { autoRenew, autoRemove, syncStorage }, @@ -50,7 +52,7 @@ export class ServiceManager implements ServiceManagerInterface { electionChannelName: `${sdk.options.clientId}-election`, syncChannelName: `${sdk.options.clientId}-sync`, }, - options + removeNils(options) ); this.started = false; diff --git a/lib/types/Service.ts b/lib/types/Service.ts index 142c9a017..94bcfe300 100644 --- a/lib/types/Service.ts +++ b/lib/types/Service.ts @@ -26,6 +26,8 @@ export interface SyncStorageServiceOptions { export interface LeaderElectionServiceOptions { electionChannelName?: string; + // TODO: remove in next major version - OKTA-473815 + broadcastChannelName?: string; } export type ServiceManagerOptions = AutoRenewServiceOptions & diff --git a/test/spec/ServiceManager.ts b/test/spec/ServiceManager.ts index 350e762ab..8532482ef 100644 --- a/test/spec/ServiceManager.ts +++ b/test/spec/ServiceManager.ts @@ -205,6 +205,7 @@ describe('ServiceManager', () => { await client.serviceManager.stop(); }); + // TODO: remove in next major version - OKTA-473815 describe('Backwards Compatibility', () => { it('`services` will supersede `tokenManager` configurations', async () => { const options = { @@ -216,6 +217,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'); + }); }); }); From afa1461060541e624d1a04448f70a6e03f081647 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Tue, 24 May 2022 01:33:16 +0300 Subject: [PATCH 25/35] adress comments --- lib/ServiceManager.ts | 2 +- test/spec/ServiceManager.ts | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/ServiceManager.ts b/lib/ServiceManager.ts index b37b8a7d7..7dbdb9b3b 100644 --- a/lib/ServiceManager.ts +++ b/lib/ServiceManager.ts @@ -44,7 +44,7 @@ export class ServiceManager implements ServiceManagerInterface { // TODO: backwards compatibility, remove in next major version - OKTA-473815 const { autoRenew, autoRemove, syncStorage } = sdk.tokenManager.getOptions(); - options.electionChannelName = options.broadcastChannelName; + options.electionChannelName = options.electionChannelName || options.broadcastChannelName; this.options = Object.assign({}, ServiceManager.defaultOptions, { autoRenew, autoRemove, syncStorage }, diff --git a/test/spec/ServiceManager.ts b/test/spec/ServiceManager.ts index 8532482ef..8c0450c07 100644 --- a/test/spec/ServiceManager.ts +++ b/test/spec/ServiceManager.ts @@ -205,6 +205,31 @@ 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 () => { From 19de42816940882483d2ac687a12e7a7fc6099db Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Tue, 24 May 2022 20:52:47 +0300 Subject: [PATCH 26/35] Overload token manager on/off --- lib/AuthStateManager.ts | 3 +- lib/TokenManager.ts | 51 ++++++++++++++++++++++-------- lib/services/AutoRenewService.ts | 4 +-- lib/services/SyncStorageService.ts | 7 ++-- lib/types/TokenManager.ts | 32 +++++++++++++++---- 5 files changed, 71 insertions(+), 26 deletions(-) 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/TokenManager.ts b/lib/TokenManager.ts index c95f56f80..8e36be528 100644 --- a/lib/TokenManager.ts +++ b/lib/TokenManager.ts @@ -28,12 +28,23 @@ import { StorageType, OktaAuthInterface, StorageProvider, - TokenManagerAnyEventHandler, + 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'; @@ -48,12 +59,6 @@ const DEFAULT_OPTIONS = { expireEarlySeconds: 30, 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'; -export const EVENT_SET_STORAGE = 'set_storage'; interface TokenManagerState { expireTimeouts: Record; @@ -73,8 +78,31 @@ export class TokenManager implements TokenManagerInterface { private state: TokenManagerState; private options: TokenManagerOptions; - on: (event: string, handler: TokenManagerAnyEventHandler, context?: object) => void; - off: (event: string, handler?: TokenManagerAnyEventHandler) => 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 = {}) { @@ -105,9 +133,6 @@ 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() { diff --git a/lib/services/AutoRenewService.ts b/lib/services/AutoRenewService.ts index b1efef9ac..eb838f862 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 { diff --git a/lib/services/SyncStorageService.ts b/lib/services/SyncStorageService.ts index 5ef80cfaa..b0fe730d4 100644 --- a/lib/services/SyncStorageService.ts +++ b/lib/services/SyncStorageService.ts @@ -10,10 +10,13 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { TokenManager, EVENT_ADDED, EVENT_REMOVED, EVENT_RENEWED, EVENT_SET_STORAGE } from '../TokenManager'; +import { TokenManager } from '../TokenManager'; import { BroadcastChannel } from 'broadcast-channel'; import { isBrowser } from '../features'; -import { ServiceManagerOptions, ServiceInterface, Token, Tokens } from '../types'; +import { + ServiceManagerOptions, ServiceInterface, Token, Tokens, + EVENT_ADDED, EVENT_REMOVED, EVENT_RENEWED, EVENT_SET_STORAGE +} from '../types'; export type SyncMessage = { type: string; diff --git a/lib/types/TokenManager.ts b/lib/types/TokenManager.ts index 488311e31..ad39185d0 100644 --- a/lib/types/TokenManager.ts +++ b/lib/types/TokenManager.ts @@ -9,19 +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 TokenManagerSetStorageEventHandler = (storage: Tokens) => void; -export declare type TokenManagerAnyEventHandler = TokenManagerEventHandler | TokenManagerErrorEventHandler | TokenManagerSetStorageEventHandler; - 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: TokenManagerAnyEventHandler, context?: object) => void; - off: (event: string, handler?: TokenManagerAnyEventHandler) => 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; From c336d3fc1fe6592d256585181831155659b3b426 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Tue, 24 May 2022 21:02:07 +0300 Subject: [PATCH 27/35] fix --- test/apps/app/src/testApp.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/apps/app/src/testApp.ts b/test/apps/app/src/testApp.ts index 98a314950..abe2a61d8 100644 --- a/test/apps/app/src/testApp.ts +++ b/test/apps/app/src/testApp.ts @@ -281,11 +281,14 @@ 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); + ['expired', 'added', 'removed'].forEach(event => { + this.oktaAuth.tokenManager.on(event, (arg1: unknown) => { + console.log(`TokenManager::${event}`, 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); this._onTokenError(err); From 4e4a02f9b909104475f35614f55f6a09c9df25de Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Tue, 24 May 2022 21:19:11 +0300 Subject: [PATCH 28/35] fix --- test/apps/app/src/testApp.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/apps/app/src/testApp.ts b/test/apps/app/src/testApp.ts index abe2a61d8..6fbf57510 100644 --- a/test/apps/app/src/testApp.ts +++ b/test/apps/app/src/testApp.ts @@ -281,10 +281,14 @@ class TestApp { } subscribeToTokenEvents(): void { - ['expired', 'added', 'removed'].forEach(event => { - this.oktaAuth.tokenManager.on(event, (arg1: unknown) => { - console.log(`TokenManager::${event}`, arg1); - }); + 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); From 97eb8d991d042ae1c0f90619c3f223cfae22c580 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Thu, 2 Jun 2022 00:53:05 +0300 Subject: [PATCH 29/35] async fix after rebase async . --- README.md | 6 +- lib/OktaAuth.ts | 4 +- lib/ServiceManager.ts | 18 ++--- lib/services/AutoRenewService.ts | 6 +- lib/services/LeaderElectionService.ts | 28 ++++---- lib/services/SyncStorageService.ts | 8 +-- lib/types/Service.ts | 4 +- test/spec/AuthStateManager.js | 2 +- test/spec/OktaAuth/api.ts | 12 ++-- test/spec/ServiceManager.ts | 80 ++++++++++----------- test/spec/services/LeaderElectionService.ts | 29 ++++---- test/spec/services/SyncStorageService.ts | 59 ++++++++------- 12 files changed, 130 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index 85832e604..ca08892a1 100644 --- a/README.md +++ b/README.md @@ -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/lib/OktaAuth.ts b/lib/OktaAuth.ts index 5bc047ec2..23eb0b530 100644 --- a/lib/OktaAuth.ts +++ b/lib/OktaAuth.ts @@ -376,8 +376,8 @@ class OktaAuth implements OktaAuthInterface, SigninAPI, SignoutAPI { this.serviceManager = new ServiceManager(this, args.services); } - start() { - this.serviceManager.start(); + async start() { + await this.serviceManager.start(); // TODO: review tokenManager.start this.tokenManager.start(); if (!this.token.isLoginRedirect()) { diff --git a/lib/ServiceManager.ts b/lib/ServiceManager.ts index 7dbdb9b3b..aeece01dc 100644 --- a/lib/ServiceManager.ts +++ b/lib/ServiceManager.ts @@ -66,10 +66,10 @@ export class ServiceManager implements ServiceManagerInterface { }); } - private onLeader() { + private async onLeader() { if (this.started) { // Start services that requires leadership - this.startServices(); + await this.startServices(); } } @@ -85,12 +85,12 @@ export class ServiceManager implements ServiceManagerInterface { if (this.started) { return; // noop if services have already started } - this.startServices(); + await this.startServices(); this.started = true; } - stop() { - this.stopServices(); + async stop() { + await this.stopServices(); this.started = false; } @@ -98,17 +98,17 @@ export class ServiceManager implements ServiceManagerInterface { return this.services.get(name); } - private startServices() { + private async startServices() { for (const [name, srv] of this.services.entries()) { if (this.canStartService(name, srv)) { - srv.start(); + await srv.start(); } } } - private stopServices() { + private async stopServices() { for (const srv of this.services.values()) { - srv.stop(); + await srv.stop(); } } diff --git a/lib/services/AutoRenewService.ts b/lib/services/AutoRenewService.ts index eb838f862..351167d3b 100644 --- a/lib/services/AutoRenewService.ts +++ b/lib/services/AutoRenewService.ts @@ -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 index 5ecaa2cc6..f7f8bd8ca 100644 --- a/lib/services/LeaderElectionService.ts +++ b/lib/services/LeaderElectionService.ts @@ -19,7 +19,7 @@ import { } from 'broadcast-channel'; import { isBrowser } from '../features'; -declare type OnLeaderHandler = (() => void); +declare type OnLeaderHandler = (() => Promise); declare type ServiceOptions = ServiceManagerOptions & { onLeader?: OnLeaderHandler; }; @@ -39,8 +39,8 @@ export class LeaderElectionService implements ServiceInterface { private onLeaderDuplicate() { } - private onLeader() { - this.options.onLeader?.(); + private async onLeader() { + await this.options.onLeader?.(); } isLeader() { @@ -51,8 +51,8 @@ export class LeaderElectionService implements ServiceInterface { return !!this.elector?.hasLeader; } - start() { - this.stop(); + async start() { + await this.stop(); if (this.canStart()) { const { electionChannelName } = this.options; this.channel = new BroadcastChannel(electionChannelName as string); @@ -63,14 +63,18 @@ export class LeaderElectionService implements ServiceInterface { } } - stop() { + async stop() { if (this.started) { - this.elector?.die(); - this.elector = undefined; - this.channel?.close(); - // Workaround to fix error `Failed to execute 'postMessage' on 'BroadcastChannel': Channel is closed` - (this.channel as any).postInternal = () => Promise.resolve(); - this.channel = undefined; + 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; } } diff --git a/lib/services/SyncStorageService.ts b/lib/services/SyncStorageService.ts index b0fe730d4..10007fa36 100644 --- a/lib/services/SyncStorageService.ts +++ b/lib/services/SyncStorageService.ts @@ -54,9 +54,9 @@ export class SyncStorageService implements ServiceInterface { return !!this.options.syncStorage && isBrowser(); } - start() { + async start() { if (this.canStart()) { - this.stop(); + await this.stop(); const { syncChannelName } = this.options; this.channel = new BroadcastChannel(syncChannelName as string); this.tokenManager.on(EVENT_ADDED, this.onTokenAddedHandler); @@ -68,14 +68,14 @@ export class SyncStorageService implements ServiceInterface { } } - stop() { + async stop() { if (this.started) { 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); - this.channel?.close(); + await this.channel?.close(); this.channel = undefined; this.started = false; } diff --git a/lib/types/Service.ts b/lib/types/Service.ts index 94bcfe300..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; diff --git a/test/spec/AuthStateManager.js b/test/spec/AuthStateManager.js index 88c3b3446..ecc2cde3f 100644 --- a/test/spec/AuthStateManager.js +++ b/test/spec/AuthStateManager.js @@ -137,7 +137,7 @@ describe('AuthStateManager', () => { const auth = createAuth(); auth.authStateManager.updateAuthState = jest.fn(); auth.tokenManager.start(); // uses TokenService / crossTabs - auth.serviceManager.start(); + await auth.serviceManager.start(); // simulate change from other dom context const channel = new BroadcastChannel('syncChannel'); await channel.postMessage({ 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 8c0450c07..ccd43fc62 100644 --- a/test/spec/ServiceManager.ts +++ b/test/spec/ServiceManager.ts @@ -26,12 +26,12 @@ jest.mock('../../lib/services/LeaderElectionService', () => { canStart() { return true; } requiresLeadership() { return false; } isStarted() { return this.started; } - start() { this.started = true; } - stop() { this.started = false; } + async start() { this.started = true; } + async stop() { this.started = false; } isLeader() { return this._isLeader; } _setLeader() { this._isLeader = true; } - public onLeader() { - (this.options as any).onLeader?.(); + async onLeader() { + await (this.options as any).onLeader?.(); } } return { @@ -67,128 +67,128 @@ describe('ServiceManager', () => { }); describe('syncStorage', () => { - it('allows syncStorage for storage type "cookie"', () => { + it('allows syncStorage for storage type "cookie"', async () => { const options = { tokenManager: { syncStorage: true, storage: 'cookie' } }; util.disableLeaderElection(); const client = createAuth(options); - client.serviceManager.start(); + await client.serviceManager.start(); expect(client.serviceManager.getService('syncStorage')?.isStarted()).toBeTruthy(); - client.serviceManager.stop(); + await client.serviceManager.stop(); }); - it('allows syncStorage for storage type "localStorage"', () => { + it('allows syncStorage for storage type "localStorage"', async () => { const options = { tokenManager: { syncStorage: true, storage: 'localStorage' } }; util.disableLeaderElection(); const client = createAuth(options); - client.serviceManager.start(); + await client.serviceManager.start(); expect(client.serviceManager.getService('syncStorage')?.isStarted()).toBeTruthy(); - client.serviceManager.stop(); + await client.serviceManager.stop(); }); - it('NOT allows syncStorage for storage type "sessionStorage"', () => { + it('NOT allows syncStorage for storage type "sessionStorage"', async () => { const options = { tokenManager: { syncStorage: true, storage: 'sessionStorage' } }; util.disableLeaderElection(); const client = createAuth(options); - client.serviceManager.start(); + await client.serviceManager.start(); expect(client.serviceManager.getService('syncStorage')?.isStarted()).toBeFalsy(); - client.serviceManager.stop(); + await client.serviceManager.stop(); }); - it('NOT allows syncStorage for storage type "memory"', () => { + it('NOT allows syncStorage for storage type "memory"', async () => { const options = { tokenManager: { syncStorage: true, storage: 'memory' } }; util.disableLeaderElection(); const client = createAuth(options); - client.serviceManager.start(); + await client.serviceManager.start(); expect(client.serviceManager.getService('syncStorage')?.isStarted()).toBeFalsy(); - client.serviceManager.stop(); + await client.serviceManager.stop(); }); }); describe('leaderElection', () => { - it('doesn\'t start leaderElection service if other services don\'t require leadership', () => { + 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); - client.serviceManager.start(); + await client.serviceManager.start(); expect(client.serviceManager.isLeaderRequired()).toBeFalsy(); expect(client.serviceManager.getService('leaderElection')?.isStarted()).toBeFalsy(); - client.serviceManager.stop(); + await client.serviceManager.stop(); }); - it('starts leaderElection service if any service (autoRenew) requires leadership', () => { + it('starts leaderElection service if any service (autoRenew) requires leadership', async () => { const options = { tokenManager: { syncStorage: true, autoRenew: true } }; const client = createAuth(options); - client.serviceManager.start(); + await client.serviceManager.start(); expect(client.serviceManager.isLeaderRequired()).toBeTruthy(); expect(client.serviceManager.getService('leaderElection')?.isStarted()).toBeTruthy(); - client.serviceManager.stop(); + await client.serviceManager.stop(); }); }); describe('autoRenew', () => { - it('starts syncStorage service for every tab, autoRenew service for leader tab (for syncStorage == true)', () => { + 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); - client1.serviceManager.start(); - client2.serviceManager.start(); + 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(); - client1.serviceManager.stop(); - client2.serviceManager.stop(); + await client1.serviceManager.stop(); + await client2.serviceManager.stop(); }); - it('starts autoRenew service for every tab (for syncStorage == false)', () => { + 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); - client1.serviceManager.start(); - client2.serviceManager.start(); + 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(); - client1.serviceManager.stop(); - client2.serviceManager.stop(); + await client1.serviceManager.stop(); + await client2.serviceManager.stop(); }); - it('starts no services for syncStorage == false and autoRenew == false', () => { + 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); - client1.serviceManager.start(); - client2.serviceManager.start(); + 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(); - client1.serviceManager.stop(); - client2.serviceManager.stop(); + 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); - client.serviceManager.start(); + 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(); - (client.serviceManager.getService('leaderElection') as any)?.onLeader(); + await (client.serviceManager.getService('leaderElection') as any)?.onLeader(); expect(client.serviceManager.isLeader()).toBeTruthy(); expect(client.serviceManager.getService('autoRenew')?.isStarted()).toBeTruthy(); - client.serviceManager.stop(); + await client.serviceManager.stop(); }); }); diff --git a/test/spec/services/LeaderElectionService.ts b/test/spec/services/LeaderElectionService.ts index c34972548..9d2800662 100644 --- a/test/spec/services/LeaderElectionService.ts +++ b/test/spec/services/LeaderElectionService.ts @@ -16,7 +16,7 @@ import { LeaderElectionService } from '../../../lib/services/LeaderElectionServi jest.mock('broadcast-channel', () => { const actual = jest.requireActual('broadcast-channel'); class FakeBroadcastChannel { - close() {} + async close() {} } return { createLeaderElection: actual.createLeaderElection, @@ -29,7 +29,7 @@ const mocked = { }; describe('LeaderElectionService', () => { - let service; + let service: LeaderElectionService | null; beforeEach(function() { jest.useFakeTimers(); service = null; @@ -67,18 +67,18 @@ describe('LeaderElectionService', () => { resolve(); }, 100); }) as Promise, - die: () => {}, + die: () => Promise.resolve(undefined), }; return mockedElector; } describe('start', () => { - it('creates elector and awaits leadership', () => { + it('creates elector and awaits leadership', async () => { const elector = createElectorWithLeadership(); jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(elector); const service = createService(); - service.start(); + await service.start(); expect(service.isStarted()).toBeTruthy(); expect((service as any).elector).toStrictEqual(elector); expect(elector.awaitLeadership).toHaveBeenCalledTimes(1); @@ -88,20 +88,20 @@ describe('LeaderElectionService', () => { const elector = createElectorWithLeadership(); jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(elector); const service = createService(); - service.start(); - service.start(); + await service.start(); + await service.start(); expect(service.isStarted()).toBeTruthy(); expect(elector.die).toHaveBeenCalledTimes(1); }); }); describe('stop', () => { - it('should kill elector', () => { + it('should kill elector', async () => { const elector = createElectorWithLeadership(); jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(elector); const service = createService(); - service.start(); - service.stop(); + await service.start(); + await service.stop(); expect(service.isStarted()).toBeFalsy(); expect(elector.die).toHaveBeenCalledTimes(1); }); @@ -110,24 +110,23 @@ describe('LeaderElectionService', () => { const elector = createElectorWithLeadership(); jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(elector); const service = createService(); - service.start(); + await service.start(); expect(service.isStarted()).toBeTruthy(); await Promise.race([ service.stop(), service.stop() ]); expect(service.isStarted()).toBeFalsy(); - expect(elector.die).toHaveBeenCalledTimes(1); }); }); describe('isLeader', () => { - it('returns true if current tab is elected as leader', () => { + 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(); - service.start(); + await service.start(); expect(service.isLeader()).toBeTruthy(); }); }); @@ -138,7 +137,7 @@ describe('LeaderElectionService', () => { const elector = createElectorWithDelayedLeadership(); jest.spyOn(mocked.broadcastChannel, 'createLeaderElection').mockReturnValue(elector); const service = createService({ onLeader }); - service.start(); + await service.start(); await Promise.resolve(); expect(onLeader).toHaveBeenCalledTimes(0); expect(service.isLeader()).toBeFalsy(); diff --git a/test/spec/services/SyncStorageService.ts b/test/spec/services/SyncStorageService.ts index 183e48c2c..5653e28d0 100644 --- a/test/spec/services/SyncStorageService.ts +++ b/test/spec/services/SyncStorageService.ts @@ -67,14 +67,14 @@ describe('SyncStorageService', () => { } }); - function createInstance(options?) { + async function createInstance(options?) { tokenManager = new TokenManager(sdkMock, options); tokenManager.start(); service = new SyncStorageService(tokenManager, { ...tokenManager.getOptions(), syncChannelName: 'syncChannel' }); - service.start(); + await service.start(); // Create another channel with same name for communication channel = new BroadcastChannel('syncChannel'); return tokenManager; @@ -82,10 +82,10 @@ describe('SyncStorageService', () => { describe('start', () => { it('stops service if already started, closes and recreates channel', async () => { - createInstance(); + await createInstance(); const oldChannel = (service as any).channel; jest.spyOn(oldChannel, 'close'); - service.start(); // restart + await service.start(); // restart const newChannel = (service as any).channel; expect(service.isStarted()).toBeTruthy(); expect(oldChannel.close).toHaveBeenCalledTimes(1); @@ -95,22 +95,19 @@ describe('SyncStorageService', () => { describe('stop', () => { it('can be called twice without error', async () => { - createInstance(); - const serviceChannel = (service as any).channel; - jest.spyOn(serviceChannel, 'close'); + await createInstance(); await Promise.race([ service.stop(), service.stop() ]); expect(service.isStarted()).toBeFalsy(); - expect(serviceChannel.close).toHaveBeenCalledTimes(1); expect((service as any).channel).not.toBeDefined(); }); }); describe('handling sync messages', () => { it('should emit "added" event if new token is added from another tab', async () => { - createInstance(); + await createInstance(); jest.spyOn(sdkMock.emitter, 'emit'); await channel.postMessage({ type: 'added', @@ -121,7 +118,7 @@ describe('SyncStorageService', () => { }); it('should emit "removed" event if token is removed from another tab', async () => { - createInstance(); + await createInstance(); jest.spyOn(sdkMock.emitter, 'emit'); await channel.postMessage({ type: 'removed', @@ -132,7 +129,7 @@ describe('SyncStorageService', () => { }); it('should emit "renewed" event if token is chnaged from another tab', async () => { - createInstance(); + await createInstance(); jest.spyOn(sdkMock.emitter, 'emit'); await channel.postMessage({ type: 'renewed', @@ -144,7 +141,7 @@ describe('SyncStorageService', () => { }); it('should not post sync message to other tabs', async () => { - createInstance(); + await createInstance(); const serviceChannel = (service as any).channel; jest.spyOn(serviceChannel, 'postMessage'); await channel.postMessage({ @@ -157,8 +154,8 @@ describe('SyncStorageService', () => { }); describe('posting sync messages', () => { - it('should post "added" sync message when new token is added', () => { - createInstance(); + 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); @@ -169,8 +166,8 @@ describe('SyncStorageService', () => { }); }); - it('should post "removed" sync message when token is removed', () => { - createInstance(); + 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'); @@ -181,8 +178,8 @@ describe('SyncStorageService', () => { }); }); - it('should post "removed", "added", "renewed" sync messages when token is changed', () => { - createInstance(); + 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({ @@ -206,8 +203,8 @@ describe('SyncStorageService', () => { }); }); - it('should post "remove" events when token storage is cleared', () => { - createInstance(); + 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(); @@ -219,8 +216,8 @@ describe('SyncStorageService', () => { }); }); - it('should not post "set_storage" event on storage change (for non-IE)', () => { - createInstance(); + 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); @@ -233,8 +230,8 @@ describe('SyncStorageService', () => { jest.spyOn(features, 'isIE11OrLess').mockReturnValue(true); }); - it('should post "set_storage" event when new token is added', () => { - createInstance(); + 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); @@ -252,8 +249,8 @@ describe('SyncStorageService', () => { }); }); - it('should post "set_storage" event when token is removed', () => { - createInstance(); + 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'); @@ -270,8 +267,8 @@ describe('SyncStorageService', () => { }); }); - it('should post "set_storage" event when token storage is cleared', () => { - createInstance(); + 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(); @@ -288,8 +285,8 @@ describe('SyncStorageService', () => { }); }); - it('should post "set_storage" event when token is chnaged', () => { - createInstance(); + 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({ @@ -321,7 +318,7 @@ describe('SyncStorageService', () => { }); it('should update storage excplicitly on "set_storage" event', async () => { - createInstance(); + await createInstance(); const newStorage = { idToken: tokens.standardIdToken2Parsed }; From 049f7edbdb64b99c7f50a3327b5c4a45d64c9a68 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Thu, 2 Jun 2022 17:55:33 +0300 Subject: [PATCH 30/35] fix clearPendingRemoveTokens --- lib/TokenManager.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/TokenManager.ts b/lib/TokenManager.ts index 8e36be528..b1152e6e4 100644 --- a/lib/TokenManager.ts +++ b/lib/TokenManager.ts @@ -468,12 +468,20 @@ export class TokenManager implements TokenManagerInterface { } 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]; } }); + this.storage.setStorage(tokenStorage); + this.emitSetStorageEvent(); + Object.keys(removedTokens).forEach(key => { + this.clearExpireEventTimeout(key); + this.emitRemoved(key, removedTokens[key]); + }); } updateRefreshToken(token: RefreshToken) { From 32faaded3b50117b3fcf0efb0e172d6a6c830262 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Thu, 2 Jun 2022 18:03:39 +0300 Subject: [PATCH 31/35] test --- test/spec/TokenManager/core.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/test/spec/TokenManager/core.ts b/test/spec/TokenManager/core.ts index 194288d1c..6227c0512 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); }); }); From fce8c44eebb2bb6a361aa01bf12dfe2afe8a2446 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Thu, 2 Jun 2022 18:18:09 +0300 Subject: [PATCH 32/35] fix --- test/spec/TokenManager/core.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/spec/TokenManager/core.ts b/test/spec/TokenManager/core.ts index 6227c0512..503f57772 100644 --- a/test/spec/TokenManager/core.ts +++ b/test/spec/TokenManager/core.ts @@ -700,7 +700,7 @@ describe('TokenManager', function() { jest.spyOn(client.tokenManager, 'emitRemoved'); jest.spyOn(storageProvider, 'setItem'); client.tokenManager.clearPendingRemoveTokens(); - expect(storageProvider.setItem).toHaveBeenNthCalledWith(1, 'okta-token-storage', {}); + 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); From 35be52ad61d1280e819542ca2acd1aed03c6af7e Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Thu, 2 Jun 2022 23:26:34 +0300 Subject: [PATCH 33/35] chlog --- CHANGELOG.md | 9 +++++++++ README.md | 6 +++--- 2 files changed, 12 insertions(+), 3 deletions(-) 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 ca08892a1..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). From 45fc202e3ad7d5b14166c60ea93d6c8a5d5f9809 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Thu, 2 Jun 2022 23:37:04 +0300 Subject: [PATCH 34/35] await updateAuthState in start --- lib/OktaAuth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/OktaAuth.ts b/lib/OktaAuth.ts index 23eb0b530..fec5e5719 100644 --- a/lib/OktaAuth.ts +++ b/lib/OktaAuth.ts @@ -381,7 +381,7 @@ class OktaAuth implements OktaAuthInterface, SigninAPI, SignoutAPI { // TODO: review tokenManager.start this.tokenManager.start(); if (!this.token.isLoginRedirect()) { - this.authStateManager.updateAuthState(); + await this.authStateManager.updateAuthState(); } } From 8897c988530d363131d43c20c0fe18bb85aef195 Mon Sep 17 00:00:00 2001 From: Denys Oblohin Date: Fri, 3 Jun 2022 11:41:01 +0300 Subject: [PATCH 35/35] fix test memory storage --- samples/generated/webpack-spa/src/index.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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;