Skip to content

Commit

Permalink
STCOR-862 terminate session when fixed-length session expires (#1503)
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`.

Cache the current path in session storage prior to a timeout-logout,
allowing the user to return directly to that page when
re-authenticating.

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

(cherry picked from commit 8b5274e)
  • Loading branch information
zburke committed Jul 25, 2024
1 parent 3cc5d04 commit 9a7b8d4
Show file tree
Hide file tree
Showing 17 changed files with 553 additions and 126 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# Change history for stripes-core

>>>>>>> f93f21d6 (STCOR-866 include `/users-keycloak/_self` in auth-n requests (#1502))
## [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 All @@ -10,6 +9,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.
* Correctly evaluate `stripes.okapi` before rendering `<RootWithIntl>`. Refs STCOR-864.
* `/users-keycloak/_self` is an authentication request. Refs STCOR-866.
* Terminate the session when the fixed-length session expires. Refs STCOR-862.

## [10.1.0](https://github.com/folio-org/stripes-core/tree/v10.1.0) (2024-03-12)
[Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.0.0...v10.1.0)
Expand Down
21 changes: 17 additions & 4 deletions src/components/AuthnLogin/AuthnLogin.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,23 @@ const AuthnLogin = ({ stripes }) => {
};

useEffect(() => {
if (okapi.authnUrl) {
/** Store unauthorized pathname to session storage. Refs STCOR-789
* @see OIDCRedirect
*/
/**
* Cache the current path so we can return to it after authenticating.
* In RootWithIntl, unauthenticated visits to protected paths will be
* handled by this component, i.e.
* /some-interesting-path <AuthnLogin>
* but if the user was de-authenticated due to a session timeout, they
* will have a history something like
* /some-interesting-path <SomeInterestingComponent>
* /logout <Logout>
* / <AuthnLogin>
* but we still want to return to /some-interesting-path, which will
* have been cached by the logout-timeout handler, and must not be
* overwritten here.
*
* @see OIDCRedirect
*/
if (okapi.authnUrl && window.location.pathname !== '/') {
setUnauthorizedPathToSession(window.location.pathname);
}

Expand Down
110 changes: 82 additions & 28 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,52 @@ export class FFetch {
global.XMLHttpRequest = FXHR(this);
};

/**
* scheduleRotation
* Given a promise that resolves with timestamps for the AT's and RT's
* expiration, configure relevant corresponding timers:
* * before the AT expires, conduct RTR
* * when the RT is about to expire, send a "session will end" event
* * when the RT expires, send a "session ended" event"
*
* @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;

// 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);

// 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 +157,47 @@ 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');

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)));
});
};
this.logger.log('rtr', 'rotation callback setup', res);

// 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 +234,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
Loading

0 comments on commit 9a7b8d4

Please sign in to comment.