Skip to content

Commit

Permalink
STCOR-862 terminate session when fixed-length session expires
Browse files Browse the repository at this point in the history
RTR may be implemented such that each refresh extends the session by a
fixed interval, or the session-length may be fixed causing the RT TTL to
gradually shrink until the session ends and the user is forced to
re-authenticate. This PR implements handling for the latter scenario,
showing a non-interactive "this session will expire" banner before the
session expires and then redirecting to `/logout` to clear out session
data.

By default the warning is visible for one minute. It may be changed at
build-time by setting the `stripes.config.js` value
`config.rtr.fixedLengthSessionWarningTTL` to any value parseable by
`ms()`, e.g. `30s`, `1m`, `1h`.

The "interesting" bits are mostly in `FFetch` where, in addition to
scheduling AT rotation, there are two new `setTimer()` calls to dispatch
the FLS-warning and FLS-timeout events. Handlers for these are events
are located with other RTR event handlers in  `SessionEventContainer`.

There are corresponding reducer functions in `okapiActions`. Both it and
`okapiReducer` were refactored to use constants instead of strings for
their action-types. The refactor is otherwise insignificant.

Refs STCOR-862
  • Loading branch information
zburke committed Jul 16, 2024
1 parent 8daa267 commit fa79961
Show file tree
Hide file tree
Showing 16 changed files with 523 additions and 121 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* Always retrieve `clientId` and `tenant` values from `config.tenantOptions` in stripes.config.js. Retires `okapi.tenant`, `okapi.clientId`, and `config.isSingleTenant`. Refs STCOR-787.
* List UI apps in "Applications/modules/interfaces" column. STCOR-773
* Correctly evaluate `stripes.okapi` before rendering `<RootWithIntl>`. Refs STCOR-864.
* Terminate the session when the fixed-length session expires. Refs STCOR-862.

## [10.1.1](https://github.com/folio-org/stripes-core/tree/v10.1.1) (2024-03-25)
[Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.1.0...v10.1.1)
Expand Down
5 changes: 0 additions & 5 deletions src/components/Root/Events.js

This file was deleted.

107 changes: 80 additions & 27 deletions src/components/Root/FFetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
import ms from 'ms';
import { okapi as okapiConfig } from 'stripes-config';
import {
setRtrTimeout
setRtrTimeout,
setRtrFlsTimeout,
} from '../../okapiActions';

import { getTokenExpiry } from '../../loginServices';
Expand All @@ -62,6 +63,9 @@ import {
RTR_AT_EXPIRY_IF_UNKNOWN,
RTR_AT_TTL_FRACTION,
RTR_ERROR_EVENT,
RTR_FLS_TIMEOUT_EVENT,
RTR_FLS_WARNING_EVENT,
RTR_RT_EXPIRY_IF_UNKNOWN,
} from './constants';
import FXHR from './FXHR';

Expand All @@ -71,9 +75,10 @@ const OKAPI_FETCH_OPTIONS = {
};

export class FFetch {
constructor({ logger, store }) {
constructor({ logger, store, rtrConfig }) {
this.logger = logger;
this.store = store;
this.rtrConfig = rtrConfig;
}

/**
Expand All @@ -92,6 +97,47 @@ export class FFetch {
global.XMLHttpRequest = FXHR(this);
};

/**
* scheduleRotation
*
* @param {Promise} rotationP
*/
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;

// rotationInterval.refreshTokenExpiration = Date.now() + ms('75s');
const rtTimeoutInterval = (rotationInterval.refreshTokenExpiration - Date.now());
const rtWarningInterval = (rotationInterval.refreshTokenExpiration - Date.now()) - ms(this.rtrConfig.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
// AT TTL windows.
if (rotationInterval.accessTokenExpiration < rotationInterval.refreshTokenExpiration) {
this.logger.log('rtr', `rotation scheduled from rotateCallback; next callback in ${ms(atInterval)}`);
this.store.dispatch(setRtrTimeout(setTimeout(() => {
const { okapi } = this.store.getState();
rtr(this.nativeFetch, this.logger, this.rotateCallback, okapi);
}, atInterval)));
} else {
this.logger.log('rtr', 'rotation canceled; AT and RT will expire simultaneously');
}

// 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.store.dispatch(setRtrFlsTimeout(setTimeout(() => {
window.dispatchEvent(new Event(RTR_FLS_WARNING_EVENT));
}, rtWarningInterval)));

// schedule FLS end-of-session logout
this.logger.log('rtr-fls', `session will end at ${new Date(rotationInterval.refreshTokenExpiration)}`);
setTimeout(() => {
window.dispatchEvent(new Event(RTR_FLS_TIMEOUT_EVENT));
}, rtTimeoutInterval);
});
};

/**
* rotateCallback
* Set a timeout to rotate the AT before it expires. Stash the timer-id
Expand All @@ -106,44 +152,51 @@ export class FFetch {
* where the values are ISO-8601 datestamps like YYYY-MM-DDTHH:mm:ssZ
*/
rotateCallback = (res) => {
this.logger.log('rtr', 'rotation callback setup');
this.logger.log('rtr', 'rotation callback setup', res);

const scheduleRotation = (rotationP) => {
rotationP.then((rotationInterval) => {
this.logger.log('rtr', `rotation fired from rotateCallback; next callback in ${ms(rotationInterval)}`);
this.store.dispatch(setRtrTimeout(setTimeout(() => {
const { okapi } = this.store.getState();
rtr(this.nativeFetch, this.logger, this.rotateCallback, okapi);
}, rotationInterval)));
});
};
// @@ const rotationPromise = Promise.resolve(ms(RTR_AT_EXPIRY_IF_UNKNOWN));
// @@ scheduleRotation(rotationPromise);
// @@ return;

// When starting a new session, the response from /bl-users/login-with-expiry
// will contain AT expiration info, but when restarting an existing session,
// will contain token expiration info, but when restarting an existing session,
// the response from /bl-users/_self will NOT, although that information should
// have been cached in local-storage.
//
// This means there are many places we have to check to figure out when the
// AT is likely to expire, and thus when we want to rotate. First inspect
// the response, then the session, then default to 10 seconds.
// have been cached in local-storage. Thus, we check the following places for
// token expiration data:
// 1. response
// 2. session storage
// 3. hard-coded default
if (res?.accessTokenExpiration) {
this.logger.log('rtr', 'rotation scheduled with login response data');
const rotationPromise = Promise.resolve((new Date(res.accessTokenExpiration).getTime() - Date.now()) * RTR_AT_TTL_FRACTION);
const rotationPromise = Promise.resolve({
accessTokenExpiration: new Date(res.accessTokenExpiration).getTime(),
refreshTokenExpiration: new Date(res.refreshTokenExpiration).getTime(),
});

scheduleRotation(rotationPromise);
this.scheduleRotation(rotationPromise);
} else {
const rotationPromise = getTokenExpiry().then((expiry) => {
if (expiry?.atExpires) {
this.logger.log('rtr', 'rotation scheduled with cached session data');
return (new Date(expiry.atExpires).getTime() - Date.now()) * RTR_AT_TTL_FRACTION;
if (expiry?.atExpires && expiry?.atExpires >= Date.now()) {
this.logger.log('rtr', 'rotation scheduled with cached session data', expiry);
return {
accessTokenExpiration: new Date(expiry.atExpires).getTime(),
refreshTokenExpiration: new Date(expiry.rtExpires).getTime(),
};
}

// default: 10 seconds
// default: session data was corrupt but the resume-session request
// succeeded so we know the cookies were valid at the time. short-term
// expiry-values will kick off RTR in the very new future, allowing us
// to grab values from the response.
this.logger.log('rtr', 'rotation scheduled with default value');
return ms(RTR_AT_EXPIRY_IF_UNKNOWN);

return {
accessTokenExpiration: Date.now() + ms(RTR_AT_EXPIRY_IF_UNKNOWN),
refreshTokenExpiration: Date.now() + ms(RTR_RT_EXPIRY_IF_UNKNOWN),
};
});

scheduleRotation(rotationPromise);
this.scheduleRotation(rotationPromise);
}
}

Expand Down Expand Up @@ -180,7 +233,7 @@ export class FFetch {
// on authentication, grab the response to kick of the rotation cycle,
// then return the response
if (isAuthenticationRequest(resource, okapiConfig.url)) {
this.logger.log('rtr', 'authn request');
this.logger.log('rtr', 'authn request', resource);
return this.nativeFetch.apply(global, [resource, options && { ...options, ...OKAPI_FETCH_OPTIONS }])
.then(res => {
// a response can only be read once, so we clone it to grab the
Expand Down
96 changes: 86 additions & 10 deletions src/components/Root/FFetch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { RTRError, UnexpectedResourceError } from './Errors';
import {
RTR_AT_EXPIRY_IF_UNKNOWN,
RTR_AT_TTL_FRACTION,
RTR_FLS_WARNING_TTL,
} from './constants';

jest.mock('../../loginServices', () => ({
Expand Down Expand Up @@ -159,6 +160,7 @@ describe('FFetch class', () => {
// a static timestamp of when the AT will expire, in the future
// this value will be pushed into the response returned from the fetch
const accessTokenExpiration = whatTimeIsItMrFox + 5000;
const refreshTokenExpiration = whatTimeIsItMrFox + ms('20m');

const st = jest.spyOn(window, 'setTimeout');

Expand All @@ -168,7 +170,7 @@ describe('FFetch class', () => {
const cloneJson = jest.fn();
const clone = () => ({
ok: true,
json: () => Promise.resolve({ tokenExpiration: { accessTokenExpiration } })
json: () => Promise.resolve({ tokenExpiration: { accessTokenExpiration, refreshTokenExpiration } })
});

mockFetch.mockResolvedValueOnce({
Expand All @@ -181,7 +183,10 @@ describe('FFetch class', () => {
logger: { log },
store: {
dispatch: jest.fn(),
}
},
rtrConfig: {
fixedLengthSessionWarningTTL: '1m',
},
});
testFfetch.replaceFetch();
testFfetch.replaceXMLHttpRequest();
Expand All @@ -193,7 +198,15 @@ 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);

// AT rotation
expect(st).toHaveBeenCalledWith(expect.any(Function), (accessTokenExpiration - whatTimeIsItMrFox) * RTR_AT_TTL_FRACTION);

// FLS warning
expect(st).toHaveBeenCalledWith(expect.any(Function), (refreshTokenExpiration - whatTimeIsItMrFox) - ms(RTR_FLS_WARNING_TTL));

// FLS timeout
expect(st).toHaveBeenCalledWith(expect.any(Function), (refreshTokenExpiration - whatTimeIsItMrFox));
});

it('handles RTR data in the session', async () => {
Expand All @@ -203,10 +216,11 @@ describe('FFetch class', () => {
// a static timestamp of when the AT will expire, in the future
// this value will be retrieved from local storage via getTokenExpiry
const atExpires = whatTimeIsItMrFox + 5000;
const rtExpires = whatTimeIsItMrFox + 15000;

const st = jest.spyOn(window, 'setTimeout');

getTokenExpiry.mockResolvedValue({ atExpires });
getTokenExpiry.mockResolvedValue({ atExpires, rtExpires });
Date.now = () => whatTimeIsItMrFox;

const cloneJson = jest.fn();
Expand All @@ -225,7 +239,10 @@ describe('FFetch class', () => {
logger: { log },
store: {
dispatch: jest.fn(),
}
},
rtrConfig: {
fixedLengthSessionWarningTTL: '1m',
},
});
testFfetch.replaceFetch();
testFfetch.replaceXMLHttpRequest();
Expand Down Expand Up @@ -260,7 +277,10 @@ describe('FFetch class', () => {
logger: { log },
store: {
dispatch: jest.fn(),
}
},
rtrConfig: {
fixedLengthSessionWarningTTL: '1m',
},
});
testFfetch.replaceFetch();
testFfetch.replaceXMLHttpRequest();
Expand All @@ -270,10 +290,10 @@ describe('FFetch class', () => {
// promise in a separate thread fired off by setTimout, and we need to
// give it the chance to complete. on the one hand, this feels super
// 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.
// 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));
expect(st).toHaveBeenCalledWith(expect.any(Function), ms(RTR_AT_EXPIRY_IF_UNKNOWN) * RTR_AT_TTL_FRACTION);
});

it('handles unsuccessful responses', async () => {
Expand Down Expand Up @@ -305,6 +325,62 @@ describe('FFetch class', () => {
expect(mockFetch.mock.calls).toHaveLength(1);
expect(cloneJson).not.toHaveBeenCalled();
});

it('avoids rotation when AT and RT expire together', async () => {
// a static timestamp representing "now"
const whatTimeIsItMrFox = 1718042609734;

// a static timestamp of when the AT will expire, in the future
// this value will be pushed into the response returned from the fetch
const accessTokenExpiration = whatTimeIsItMrFox + 5000;
const refreshTokenExpiration = accessTokenExpiration;

const st = jest.spyOn(window, 'setTimeout');

// dummy date data: assume session
Date.now = () => whatTimeIsItMrFox;

const cloneJson = jest.fn();
const clone = () => ({
ok: true,
json: () => Promise.resolve({ tokenExpiration: { accessTokenExpiration, refreshTokenExpiration } })
});

mockFetch.mockResolvedValueOnce({
ok: false,
clone,
});

mockFetch.mockResolvedValueOnce('okapi success');
const testFfetch = new FFetch({
logger: { log },
store: {
dispatch: jest.fn(),
},
rtrConfig: {
fixedLengthSessionWarningTTL: '1m',
},
});
testFfetch.replaceFetch();
testFfetch.replaceXMLHttpRequest();

const response = await global.fetch('okapiUrl/bl-users/_self', { testOption: 'test' });
// why this extra await/setTimeout? Because RTR happens in an un-awaited
// promise in a separate thread fired off by setTimout, and we need to
// give it the chance to complete. on the one hand, this feels super
// 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);

// AT rotation
expect(st).not.toHaveBeenCalledWith(expect.any(Function), (accessTokenExpiration - whatTimeIsItMrFox) * RTR_AT_TTL_FRACTION);

// FLS warning
expect(st).toHaveBeenCalledWith(expect.any(Function), (refreshTokenExpiration - whatTimeIsItMrFox) - ms(RTR_FLS_WARNING_TTL));

// FLS timeout
expect(st).toHaveBeenCalledWith(expect.any(Function), (refreshTokenExpiration - whatTimeIsItMrFox));
});
});


Expand Down Expand Up @@ -387,7 +463,7 @@ describe('FFetch class', () => {
.mockResolvedValueOnce(new Response(
JSON.stringify({ errors: ['missing token-getting ability'] }),
{
status: 303,
status: 403,
headers: {
'content-type': 'application/json',
}
Expand Down Expand Up @@ -417,7 +493,7 @@ describe('FFetch class', () => {
.mockResolvedValueOnce(new Response(
JSON.stringify({ errors: ['missing token-getting ability'] }),
{
status: 303,
status: 403,
headers: {
'content-type': 'application/json',
}
Expand Down Expand Up @@ -447,7 +523,7 @@ describe('FFetch class', () => {
.mockResolvedValueOnce(new Response(
JSON.stringify({ errors: ['missing token-getting ability'] }),
{
status: 303,
status: 403,
headers: {
'content-type': 'application/json',
}
Expand Down
Loading

0 comments on commit fa79961

Please sign in to comment.