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

adds new service renewOnTabActivation #1512

Merged
merged 3 commits into from
Apr 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Features
- [#1507](https://github.com/okta/okta-auth-js/pull/1507) add: new method `getOrRenewAccessToken`
- [#1505](https://github.com/okta/okta-auth-js/pull/1505) add: support of `revokeSessions` param for `OktaPassword` authenticator (can be used in `reset-authenticator` remediation)
- [#1512](https://github.com/okta/okta-auth-js/pull/1512) add: new service `RenewOnTabActivation`

## 7.5.1

Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,8 @@ services: {
autoRenew: true,
autoRemove: true,
syncStorage: true,
renewOnTabActivation: true,
tabInactivityDuration: 1800 // seconds
}
```

Expand All @@ -872,6 +874,15 @@ Automatically syncs tokens across browser tabs when it's supported in browser (b

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.

#### `renewOnTabActivation`
> NOTE: This service requires `autoRenew: true`

When enabled (`{ autoRenew: true, renewOnTabActivation: true }`), this service binds a handler to the [Page Visibility API](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API) which attempts a token renew (if needed) when the tab becomes active after a (configurable) inactivity period

#### `tabInactivityDuration`
The amount of time, in seconds, a tab needs to be inactive for the `RenewOnTabActivation` service to attempt a token renew. Defaults to `1800` (30 mins)


## API Reference
<!-- no toc -->
* [start](#start)
Expand Down
18 changes: 14 additions & 4 deletions lib/core/ServiceManager/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,17 @@ import {
OktaAuthCoreInterface,
OktaAuthCoreOptions
} from '../types';
import { AutoRenewService, SyncStorageService, LeaderElectionService } from '../../services';
import { AutoRenewService,
SyncStorageService,
LeaderElectionService,
RenewOnTabActivationService
} from '../../services';
import { removeNils } from '../../util';

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

export class ServiceManager
<
Expand All @@ -43,12 +48,14 @@ implements ServiceManagerInterface
private services: Map<string, ServiceInterface>;
private started: boolean;

private static knownServices = [AUTO_RENEW, SYNC_STORAGE, LEADER_ELECTION];
private static knownServices = [AUTO_RENEW, SYNC_STORAGE, LEADER_ELECTION, RENEW_ON_TAB_ACTIVATION];

private static defaultOptions = {
private static defaultOptions: ServiceManagerOptions = {
autoRenew: true,
autoRemove: true,
syncStorage: true
syncStorage: true,
renewOnTabActivation: true,
tabInactivityDuration: 1800, // 30 mins in seconds
};

constructor(sdk: OktaAuthCoreInterface<M, S, O>, options: ServiceManagerOptions = {}) {
Expand Down Expand Up @@ -151,6 +158,9 @@ implements ServiceManagerInterface
case SYNC_STORAGE:
service = new SyncStorageService(tokenManager, {...this.options});
break;
case RENEW_ON_TAB_ACTIVATION:
service = new RenewOnTabActivationService(tokenManager, {...this.options});
break;
default:
throw new Error(`Unknown service ${name}`);
}
Expand Down
14 changes: 12 additions & 2 deletions lib/core/types/Service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,15 @@ export interface LeaderElectionServiceOptions {
broadcastChannelName?: string;
}

export type ServiceManagerOptions = AutoRenewServiceOptions &
SyncStorageServiceOptions & LeaderElectionServiceOptions;
type seconds = number;

export interface RenewOnTabActivationServiceOptions {
renewOnTabActivation?: boolean;
tabInactivityDuration?: seconds;
}

export type ServiceManagerOptions =
AutoRenewServiceOptions &
SyncStorageServiceOptions &
LeaderElectionServiceOptions &
RenewOnTabActivationServiceOptions;
71 changes: 71 additions & 0 deletions lib/services/RenewOnTabActivationService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { ServiceInterface, ServiceManagerOptions } from '../core/types';
import { TokenManagerInterface } from '../oidc/types';
import { isBrowser } from '../features';

const getNow = () => Math.floor(Date.now() / 1000);

export class RenewOnTabActivationService implements ServiceInterface {
private tokenManager: TokenManagerInterface;
private started = false;
private options: ServiceManagerOptions;
private lastHidden = -1;
onPageVisbilityChange: () => void;

constructor(tokenManager: TokenManagerInterface, options: ServiceManagerOptions = {}) {
this.tokenManager = tokenManager;
this.options = options;
// store this context for event handler
this.onPageVisbilityChange = this._onPageVisbilityChange.bind(this);
}

// do not use directly, use `onPageVisbilityChange` (with binded this context)
/* eslint complexity: [0, 10] */
private _onPageVisbilityChange () {
if (document.hidden) {
this.lastHidden = getNow();
}
// renew will only attempt if tab was inactive for duration
else if (this.lastHidden > 0 && (getNow() - this.lastHidden >= this.options.tabInactivityDuration!)) {
const { accessToken, idToken } = this.tokenManager.getTokensSync();
if (!!accessToken && this.tokenManager.hasExpired(accessToken)) {
const key = this.tokenManager.getStorageKeyByType('accessToken');
// Renew errors will emit an "error" event
this.tokenManager.renew(key).catch(() => {});
}
else if (!!idToken && this.tokenManager.hasExpired(idToken)) {
const key = this.tokenManager.getStorageKeyByType('idToken');
// Renew errors will emit an "error" event
this.tokenManager.renew(key).catch(() => {});
}
}
}

async start () {
if (this.canStart() && !!document) {
document.addEventListener('visibilitychange', this.onPageVisbilityChange);
this.started = true;
}
}

async stop () {
if (document) {
document.removeEventListener('visibilitychange', this.onPageVisbilityChange);
this.started = false;
}
}

canStart(): boolean {
return isBrowser() &&
!!this.options.autoRenew &&
!!this.options.renewOnTabActivation &&
!this.started;
}

requiresLeadership(): boolean {
return false;
}

isStarted(): boolean {
return this.started;
}
}
1 change: 1 addition & 0 deletions lib/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@
export * from './AutoRenewService';
export * from './SyncStorageService';
export * from './LeaderElectionService';
export * from './RenewOnTabActivationService';
174 changes: 174 additions & 0 deletions test/spec/services/RenewOnTabActivationService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { OktaAuth } from '@okta/okta-auth-js';
import tokens from '@okta/test.support/tokens';
import * as features from '../../../lib/features';
import { TokenManager } from '../../../lib/oidc/TokenManager';
import { RenewOnTabActivationService } from '../../../lib/services/RenewOnTabActivationService';

function createAuth(options) {
options = options || {};
return new OktaAuth({
pkce: false,
issuer: 'https://auth-js-test.okta.com',
clientId: 'NPSfOkH5eZrTy8PMDlvx',
redirectUri: 'https://example.com/redirect',
storageUtil: options.storageUtil,
services: options.services || {},
tokenManager: options.tokenManager || {},
});
}


describe('RenewOnTabActivationService', () => {
let client: OktaAuth;
let service: RenewOnTabActivationService;

async function setup(options = {}, start = true) {
client = createAuth(options);

const tokenManager = client.tokenManager as TokenManager;
tokenManager.renew = jest.fn().mockImplementation(() => Promise.resolve());
tokenManager.remove = jest.fn();
// clear downstream listeners
tokenManager.off('added');
tokenManager.off('removed');

service = new RenewOnTabActivationService(tokenManager, (client.serviceManager as any).options);

if (start) {
client.tokenManager.start();
await service.start();
}
return client;
}

beforeEach(function() {
client = null as any;
service = null as any;
jest.useFakeTimers();
jest.spyOn(features, 'isBrowser').mockReturnValue(true);
});

afterEach(async function() {
if (service) {
await service.stop();
}
if (client) {
client.tokenManager.stop();
client.tokenManager.clear();
}
jest.useRealTimers();
});

describe('start', () => {
it('binds `visibilitychange` listener when started', async () => {
const addEventSpy = jest.spyOn(document, 'addEventListener');
await setup({}, false);
client.tokenManager.start();
await service.start();
expect(addEventSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
});

it('does not start service when autoRenew=false', async () => {
const addEventSpy = jest.spyOn(document, 'addEventListener');
await setup({ services: { autoRenew: false }}, false);
client.tokenManager.start();
await service.start();
expect(addEventSpy).not.toHaveBeenCalled();
});

it('does not start service when renewOnTabActivation=false', async () => {
const addEventSpy = jest.spyOn(document, 'addEventListener');
await setup({ services: { renewOnTabActivation: false }}, false);
client.tokenManager.start();
await service.start();
expect(addEventSpy).not.toHaveBeenCalled();
});
});

describe('stop', () => {
it('removes `visibilitychange` listener when stopped', async () => {
const removeEventSpy = jest.spyOn(document, 'removeEventListener');
await setup();
expect(service.isStarted()).toBe(true);
await service.stop();
expect(removeEventSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
});
});

describe('onPageVisbilityChange', () => {
it('document is hidden', async () => {
jest.spyOn(document, 'hidden', 'get').mockReturnValue(true);
await setup();
service.onPageVisbilityChange();
expect(client.tokenManager.renew).not.toHaveBeenCalled();
});

it('should not renew if visibility toggle occurs within inactivity duration', async () => {
jest.spyOn(document, 'hidden', 'get')
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
await setup();
service.onPageVisbilityChange();
service.onPageVisbilityChange();
expect(client.tokenManager.renew).not.toHaveBeenCalled();
});

it('should renew tokens if none exist', async () => {
jest.spyOn(document, 'hidden', 'get')
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
await setup();
jest.spyOn(client.tokenManager, 'getTokensSync').mockReturnValue({});
service.onPageVisbilityChange();
jest.advanceTimersByTime((1800 * 1000) + 500);
service.onPageVisbilityChange();
expect(client.tokenManager.renew).not.toHaveBeenCalled();
});

it('should not renew tokens if they are not expired', async () => {
const accessToken = tokens.standardAccessTokenParsed;
const idToken = tokens.standardIdTokenParsed;
const refreshToken = tokens.standardRefreshTokenParsed;
jest.spyOn(document, 'hidden', 'get')
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
await setup();
jest.spyOn(client.tokenManager, 'getTokensSync').mockReturnValue({ accessToken, idToken, refreshToken });
jest.spyOn(client.tokenManager, 'hasExpired').mockReturnValue(false);
service.onPageVisbilityChange();
jest.advanceTimersByTime((1800 * 1000) + 500);
service.onPageVisbilityChange();
expect(client.tokenManager.renew).not.toHaveBeenCalled();
});

it('should renew tokens after visiblity toggle', async () => {
const accessToken = tokens.standardAccessTokenParsed;
const idToken = tokens.standardIdTokenParsed;
const refreshToken = tokens.standardRefreshTokenParsed;
jest.spyOn(document, 'hidden', 'get')
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
await setup();
jest.spyOn(client.tokenManager, 'getTokensSync').mockReturnValue({ accessToken, idToken, refreshToken });
service.onPageVisbilityChange();
jest.advanceTimersByTime((1800 * 1000) + 500);
service.onPageVisbilityChange();
expect(client.tokenManager.renew).toHaveBeenCalled();
});

it('should accept configured inactivity duration', async () => {
const accessToken = tokens.standardAccessTokenParsed;
const idToken = tokens.standardIdTokenParsed;
const refreshToken = tokens.standardRefreshTokenParsed;
jest.spyOn(document, 'hidden', 'get')
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
await setup({ services: { tabInactivityDuration: 3600 }}); // 1 hr in seconds
jest.spyOn(client.tokenManager, 'getTokensSync').mockReturnValue({ accessToken, idToken, refreshToken });
service.onPageVisbilityChange();
jest.advanceTimersByTime((1800 * 1000) + 500); // advance timer by 30 mins (and change)
service.onPageVisbilityChange();
expect(client.tokenManager.renew).not.toHaveBeenCalled();
});
});
});