Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement SyncStorageService using broadcast-channel #1197

Closed
wants to merge 35 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
419224d
Added LeaderElectionService
denysoblohin-okta Apr 24, 2022
d795662
broadcastChannelName -> electionChannelName
denysoblohin-okta Apr 24, 2022
20c5d10
New impl of SyncStorageService
denysoblohin-okta Apr 25, 2022
0f461e8
fix message loop
denysoblohin-okta Apr 26, 2022
5991101
allow syncStorage for localStorage and cookie
denysoblohin-okta Apr 26, 2022
bd6ac2d
.
denysoblohin-okta Apr 26, 2022
e890015
fixes
denysoblohin-okta Apr 27, 2022
ca08809
fix
denysoblohin-okta Apr 27, 2022
095faf4
support IE11
denysoblohin-okta May 2, 2022
714ed25
IE11 workaround
denysoblohin-okta May 2, 2022
a4adcfe
Use event set_storage for IE11
denysoblohin-okta May 3, 2022
5850351
Added polyfills for IE11
denysoblohin-okta May 3, 2022
3453ebb
lint fix
denysoblohin-okta May 3, 2022
85138a4
fix Channel is closed
denysoblohin-okta May 3, 2022
e442b2c
added tests LES
denysoblohin-okta May 4, 2022
34acc6f
added test for starting leaderElection service
denysoblohin-okta May 4, 2022
246b2af
added tests for services
denysoblohin-okta May 4, 2022
34e6ff4
added test
denysoblohin-okta May 5, 2022
f9294ee
added tests
denysoblohin-okta May 5, 2022
b156e62
added SM tests
denysoblohin-okta May 5, 2022
353ba5e
fix: post set_storage event when storage is cleared on logout
denysoblohin-okta May 5, 2022
3949afb
fix: SM should be started before TM
denysoblohin-okta May 6, 2022
0cf1982
fix test 'can use memory token storage' (OKTA-464122)
denysoblohin-okta May 9, 2022
f8ecc6e
Support broadcastChannelName as old name for electionChannelName (OKT…
denysoblohin-okta May 10, 2022
afa1461
adress comments
denysoblohin-okta May 23, 2022
19de428
Overload token manager on/off
denysoblohin-okta May 24, 2022
c336d3f
fix
denysoblohin-okta May 24, 2022
4e4a02f
fix
denysoblohin-okta May 24, 2022
97eb8d9
async fix after rebase
denysoblohin-okta Jun 1, 2022
049f7ed
fix clearPendingRemoveTokens
denysoblohin-okta Jun 2, 2022
32faade
test
denysoblohin-okta Jun 2, 2022
fce8c44
fix
denysoblohin-okta Jun 2, 2022
35be52a
chlog
denysoblohin-okta Jun 2, 2022
45fc202
await updateAuthState in start
denysoblohin-okta Jun 2, 2022
8897c98
fix test memory storage
denysoblohin-okta Jun 3, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/test/support/xhr
/test/app/public
/test/apps/app/public
node_modules
/build/dist
/build/lib
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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)`

Expand Down
1 change: 1 addition & 0 deletions jest.server.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const config = Object.assign({}, baseConfig, {
'oidc/renewTokens.ts',
'TokenManager/browser',
'SyncStorageService',
'LeaderElectionService',
'ServiceManager'
])
});
Expand Down
3 changes: 1 addition & 2 deletions lib/AuthStateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 7 additions & 2 deletions lib/OktaAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,17 +367,22 @@ class OktaAuth implements OktaAuthInterface, SigninAPI, SignoutAPI {
// AuthStateManager
this.authStateManager = new AuthStateManager(this);

// Enable `syncStorage` only if token storage is shared across tabs (type is `localStorage` or `cookie`)
if (!this.tokenManager.hasSharedStorage()) {
args.services = { ...args.services, syncStorage: false };
}

// ServiceManager
this.serviceManager = new ServiceManager(this, args.services);
}

async start() {
await this.serviceManager.start();
// TODO: review tokenManager.start
this.tokenManager.start();
if (!this.token.isLoginRedirect()) {
this.authStateManager.updateAuthState();
await this.authStateManager.updateAuthState();
}
await this.serviceManager.start();
}

async stop() {
Expand Down
5 changes: 5 additions & 0 deletions lib/SavedObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export default class SavedObject implements StorageProvider {
// StorageProvider interface
//

isSharedStorage() {
return typeof localStorage !== 'undefined' && this.storageProvider === localStorage as any
|| !!this.storageProvider.isSharedStorage?.();
}

getStorage() {
var storageString = this.storageProvider.getItem(this.storageName);
storageString = storageString || '{}';
Expand Down
109 changes: 42 additions & 67 deletions lib/ServiceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,20 @@ import {
ServiceManagerOptions
} from './types';
import { OktaAuth } from '.';
import {
BroadcastChannel,
createLeaderElection,
LeaderElector
} from 'broadcast-channel';
import { AutoRenewService, SyncStorageService } from './services';
import { isBrowser } from './features';
import { AutoRenewService, SyncStorageService, LeaderElectionService } from './services';
import { removeNils } from './util';

const AUTO_RENEW = 'autoRenew';
const SYNC_STORAGE = 'syncStorage';
const LEADER_ELECTION = 'leaderElection';

export class ServiceManager implements ServiceManagerInterface {
private sdk: OktaAuth;
private options: ServiceManagerOptions;
private services: Map<string, ServiceInterface>;
private channel?: BroadcastChannel;
private elector?: LeaderElector;
private started: boolean;

private static knownServices = ['autoRenew', 'syncStorage'];
private static knownServices = [AUTO_RENEW, SYNC_STORAGE, LEADER_ELECTION];

private static defaultOptions = {
autoRenew: true,
Expand All @@ -43,19 +40,23 @@ export class ServiceManager implements ServiceManagerInterface {

constructor(sdk: OktaAuth, options: ServiceManagerOptions = {}) {
this.sdk = sdk;
this.onLeader = this.onLeader.bind(this);

// TODO: backwards compatibility, remove in next major version - OKTA-473815
const { autoRenew, autoRemove, syncStorage } = sdk.tokenManager.getOptions();
options.electionChannelName = options.electionChannelName || options.broadcastChannelName;
this.options = Object.assign({},
ServiceManager.defaultOptions,
{ autoRenew, autoRemove, syncStorage },
options
{ autoRenew, autoRemove, syncStorage },
{
electionChannelName: `${sdk.options.clientId}-election`,
syncChannelName: `${sdk.options.clientId}-sync`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add a test for these default values in test/spec/ServiceManager

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also please add a test verifying syncChannelName can be set

},
removeNils(options)
);

this.started = false;
this.services = new Map();
this.onLeaderDuplicate = this.onLeaderDuplicate.bind(this);
this.onLeader = this.onLeader.bind(this);

ServiceManager.knownServices.forEach(name => {
const svc = this.createService(name);
Expand All @@ -65,102 +66,76 @@ export class ServiceManager implements ServiceManagerInterface {
});
}

public static canUseLeaderElection() {
return isBrowser();
}

private onLeader() {
private async onLeader() {
if (this.started) {
// Start services that requires leadership
this.startServices();
await this.startServices();
}
}

private onLeaderDuplicate() {
}

isLeader() {
return !!this.elector?.isLeader;
}

hasLeader() {
return this.elector?.hasLeader;
return (this.getService(LEADER_ELECTION) as LeaderElectionService)?.isLeader();
}

isLeaderRequired() {
return [...this.services.values()].some(srv => srv.requiresLeadership());
return [...this.services.values()].some(srv => srv.canStart() && srv.requiresLeadership());
}

async start() {
if (this.started) {
return; // noop if services have already started
}
// only start election if a leader is required
if (this.isLeaderRequired()) {
await this.startElector();
}
this.startServices();
await this.startServices();
this.started = true;
}

async stop() {
await this.stopElector();
this.stopServices();
await this.stopServices();
this.started = false;
}

getService(name: string): ServiceInterface | undefined {
return this.services.get(name);
}

private startServices() {
for (const srv of this.services.values()) {
const canStart = srv.canStart() && !srv.isStarted() && (srv.requiresLeadership() ? this.isLeader() : true);
if (canStart) {
srv.start();
private async startServices() {
for (const [name, srv] of this.services.entries()) {
if (this.canStartService(name, srv)) {
await srv.start();
}
}
}

private stopServices() {
private async stopServices() {
for (const srv of this.services.values()) {
srv.stop();
}
}

private async startElector() {
await this.stopElector();
if (ServiceManager.canUseLeaderElection()) {
if (!this.channel) {
const { broadcastChannelName } = this.options;
this.channel = new BroadcastChannel(broadcastChannelName as string);
}
if (!this.elector) {
this.elector = createLeaderElection(this.channel);
this.elector.onduplicate = this.onLeaderDuplicate;
this.elector.awaitLeadership().then(this.onLeader);
}
await srv.stop();
}
}

private async stopElector() {
if (this.elector) {
await this.elector?.die();
this.elector = undefined;
await this.channel?.close();
this.channel = undefined;
// eslint-disable-next-line complexity
private canStartService(name: string, srv: ServiceInterface): boolean {
let canStart = srv.canStart() && !srv.isStarted();
// only start election if a leader is required
if (name === LEADER_ELECTION) {
canStart &&= this.isLeaderRequired();
} else if (srv.requiresLeadership()) {
canStart &&= this.isLeader();
}
return canStart;
}

private createService(name: string): ServiceInterface {
const tokenManager = this.sdk.tokenManager;

let service: ServiceInterface | undefined;
let service: ServiceInterface;
switch (name) {
case 'autoRenew':
case LEADER_ELECTION:
service = new LeaderElectionService({...this.options, onLeader: this.onLeader});
break;
case AUTO_RENEW:
service = new AutoRenewService(tokenManager, {...this.options});
break;
case 'syncStorage':
case SYNC_STORAGE:
service = new SyncStorageService(tokenManager, {...this.options});
break;
default:
Expand Down
Loading