-
Notifications
You must be signed in to change notification settings - Fork 273
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
adds new service
renewOnTabActivation
(#1512)
OKTA-722405 feat: RenewOnTabActivation Service
- Loading branch information
1 parent
323fe1f
commit 7af0a83
Showing
7 changed files
with
284 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); | ||
}); |