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..6a621b087 100644 --- a/src/components/Root/FFetch.js +++ b/src/components/Root/FFetch.js @@ -42,7 +42,7 @@ */ import ms from 'ms'; -import { okapi as okapiConfig } from 'stripes-config'; +import { okapi as okapiConfig, config } from 'stripes-config'; import { setRtrTimeout, setRtrFlsTimeout, @@ -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, @@ -77,10 +77,27 @@ const OKAPI_FETCH_OPTIONS = { }; export class FFetch { - constructor({ logger, store, rtrConfig }) { + constructor({ logger, store }) { this.logger = logger; this.store = store; - 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); } /** @@ -112,11 +129,11 @@ 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()) * config.rtr.rotationIntervalFraction; // RT timeout interval (session will end) and warning interval (warning that session will end) const rtTimeoutInterval = (rotationInterval.refreshTokenExpiration - Date.now()); - const rtWarningInterval = (rotationInterval.refreshTokenExpiration - Date.now()) - ms(this.rtrConfig.fixedLengthSessionWarningTTL); + const rtWarningInterval = (rotationInterval.refreshTokenExpiration - Date.now()) - ms(config.rtr.fixedLengthSessionWarningTTL); // schedule AT rotation IFF the AT will expire before the RT. this avoids // refresh-thrashing near the end of the FLS with progressively shorter @@ -132,7 +149,7 @@ export class FFetch { } // schedule FLS end-of-session warning - this.logger.log('rtr-fls', `end-of-session warning at ${new Date(rotationInterval.refreshTokenExpiration - ms(this.rtrConfig.fixedLengthSessionWarningTTL))}`); + this.logger.log('rtr-fls', `end-of-session warning at ${new Date(rotationInterval.refreshTokenExpiration - ms(config.rtr.fixedLengthSessionWarningTTL))}`); this.store.dispatch(setRtrFlsWarningTimeout(setTimeout(() => { this.logger.log('rtr-fls', 'emitting RTR_FLS_WARNING_EVENT'); window.dispatchEvent(new Event(RTR_FLS_WARNING_EVENT)); diff --git a/src/components/Root/FFetch.test.js b/src/components/Root/FFetch.test.js index 461414e91..2ea85dc65 100644 --- a/src/components/Root/FFetch.test.js +++ b/src/components/Root/FFetch.test.js @@ -3,13 +3,15 @@ /* eslint-disable no-unused-vars */ import ms from 'ms'; +import { waitFor } from '@testing-library/react'; +import { okapi, config } from 'stripes-config'; import { getTokenExpiry } from '../../loginServices'; import { FFetch } from './FFetch'; 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'; @@ -26,6 +28,12 @@ jest.mock('stripes-config', () => ({ okapi: { url: 'okapiUrl', tenant: 'okapiTenant' + }, + config: { + rtr: { + rotationIntervalFraction: 0.5, + fixedLengthSessionWarningTTL: '1m', + } } }), { virtual: true }); @@ -34,6 +42,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 +52,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 +166,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" @@ -185,9 +215,7 @@ describe('FFetch class', () => { store: { dispatch: jest.fn(), }, - rtrConfig: { - fixedLengthSessionWarningTTL: '1m', - }, + }); testFfetch.replaceFetch(); testFfetch.replaceXMLHttpRequest(); @@ -201,7 +229,7 @@ describe('FFetch class', () => { await setTimeout(Promise.resolve(), 2000); // AT rotation - expect(st).toHaveBeenCalledWith(expect.any(Function), (accessTokenExpiration - whatTimeIsItMrFox) * RTR_AT_TTL_FRACTION); + expect(st).toHaveBeenCalledWith(expect.any(Function), (accessTokenExpiration - whatTimeIsItMrFox) * config.rtr.rotationIntervalFraction); // FLS warning expect(st).toHaveBeenCalledWith(expect.any(Function), (refreshTokenExpiration - whatTimeIsItMrFox) - ms(RTR_FLS_WARNING_TTL)); @@ -241,9 +269,7 @@ describe('FFetch class', () => { store: { dispatch: jest.fn(), }, - rtrConfig: { - fixedLengthSessionWarningTTL: '1m', - }, + }); testFfetch.replaceFetch(); testFfetch.replaceXMLHttpRequest(); @@ -255,7 +281,7 @@ describe('FFetch class', () => { // gross, but on the other, since we're deliberately pushing rotation // into a separate thread, I'm note sure of a better way to handle this. await setTimeout(Promise.resolve(), 2000); - expect(st).toHaveBeenCalledWith(expect.any(Function), (atExpires - whatTimeIsItMrFox) * RTR_AT_TTL_FRACTION); + expect(st).toHaveBeenCalledWith(expect.any(Function), (atExpires - whatTimeIsItMrFox) * config.rtr.rotationIntervalFraction); }); it('handles missing RTR data', async () => { @@ -279,9 +305,7 @@ describe('FFetch class', () => { store: { dispatch: jest.fn(), }, - rtrConfig: { - fixedLengthSessionWarningTTL: '1m', - }, + }); testFfetch.replaceFetch(); testFfetch.replaceXMLHttpRequest(); @@ -294,7 +318,7 @@ describe('FFetch class', () => { // into a separate thread, I'm not sure of a better way to handle this. await setTimeout(Promise.resolve(), 2000); - expect(st).toHaveBeenCalledWith(expect.any(Function), ms(RTR_AT_EXPIRY_IF_UNKNOWN) * RTR_AT_TTL_FRACTION); + expect(st).toHaveBeenCalledWith(expect.any(Function), ms(RTR_AT_EXPIRY_IF_UNKNOWN) * config.rtr.rotationIntervalFraction); }); it('handles unsuccessful responses', async () => { @@ -358,9 +382,7 @@ describe('FFetch class', () => { store: { dispatch: jest.fn(), }, - rtrConfig: { - fixedLengthSessionWarningTTL: '1m', - }, + }); testFfetch.replaceFetch(); testFfetch.replaceXMLHttpRequest(); @@ -374,7 +396,7 @@ describe('FFetch class', () => { await setTimeout(Promise.resolve(), 2000); // AT rotation - expect(st).not.toHaveBeenCalledWith(expect.any(Function), (accessTokenExpiration - whatTimeIsItMrFox) * RTR_AT_TTL_FRACTION); + expect(st).not.toHaveBeenCalledWith(expect.any(Function), (accessTokenExpiration - whatTimeIsItMrFox) * config.rtr.rotationIntervalFraction); // FLS warning expect(st).toHaveBeenCalledWith(expect.any(Function), (refreshTokenExpiration - whatTimeIsItMrFox) - ms(RTR_FLS_WARNING_TTL)); diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index f63b0cfaf..7fe8b9cbc 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -63,17 +63,26 @@ class Root extends Component { this.apolloClient = createApolloClient(okapi); this.reactQueryClient = createReactQueryClient(); + // configure RTR with default props if needed + // gross: this overwrites whatever is currently stored at config.rtr + // gross: technically, the props could change and not get re-run through + // here. Realistically, that'll never happen since config values are read + // only once from a static file at build time, but still, props are props + // so technically it's possible. + // Also, ui-developer provides facilities to change some of this + this.props.config.rtr = configureRtr(this.props.config.rtr); + // enhanced security mode: // * configure fetch and xhr interceptors to conduct RTR // * see SessionEventContainer for RTR handling if (this.props.config.useSecureTokens) { - const rtrConfig = configureRtr(this.props.config.rtr); - + // FFetch relies on some of these properties, so we must ensure + // they are filled before initialization this.ffetch = new FFetch({ logger: this.props.logger, store, - rtrConfig, }); + this.ffetch.registerEventListener(); this.ffetch.replaceFetch(); this.ffetch.replaceXMLHttpRequest(); } @@ -128,15 +137,6 @@ class Root extends Component { return (); } - // make sure RTR is configured - // gross: this overwrites whatever is currently stored at config.rtr - // gross: technically, this may be different than what is configured - // in the constructor since the constructor only runs once but - // render runs when props change. realistically, that'll never happen - // since config values are read only once from a static file at build - // time, but still, props are props so technically it's possible. - config.rtr = configureRtr(this.props.config.rtr); - const stripes = new Stripes({ logger, store, 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); }); }); diff --git a/src/components/SessionEventContainer/SessionEventContainer.js b/src/components/SessionEventContainer/SessionEventContainer.js index 6774ffca8..85790c5bc 100644 --- a/src/components/SessionEventContainer/SessionEventContainer.js +++ b/src/components/SessionEventContainer/SessionEventContainer.js @@ -264,9 +264,10 @@ const SessionEventContainer = ({ history }) => { // no deps? It should be history and stripes!!! >:) // We only want to configure the event listeners once, not every time - // there is a change to stripes or history. Hence, an empty dependency - // array. - }, []); // eslint-disable-line react-hooks/exhaustive-deps + // there is a change to stripes or history. Hence, those are left out. + // we do include stripes.config.rtr, though, because these are configurable + // at runtime via ui-developer. + }, [stripes.config.rtr]); // eslint-disable-line react-hooks/exhaustive-deps const renderList = [];