diff --git a/index.js b/index.js index 67fd4d1d0..bb3a4f68f 100644 --- a/index.js +++ b/index.js @@ -49,6 +49,7 @@ export { userLocaleConfig } from './src/loginServices'; export * from './src/consortiaServices'; export { default as queryLimit } from './src/queryLimit'; export { default as init } from './src/init'; +export * as RTR_CONSTANTS from './src/components/Root/constants'; /* localforage wrappers hide the session key */ export { getOkapiSession, getTokenExpiry, setTokenExpiry } from './src/loginServices'; diff --git a/src/components/Root/FFetch.js b/src/components/Root/FFetch.js index 658139dc2..fd54672c2 100644 --- a/src/components/Root/FFetch.js +++ b/src/components/Root/FFetch.js @@ -62,8 +62,8 @@ import { } from './Errors'; import { RTR_AT_EXPIRY_IF_UNKNOWN, - RTR_AT_TTL_FRACTION, RTR_ERROR_EVENT, + RTR_FORCE_REFRESH_EVENT, RTR_FLS_TIMEOUT_EVENT, RTR_TIME_MARGIN_IN_MS, RTR_FLS_WARNING_EVENT, @@ -83,6 +83,24 @@ export class FFetch { this.rtrConfig = rtrConfig; } + /** + * registers a listener for the RTR_FORCE_REFRESH_EVENT + */ + registerEventListener = () => { + this.globalEventCallback = () => { + this.logger.log('rtr', 'forcing rotation due to RTR_FORCE_REFRESH_EVENT'); + rtr(this.nativeFetch, this.logger, this.rotateCallback, this.store.getState().okapi); + }; + window.addEventListener(RTR_FORCE_REFRESH_EVENT, this.globalEventCallback); + } + + /** + * unregister the listener for the RTR_FORCE_REFRESH_EVENT + */ + unregisterEventListener = () => { + window.removeEventListener(RTR_FORCE_REFRESH_EVENT, this.globalEventCallback); + } + /** * save a reference to fetch, and then reassign the global :scream: */ @@ -112,7 +130,7 @@ export class FFetch { scheduleRotation = (rotationP) => { rotationP.then((rotationInterval) => { // AT refresh interval: a large fraction of the actual AT TTL - const atInterval = (rotationInterval.accessTokenExpiration - Date.now()) * RTR_AT_TTL_FRACTION; + const atInterval = (rotationInterval.accessTokenExpiration - Date.now()) * this.rtrConfig.rotationIntervalFraction; // RT timeout interval (session will end) and warning interval (warning that session will end) const rtTimeoutInterval = (rotationInterval.refreshTokenExpiration - Date.now()); diff --git a/src/components/Root/FFetch.test.js b/src/components/Root/FFetch.test.js index 461414e91..e47e0a9b8 100644 --- a/src/components/Root/FFetch.test.js +++ b/src/components/Root/FFetch.test.js @@ -3,6 +3,8 @@ /* eslint-disable no-unused-vars */ import ms from 'ms'; +import { waitFor } from '@testing-library/react'; +import { okapi } from 'stripes-config'; import { getTokenExpiry } from '../../loginServices'; import { FFetch } from './FFetch'; @@ -10,6 +12,7 @@ import { RTRError, UnexpectedResourceError } from './Errors'; import { RTR_AT_EXPIRY_IF_UNKNOWN, RTR_AT_TTL_FRACTION, + RTR_FORCE_REFRESH_EVENT, RTR_FLS_WARNING_TTL, RTR_TIME_MARGIN_IN_MS, } from './constants'; @@ -34,6 +37,9 @@ const log = jest.fn(); const mockFetch = jest.fn(); +// to ensure we cleanup after each test +const instancesWithEventListeners = []; + describe('FFetch class', () => { beforeEach(() => { global.fetch = mockFetch; @@ -41,6 +47,8 @@ describe('FFetch class', () => { atExpires: Date.now() + (10 * 60 * 1000), rtExpires: Date.now() + (10 * 60 * 1000), }); + instancesWithEventListeners.forEach(instance => instance.unregisterEventListener()); + instancesWithEventListeners.length = 0; }); afterEach(() => { @@ -153,6 +161,23 @@ describe('FFetch class', () => { }); }); + describe('force refresh event', () => { + it('Invokes a refresh on RTR_FORCE_REFRESH_EVENT...', async () => { + mockFetch.mockResolvedValueOnce('okapi success'); + + const instance = new FFetch({ logger: { log }, store: { getState: () => ({ okapi }) } }); + instance.replaceFetch(); + instance.replaceXMLHttpRequest(); + + instance.registerEventListener(); + instancesWithEventListeners.push(instance); + + window.dispatchEvent(new Event(RTR_FORCE_REFRESH_EVENT)); + + await waitFor(() => expect(mockFetch.mock.calls).toHaveLength(1)); + }); + }); + describe('calling authentication resources', () => { it('handles RTR data in the response', async () => { // a static timestamp representing "now" @@ -187,6 +212,7 @@ describe('FFetch class', () => { }, rtrConfig: { fixedLengthSessionWarningTTL: '1m', + rotationIntervalFraction: 0.8, }, }); testFfetch.replaceFetch(); @@ -243,6 +269,7 @@ describe('FFetch class', () => { }, rtrConfig: { fixedLengthSessionWarningTTL: '1m', + rotationIntervalFraction: 0.8, }, }); testFfetch.replaceFetch(); @@ -281,6 +308,7 @@ describe('FFetch class', () => { }, rtrConfig: { fixedLengthSessionWarningTTL: '1m', + rotationIntervalFraction: 0.8, }, }); testFfetch.replaceFetch(); @@ -317,7 +345,11 @@ describe('FFetch class', () => { logger: { log }, store: { dispatch: jest.fn(), - } + }, + rtrConfig: { + fixedLengthSessionWarningTTL: '1m', + rotationIntervalFraction: 0.8, + }, }); testFfetch.replaceFetch(); testFfetch.replaceXMLHttpRequest(); @@ -360,6 +392,7 @@ describe('FFetch class', () => { }, rtrConfig: { fixedLengthSessionWarningTTL: '1m', + rotationIntervalFraction: 0.8, }, }); testFfetch.replaceFetch(); diff --git a/src/components/Root/constants.js b/src/components/Root/constants.js index a4cb30681..ff4a2b4a1 100644 --- a/src/components/Root/constants.js +++ b/src/components/Root/constants.js @@ -4,6 +4,9 @@ export const RTR_SUCCESS_EVENT = '@folio/stripes/core::RTRSuccess'; /** dispatched during RTR if RTR itself fails */ export const RTR_ERROR_EVENT = '@folio/stripes/core::RTRError'; +/** dispatched by ui-developer to force a token rotation */ +export const RTR_FORCE_REFRESH_EVENT = '@folio/stripes/core::RTRForceRefresh'; + /** * dispatched if the session is idle (without activity) for too long */ @@ -36,6 +39,7 @@ export const RTR_ACTIVITY_CHANNEL = '@folio/stripes/core::RTRActivityChannel'; * the RT is still good at that point. Since rotation happens in the background * (i.e. it isn't a user-visible feature), rotating early has no user-visible * impact. + * overridden in stripes.config.js::config.rtr.rotationIntervalFraction. */ export const RTR_AT_TTL_FRACTION = 0.8; diff --git a/src/components/Root/token-util.js b/src/components/Root/token-util.js index 91abf3400..53fbbe4ca 100644 --- a/src/components/Root/token-util.js +++ b/src/components/Root/token-util.js @@ -4,6 +4,7 @@ import { getTokenExpiry, setTokenExpiry } from '../../loginServices'; import { RTRError, UnexpectedResourceError } from './Errors'; import { RTR_ACTIVITY_EVENTS, + RTR_AT_TTL_FRACTION, RTR_ERROR_EVENT, RTR_FLS_WARNING_TTL, RTR_IDLE_MODAL_TTL, @@ -322,6 +323,11 @@ export const configureRtr = (config = {}) => { conf.idleModalTTL = RTR_IDLE_MODAL_TTL; } + // what fraction of the way through the session should we rotate? + if (!conf.rotationIntervalFraction) { + conf.rotationIntervalFraction = RTR_AT_TTL_FRACTION; + } + // what events constitute activity? if (isEmpty(conf.activityEvents)) { conf.activityEvents = RTR_ACTIVITY_EVENTS; diff --git a/src/components/Root/token-util.test.js b/src/components/Root/token-util.test.js index 0ed8cc7fc..f0fb8dc1e 100644 --- a/src/components/Root/token-util.test.js +++ b/src/components/Root/token-util.test.js @@ -342,19 +342,21 @@ describe('getPromise', () => { }); describe('configureRtr', () => { - it('sets idleSessionTTL and idleModalTTL', () => { - const res = configureRtr({}); - expect(res.idleSessionTTL).toBe('60m'); - expect(res.idleModalTTL).toBe('1m'); - }); - - it('leaves existing settings in place', () => { - const res = configureRtr({ - idleSessionTTL: '5m', - idleModalTTL: '5m', - }); - - expect(res.idleSessionTTL).toBe('5m'); - expect(res.idleModalTTL).toBe('5m'); + it.each([ + [ + {}, + { idleSessionTTL: '60m', idleModalTTL: '1m', rotationIntervalFraction: 0.8, activityEvents: ['keydown', 'mousedown'] } + ], + [ + { idleSessionTTL: '1s', idleModalTTL: '2m' }, + { idleSessionTTL: '1s', idleModalTTL: '2m', rotationIntervalFraction: 0.8, activityEvents: ['keydown', 'mousedown'] } + ], + [ + { idleSessionTTL: '1s', idleModalTTL: '2m', rotationIntervalFraction: -1, activityEvents: ['cha-cha-slide'] }, + { idleSessionTTL: '1s', idleModalTTL: '2m', rotationIntervalFraction: -1, activityEvents: ['cha-cha-slide'] } + ], + ])('sets default values as applicable', (config, expected) => { + const res = configureRtr(config); + expect(res).toMatchObject(expected); }); });