From 80a4435883e83c9c77691a8e027bfd9f09a4f370 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Wed, 15 May 2024 12:16:56 -0400 Subject: [PATCH 01/17] STCOR-776 show "Keep working?" prompt then terminate sessions due to inactivity (#1463) Track activity (e.g. clicks, keypresses, etc), and after a period of inactivity show a "Keep working?" modal with a countdown timer; when the countdown reaches zero, end the session. * Separate the RTR cycle from regular API requests. Previously, we would inspect the AT on each request to make sure it was valid, and fire RTR if the AT was getting old. This meant we had to inspect requests (to see if the AT was valid) and responses (to see if a 4xx failure was due to RTR or the API itself). Since RTR and regular API requests are now separate, we can assume the AT in an API request is valid and likewise that any error response is related to that API. Much simpler. * Show a "Keep working?" modal after a period of inactivity (default: 1 hour) with a countdown (default: 1 minute), and terminate the session if the countdown reaches 0 without the user clicking the modal's CTA. "Activity" defaults to `keydown` and `mousedown` events. Previously, an abandoned session with an expired RT showed no indication that the RT had expired until the user performed an API request, which would cause an immediate logout. * Provide a `/logout` route, making it possible to logout by directly accessing that URL. * When a session is terminated due to inactivity, redirect to `/logout-timeout`, a static route with a message explaining what happened. Activity across tabs/windows happens via BroadcastChannel and storage requests, both of which emit events _only_ to the channels where they were not fired, i.e. you won't receive BroadcastChannel event on the object where it was posted, nor a storage event in the window/tab where the value changed. * Confirming the "Keep working?" modal in any window keeps all windows alive. * Activity in any window keeps all windows alive. * Logging out in any window logs out in all windows. * The session timeout, keep-working timeout, and the events that constitute "activity" have default values but may be overridden via `stripes.config.js`: ``` config: { //... useSecureTokens: true, rtr: { // how long before an idle session is killed? default: 60m. // this value must be shorter than the RT's TTL. // must be a string parseable by ms, e.g. 60s, 10m, 1h idleSessionTTL: '10m', // how long to show the "warning, session is idle" modal? default: 1m. // this value must be shorter than the idleSessionTTL. // must be a string parseable by ms, e.g. 60s, 10m, 1h idleModalTTL: '30s', // which events constitute "activity" that prolongs a session? // default: keydown, mousedown activityEvents: ['keydown', 'mousedown', 'wheel', 'touchstart', 'scroll'], } } ``` * Turn on the logging channels `rtr` and `rtrv` (verbose) See https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API Refs STCOR-776. Replaces PR #1431, which implemented something similar but with RTR still attached to regular API requests. It ... sort of worked, but not really, and was reverted in PR #1433. (cherry picked from commit 39d1fc97e995fe4fba13148de52d8a51c54806ba) --- CHANGELOG.md | 2 + package.json | 1 + src/RootWithIntl.js | 293 +++++++-------- src/components/Logout/Logout.js | 42 +++ src/components/Logout/Logout.test.js | 46 +++ src/components/Logout/index.js | 1 + .../LogoutTimeout/LogoutTimeout.css | 53 +++ src/components/LogoutTimeout/LogoutTimeout.js | 61 +++ .../LogoutTimeout/LogoutTimeout.test.js | 29 ++ src/components/LogoutTimeout/index.js | 1 + src/components/MainNav/MainNav.js | 18 +- .../ProfileDropdown/ProfileDropdown.js | 5 +- src/components/Root/Events.js | 5 - src/components/Root/FFetch.js | 342 +++++++---------- src/components/Root/FFetch.test.js | 201 +++++----- src/components/Root/FXHR.js | 34 +- src/components/Root/FXHR.test.js | 32 -- src/components/Root/Root.js | 16 +- src/components/Root/constants.js | 49 +++ src/components/Root/token-util.js | 353 +++++++++--------- src/components/Root/token-util.test.js | 300 ++++++++------- .../SessionEventContainer/KeepWorkingModal.js | 73 ++++ .../KeepWorkingModal.test.js | 67 ++++ .../SessionEventContainer.js | 266 +++++++++++++ .../SessionEventContainer.test.js | 239 ++++++++++++ src/components/SessionEventContainer/index.js | 1 + src/components/index.js | 4 + src/loginServices.js | 152 ++++---- src/loginServices.test.js | 87 +++++ src/okapiActions.js | 23 ++ src/okapiReducer.js | 30 +- src/okapiReducer.test.js | 56 ++- test/jest/__mock__/BroadcastChannel.mock.js | 6 + test/jest/__mock__/index.js | 2 + translations/stripes-core/en.json | 9 +- translations/stripes-core/en_GB.json | 10 +- translations/stripes-core/en_SE.json | 10 +- translations/stripes-core/en_US.json | 8 +- 38 files changed, 1981 insertions(+), 946 deletions(-) create mode 100644 src/components/Logout/Logout.js create mode 100644 src/components/Logout/Logout.test.js create mode 100644 src/components/Logout/index.js create mode 100644 src/components/LogoutTimeout/LogoutTimeout.css create mode 100644 src/components/LogoutTimeout/LogoutTimeout.js create mode 100644 src/components/LogoutTimeout/LogoutTimeout.test.js create mode 100644 src/components/LogoutTimeout/index.js delete mode 100644 src/components/Root/Events.js create mode 100644 src/components/Root/constants.js create mode 100644 src/components/SessionEventContainer/KeepWorkingModal.js create mode 100644 src/components/SessionEventContainer/KeepWorkingModal.test.js create mode 100644 src/components/SessionEventContainer/SessionEventContainer.js create mode 100644 src/components/SessionEventContainer/SessionEventContainer.test.js create mode 100644 src/components/SessionEventContainer/index.js create mode 100644 test/jest/__mock__/BroadcastChannel.mock.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 96975e2b5..fafeb1b0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ * Utilize the `tenant` procured through the SSO login process. Refs STCOR-769. * Use keycloak URLs in place of users-bl for tenant-switch. Refs US1153537. +* Idle-session timeout and "Keep working?" modal. Refs STCOR-776. +======= ## [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) diff --git a/package.json b/package.json index e4a4de21b..9f46ae294 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "localforage": "^1.5.6", "lodash": "^4.17.21", "moment-timezone": "^0.5.14", + "ms": "^2.1.3", "prop-types": "^15.5.10", "query-string": "^7.1.2", "react-cookie": "^4.0.3", diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index a71d0b27a..5e4db3ae7 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -1,11 +1,10 @@ -import React from 'react'; +import { useState } from 'react'; import PropTypes from 'prop-types'; import { Router, Switch, Redirect as InternalRedirect } from 'react-router-dom'; - import { Provider } from 'react-redux'; import { CookiesProvider } from 'react-cookie'; @@ -29,12 +28,16 @@ import { Settings, HandlerManager, TitleManager, + Login, + Logout, + LogoutTimeout, OverlayContainer, CreateResetPassword, CheckEmailStatusPage, ForgotPasswordCtrl, ForgotUserNameCtrl, AppCtxMenuProvider, + SessionEventContainer, } from './components'; import StaleBundleWarning from './components/StaleBundleWarning'; import { StripesContext } from './StripesContext'; @@ -45,159 +48,145 @@ export const renderLogoutComponent = () => { return ; }; -class RootWithIntl extends React.Component { - static propTypes = { - stripes: PropTypes.shape({ - clone: PropTypes.func.isRequired, - config: PropTypes.object, - epics: PropTypes.object, - logger: PropTypes.object.isRequired, - okapi: PropTypes.object.isRequired, - store: PropTypes.object.isRequired - }).isRequired, - token: PropTypes.string, - isAuthenticated: PropTypes.bool, - disableAuth: PropTypes.bool.isRequired, - history: PropTypes.shape({}), - }; +const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAuth, history = {} }) => { + const connect = connectFor('@folio/core', stripes.epics, stripes.logger); + const connectedStripes = stripes.clone({ connect }); - static defaultProps = { - token: '', - isAuthenticated: false, - history: {}, + const [callout, setCallout] = useState(null); + const setCalloutDomRef = (ref) => { + setCallout(ref); }; - state = { callout: null }; - - setCalloutRef = (ref) => { - this.setState({ - callout: ref, - }); - } - - render() { - const { - token, - isAuthenticated, - disableAuth, - history, - } = this.props; - - const connect = connectFor('@folio/core', this.props.stripes.epics, this.props.stripes.logger); - const stripes = this.props.stripes.clone({ connect }); + return ( + + + + + + + + { isAuthenticated || token || disableAuth ? + <> + + + + {typeof connectedStripes?.config?.staleBundleWarning === 'object' && } + + { (connectedStripes.okapi !== 'object' || connectedStripes.discovery.isFinished) && ( + + + {connectedStripes.config.useSecureTokens && } + + } + /> + } + /> + } + /> + } + /> + } + /> + + + + )} + + + + : + + {/* The ? after :token makes that part of the path optional, so that token may optionally + be passed in via URL parameter to avoid length restrictions */} + } + /> + } + key="sso-landing" + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + } + + + + + + + + ); +}; - return ( - - - - - - - - { isAuthenticated || token || disableAuth ? - <> - - - - {typeof stripes?.config?.staleBundleWarning === 'object' && } - - { (stripes.okapi !== 'object' || stripes.discovery.isFinished) && ( - - - - } - /> - } - /> - } - /> - } - /> - - - - )} - - - - : - - } - /> - } - key="sso-landing" - /> - } - key="oidc-landing" - /> - } - /> - } - /> - } - /> - - } - /> - - } - - - - - - - - ); - } -} +RootWithIntl.propTypes = { + stripes: PropTypes.shape({ + clone: PropTypes.func.isRequired, + config: PropTypes.object, + epics: PropTypes.object, + logger: PropTypes.object.isRequired, + okapi: PropTypes.object.isRequired, + store: PropTypes.object.isRequired + }).isRequired, + token: PropTypes.string, + isAuthenticated: PropTypes.bool, + disableAuth: PropTypes.bool.isRequired, + history: PropTypes.shape({}), +}; export default RootWithIntl; diff --git a/src/components/Logout/Logout.js b/src/components/Logout/Logout.js new file mode 100644 index 000000000..c8361a16f --- /dev/null +++ b/src/components/Logout/Logout.js @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Redirect } from 'react-router'; +import { FormattedMessage } from 'react-intl'; + +import { useStripes } from '../../StripesContext'; +import { getLocale, logout } from '../../loginServices'; + +/** + * Logout + * Call logout, then redirect to root. + * + * This corresponds to the '/logout' route, allowing that route to be directly + * accessible rather than only accessible through the menu action. + * + * @param {object} history + */ +const Logout = ({ history }) => { + const stripes = useStripes(); + const [didLogout, setDidLogout] = useState(false); + + useEffect( + () => { + getLocale(stripes.okapi.url, stripes.store, stripes.okapi.tenant) + .then(logout(stripes.okapi.url, stripes.store, history)) + .then(setDidLogout(true)); + }, + // no dependencies because we only want to start the logout process once. + // we don't care about changes to history or stripes; certainly those + // could be updated as part of the logout process + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return didLogout ? : ; +}; + +Logout.propTypes = { + history: PropTypes.object, +}; + +export default Logout; diff --git a/src/components/Logout/Logout.test.js b/src/components/Logout/Logout.test.js new file mode 100644 index 000000000..383c89d0e --- /dev/null +++ b/src/components/Logout/Logout.test.js @@ -0,0 +1,46 @@ +import { render, screen, waitFor } from '@folio/jest-config-stripes/testing-library/react'; + +import Harness from '../../../test/jest/helpers/harness'; +import Logout from './Logout'; +import { logout } from '../../loginServices'; + +jest.mock('../../loginServices', () => ({ + ...(jest.requireActual('../../loginServices')), + getLocale: () => Promise.resolve(), + logout: jest.fn() +})); + +jest.mock('react-router', () => ({ + ...(jest.requireActual('react-router')), + Redirect: () =>
Redirect
+})); + +const stripes = { + config: { + rtr: { + idleModalTTL: '3s', + idleSessionTTL: '3s', + } + }, + okapi: { + url: 'https://blah', + }, + logger: { log: jest.fn() }, + store: { + getState: jest.fn(), + }, +}; + +describe('Logout', () => { + it('calls logout and redirects', async () => { + logout.mockReturnValue(Promise.resolve()); + + render(); + + await waitFor(() => { + screen.getByText('Redirect'); + }); + + expect(logout).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/Logout/index.js b/src/components/Logout/index.js new file mode 100644 index 000000000..5e6c69022 --- /dev/null +++ b/src/components/Logout/index.js @@ -0,0 +1 @@ +export { default } from './Logout'; diff --git a/src/components/LogoutTimeout/LogoutTimeout.css b/src/components/LogoutTimeout/LogoutTimeout.css new file mode 100644 index 000000000..198315603 --- /dev/null +++ b/src/components/LogoutTimeout/LogoutTimeout.css @@ -0,0 +1,53 @@ +@import "@folio/stripes-components/lib/variables.css"; + +.wrapper { + display: flex; + justify-content: center; + min-height: 100vh; +} + +.container { + width: 100%; + max-width: 940px; + min-height: 330px; + margin: 12vh 2rem 0; +} + +.linksWrapper, +.authErrorsWrapper { + margin-top: 1rem; +} + +.link { + display: block; + width: 100%; + font-size: var(--font-size-large); + font-weight: var(--text-weight-headline-basis); + margin: 0; +} + +@media (--medium-up) { + .container { + min-height: initial; + } +} + +@media (--large-up) { + .header { + font-size: var(--font-size-xx-large); + } + + .toggleButtonWrapper { + justify-content: left; + + & > button { + margin: 0 0 1rem 1rem; + } + } +} + +@media (height <= 440px) { + .container { + min-height: 330px; + } +} diff --git a/src/components/LogoutTimeout/LogoutTimeout.js b/src/components/LogoutTimeout/LogoutTimeout.js new file mode 100644 index 000000000..af9467329 --- /dev/null +++ b/src/components/LogoutTimeout/LogoutTimeout.js @@ -0,0 +1,61 @@ +import { FormattedMessage } from 'react-intl'; +import { branding } from 'stripes-config'; +import { Redirect } from 'react-router'; + +import { + Button, + Col, + Headline, + Row, +} from '@folio/stripes-components'; + +import OrganizationLogo from '../OrganizationLogo'; +import { useStripes } from '../../StripesContext'; + +import styles from './LogoutTimeout.css'; + +/** + * LogoutTimeout + * For unauthenticated users, show a "sorry, your session timed out" message + * with a link to login page. For authenticated users, redirect to / since + * showing such a message would be a misleading lie. + * + * Having a static route to this page allows the logout handler to choose + * between redirecting straight to the login page (if the user chose to + * logout) or to this page (if the session timeout out). + * + * This corresponds to the '/logout-timeout' route. + */ +const LogoutTimeout = () => { + const stripes = useStripes(); + + if (stripes.okapi.isAuthenticated) { + return ; + } + + return ( +
+
+
+ + + + + + + + + + + + + + + +
+
+
+ ); +}; + +export default LogoutTimeout; diff --git a/src/components/LogoutTimeout/LogoutTimeout.test.js b/src/components/LogoutTimeout/LogoutTimeout.test.js new file mode 100644 index 000000000..068285092 --- /dev/null +++ b/src/components/LogoutTimeout/LogoutTimeout.test.js @@ -0,0 +1,29 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; + +import LogoutTimeout from './LogoutTimeout'; +import { useStripes } from '../../StripesContext'; + + +jest.mock('../OrganizationLogo'); +jest.mock('../../StripesContext'); +jest.mock('react-router', () => ({ + Redirect: () =>
Redirect
, +})); + +describe('LogoutTimeout', () => { + it('if not authenticated, renders a timeout message', async () => { + const mockUseStripes = useStripes; + mockUseStripes.mockReturnValue({ okapi: { isAuthenticated: false } }); + + render(); + screen.getByText('rtr.idleSession.sessionExpiredSoSad'); + }); + + it('if authenticated, renders a redirect', async () => { + const mockUseStripes = useStripes; + mockUseStripes.mockReturnValue({ okapi: { isAuthenticated: true } }); + + render(); + screen.getByText('Redirect'); + }); +}); diff --git a/src/components/LogoutTimeout/index.js b/src/components/LogoutTimeout/index.js new file mode 100644 index 000000000..a355b4149 --- /dev/null +++ b/src/components/LogoutTimeout/index.js @@ -0,0 +1 @@ +export { default } from './LogoutTimeout'; diff --git a/src/components/MainNav/MainNav.js b/src/components/MainNav/MainNav.js index fc418e8a1..856f82a44 100644 --- a/src/components/MainNav/MainNav.js +++ b/src/components/MainNav/MainNav.js @@ -5,7 +5,7 @@ import { compose } from 'redux'; import { injectIntl } from 'react-intl'; import { withRouter } from 'react-router'; -import { branding, config } from 'stripes-config'; +import { branding } from 'stripes-config'; import { Icon } from '@folio/stripes-components'; @@ -116,22 +116,12 @@ class MainNav extends Component { }); } - // Return the user to the login screen, but after logging in they will return to their previous activity. - returnToLogin() { + // return the user to the login screen, but after logging in they will be brought to the default screen. + logout() { const { okapi } = this.store.getState(); return getLocale(okapi.url, this.store, okapi.tenant) - .then(sessionLogout(okapi.url, this.store)); - } - - // return the user to the login screen, but after logging in they will be brought to the default screen. - logout() { - if (!config.preserveConsole) { - console.clear(); // eslint-disable-line no-console - } - this.returnToLogin().then(() => { - this.props.history.push('/logout'); - }); + .then(sessionLogout(okapi.url, this.store, this.props.history)); } getAppList(lastVisited) { diff --git a/src/components/MainNav/ProfileDropdown/ProfileDropdown.js b/src/components/MainNav/ProfileDropdown/ProfileDropdown.js index 6cc58ba8a..1ecf70da0 100644 --- a/src/components/MainNav/ProfileDropdown/ProfileDropdown.js +++ b/src/components/MainNav/ProfileDropdown/ProfileDropdown.js @@ -28,7 +28,6 @@ class ProfileDropdown extends Component { modules: PropTypes.shape({ app: PropTypes.arrayOf(PropTypes.object), }), - onLogout: PropTypes.func.isRequired, stripes: PropTypes.shape({ config: PropTypes.shape({ showPerms: PropTypes.bool, @@ -172,7 +171,7 @@ class ProfileDropdown extends Component { }; getDropdownContent() { - const { stripes, onLogout } = this.props; + const { stripes } = this.props; const user = this.getUserData(); const currentPerms = stripes.user ? stripes.user.perms : undefined; const messageId = stripes.okapi.ssoEnabled ? 'stripes-core.logoutKeepSso' : 'stripes-core.logout'; @@ -230,7 +229,7 @@ class ProfileDropdown extends Component { } {this.userLinks} - + diff --git a/src/components/Root/Events.js b/src/components/Root/Events.js deleted file mode 100644 index bbf2bd122..000000000 --- a/src/components/Root/Events.js +++ /dev/null @@ -1,5 +0,0 @@ -/** dispatched during RTR when it is successful */ -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'; diff --git a/src/components/Root/FFetch.js b/src/components/Root/FFetch.js index 7208d5438..5312445c0 100644 --- a/src/components/Root/FFetch.js +++ b/src/components/Root/FFetch.js @@ -1,7 +1,8 @@ /* eslint-disable import/prefer-default-export */ /** - * TLDR: override global `fetch` and `XMLHttpRequest` to perform RTR for FOLIO API requests. + * TLDR: override global `fetch` and `XMLHttpRequest` to perform RTR for + * FOLIO API requests. * * RTR Primers: * @see https://authjs.dev/guides/basics/refresh-token-rotation @@ -11,46 +12,55 @@ * to them. The AT cookie accompanies every request, and the RT cookie is sent * only in refresh requests. * - * The basic workflow here is intercept requests for FOLIO APIs and trap - * response failures that are caused by expired ATs, conduct RTR, then replay - * the original requests. FOLIO API requests that arrive while RTR is in-process - * are held until RTR finishes and then allowed to flow through. AT failure is - * recognized in a response with status code 403 and an error message beginning with - * "Token missing". (Eventually, it may be 401 instead, but not today.) Requests - * to non-FOLIO APIs flow through without intervention. + * The basic plot here is that RTR requests happen independently of other + * activity. The login-response is used to trigger the RTR cycle, which then + * continues in perpetuity until logout. Likewise, the response from each RTR + * request is used to start the timer that will trigger the next round in the + * cycle. * - * RTR failures should cause logout since they indicate an expired or + * Requests that arrive while RTR is in flight are held until the RTR promise + * resolves and then processed. This avoids the problem of a request's AT + * expiring (or changing, if RTR succeeds) while it is in-flight. + * + * RTR failures will cause logout since they indicate an expired or * otherwise invalid RT, which is unrecoverable. Other request failures * should be handled locally within the applications that initiated the - * requests. + * requests; thus, such errors are untrapped and bubble up. * * The gross gory details: * In an ideal world, we would simply export a function and a class and * tell folks to use those, but we don't live in that world, at least not - * yet. So. For now, we override the global implementations in the constructor - * :scream: so any calls directly invoking `fetch()` or instantiating + * yet. So. For now, we override the global implementations in `replace...` + * methods so any calls directly invoking `fetch()` or instantiating * `XMLHttpRequest` get these updated versions that handle token rotation * automatically. * + * Logging categories: + * rtr: rotation + * rtrv: verbose + * */ +import ms from 'ms'; import { okapi } from 'stripes-config'; -import { getTokenExpiry } from '../../loginServices'; import { + setRtrTimeout +} from '../../okapiActions'; + +import { + getPromise, + isAuthenticationRequest, isFolioApiRequest, isLogoutRequest, - isValidAT, - isValidRT, - resourceMapper, rtr, } from './token-util'; import { RTRError, - UnexpectedResourceError, } from './Errors'; import { + RTR_AT_TTL_FRACTION, RTR_ERROR_EVENT, -} from './Events'; +} from './constants'; import FXHR from './FXHR'; @@ -60,236 +70,136 @@ const OKAPI_FETCH_OPTIONS = { }; export class FFetch { - constructor({ logger }) { + constructor({ logger, store }) { this.logger = logger; - - // save a reference to fetch, and then reassign the global :scream: - this.nativeFetch = global.fetch; - global.fetch = this.ffetch; - - this.NativeXHR = global.XMLHttpRequest; - global.XMLHttpRequest = FXHR(this); + this.store = store; } - /** { atExpires, rtExpires } both are JS millisecond timestamps */ - tokenExpiration = null; - - /** lock to indicate whether a rotation request is already in progress */ - // @@ needs to be stored in localforage??? - isRotating = false; - /** - * isPermissibleRequest - * Some requests are always permissible, e.g. auth-n and forgot-password. - * Others are only permissible if the Access Token is still valid. - * - * @param {Request} req clone of the original event.request object - * @param {object} te token expiration shaped like { atExpires, rtExpires } - * @param {string} oUrl Okapi URL - * @returns boolean true if the AT is valid or the request is always permissible + * save a reference to fetch, and then reassign the global :scream: */ - isPermissibleRequest = (resource, te, oUrl) => { - if (isValidAT(te, this.logger)) { - return true; - } - - const isPermissibleResource = (string) => { - const permissible = [ - '/authn/token', - '/bl-users/forgotten/password', - '/bl-users/forgotten/username', - '/bl-users/login-with-expiry', - '/bl-users/password-reset', - '/saml/check', - `/_/invoke/tenant/${okapi.tenant}/saml/login` - ]; - - this.logger.log('rtr', `AT invalid for ${resource}`); - return !!permissible.find(i => string.startsWith(`${oUrl}${i}`)); - }; - - - try { - return resourceMapper(resource, isPermissibleResource); - } catch (rme) { - if (rme instanceof UnexpectedResourceError) { - console.warn(rme.message, resource); // eslint-disable-line no-console - return false; - } - - throw rme; - } + replaceFetch = () => { + this.nativeFetch = global.fetch; + global.fetch = this.ffetch; }; /** - * passThroughWithRT - * Perform RTR then execute the original request. - * If RTR fails, dispatch RTR_ERROR_EVENT and die softly. - * - * @param {*} resource one of string, URL, Request - * @params {object} options - * @returns Promise + * save a reference to XMLHttpRequest, and then reassign the global :scream: */ - passThroughWithRT = (resource, options) => { - this.logger.log('rtr', 'pre-rtr-fetch', resource); - return rtr(this) - .then(() => { - this.logger.log('rtr', 'post-rtr-fetch', resource); - return this.nativeFetch.apply(global, [resource, options && { ...options, ...OKAPI_FETCH_OPTIONS }]); - }) - .catch(err => { - if (err instanceof RTRError) { - console.error('RTR failure', err); // eslint-disable-line no-console - document.dispatchEvent(new Event(RTR_ERROR_EVENT, { detail: err })); - return Promise.resolve(new Response(JSON.stringify({}))); - } - - throw err; - }); + replaceXMLHttpRequest = () => { + this.NativeXHR = global.XMLHttpRequest; + global.XMLHttpRequest = FXHR(this); }; /** - * passThroughWithAT - * Given we believe the AT to be valid, pass the fetch through. - * If it fails, maybe our beliefs were wrong, maybe everything is wrong, - * maybe there is no God, or there are many gods, or god is a she, or - * she is a he, or Lou Reed is god. Or maybe we were just wrong about the - * AT and we need to conduct token rotation, so try that. If RTR succeeds, - * it'll pass through the fetch as we originally intended because now we - * know the AT will be valid. If RTR fails, then it doesn't matter about - * Lou Reed. He may be god, but this is out of our hands now. + * rotateCallback + * Set a timeout to rotate the AT before it expires. Stash the timer-id + * in redux so the setRtrTimeout action can be used to cancel the existing + * timer when a new one is set. * - * @param {*} resource any resource acceptable to fetch() - * @param {*} options - * @returns Promise + * The rotation interval is set to a fraction of the AT's expiration + * time, e.g. if the AT expires in 1000 seconds and the fraction is .8, + * the timeout will be 800 seconds. + * + * @param {object} res object shaped like { accessTokenExpiration, refreshTokenExpiration } + * where the values are ISO-8601 datestamps like YYYY-MM-DDTHH:mm:ssZ */ - passThroughWithAT = (resource, options) => { - return this.nativeFetch.apply(global, [resource, options && { ...options, ...OKAPI_FETCH_OPTIONS }]) - .then(response => { - // certain 4xx responses indicate RTR problems (that need to be - // handled here) rather than application-specific problems (that need - // to bubble up to the applications themselves). Duplicate logic here - // is due to needing to parse different kinds of responses. Maybe it's - // JSON, maybe text. Srsly, Okapi??? :| - // - // 401/UnauthorizedException: from keycloak when the AT is missing - // 400/Token missing: from Okapi when the AT is missing - if (response.status === 401) { - const res = response.clone(); - return res.json() - .then(message => { - if (Array.isArray(message.errors) && message.errors.length === 1) { - const error = message.errors[0]; - if (error.type === 'UnauthorizedException' && error.code === 'authorization_error') { - this.logger.log('rtr', ' (whoops, invalid AT; retrying)'); - return this.passThroughWithRT(resource, options); - } - } - - // yes, it was a 401 but not a Keycloak 401: - // hand it back to the application to handle - return response; - }); - } - - if (response.status === 400 && response.headers.get('content-type') === 'text/plain') { - const res = response.clone(); - return res.text() - .then(text => { - if (text.startsWith('Token missing')) { - this.logger.log('rtr', ' (whoops, invalid AT; retrying)'); - return this.passThroughWithRT(resource, options); - } - - // yes, we got a 4xx, but not an RTR 4xx. leave that to the - // original application to handle. it's not our problem. - return response; - }); - } + rotateCallback = (res) => { + this.logger.log('rtr', 'rotation callback setup'); + + // set a short rotation interval by default, then inspect the response for + // token-expiration data to use instead if available. not all responses + // (e.g. those from _self) contain token-expiration values, so it is + // necessary to provide a default. + let rotationInterval = 10 * 1000; + if (res?.accessTokenExpiration) { + rotationInterval = (new Date(res.accessTokenExpiration).getTime() - Date.now()) * RTR_AT_TTL_FRACTION; + } - return response; - }); + this.logger.log('rtr', `rotation fired from rotateCallback; next callback in ${ms(rotationInterval)}`); + this.store.dispatch(setRtrTimeout(setTimeout(() => { + rtr(this.nativeFetch, this.logger, this.rotateCallback); + }, rotationInterval))); } /** - * passThroughLogout - * The logout request should never fail, even if it fails. - * That is, if it fails, we just pretend like it never happened - * instead of blowing up and causing somebody to get stuck in the - * logout process. - * - * @param {*} resource any resource acceptable to fetch() - * @param {object} options - * @returns Promise - */ - passThroughLogout = (resource, options) => { - this.logger.log('rtr', ' (logout request)'); - return this.nativeFetch.apply(global, [resource, options && { ...options, ...OKAPI_FETCH_OPTIONS }]) - .catch(err => { - // kill me softly: return an empty response to allow graceful failure - console.error('-- (rtr-sw) logout failure', err); // eslint-disable-line no-console - return Promise.resolve(new Response(JSON.stringify({}))); - }); - }; - - /** - * passThrough + * ffetch * Inspect resource to determine whether it's a FOLIO API request. - * Handle it with RTR if it is; let it trickle through if not. - * - * Given we believe the AT to be valid, pass the fetch through. - * If it fails, maybe our beliefs were wrong, maybe everything is wrong, - * maybe there is no God, or there are many gods, or god is a she, or - * she is a he, or Lou Reed is god. Or maybe we were just wrong about the - * AT and we need to conduct token rotation, so try that. If RTR succeeds, - * yay, pass through the fetch as we originally intended because now we - * know the AT will be valid. If RTR fails, then it doesn't matter about - * Lou Reed. He may be god. We'll dispatch an RTR_ERROR_EVENT and then - * return a dummy promise, which gives the root-level (stripes-core level) - * event handler the opportunity to respond (presumably by logging out) - * without tripping up the application-level error handler which isn't - * responsible for handling such things. + * * If it is an authentication-related request, complete the request + * and then execute the RTR callback to initiate that cycle. + * * If it is a logout request, complete the request and swallow any + * errors because ... what would be the point of a failed logout + * request? It's telling you "you couldn't call /logout because + * you didn't have a cookie" i.e. you're already logged out". + * * If it is a regular request, make sure RTR isn't in-flight (which + * would cause this request to fail if the RTR request finished + * processing first, because it would invalidate the old AT) and + * then proceed. + * If we catch an RTR error, emit a RTR_ERR_EVENT on the window and + * then swallow the error, allowing the application-level event handlers + * to handle that event. + * If we catch any other kind of error, re-throw it because it represents + * an application-specific problem that needs to be handled by an + * application-specific handler. * * @param {*} resource any resource acceptable to fetch() * @param {object} options * @returns Promise * @throws if any fetch fails */ - ffetch = async (resource, ffOptions = {}) => { - const { rtrIgnore = false, ...options } = ffOptions; - + ffetch = async (resource, options = {}) => { // FOLIO API requests are subject to RTR if (isFolioApiRequest(resource, okapi.url)) { - this.logger.log('rtr', 'will fetch', resource); - - // logout requests must not fail - if (isLogoutRequest(resource, okapi.url)) { - return this.passThroughLogout(resource, options); - } - - // if our cached tokens appear to have expired, pull them from storage. - // maybe another window updated them for us without us knowing. - if (!isValidAT(this.tokenExpiration, this.logger)) { - this.logger.log('rtr', 'local tokens expired; fetching from storage'); - this.tokenExpiration = await getTokenExpiry(); - } + this.logger.log('rtrv', 'will fetch', resource); + + // on authentication, grab the response to kick of the rotation cycle, + // then return the response + if (isAuthenticationRequest(resource, okapi.url)) { + this.logger.log('rtr', 'authn request'); + return this.nativeFetch.apply(global, [resource, options && { ...options, ...OKAPI_FETCH_OPTIONS }]) + .then(res => { + this.logger.log('rtr', 'authn success!'); + // a response can only be read once, so we clone it to grab the + // tokenExpiration in order to kick of the rtr cycle, then return + // the original + res.clone().json().then(json => { + this.rotateCallback(json.tokenExpiration); + }); - // AT is valid or unnecessary; execute the fetch - if (rtrIgnore || this.isPermissibleRequest(resource, this.tokenExpiration, okapi.url)) { - return this.passThroughWithAT(resource, options); + return res; + }); } - // AT was expired, but RT is valid; perform RTR then execute the fetch - if (isValidRT(this.tokenExpiration, this.logger)) { - return this.passThroughWithRT(resource, options); + // on logout, never fail + // if somebody does something silly like delete their cookies and then + // tries to logout, the logout request will fail. And that's fine, just + // fine. We will let them fail, capturing the response and swallowing it + // to avoid getting stuck in an error loop. + if (isLogoutRequest(resource, okapi.url)) { + this.logger.log('rtr', 'logout request'); + + return this.nativeFetch.apply(global, [resource, options && { ...options, ...OKAPI_FETCH_OPTIONS }]) + .catch(err => { + // kill me softly: return an empty response to allow graceful failure + console.error('-- (rtr-sw) logout failure', err); // eslint-disable-line no-console + return Promise.resolve(new Response(JSON.stringify({}))); + }); } - // AT is expired. RT is expired. It's the end of the world as we know it. - // So, maybe Michael Stipe is god. Oh, wait, crap, he lost his religion. - // Look, RTR is complicated, what do you want? - console.error('All tokens expired'); // eslint-disable-line no-console - document.dispatchEvent(new Event(RTR_ERROR_EVENT, { detail: 'All tokens expired' })); - return Promise.resolve(new Response(JSON.stringify({}))); + return getPromise(this.logger) + .then(() => { + this.logger.log('rtrv', 'post-rtr-fetch', resource); + return this.nativeFetch.apply(global, [resource, options && { ...options, ...OKAPI_FETCH_OPTIONS }]); + }) + .catch(err => { + if (err instanceof RTRError) { + console.error('RTR failure', err); // eslint-disable-line no-console + window.dispatchEvent(new Event(RTR_ERROR_EVENT, { detail: err })); + return Promise.resolve(new Response(JSON.stringify({}))); + } + + throw err; + }); } // default: pass requests through to the network diff --git a/src/components/Root/FFetch.test.js b/src/components/Root/FFetch.test.js index bd96a9449..8013b93ff 100644 --- a/src/components/Root/FFetch.test.js +++ b/src/components/Root/FFetch.test.js @@ -39,20 +39,73 @@ describe('FFetch class', () => { jest.resetAllMocks(); }); - describe('Calling a non-okapi fetch', () => { + describe('Calling a non-FOLIO API', () => { it('calls native fetch once', async () => { mockFetch.mockResolvedValueOnce('non-okapi-success'); const testFfetch = new FFetch({ logger: { log } }); + testFfetch.replaceFetch(); + testFfetch.replaceXMLHttpRequest(); + const response = await global.fetch('nonOkapiURL', { testOption: 'test' }); await expect(mockFetch.mock.calls).toHaveLength(1); expect(response).toEqual('non-okapi-success'); }); }); + describe('Calling a FOLIO API fetch', () => { + it('calls native fetch once', async () => { + mockFetch.mockResolvedValueOnce('okapi-success'); + const testFfetch = new FFetch({ logger: { log } }); + testFfetch.replaceFetch(); + testFfetch.replaceXMLHttpRequest(); + const response = await global.fetch('okapiUrl/whatever', { testOption: 'test' }); + await expect(mockFetch.mock.calls).toHaveLength(1); + expect(response).toEqual('okapi-success'); + }); + }); + + describe('logging in', () => { + it('calls native fetch once', async () => { + const tokenExpiration = { + accessTokenExpiration: new Date().toISOString() + }; + const json = () => Promise.resolve({ tokenExpiration }); + // this mock is a mess because the login-handler clones the response + // in order to (1) grab token expiration and kick off RTR and (2) pass + // the un-read-response back to the login handler + mockFetch.mockResolvedValueOnce({ + clone: () => ({ + json + }), + json, + }); + const testFfetch = new FFetch({ + logger: { log }, + store: { + dispatch: jest.fn(), + } + }); + testFfetch.replaceFetch(); + testFfetch.replaceXMLHttpRequest(); + + const response = await global.fetch('okapiUrl/bl-users/login-with-expiry', { testOption: 'test' }); + + // calls native fetch + expect(mockFetch.mock.calls).toHaveLength(1); + + // login returns the original response + const res = await response.json(); + expect(res).toMatchObject({ tokenExpiration }); + }); + }); + describe('logging out', () => { it('calls native fetch once to log out', async () => { mockFetch.mockResolvedValueOnce('logged out'); const testFfetch = new FFetch({ logger: { log } }); + testFfetch.replaceFetch(); + testFfetch.replaceXMLHttpRequest(); + const response = await global.fetch('okapiUrl/authn/logout', { testOption: 'test' }); expect(mockFetch.mock.calls).toHaveLength(1); expect(response).toEqual('logged out'); @@ -60,11 +113,20 @@ describe('FFetch class', () => { }); describe('logging out fails', () => { - it('calls native fetch once to log out', async () => { - mockFetch.mockImplementationOnce(() => new Promise((res, rej) => rej())); + it('fetch failure is silently trapped', async () => { + mockFetch.mockRejectedValueOnce('logged out FAIL'); const testFfetch = new FFetch({ logger: { log } }); - const response = await global.fetch('okapiUrl/authn/logout', { testOption: 'test' }); + testFfetch.replaceFetch(); + testFfetch.replaceXMLHttpRequest(); + + let ex = null; + let response = null; + try { + response = await global.fetch('okapiUrl/authn/logout', { testOption: 'test' }); + } catch (e) { + ex = e; + } expect(mockFetch.mock.calls).toHaveLength(1); expect(response).toEqual(new Response(JSON.stringify({}))); }); @@ -74,6 +136,9 @@ describe('FFetch class', () => { it('Calling an okapi fetch with valid token...', async () => { mockFetch.mockResolvedValueOnce('okapi success'); const testFfetch = new FFetch({ logger: { log } }); + testFfetch.replaceFetch(); + testFfetch.replaceXMLHttpRequest(); + const response = await global.fetch('okapiUrl/valid', { testOption: 'test' }); expect(mockFetch.mock.calls).toHaveLength(1); expect(response).toEqual('okapi success'); @@ -81,119 +146,16 @@ describe('FFetch class', () => { }); describe('Calling an okapi fetch with missing token...', () => { - it('triggers rtr...calls fetch 3 times, failed call, token call, successful call', async () => { + it('returns the error', async () => { mockFetch.mockResolvedValue('success') - .mockResolvedValueOnce(new Response( - 'Token missing', - { - status: 400, - headers: { - 'content-type': 'text/plain', - }, - } - )) - .mockResolvedValueOnce(new Response(JSON.stringify({ - accessTokenExpiration: new Date().getTime() + 1000, - refreshTokenExpiration: new Date().getTime() + 2000, - }), { ok: true })); + .mockResolvedValueOnce('failure'); const testFfetch = new FFetch({ logger: { log } }); - const response = await global.fetch('okapiUrl', { testOption: 'test' }); - expect(mockFetch.mock.calls).toHaveLength(3); - expect(mockFetch.mock.calls[1][0]).toEqual('okapiUrl/authn/refresh'); - expect(response).toEqual('success'); - }); + testFfetch.replaceFetch(); + testFfetch.replaceXMLHttpRequest(); - it('400 NOT token missing: bubbles failure up to the application', async () => { - mockFetch.mockResolvedValue( - new Response( - 'Tolkien missing, send Frodo?', - { - status: 400, - headers: { - 'content-type': 'text/plain', - }, - } - )); - const testFfetch = new FFetch({ logger: { log } }); const response = await global.fetch('okapiUrl', { testOption: 'test' }); expect(mockFetch.mock.calls).toHaveLength(1); - expect(response.status).toEqual(400); - }); - - it('401 UnauthorizedException: triggers rtr...calls fetch 3 times, failed call, token call, successful call', async () => { - mockFetch.mockResolvedValue('success') - .mockResolvedValueOnce(new Response( - JSON.stringify({ - 'errors': [ - { - 'type': 'UnauthorizedException', - 'code': 'authorization_error', - 'message': 'Unauthorized' - } - ], - 'total_records': 1 - }), - { - status: 401, - headers: { - 'content-type': 'application/json', - }, - } - )) - .mockResolvedValueOnce(new Response(JSON.stringify({ - accessTokenExpiration: new Date().getTime() + 1000, - refreshTokenExpiration: new Date().getTime() + 2000, - }), { ok: true })); - const testFfetch = new FFetch({ logger: { log } }); - const response = await global.fetch('okapiUrl', { testOption: 'test' }); - expect(mockFetch.mock.calls).toHaveLength(3); - expect(mockFetch.mock.calls[1][0]).toEqual('okapiUrl/authn/refresh'); - expect(response).toEqual('success'); - }); - - it('401 NOT UnauthorizedException: bubbles failure up to the application', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - 'errors': [ - { - 'type': 'AuthorizedException', - 'code': 'chuck_brown', - 'message': 'Gong!' - } - ], - 'total_records': 1 - }), - { - status: 401, - headers: { - 'content-type': 'application/json', - }, - } - )); - const testFfetch = new FFetch({ logger: { log } }); - const response = (await global.fetch('okapiUrl', { testOption: 'test' })); - expect(mockFetch.mock.calls).toHaveLength(1); - expect(response.status).toEqual(401); - }); - }); - - describe('Calling an okapi fetch with expired AT...', () => { - it('triggers rtr...calls fetch 2 times - token call, successful call', async () => { - getTokenExpiry.mockResolvedValueOnce({ - atExpires: Date.now() - (10 * 60 * 1000), - rtExpires: Date.now() + (10 * 60 * 1000), - }); - mockFetch.mockResolvedValue('token rotation success') - .mockResolvedValueOnce(new Response(JSON.stringify({ - accessTokenExpiration: new Date().getTime() + 1000, - refreshTokenExpiration: new Date().getTime() + 2000, - }), { ok: true })); - const testFfetch = new FFetch({ logger: { log } }); - const response = await global.fetch('okapiUrl', { testOption: 'test' }); - expect(mockFetch.mock.calls).toHaveLength(2); - expect(mockFetch.mock.calls[0][0]).toEqual('okapiUrl/authn/refresh'); - expect(response).toEqual('token rotation success'); + expect(response).toEqual('failure'); }); }); @@ -210,6 +172,9 @@ describe('FFetch class', () => { } )); const testFfetch = new FFetch({ logger: { log } }); + testFfetch.replaceFetch(); + testFfetch.replaceXMLHttpRequest(); + const response = await global.fetch('okapiUrl', { testOption: 'test' }); const message = await response.text(); expect(mockFetch.mock.calls).toHaveLength(1); @@ -231,6 +196,9 @@ describe('FFetch class', () => { )) .mockRejectedValueOnce(new Error('token error message')); const testFfetch = new FFetch({ logger: { log } }); + testFfetch.replaceFetch(); + testFfetch.replaceXMLHttpRequest(); + try { await global.fetch('okapiUrl', { testOption: 'test' }); } catch (e) { @@ -263,6 +231,9 @@ describe('FFetch class', () => { } )); const testFfetch = new FFetch({ logger: { log } }); + testFfetch.replaceFetch(); + testFfetch.replaceXMLHttpRequest(); + try { await global.fetch('okapiUrl', { testOption: 'test' }); } catch (e) { @@ -290,6 +261,9 @@ describe('FFetch class', () => { } )); const testFfetch = new FFetch({ logger: { log } }); + testFfetch.replaceFetch(); + testFfetch.replaceXMLHttpRequest(); + try { await global.fetch('okapiUrl', { testOption: 'test' }); } catch (e) { @@ -317,6 +291,9 @@ describe('FFetch class', () => { } )); const testFfetch = new FFetch({ logger: { log } }); + testFfetch.replaceFetch(); + testFfetch.replaceXMLHttpRequest(); + try { await global.fetch({ foo: 'okapiUrl' }, { testOption: 'test' }); } catch (e) { diff --git a/src/components/Root/FXHR.js b/src/components/Root/FXHR.js index 166061eb5..c5232fc97 100644 --- a/src/components/Root/FXHR.js +++ b/src/components/Root/FXHR.js @@ -1,9 +1,8 @@ import { okapi } from 'stripes-config'; -import { isFolioApiRequest, rtr, isValidAT, isValidRT } from './token-util'; -import { getTokenExpiry } from '../../loginServices'; +import { getPromise, isFolioApiRequest } from './token-util'; import { RTR_ERROR_EVENT, -} from './Events'; +} from './constants'; import { RTRError } from './Errors'; export default (deps) => { @@ -24,30 +23,15 @@ export default (deps) => { const { logger } = this.FFetchContext; this.FFetchContext.logger?.log('rtr', 'capture XHR send'); if (this.shouldEnsureToken) { - if (!isValidAT(this.FFetchContext.tokenExpiration, logger)) { - logger.log('rtr', 'local tokens expired; fetching from storage for XHR..'); - this.FFetchContext.tokenExpiration = await getTokenExpiry(); - } - - if (isValidAT(this.FFetchContext.tokenExpiration, logger)) { - logger.log('rtr', 'local AT valid, sending XHR...'); + try { + await getPromise(logger); super.send(payload); - } else if (isValidRT(this.FFetchContext.tokenExpiration, logger)) { - logger.log('rtr', 'local RT valid, sending XHR...'); - try { - await rtr(this.FFetchContext); - logger.log('rtr', 'local RTtoken refreshed, sending XHR...'); - super.send(payload); - } catch (err) { - if (err instanceof RTRError) { - console.error('RTR failure while attempting XHR', err); // eslint-disable-line no-console - document.dispatchEvent(new Event(RTR_ERROR_EVENT, { detail: err })); - } - throw err; + } catch (err) { + if (err instanceof RTRError) { + console.error('RTR failure while attempting XHR', err); // eslint-disable-line no-console + window.dispatchEvent(new Event(RTR_ERROR_EVENT, { detail: err })); } - } else { - logger.log('rtr', 'All tokens expired when attempting to send XHR'); - document.dispatchEvent(new Event(RTR_ERROR_EVENT, { detail: 'All tokens expired when sending XHR' })); + throw err; } } else { logger.log('rtr', 'request passed through, sending XHR...'); diff --git a/src/components/Root/FXHR.test.js b/src/components/Root/FXHR.test.js index bae938dd5..c5d5efed9 100644 --- a/src/components/Root/FXHR.test.js +++ b/src/components/Root/FXHR.test.js @@ -69,36 +69,4 @@ describe('FXHR', () => { expect(aelSpy.mock.calls).toHaveLength(1); expect(rtr.mock.calls).toHaveLength(0); }); - - it('If AT is invalid, but RT is valid, refresh the token before sending...', async () => { - getTokenExpiry.mockResolvedValue({ - atExpires: Date.now() - (10 * 60 * 1000), - rtExpires: Date.now() + (10 * 60 * 1000), - }); - testXHR.addEventListener('abort', mockHandler); - testXHR.open('POST', 'okapiUrl'); - await testXHR.send(new ArrayBuffer(8)); - expect(openSpy.mock.calls).toHaveLength(1); - expect(aelSpy.mock.calls).toHaveLength(1); - expect(rtr.mock.calls).toHaveLength(1); - }); - - - it('Handles Errors during token rotation', async () => { - rtr.mockRejectedValueOnce(new RTRError('rtr test failure')); - getTokenExpiry.mockResolvedValue({ - atExpires: Date.now() - (10 * 60 * 1000), - rtExpires: Date.now() + (10 * 60 * 1000), - }); - let error = null; - try { - testXHR.addEventListener('abort', mockHandler); - testXHR.open('POST', 'okapiUrl'); - await testXHR.send(new ArrayBuffer(8)); - } catch (err) { - error = err; - } finally { - expect(error instanceof RTRError).toBe(true); - } - }); }); diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index ecba25a32..cddcaedae 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -21,11 +21,12 @@ import enhanceReducer from '../../enhanceReducer'; import createApolloClient from '../../createApolloClient'; import createReactQueryClient from '../../createReactQueryClient'; import { setSinglePlugin, setBindings, setIsAuthenticated, setOkapiToken, setTimezone, setCurrency, updateCurrentUser } from '../../okapiActions'; -import { loadTranslations, checkOkapiSession, addRtrEventListeners } from '../../loginServices'; +import { loadTranslations, checkOkapiSession } from '../../loginServices'; import { getQueryResourceKey, getCurrentModule } from '../../locationService'; import Stripes from '../../Stripes'; import RootWithIntl from '../../RootWithIntl'; import SystemSkeleton from '../SystemSkeleton'; +import { configureRtr } from './token-util'; import './Root.css'; @@ -68,10 +69,14 @@ class Root extends Component { // enhanced security mode: // * configure fetch and xhr interceptors to conduct RTR - // * configure document-level event listeners to listen for RTR events + // * see SessionEventContainer for RTR handling if (this.props.config.useSecureTokens) { - this.ffetch = new FFetch({ logger: this.props.logger }); - addRtrEventListeners(okapi, store); + this.ffetch = new FFetch({ + logger: this.props.logger, + store, + }); + this.ffetch.replaceFetch(); + this.ffetch.replaceXMLHttpRequest(); } } @@ -127,6 +132,9 @@ class Root extends Component { return (); } + // make sure RTR is configured + 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 new file mode 100644 index 000000000..58ddc16db --- /dev/null +++ b/src/components/Root/constants.js @@ -0,0 +1,49 @@ +/** dispatched during RTR when it is successful */ +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 if the session is idle (without activity) for too long + */ +export const RTR_TIMEOUT_EVENT = '@folio/stripes/core::RTRIdleSessionTimeout'; + +/** BroadcastChannel for cross-window activity pings */ +export const RTR_ACTIVITY_CHANNEL = '@folio/stripes/core::RTRActivityChannel'; + +/** how much of an AT's lifespan can elapse before it is considered expired */ +export const RTR_AT_TTL_FRACTION = 0.8; + +/** + * events that constitute "activity" and will prolong the session. + * overridden in stripes.config.js::config.rtr.activityEvents. + */ +export const RTR_ACTIVITY_EVENTS = ['keydown', 'mousedown']; + +/** + * how long does an idle session last? + * When this interval elapses without activity, the session will end and + * the user will be signed out. This value must be shorter than the RT's TTL, + * otherwise the RT will expire while the session is still active, leading to + * a problem where the session appears to be active because the UI is available + * but the first action that makes and API request (which will fail with an + * RTR error) causes the session to end. + * + * overridden in stripes.configs.js::config.rtr.idleSessionTTL + * value must be a string parsable by ms() + */ +export const RTR_IDLE_SESSION_TTL = '60m'; + +/** + * how long is the "keep working?" modal visible + * This interval describes how long the "keep working?" modal should be + * visible before the idle-session timer expires. For example, if + * RTR_IDLE_SESSION_TTL is set to "60m" and this value is set to "1m", + * then the modal will be displayed after 59 minutes of inactivity and + * be displayed for one minute. + * + * overridden in stripes.configs.js::config.rtr.idleModalTTL + * value must be a string parsable by ms() + */ +export const RTR_IDLE_MODAL_TTL = '1m'; diff --git a/src/components/Root/token-util.js b/src/components/Root/token-util.js index 2ebd9c7f4..ea24f0c98 100644 --- a/src/components/Root/token-util.js +++ b/src/components/Root/token-util.js @@ -1,33 +1,8 @@ import { okapi } from 'stripes-config'; -import { setTokenExpiry } from '../../loginServices'; +import { getTokenExpiry, setTokenExpiry } from '../../loginServices'; import { RTRError, UnexpectedResourceError } from './Errors'; -import { RTR_SUCCESS_EVENT } from './Events'; - -/** - * RTR_TTL_WINDOW (float) - * How much of a token's TTL can elapse before it is considered expired? - * This helps us avoid a race-like condition where a token expires in the - * gap between when we check whether we think it's expired and when we use - * it to authorize a new request. Say the last RTR response took a long time - * to arrive, so it was generated at 12:34:56 but we didn't process it until - * 12:34:59. That could cause problems if (just totally hypothetically) we - * had an application (again, TOTALLY hypothetically) that was polling every - * five seconds and one of its requests landed in that three-second gap. Oh, - * hey STCOR-754, what are you doing here? - * - * So this is a buffer. Instead of letting a token be used up until the very - * last second of its life, we'll consider it expired a little early. This will - * cause RTR to happen a little early (i.e. a little more frequently) but that - * should be OK since it increases our confidence that when an AT accompanies - * the RTR request it is still valid. - * - * 0 < value < 1. Closer to 0 means more frequent rotation. Closer to 1 means - * closer to the exact value of its TTL. 0.8 is just a SWAG at a "likely to be - * useful" value. Given a 600 second TTL (the current default for ATs) it - * corresponds to 480 seconds. - */ -export const RTR_TTL_WINDOW = 0.8; +import { RTR_ERROR_EVENT, RTR_SUCCESS_EVENT } from './constants'; /** localstorage flag indicating whether an RTR request is already under way. */ export const RTR_IS_ROTATING = '@folio/stripes/core::rtrIsRotating'; @@ -62,7 +37,7 @@ export const RTR_MAX_AGE = 2000; * * @param {string|URL|Request} resource * @param {*} fx function to call - * @returns boolean + * @returns result of fx() * @throws UnexpectedResourceError if resource is not a string, URL, or Request */ export const resourceMapper = (resource, fx) => { @@ -77,6 +52,38 @@ export const resourceMapper = (resource, fx) => { throw new UnexpectedResourceError(resource); }; + +/** + * isAuthenticationRequest + * Return true if the given resource is an authentication request, + * i.e. a request that should kick off the RTR cycle. + * + * @param {*} resource one of string, URL, Request + * @param {string} oUrl FOLIO API origin + * @returns boolean + */ +export const isAuthenticationRequest = (resource, oUrl) => { + const isPermissibleResource = (string) => { + const permissible = [ + '/bl-users/login-with-expiry', + '/bl-users/_self', + ]; + + return !!permissible.find(i => string.startsWith(`${oUrl}${i}`)); + }; + + try { + return resourceMapper(resource, isPermissibleResource); + } catch (rme) { + if (rme instanceof UnexpectedResourceError) { + console.warn(rme.message, resource); // eslint-disable-line no-console + return false; + } + + throw rme; + } +}; + /** * isLogoutRequest * Return true if the given resource is a logout request; false otherwise. @@ -133,182 +140,178 @@ export const isFolioApiRequest = (resource, oUrl) => { }; /** - * isValidAT - * Return true if tokenExpiration.atExpires is in the future; false otherwise. - * - * @param {object} te tokenExpiration shaped like { atExpires, rtExpires } - * @param {@folio/stripes/logger} logger + * isRotating + * Return true if a rotation-request is pending; false otherwise. + * A pending rotation-request that is older than RTR_MAX_AGE is + * considered stale and ignored. + * @param {*} logger * @returns boolean */ -export const isValidAT = (te, logger) => { - const isValid = !!(te?.atExpires > Date.now()); - logger.log('rtr', `AT isValid? ${isValid}; expires ${new Date(te?.atExpires || null).toISOString()}`); - return isValid; -}; - -/** - * isValidRT - * Return true if tokenExpiration.rtExpires is in the future; false otherwise. - * - * @param {object} te tokenExpiration shaped like { atExpires, rtExpires } - * @param {@folio/stripes/logger} logger - * @returns boolean - */ -export const isValidRT = (te, logger) => { - const isValid = !!(te?.rtExpires > Date.now()); - logger.log('rtr', `RT isValid? ${isValid}; expires ${new Date(te?.rtExpires || null).toISOString()}`); - return isValid; -}; - -/** - * adjustTokenExpiration - * Set the AT and RT token expirations to the fraction of their TTL given by - * RTR_TTL_WINDOW. e.g. if a token should be valid for 100 more seconds and - * RTR_TTL_WINDOW is 0.8, set to the expiration time to 80 seconds from now. - * - * @param {object} value { tokenExpiration: { atExpires, rtExpires }} both are millisecond timestamps - * @param {number} fraction float in the range (0..1] - * @returns { tokenExpiration: { atExpires, rtExpires }} both are millisecond timestamps - */ -export const adjustTokenExpiration = (value, fraction) => ({ - atExpires: Date.now() + ((value.tokenExpiration.atExpires - Date.now()) * fraction), - rtExpires: Date.now() + ((value.tokenExpiration.rtExpires - Date.now()) * fraction), -}); - -/** - * shouldRotate - * Return true if we should start a new rotation request, false if a request is - * already pending. - * - * When RTR begins, the current time in milliseconds (i.e. Date.now()) is - * cached in localStorage and the existence of that value is used as a flag - * in subsequent requests to indicate that they should wait for that request - * rather then firing a new one. If that flag isn't properly cleared when the - * RTR request completes, it will block future RTR requests since it will - * appear that a request is already in-progress. Thus, instead of merely - * checking for the presence of the flag, this function ALSO checks the age - * of that flag. If the flag is older than RTR_MAX_AGE it is considered - * stale, indicating a new request should begin. - * - * @param {@folio/stripes/logger} logger - * @returns boolean - */ -export const shouldRotate = (logger) => { +export const isRotating = () => { const rotationTimestamp = localStorage.getItem(RTR_IS_ROTATING); if (rotationTimestamp) { if (Date.now() - rotationTimestamp < RTR_MAX_AGE) { - return false; + return true; } - logger.log('rtr', 'rotation request is stale'); + console.warn('rotation request is stale'); // eslint-disable-line no-console + localStorage.removeItem(RTR_IS_ROTATING); } - return true; + return false; }; /** * rtr - * exchange an RT for a new one. - * Make a POST request to /authn/refresh, including the current credentials, - * and send a TOKEN_EXPIRATION event to clients that includes the new AT/RT - * expiration timestamps. + * exchange an RT for new tokens; dispatch RTR_ERROR_EVENT on error. * * Since all windows share the same cookie, this means the must also share * rotation, and when rotation starts in one window requests in all others - * must await the same promise. Thus, the isRotating flag is stored in - * localstorage (rather than a local variable) where it is globally accessible, - * rather than in a local variable (which would only be available in the scope - * of a single window). + * must await that promise. Thus, the RTR_IS_ROTATING flag is stored via + * localStorage where it is globally accessible, rather than in a local + * variable (which would only be available in the scope of a single window). * * The basic plot is for this function to return a promise that resolves when * rotation is finished. If rotation hasn't started, that's the rotation * promise itself (with some other business chained on). If rotation has - * started, it's a promise that resolves when it receives a "rotation complete" - * event (part of that other business). + * started, it's a promise that auto-resolves after RTR_MAX_AGEms have elapsed. + * That * * The other business consists of: * 1 unsetting the isRotating flag in localstorage - * 2 capturing the new expiration data, shrinking its TTL window, calling - * setTokenExpiry to push the new values to localstorage, and caching it - * on the calling context. + * 2 capturing the new expiration data and storing it * 3 dispatch RTR_SUCCESS_EVENT * - * @returns Promise - * @throws if RTR fails + * @param {function} fetchfx native fetch function + * @param {@folio/stripes/logger} logger + * @param {function} callback + * @returns void */ -export const rtr = async (context) => { - context.logger.log('rtr', '** RTR ...'); - - let rtrPromise = null; - if (shouldRotate(context.logger)) { - localStorage.setItem(RTR_IS_ROTATING, `${Date.now()}`); - rtrPromise = context.nativeFetch.apply(global, [`${okapi.url}/authn/refresh`, { - headers: { - 'content-type': 'application/json', - 'x-okapi-tenant': okapi.tenant, - }, - method: 'POST', - credentials: 'include', - mode: 'cors', - }]) - .then(res => { - if (res.ok) { - return res.json(); - } - // rtr failure. return an error message if we got one. - return res.json() - .then(json => { - localStorage.removeItem(RTR_IS_ROTATING); - if (Array.isArray(json.errors) && json.errors[0]) { - throw new RTRError(`${json.errors[0].message} (${json.errors[0].code})`); - } else { - throw new RTRError('RTR response failure'); - } +export const rtr = (fetchfx, logger, callback) => { + logger.log('rtr', '** RTR ...'); + + // rotation is already in progress, maybe in this window, + // maybe in another: wait until RTR_MAX_AGE has elapsed, + // which means the RTR request will either be finished or + // stale, then continue + if (isRotating()) { + logger.log('rtr', '** already in progress; exiting'); + return new Promise(res => setTimeout(res, RTR_MAX_AGE)) + .then(() => { + if (!isRotating()) { + logger.log('rtr', '** success after waiting!'); + getTokenExpiry().then((te) => { + callback(te); + window.dispatchEvent(new Event(RTR_SUCCESS_EVENT)); }); - }) - .then(json => { - context.logger.log('rtr', '** success!'); - const te = adjustTokenExpiration({ - tokenExpiration: { - atExpires: new Date(json.accessTokenExpiration).getTime(), - rtExpires: new Date(json.refreshTokenExpiration).getTime(), - } - }, RTR_TTL_WINDOW); - context.tokenExpiration = te; - return setTokenExpiry(te); - }) - .finally(() => { - localStorage.removeItem(RTR_IS_ROTATING); - window.dispatchEvent(new Event(RTR_SUCCESS_EVENT)); - }); - } else { - // isRotating is true, so rotation has already started. - // create a new promise that resolves when it receives - // either an RTR_SUCCESS_EVENT or storage event and - // the isRotating value in storage is false, indicating rotation - // has completed. - // - // the promise itself sets up the listener, and cancels it when - // it resolves. - context.logger.log('rtr', 'rotation is already pending!'); - rtrPromise = new Promise((res) => { - const rotationHandler = () => { - if (localStorage.getItem(RTR_IS_ROTATING) === null) { - window.removeEventListener(RTR_SUCCESS_EVENT, rotationHandler); - window.removeEventListener('storage', rotationHandler); - context.logger.log('rtr', 'token rotation has resolved, continue as usual!'); - res(); } + }); + } + + logger.log('rtr', '** rotation beginning...'); + + localStorage.setItem(RTR_IS_ROTATING, `${Date.now()}`); + return fetchfx.apply(global, [`${okapi.url}/authn/refresh`, { + headers: { + 'content-type': 'application/json', + 'x-okapi-tenant': okapi.tenant, + }, + method: 'POST', + credentials: 'include', + mode: 'cors', + }]) + .then(res => { + if (res.ok) { + return res.json(); + } + // rtr failure. return an error message if we got one. + return res.json() + .then(json => { + localStorage.removeItem(RTR_IS_ROTATING); + if (Array.isArray(json.errors) && json.errors[0]) { + throw new RTRError(`${json.errors[0].message} (${json.errors[0].code})`); + } else { + throw new RTRError('RTR response failure'); + } + }); + }) + .then(json => { + logger.log('rtr', '** success!'); + callback(json); + const te = { + atExpires: new Date(json.accessTokenExpiration).getTime(), + rtExpires: new Date(json.refreshTokenExpiration).getTime(), }; - // same window: listen for custom event - window.addEventListener(RTR_SUCCESS_EVENT, rotationHandler); - - // other windows: listen for storage event - // @see https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event - // "This [is] a way for other pages on the domain using the storage - // to sync any changes that are made." - window.addEventListener('storage', rotationHandler); + setTokenExpiry(te); + window.dispatchEvent(new Event(RTR_SUCCESS_EVENT)); + }) + .catch((err) => { + console.error('RTR_ERROR_EVENT', err); // eslint-disable-line no-console + window.dispatchEvent(new Event(RTR_ERROR_EVENT)); + }) + .finally(() => { + localStorage.removeItem(RTR_IS_ROTATING); }); +}; + + + +/** + * rotationPromise + * Return a promise that will resolve when the active rotation request + * completes and (a) issues a storage event that removes the RTR_IS_ROTATING + * value and (b) issues an RTR_SUCCESS_EVENT. The promise itself sets up the + * listeners for these events, and then removes them when it resolves. + * + * @param {*} logger + * @returns Promise + */ +const rotationPromise = async (logger) => { + logger.log('rtr', 'rotation pending!'); + return new Promise((res) => { + const rotationHandler = () => { + if (localStorage.getItem(RTR_IS_ROTATING) === null) { + window.removeEventListener(RTR_SUCCESS_EVENT, rotationHandler); + window.removeEventListener('storage', rotationHandler); + logger.log('rtr', 'rotation resolved'); + res(); + } + }; + // same window: listen for custom event + window.addEventListener(RTR_SUCCESS_EVENT, rotationHandler); + + // other windows: listen for storage event + // @see https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event + // "This [is] a way for other pages on the domain using the storage + // to sync any changes that are made." + window.addEventListener('storage', rotationHandler); + }); +}; + +export const getPromise = async (logger) => { + return isRotating() ? rotationPromise(logger) : Promise.resolve(); +}; + +/** + * configureRtr + * Provide default values necessary for RTR. They may be overriden by setting + * config.rtr in stripes.config.js. + * + * @param {object} config + */ +export const configureRtr = (config = {}) => { + const conf = { ...config }; + + // how long does an idle session last before being killed? + if (!conf.idleSessionTTL) { + conf.idleSessionTTL = '60m'; } - return rtrPromise; + // how long is the "warning, session is idle!" modal shown + // before the session is killed? + if (!conf.idleModalTTL) { + conf.idleModalTTL = '1m'; + } + + return conf; }; + diff --git a/src/components/Root/token-util.test.js b/src/components/Root/token-util.test.js index 42f3ae2ac..c103df9c1 100644 --- a/src/components/Root/token-util.test.js +++ b/src/components/Root/token-util.test.js @@ -1,16 +1,18 @@ +import { waitFor } from '@folio/jest-config-stripes/testing-library/react'; import { RTRError, UnexpectedResourceError } from './Errors'; import { + configureRtr, + getPromise, + isAuthenticationRequest, isFolioApiRequest, isLogoutRequest, - isValidAT, - isValidRT, + isRotating, resourceMapper, rtr, - shouldRotate, RTR_IS_ROTATING, RTR_MAX_AGE, } from './token-util'; -import { RTR_SUCCESS_EVENT } from './Events'; +import { RTR_SUCCESS_EVENT } from './constants'; describe('isFolioApiRequest', () => { it('accepts requests whose origin matches okapi\'s', () => { @@ -30,62 +32,42 @@ describe('isFolioApiRequest', () => { }); }); -describe('isLogoutRequest', () => { - it('accepts logout endpoints', () => { - const path = '/authn/logout'; - expect(isLogoutRequest(path, '')).toBe(true); +describe('isAuthenticationRequest', () => { + it('accepts authn endpoints', () => { + const path = '/bl-users/_self'; + + expect(isAuthenticationRequest(path, '')).toBe(true); }); it('rejects unknown endpoints', () => { const path = '/maybe/oppie/would/have/been/happier/in/malibu'; - expect(isLogoutRequest(path, '')).toBe(false); + expect(isAuthenticationRequest(path, '')).toBe(false); }); it('rejects invalid input', () => { - const path = { wat: '/maybe/oppie/would/have/been/happier/in/malibu' }; - expect(isLogoutRequest(path, '')).toBe(false); + const path = { wat: '/if/you/need/to/go/to/church/is/that/a/critical/mass' }; + expect(isAuthenticationRequest(path, '')).toBe(false); }); }); -describe('isValidAT', () => { - it('returns true for valid ATs', () => { - const logger = { log: jest.fn() }; - expect(isValidAT({ atExpires: Date.now() + 1000 }, logger)).toBe(true); - expect(logger.log).toHaveBeenCalled(); - }); - - it('returns false for expired ATs', () => { - const logger = { log: jest.fn() }; - expect(isValidAT({ atExpires: Date.now() - 1000 }, logger)).toBe(false); - expect(logger.log).toHaveBeenCalled(); - }); +describe('isLogoutRequest', () => { + it('accepts logout endpoints', () => { + const path = '/authn/logout'; - it('returns false when AT info is missing', () => { - const logger = { log: jest.fn() }; - expect(isValidAT({ monkey: 'bagel' }, logger)).toBe(false); - expect(logger.log).toHaveBeenCalled(); + expect(isLogoutRequest(path, '')).toBe(true); }); -}); -describe('isValidRT', () => { - it('returns true for valid RTs', () => { - const logger = { log: jest.fn() }; - expect(isValidRT({ rtExpires: Date.now() + 1000 }, logger)).toBe(true); - expect(logger.log).toHaveBeenCalled(); - }); + it('rejects unknown endpoints', () => { + const path = '/maybe/oppie/would/have/been/happier/in/malibu'; - it('returns false for expired RTs', () => { - const logger = { log: jest.fn() }; - expect(isValidRT({ rtExpires: Date.now() - 1000 }, logger)).toBe(false); - expect(logger.log).toHaveBeenCalled(); + expect(isLogoutRequest(path, '')).toBe(false); }); - it('returns false when RT info is missing', () => { - const logger = { log: jest.fn() }; - expect(isValidRT({ monkey: 'bagel' }, logger)).toBe(false); - expect(logger.log).toHaveBeenCalled(); + it('rejects invalid input', () => { + const path = { wat: '/maybe/oppie/would/have/been/happier/in/malibu' }; + expect(isLogoutRequest(path, '')).toBe(false); }); }); @@ -118,31 +100,39 @@ describe('resourceMapper', () => { }); describe('rtr', () => { + beforeEach(() => { + localStorage.removeItem(RTR_IS_ROTATING); + }); + + afterEach(() => { + localStorage.removeItem(RTR_IS_ROTATING); + }); + it('rotates', async () => { - const context = { - logger: { - log: jest.fn(), - }, - nativeFetch: { - apply: () => Promise.resolve({ - ok: true, - json: () => Promise.resolve({ - accessTokenExpiration: '2023-11-17T10:39:15.000Z', - refreshTokenExpiration: '2023-11-27T10:39:15.000Z' - }), - }) - } + const logger = { + log: console.log, + }; + const fetchfx = { + apply: () => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + accessTokenExpiration: '2023-11-17T10:39:15.000Z', + refreshTokenExpiration: '2023-11-27T10:39:15.000Z' + }), + }) }; - let res = null; + const callback = jest.fn(); + let ex = null; + // const callback = () => { console.log('HOLA!!!')}; // jest.fn(); try { - res = await rtr(context); + await rtr(fetchfx, logger, callback); + expect(callback).toHaveBeenCalled(); } catch (e) { ex = e; } - expect(res.tokenExpiration).toBeTruthy(); expect(ex).toBe(null); }); @@ -155,19 +145,17 @@ describe('rtr', () => { }); it('same window (RTR_SUCCESS_EVENT)', async () => { - const context = { - logger: { - log: jest.fn(), - }, - nativeFetch: { - apply: () => Promise.resolve({ - ok: true, - json: () => Promise.resolve({ - accessTokenExpiration: '2023-11-17T10:39:15.000Z', - refreshTokenExpiration: '2023-11-27T10:39:15.000Z' - }), - }) - } + const logger = { + log: jest.fn(), + }; + const nativeFetch = { + apply: () => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + accessTokenExpiration: '2023-11-17T10:39:15.000Z', + refreshTokenExpiration: '2023-11-27T10:39:15.000Z' + }), + }) }; setTimeout(() => { @@ -181,7 +169,7 @@ describe('rtr', () => { let ex = null; try { - await rtr(context); + await rtr(nativeFetch, logger, jest.fn()); } catch (e) { ex = e; } @@ -191,19 +179,17 @@ describe('rtr', () => { }); it('multiple window (storage event)', async () => { - const context = { - logger: { - log: jest.fn(), - }, - nativeFetch: { - apply: () => Promise.resolve({ - ok: true, - json: () => Promise.resolve({ - accessTokenExpiration: '2023-11-17T10:39:15.000Z', - refreshTokenExpiration: '2023-11-27T10:39:15.000Z' - }), - }) - } + const logger = { + log: jest.fn(), + }; + const nativeFetch = { + apply: () => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + accessTokenExpiration: '2023-11-17T10:39:15.000Z', + refreshTokenExpiration: '2023-11-27T10:39:15.000Z' + }), + }) }; setTimeout(() => { @@ -217,7 +203,7 @@ describe('rtr', () => { let ex = null; try { - await rtr(context); + await rtr(nativeFetch, logger, jest.fn()); } catch (e) { ex = e; } @@ -227,66 +213,68 @@ describe('rtr', () => { }); }); - - it('on known error, throws error', async () => { + jest.spyOn(window, 'dispatchEvent'); + jest.spyOn(console, 'error'); + const errors = [{ message: 'Actually I love my Birkenstocks', code: 'Chacos are nice, too. Also Tevas' }]; - const context = { - logger: { - log: jest.fn(), - }, - nativeFetch: { - apply: () => Promise.resolve({ - ok: false, - json: () => Promise.resolve({ - errors, - }), - }) - } + const logger = { + log: jest.fn(), + }; + + const nativeFetch = { + apply: () => Promise.resolve({ + ok: false, + json: () => Promise.resolve({ + errors, + }), + }) }; let ex = null; try { - await rtr(context); + await rtr(nativeFetch, logger, jest.fn()); } catch (e) { ex = e; } - expect(ex instanceof RTRError).toBe(true); - expect(ex.message).toMatch(errors[0].message); - expect(ex.message).toMatch(errors[0].code); + expect(console.error).toHaveBeenCalledWith('RTR_ERROR_EVENT', expect.any(RTRError)); + expect(window.dispatchEvent).toHaveBeenCalledWith(expect.any(Event)); + expect(ex).toBe(null); }); it('on unknown error, throws generic error', async () => { + jest.spyOn(window, 'dispatchEvent'); + jest.spyOn(console, 'error'); + const error = 'I love my Birkenstocks. Chacos are nice, too. Also Tevas'; - const context = { - logger: { - log: jest.fn(), - }, - nativeFetch: { - apply: () => Promise.resolve({ - ok: false, - json: () => Promise.resolve({ - error, - }), - }) - } + const logger = { + log: jest.fn(), + }; + const nativeFetch = { + apply: () => Promise.resolve({ + ok: false, + json: () => Promise.resolve({ + error, + }), + }) }; let ex = null; try { - await rtr(context); + await rtr(nativeFetch, logger, jest.fn()); } catch (e) { ex = e; } - expect(ex instanceof RTRError).toBe(true); - expect(ex.message).toMatch('RTR response failure'); + expect(console.error).toHaveBeenCalledWith('RTR_ERROR_EVENT', expect.any(RTRError)); + expect(window.dispatchEvent).toHaveBeenCalledWith(expect.any(Event)); + expect(ex).toBe(null); }); }); -describe('shouldRotate', () => { +describe('isRotating', () => { afterEach(() => { localStorage.removeItem(RTR_IS_ROTATING); }); @@ -295,18 +283,74 @@ describe('shouldRotate', () => { log: jest.fn(), }; - it('returns true if key is absent', () => { - localStorage.removeItem(RTR_IS_ROTATING); - expect(shouldRotate(logger)).toBe(true); + it('returns true if key is present and not stale', () => { + localStorage.setItem(RTR_IS_ROTATING, Date.now()); + expect(isRotating(logger)).toBe(true); }); - it('returns true if key is expired', () => { + it('returns false if key is present but expired', () => { localStorage.setItem(RTR_IS_ROTATING, Date.now() - (RTR_MAX_AGE + 1000)); - expect(shouldRotate(logger)).toBe(true); + expect(isRotating(logger)).toBe(false); }); - it('returns false if key is active', () => { - localStorage.setItem(RTR_IS_ROTATING, Date.now() - 1); - expect(shouldRotate(logger)).toBe(false); + it('returns false if key is absent', () => { + expect(isRotating(logger)).toBe(false); + }); +}); + +describe('getPromise', () => { + describe('when isRotating is true', () => { + beforeEach(() => { + localStorage.setItem(RTR_IS_ROTATING, Date.now()); + }); + afterEach(() => { + localStorage.removeItem(RTR_IS_ROTATING); + }); + + it('waits until localStorage\'s RTR_IS_ROTATING flag is cleared', async () => { + let res = null; + const logger = { log: jest.fn() }; + const p = getPromise(logger); + localStorage.removeItem(RTR_IS_ROTATING); + window.dispatchEvent(new Event(RTR_SUCCESS_EVENT)); + + await p.then(() => { + res = true; + }); + expect(res).toBeTruthy(); + }); + }); + + describe('when isRotating is false', () => { + beforeEach(() => { + localStorage.removeItem(RTR_IS_ROTATING); + }); + + it('receives a resolved promise', async () => { + let res = null; + await getPromise() + .then(() => { + res = true; + }); + expect(res).toBeTruthy(); + }); + }); +}); + +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'); }); }); diff --git a/src/components/SessionEventContainer/KeepWorkingModal.js b/src/components/SessionEventContainer/KeepWorkingModal.js new file mode 100644 index 000000000..d8d1a88d6 --- /dev/null +++ b/src/components/SessionEventContainer/KeepWorkingModal.js @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import ms from 'ms'; + +import { + Button, + Modal +} from '@folio/stripes-components'; + +import { useStripes } from '../../StripesContext'; + +/** + * KeepWorkingModal + * Show a modal with a countdown timer representing the number of seconds + * remaining until the session will expire due to inactivity. + * + * @param {function} callback function to call when clicking "Keep working" button + */ +const KeepWorkingModal = ({ callback }) => { + const stripes = useStripes(); + const [remainingMillis, setRemainingMillis] = useState(ms(stripes.config.rtr.idleModalTTL)); + + // configure an interval timer that sets state each second, + // counting down to 0. + useEffect(() => { + const interval = setInterval(() => { + setRemainingMillis(i => i - 1000); + }, 1000); + + // cleanup: clear the timer + return () => { + clearInterval(interval); + }; + }, []); + + /** + * timestampFormatter + * convert time-remaining to mm:ss. Given the remaining time can easily be + * represented as elapsed-time since the JSDate epoch, convert to a + * Date object, format it, and extract the minutes and seconds. + * That is, given we have 99 seconds left, that converts to a Date + * like `1970-01-01T00:01:39.000Z`; extract the `01:39`. + */ + const timestampFormatter = () => { + if (remainingMillis >= 1000) { + return new Date(remainingMillis).toISOString().substring(14, 19); + } + + return '00:00'; + }; + + return ( + } + open + onClose={callback} + footer={ + + } + > +
+ : {timestampFormatter()} +
+
+ ); +}; + +KeepWorkingModal.propTypes = { + callback: PropTypes.func, +}; + +export default KeepWorkingModal; diff --git a/src/components/SessionEventContainer/KeepWorkingModal.test.js b/src/components/SessionEventContainer/KeepWorkingModal.test.js new file mode 100644 index 000000000..47d39b01a --- /dev/null +++ b/src/components/SessionEventContainer/KeepWorkingModal.test.js @@ -0,0 +1,67 @@ +import { render, screen, waitFor } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; + +import Harness from '../../../test/jest/helpers/harness'; +import KeepWorkingModal from './KeepWorkingModal'; + +jest.mock('../Root/token-util'); + +const stripes = { + config: { + rtr: { + idleModalTTL: '99s' + } + } +}; + +describe('KeepWorkingModal', () => { + it('renders a modal with seconds remaining', async () => { + render(); + screen.getByText(/stripes-core.rtr.idleSession.timeRemaining/); + screen.getByText(/01:39/); + }); + + it('renders 0:00 when time expires', async () => { + const zeroSecondsStripes = { + config: { + rtr: { + idleModalTTL: '0s' + } + } + }; + + render(); + screen.getByText(/stripes-core.rtr.idleSession.timeRemaining/); + screen.getByText(/0:00/); + }); + + it('calls the callback', async () => { + const callback = jest.fn(); + render(); + await userEvent.click(screen.getByRole('button')); + expect(callback).toHaveBeenCalled(); + }); + + // I've never had great luck with jest's fake timers, https://jestjs.io/docs/timer-mocks + // The modal counts down one second at a time so this test just waits for + // two seconds. Great? Nope. Good enough? Sure is. + describe('uses timers', () => { + it('like sand through an hourglass, so are the elapsed seconds of this modal', async () => { + jest.spyOn(global, 'setInterval'); + const zeroSecondsStripes = { + config: { + rtr: { + idleModalTTL: '10s' + } + } + }; + + render(); + + expect(setInterval).toHaveBeenCalledTimes(1); + expect(setInterval).toHaveBeenLastCalledWith(expect.any(Function), 1000); + + await waitFor(() => screen.getByText(/00:09/), { timeout: 2000 }); + }); + }); +}); diff --git a/src/components/SessionEventContainer/SessionEventContainer.js b/src/components/SessionEventContainer/SessionEventContainer.js new file mode 100644 index 000000000..fa65bdcf6 --- /dev/null +++ b/src/components/SessionEventContainer/SessionEventContainer.js @@ -0,0 +1,266 @@ +import { useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import createInactivityTimer from 'inactivity-timer'; +import ms from 'ms'; + +import { logout, SESSION_NAME } from '../../loginServices'; +import KeepWorkingModal from './KeepWorkingModal'; +import { useStripes } from '../../StripesContext'; +import { + RTR_ACTIVITY_CHANNEL, + RTR_ACTIVITY_EVENTS, + RTR_ERROR_EVENT, + RTR_TIMEOUT_EVENT +} from '../Root/constants'; +import { toggleRtrModal } from '../../okapiActions'; + +// +// event listeners +// exported only to expose them for tests +// + +// RTR error in this window: logout +export const thisWindowRtrError = (_e, stripes, history) => { + console.warn('rtr error; logging out', history); // eslint-disable-line no-console + return logout(stripes.okapi.url, stripes.store) + .then(() => { + history.push('/logout-timeout'); + }); +}; + +// idle session timeout in this window: logout +export const thisWindowRtrTimeout = (_e, stripes, history) => { + stripes.logger.log('rtr', 'idle session timeout; logging out'); + return logout(stripes.okapi.url, stripes.store) + .then(() => { + history.push('/logout-timeout'); + }); +}; + +// localstorage change in another window: logout? +// logout if it was a timeout event or if SESSION_NAME is being +// removed from localStorage, an indicator that logout is in-progress +// in another window and so must occur here as well +export const otherWindowStorage = (e, stripes, history) => { + if (e.key === RTR_TIMEOUT_EVENT) { + stripes.logger.log('rtr', 'idle session timeout; logging out'); + return logout(stripes.okapi.url, stripes.store) + .then(() => { + history.push('/logout-timeout'); + }); + } else if (!localStorage.getItem(SESSION_NAME)) { + stripes.logger.log('rtr', 'external localstorage change; logging out'); + return logout(stripes.okapi.url, stripes.store) + .then(() => { + history.push('/'); + }); + } + return Promise.resolve(); +}; + +// activity in another window: send keep-alive to idle-timers. +// +// when multiple tabs/windows are open, there is probably only activity +// in one but they will each have an idle-session timer running. thus, +// activity in each window is published on a BroadcastChannel to announce +// it to all windows in order to send a keep-alive ping to their timers. +export const otherWindowActivity = (_m, stripes, timers, setIsVisible) => { + stripes.logger.log('rtrv', 'external activity signal'); + if (timers.current) { + Object.values(timers.current).forEach((it) => { + it.signal(); + }); + } + + // leverage state.okapi.rtrModalIsVisible, rather than isVisible. + // due to early binding, the value of isVisible is locked-in when + // this function is created. + if (stripes.store.getState().okapi.rtrModalIsVisible) { + setIsVisible(false); + stripes.store.dispatch(toggleRtrModal(false)); + } +}; + +// activity in this window: ping idle-timers and BroadcastChannel +// if the "Keep working?" modal is visible, however, ignore all activity; +// then that is showing, only clicking its "confirm" button should +// constitute activity. +export const thisWindowActivity = (_e, stripes, timers, broadcastChannel) => { + const state = stripes.store.getState(); + // leverage state.okapi.rtrModalIsVisible, rather than isVisible. + // due to early binding, the value of isVisible is locked-in when + // this function is created. + if (!state.okapi.rtrModalIsVisible) { + stripes.logger.log('rtrv', 'local activity signal'); + if (timers.current) { + broadcastChannel.postMessage('signal'); + Object.values(timers.current).forEach((it) => { + it.signal(); + }); + } + } +}; + + +/** + * SessionEventContainer + * This component component performs several jobs: + * 1. it configures inactivity timers that fire after some period of + * inactivity, whether in this window or any other. + * 2. it renders a "Keep working?" modal if the inactivity-timer fires. + * 3. it configures activity listeners that (a) listen for activity in this + * window and reflect it to a BroadcastChannel to keep sessions alive in + * other windows, and (b) listen for activity on a BroadcastChannel to + * keep this window's session alive. + * + * By default, a session will be terminated after 60 minutes without activity. + * By default, the "keep working?" modal will be visible for 1 minute, i.e. + * after 59 minutes of inactivity. These values may be overridden in + * stripes.config.js in the `config.rtr` object by the values `idleSessionTTL` + * and `idleModalTTL`, respectively; the values must be strings parsable by ms. + * + * @param {object} history + * @returns KeepWorkingModal or null + */ +const SessionEventContainer = ({ history }) => { + // is the "keep working?" modal visible? + const [isVisible, setIsVisible] = useState(false); + + // inactivity timers + const timers = useRef(); + const stripes = useStripes(); + + /** + * keepWorkingCallback + * handler for the "keep working" button in KeepWorkingModal + * 1. hide the modal + * 2. dispatch toggleRtrModal(false), reanimating listeners + * 3. dispatch an event, which listeners will observe, triggering timers. + * listeners are put on hold while the modal is visible, so it's + * important to dispatch toggleRtrModal() before emitting the event. + */ + const keepWorkingCallback = () => { + setIsVisible(false); + stripes.store.dispatch(toggleRtrModal(false)); + window.dispatchEvent(new Event(stripes.config.rtr.activityEvents[0])); + }; + + /** + * 1. configure the idle-activity timers and attach them to a ref. + * 2. configure event listeners for RTR activity. + */ + useEffect(() => { + // track activity in other windows + // the only events emitted to this channel are pings, empty keep-alive + // messages that indicate an event was processed, i.e. some activity + // occurred, in another window, so this one should be kept alive too. + // this means we just have to listen for "message received", we don't + // actually have to parse the message. + // + // Logout events also leverage localStorage which, like BroadcastChannel, + // emits events across tabs and windows. That would require actually + // parsing the messages, contrary to the comment above, but would have + // the benefit of consolidating listeners a little bit. + const bc = new BroadcastChannel(RTR_ACTIVITY_CHANNEL); + + // mapping of channelName => eventName => handler + // channels: + // window: + // event-a: foo() + // event-b: bar() + // bc: + // event-c: bat() + const channels = { window: {}, bc: {} }; + + // mapping of channelName => channel + // i.e. same keys as channels, but the value is the identically named object + const channelListeners = { window, bc }; + + if (stripes.config.useSecureTokens) { + const { idleModalTTL, idleSessionTTL } = stripes.config.rtr; + + // inactive timer: show the "keep working?" modal + const showModalIT = createInactivityTimer(ms(idleSessionTTL) - ms(idleModalTTL), () => { + stripes.logger.log('rtr', 'session idle; showing modal'); + stripes.store.dispatch(toggleRtrModal(true)); + setIsVisible(true); + }); + showModalIT.signal(); + + // inactive timer: logout + const logoutIT = createInactivityTimer(idleSessionTTL, () => { + stripes.logger.log('rtr', 'session idle; dispatching RTR_TIMEOUT_EVENT'); + // set a localstorage key so other windows know it was a timeout + localStorage.setItem(RTR_TIMEOUT_EVENT, 'true'); + + // dispatch a timeout event for handling in this window + window.dispatchEvent(new Event(RTR_TIMEOUT_EVENT)); + }); + logoutIT.signal(); + + timers.current = { showModalIT, logoutIT }; + + // RTR error in this window: logout + channels.window[RTR_ERROR_EVENT] = (e) => thisWindowRtrError(e, stripes, history); + + // idle session timeout in this window: logout + channels.window[RTR_TIMEOUT_EVENT] = (e) => thisWindowRtrTimeout(e, stripes, history); + + // localstorage change in another window: logout? + channels.window.storage = (e) => otherWindowStorage(e, stripes, history); + + // activity in another window: send keep-alive to idle-timers. + channels.bc.message = (message) => otherWindowActivity(message, stripes, timers, setIsVisible); + + // activity in this window: ping idle-timers and BroadcastChannel + const activityEvents = stripes.config.rtr?.activityEvents ?? RTR_ACTIVITY_EVENTS; + activityEvents.forEach(eventName => { + channels.window[eventName] = (e) => thisWindowActivity(e, stripes, timers, bc); + }); + + // add listeners + Object.entries(channels).forEach(([k, channel]) => { + Object.entries(channel).forEach(([e, h]) => { + stripes.logger.log('rtrv', `adding listener ${k}.${e}`); + channelListeners[k].addEventListener(e, h); + }); + }); + } + + // cleanup: clear timers and event listeners + return () => { + if (timers.current) { + Object.values(timers.current).forEach((it) => { + it.clear(); + }); + } + Object.entries(channels).forEach(([k, channel]) => { + Object.entries(channel).forEach(([e, h]) => { + stripes.logger.log('rtrv', `removing listener ${k}.${e}`); + channelListeners[k].removeEventListener(e, h); + }); + }); + + bc.close(); + }; + + // 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 + + // show the idle-session warning modal if necessary; + // otherwise return null + if (isVisible) { + return ; + } + + return null; +}; + +SessionEventContainer.propTypes = { + history: PropTypes.object, +}; + +export default SessionEventContainer; diff --git a/src/components/SessionEventContainer/SessionEventContainer.test.js b/src/components/SessionEventContainer/SessionEventContainer.test.js new file mode 100644 index 000000000..269a2f98f --- /dev/null +++ b/src/components/SessionEventContainer/SessionEventContainer.test.js @@ -0,0 +1,239 @@ +import { render, screen, waitFor } from '@folio/jest-config-stripes/testing-library/react'; +import ms from 'ms'; + +import Harness from '../../../test/jest/helpers/harness'; +import SessionEventContainer, { + otherWindowActivity, + otherWindowStorage, + thisWindowActivity, + thisWindowRtrError, + thisWindowRtrTimeout, +} from './SessionEventContainer'; +import { logout, SESSION_NAME } from '../../loginServices'; +import { RTR_TIMEOUT_EVENT } from '../Root/constants'; + +import { toggleRtrModal } from '../../okapiActions'; + +jest.mock('./KeepWorkingModal', () => (() =>
KeepWorkingModal
)); +jest.mock('../../loginServices'); + +const stripes = { + config: { + useSecureTokens: true, + rtr: { + idleModalTTL: '3s', + idleSessionTTL: '3s', + } + }, + okapi: { + isAuthenticated: true, + }, + logger: { log: jest.fn() }, + store: { dispatch: jest.fn() }, +}; + +describe('SessionEventContainer', () => { + it('Renders nothing if useSecureTokens is false', async () => { + const inSecureStripes = { + config: { + useSecureTokens: false, + }, + }; + render(); + + expect(screen.queryByText('KeepWorkingModal')).toBe(null); + }); + + it('Shows a modal when idle timer expires', async () => { + render(); + + await waitFor(() => { + screen.getByText('KeepWorkingModal', { timeout: ms(stripes.config.rtr.idleModalTTL) }); + }); + + // expect(stripes.store.dispatch).toHaveBeenCalledWith(expect.any(String)); + }); + + it('Dispatches logout when modal timer expires', async () => { + const dispatchEvent = jest.spyOn(window, 'dispatchEvent').mockImplementation(() => { }); + render(); + + await waitFor(() => { + expect(dispatchEvent).toHaveBeenCalled(); + }, { timeout: 5000 }); + }); +}); + + +describe('SessionEventContainer event listeners', () => { + it('thisWindowRtrError', async () => { + const history = { push: jest.fn() }; + const logoutMock = logout; + logoutMock.mockReturnValue(Promise.resolve()); + + await thisWindowRtrError(null, { okapi: { url: 'http' } }, history); + expect(logout).toHaveBeenCalled(); + expect(history.push).toHaveBeenCalledWith('/logout-timeout'); + }); + + it('thisWindowRtrTimeout', async () => { + const s = { + okapi: { + url: 'http' + }, + store: {}, + logger: { + log: jest.fn(), + } + }; + + const history = { push: jest.fn() }; + const logoutMock = logout; + await logoutMock.mockReturnValue(Promise.resolve()); + + await thisWindowRtrTimeout(null, s, history); + expect(logout).toHaveBeenCalled(); + expect(history.push).toHaveBeenCalledWith('/logout-timeout'); + }); + + describe('otherWindowStorage', () => { + beforeEach(() => { + localStorage.removeItem(SESSION_NAME); + }); + + it('timeout', async () => { + const e = { key: RTR_TIMEOUT_EVENT }; + const s = { + okapi: { + url: 'http' + }, + store: {}, + logger: { + log: jest.fn(), + } + }; + const history = { push: jest.fn() }; + + await otherWindowStorage(e, s, history); + expect(logout).toHaveBeenCalledWith(s.okapi.url, s.store); + expect(history.push).toHaveBeenCalledWith('/logout-timeout'); + }); + + it('logout', async () => { + const e = { key: '' }; + const s = { + okapi: { + url: 'http' + }, + store: {}, + logger: { + log: jest.fn(), + } + }; + const history = { push: jest.fn() }; + + await otherWindowStorage(e, s, history); + expect(logout).toHaveBeenCalledWith(s.okapi.url, s.store); + expect(history.push).toHaveBeenCalledWith('/'); + }); + }); + + it('otherWindowActivity', () => { + const m = { key: '' }; + const okapi = { + url: 'http', + rtrModalIsVisible: true, + }; + const s = { + okapi, + store: { + dispatch: jest.fn(), + getState: () => ({ okapi }), + }, + logger: { + log: jest.fn(), + } + }; + const signal = jest.fn(); + const timers = { + current: { + timer: { signal }, + } + }; + const setIsVisible = jest.fn(); + + otherWindowActivity(m, s, timers, setIsVisible); + + expect(signal).toHaveBeenCalled(); + expect(setIsVisible).toHaveBeenCalledWith(false); + expect(s.store.dispatch).toHaveBeenCalledWith(expect.objectContaining(toggleRtrModal(false))); + }); + + describe('thisWindowActivity', () => { + it('pings when modal is hidden', () => { + const e = { key: '' }; + const okapi = { + url: 'http', + rtrModalIsVisible: false, + }; + const s = { + okapi, + store: { + dispatch: jest.fn(), + getState: () => ({ okapi }), + }, + logger: { + log: jest.fn(), + } + }; + const signal = jest.fn(); + const timers = { + current: { + timer: { signal }, + } + }; + const postMessage = jest.fn(); + const broadcastChannel = { + postMessage, + }; + + thisWindowActivity(e, s, timers, broadcastChannel); + + expect(signal).toHaveBeenCalled(); + expect(postMessage).toHaveBeenCalled(); + }); + + it('does not ping when modal is visible', () => { + const e = { key: '' }; + const okapi = { + url: 'http', + rtrModalIsVisible: true, + }; + const s = { + okapi, + store: { + dispatch: jest.fn(), + getState: () => ({ okapi }), + }, + logger: { + log: jest.fn(), + } + }; + const signal = jest.fn(); + const timers = { + current: { + timer: { signal }, + } + }; + const postMessage = jest.fn(); + const broadcastChannel = { + postMessage, + }; + + thisWindowActivity(e, s, timers, broadcastChannel); + + expect(signal).not.toHaveBeenCalled(); + expect(postMessage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/SessionEventContainer/index.js b/src/components/SessionEventContainer/index.js new file mode 100644 index 000000000..46995e871 --- /dev/null +++ b/src/components/SessionEventContainer/index.js @@ -0,0 +1 @@ +export { default } from './SessionEventContainer'; diff --git a/src/components/index.js b/src/components/index.js index 31ca9e21c..980f697bd 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -5,6 +5,8 @@ export { default as HandlerManager } from './HandlerManager'; export { default as AppCtxMenuProvider } from './MainNav/CurrentApp/AppCtxMenuProvider'; export { LastVisitedContext, withLastVisited } from './LastVisited'; export { default as Login } from './Login'; +export { default as Logout } from './Logout'; +export { default as LogoutTimeout } from './LogoutTimeout'; export { default as MainContainer } from './MainContainer'; export { default as MainNav } from './MainNav'; export { default as ModuleContainer } from './ModuleContainer'; @@ -30,5 +32,7 @@ export { default as CheckEmailStatusPage } from './CheckEmailStatusPage'; export { default as BadRequestScreen } from './BadRequestScreen'; export { default as NoPermissionScreen } from './NoPermissionScreen'; export { default as ResetPasswordNotAvailableScreen } from './ResetPasswordNotAvailableScreen'; +export { default as SessionEventContainer } from './SessionEventContainer'; + export * from './ModuleHierarchy'; export * from './Namespace'; diff --git a/src/loginServices.js b/src/loginServices.js index 4f683e846..58774458c 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -26,9 +26,10 @@ import { updateCurrentUser, } from './okapiActions'; import processBadResponse from './processBadResponse'; -import configureLogger from './configureLogger'; -import { RTR_ERROR_EVENT } from './components/Root/Events'; +import { + RTR_TIMEOUT_EVENT +} from './components/Root/constants'; // export supported locales, i.e. the languages we provide translations for export const supportedLocales = [ @@ -69,7 +70,7 @@ export const supportedNumberingSystems = [ ]; /** name for the session key in local storage */ -const SESSION_NAME = 'okapiSess'; +export const SESSION_NAME = 'okapiSess'; /** * getTokenSess @@ -105,7 +106,8 @@ export const getTokenExpiry = async () => { */ export const setTokenExpiry = async (te) => { const sess = await getOkapiSession(); - return localforage.setItem(SESSION_NAME, { ...sess, tokenExpiration: te }); + const val = { ...sess, tokenExpiration: te }; + return localforage.setItem(SESSION_NAME, val); }; /** @@ -126,8 +128,6 @@ export const userLocaleConfig = { 'module': '@folio/stripes-core', }; -const logger = configureLogger(config); - function getHeaders(tenant, token) { return { 'X-Okapi-Tenant': tenant, @@ -148,7 +148,7 @@ function getHeaders(tenant, token) { */ function canReadConfig(store) { const perms = store.getState().okapi.currentPerms; - return perms['configuration.entries.collection.get']; + return perms?.['configuration.entries.collection.get']; } /** @@ -433,34 +433,77 @@ export function spreadUserWithPerms(userWithPerms) { /** * logout - * dispatch events to clear the store, then clear the session, - * clear localStorage, and call `/authn/logout` to end the session - * on the server too. + * logout is a multi-part process, but this function is idempotent. + * 1. there are server-side things to do, i.e. fetch /authn/logout. + * these must only be done once, no matter how many tabs are open + * because once the fetch completes the cookies are gone, which + * means a repeat request will fail. + * 2. there is shared storage to clean out, i.e. storage that is shared + * across tabs such as localStorage and localforage. clearing storage + * that another tab has already cleared is fine, if pointless. + * 3. there is private storage to clean out, i.e. storage that is unique + * to the current tab/window. this storage _must_ be cleared in each + * instance of stripes (i.e. in each separate tab/window) because the + * instances running in other tabs do not have access to it. + * What does all this mean? It means some things we need to check on and + * maybe do (the server-side things), some we can do (the shared storage) + * and some we must do (the private storage). * + * @param {string} okapiUrl * @param {object} redux store * * @returns {Promise} */ +export const IS_LOGGING_OUT = '@folio/stripes/core::Logout'; export async function logout(okapiUrl, store) { - // tenant is necessary to populate the X-Okapi-Tenant header - // which is required in ECS environments - const { okapi: { tenant } } = store.getState(); - store.dispatch(setIsAuthenticated(false)); - store.dispatch(clearCurrentUser()); - store.dispatch(clearOkapiToken()); - store.dispatch(resetStore()); - return fetch(`${okapiUrl}/authn/logout`, { - method: 'POST', - mode: 'cors', - credentials: 'include', - headers: { 'X-Okapi-Tenant': tenant, 'Accept': 'application/json' }, - }) - .then(localStorage.removeItem('tenant')) + // check the private-storage sentinel: if logout has already started + // in this window, we don't want to start it again. + if (sessionStorage.getItem(IS_LOGGING_OUT)) { + return Promise.resolve(); + } + + // check the shared-storage sentinel: if logout has already started + // in another window, we don't want to invoke shared functions again + // (like calling /authn/logout, which can only be called once) + // BUT we DO want to clear private storage such as session storage + // and redux, which are not shared across tabs/windows. + const logoutPromise = localStorage.getItem(SESSION_NAME) ? + fetch(`${okapiUrl}/authn/logout`, { + method: 'POST', + mode: 'cors', + credentials: 'include' + }) + : + Promise.resolve(); + return logoutPromise + // clear private-storage + .then(() => { + // set the private-storage sentinel to indicate logout is in-progress + sessionStorage.setItem(IS_LOGGING_OUT, 'true'); + + // localStorage events emit across tabs so we can use it like a + // BroadcastChannel to communicate with all tabs/windows + localStorage.removeItem(SESSION_NAME); + localStorage.removeItem(RTR_TIMEOUT_EVENT); + + store.dispatch(setIsAuthenticated(false)); + store.dispatch(clearCurrentUser()); + store.dispatch(clearOkapiToken()); + store.dispatch(resetStore()); + }) + // clear shared storage .then(localforage.removeItem(SESSION_NAME)) .then(localforage.removeItem('loginResponse')) - .catch((error) => { - // eslint-disable-next-line no-console - console.log(`Error logging out: ${JSON.stringify(error)}`); + .catch(e => { + console.error('error during logout', e); // eslint-disable-line no-console + }) + .finally(() => { + // clear the console unless config asks to preserve it + if (!config.preserveConsole) { + console.clear(); // eslint-disable-line no-console + } + // clear the storage sentinel + sessionStorage.removeItem(IS_LOGGING_OUT); }); } @@ -517,7 +560,12 @@ export function createOkapiSession(store, tenant, token, data) { tokenExpiration, }; - // provide token-expiration info to the service worker + // localStorage events emit across tabs so we can use it like a + // BroadcastChannel to communicate with all tabs/windows. + // here, we set a dummy 'true' value just so we have something to + // remove (and therefore emit and respond to) on logout + localStorage.setItem(SESSION_NAME, 'true'); + return localforage.setItem('loginResponse', data) .then(() => localforage.setItem(SESSION_NAME, okapiSess)) .then(() => { @@ -527,41 +575,6 @@ export function createOkapiSession(store, tenant, token, data) { }); } -/** - * handleRtrError - * Clear out the redux store and logout. - * - * @param {*} event - * @param {*} store - * @returns void - */ -export const handleRtrError = (event, store) => { - logger.log('rtr', 'rtr error; logging out', event.detail); - store.dispatch(setIsAuthenticated(false)); - store.dispatch(clearCurrentUser()); - store.dispatch(resetStore()); - localforage.removeItem(SESSION_NAME) - .then(localforage.removeItem('loginResponse')); -}; - -/** - * addRtrEventListeners - * RTR_ERROR_EVENT: RTR error, logout - * RTR_ROTATION_EVENT: configure a timer for auto-logout - * - * @param {*} okapiConfig - * @param {*} store - */ -export function addRtrEventListeners(okapiConfig, store) { - document.addEventListener(RTR_ERROR_EVENT, (e) => { - handleRtrError(e, store); - }); - - // document.addEventListener(RTR_ROTATION_EVENT, (e) => { - // handleRtrRotation(e, store); - // }); -} - /** * getSSOEnabled * return a promise that fetches from /saml/check and dispatches checkSSO. @@ -687,7 +700,7 @@ export function processOkapiSession(store, tenant, resp, ssoToken) { /** * validateUser - * return a promise that fetches from .../_self. + * return a promise that fetches from bl-users/_self. * if successful, dispatch the result to create a session * if not, clear the session and token. * @@ -725,16 +738,20 @@ export function validateUser(okapiUrl, store, tenant, session) { store.dispatch(setSessionData({ isAuthenticated: true, - user, + user: data.user, perms, tenant: sessionTenant, token, tokenExpiration, })); - return loadResources(store, sessionTenant, user.id); + + return loadResources(okapiUrl, store, sessionTenant, user.id); }); } else { - return logout(okapiUrl, store); + store.dispatch(clearCurrentUser()); + return resp.text((text) => { + throw text; + }); } }).catch((error) => { console.error(error); // eslint-disable-line no-console @@ -906,6 +923,5 @@ export async function updateTenant(okapi, tenant) { const okapiSess = await getOkapiSession(); const userWithPermsResponse = await fetchUserWithPerms(okapi.url, tenant, okapi.token); const userWithPerms = await userWithPermsResponse.json(); - await localforage.setItem(SESSION_NAME, { ...okapiSess, tenant, ...spreadUserWithPerms(userWithPerms) }); } diff --git a/src/loginServices.test.js b/src/loginServices.test.js index ca706d705..21f2c75e5 100644 --- a/src/loginServices.test.js +++ b/src/loginServices.test.js @@ -7,6 +7,7 @@ import { getTokenExpiry, handleLoginError, loadTranslations, + logout, processOkapiSession, setTokenExpiry, spreadUserWithPerms, @@ -15,10 +16,13 @@ import { updateTenant, updateUser, validateUser, + IS_LOGGING_OUT, + SESSION_NAME } from './loginServices'; import { clearCurrentUser, + clearOkapiToken, setCurrentPerms, setLocale, // setTimezone, @@ -350,6 +354,7 @@ describe('validateUser', () => { await validateUser('url', store, 'tenant', {}); expect(store.dispatch).toHaveBeenCalledWith(clearCurrentUser()); + expect(store.dispatch).toHaveBeenCalledWith(setServerDown()); mockFetchCleanUp(); }); }); @@ -442,3 +447,85 @@ describe('localforage session wrapper', () => { expect(s).toMatchObject({ ...o, tokenExpiration: te }); }); }); + +describe('logout', () => { + describe('when logout has started in this window', () => { + it('returns immediately', async () => { + const store = { + dispatch: jest.fn(), + }; + window.sessionStorage.clear(); + window.sessionStorage.setItem(IS_LOGGING_OUT, 'true'); + + let res; + await logout('', store) + .then(() => { + res = true; + }); + expect(res).toBe(true); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + + describe('when logout has not started in this window', () => { + afterEach(() => { + mockFetchCleanUp(); + }); + + it('clears the redux store', async () => { + global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); + const store = { + dispatch: jest.fn(), + }; + window.sessionStorage.clear(); + + let res; + await logout('', store) + .then(() => { + res = true; + }); + expect(res).toBe(true); + + // expect(setItemSpy).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(setIsAuthenticated(false)); + expect(store.dispatch).toHaveBeenCalledWith(clearCurrentUser()); + expect(store.dispatch).toHaveBeenCalledWith(clearOkapiToken()); + }); + + it('calls fetch() when other window is not logging out', async () => { + global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); + localStorage.setItem(SESSION_NAME, 'true'); + const store = { + dispatch: jest.fn(), + }; + window.sessionStorage.clear(); + + let res; + await logout('', store) + .then(() => { + res = true; + }); + + expect(res).toBe(true); + expect(global.fetch).toHaveBeenCalled(); + }); + + it('does not call fetch() when other window is logging out', async () => { + global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); + localStorage.clear(); + const store = { + dispatch: jest.fn(), + }; + window.sessionStorage.clear(); + + let res; + await logout('', store) + .then(() => { + res = true; + }); + + expect(res).toBe(true); + expect(global.fetch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/okapiActions.js b/src/okapiActions.js index f6aa021fc..b6ff9554f 100644 --- a/src/okapiActions.js +++ b/src/okapiActions.js @@ -149,10 +149,31 @@ function setTokenExpiration(tokenExpiration) { }; } +function setRtrTimeout(rtrTimeout) { + return { + type: 'SET_RTR_TIMEOUT', + rtrTimeout, + }; +} + +function clearRtrTimeout() { + return { + type: 'CLEAR_RTR_TIMEOUT', + }; +} + +function toggleRtrModal(isVisible) { + return { + type: 'TOGGLE_RTR_MODAL', + isVisible, + }; +} + export { checkSSO, clearCurrentUser, clearOkapiToken, + clearRtrTimeout, setAuthError, setBindings, setCurrency, @@ -164,12 +185,14 @@ export { setOkapiReady, setOkapiToken, setPlugins, + setRtrTimeout, setServerDown, setSessionData, setSinglePlugin, setTimezone, setTokenExpiration, setTranslations, + toggleRtrModal, updateCurrentUser, setOkapiTenant }; diff --git a/src/okapiReducer.js b/src/okapiReducer.js index 67ef13e4d..6c4f1f475 100644 --- a/src/okapiReducer.js +++ b/src/okapiReducer.js @@ -10,8 +10,20 @@ export default function okapiReducer(state = {}, action) { return Object.assign({}, state, { token: null }); case 'SET_CURRENT_USER': return Object.assign({}, state, { currentUser: action.currentUser }); - case 'SET_IS_AUTHENTICATED': - return Object.assign({}, state, { isAuthenticated: action.isAuthenticated }); + case 'SET_IS_AUTHENTICATED': { + const newState = { + isAuthenticated: action.isAuthenticated, + }; + // if we're logging out, clear the RTR timeout + // and other rtr-related values + if (!action.isAuthenticated) { + clearTimeout(state.rtrTimeout); + newState.rtrModalIsVisible = false; + newState.rtrTimeout = undefined; + } + + return { ...state, ...newState }; + } case 'SET_LOCALE': return Object.assign({}, state, { locale: action.locale }); case 'SET_TIMEZONE': @@ -50,6 +62,20 @@ export default function okapiReducer(state = {}, action) { return Object.assign({}, state, { serverDown: true }); case 'UPDATE_CURRENT_USER': return { ...state, currentUser: { ...state.currentUser, ...action.data } }; + + // clear existing timeout and set a new one + case 'SET_RTR_TIMEOUT': { + clearTimeout(state.rtrTimeout); + return { ...state, rtrTimeout: action.rtrTimeout }; + } + case 'CLEAR_RTR_TIMEOUT': { + clearTimeout(state.rtrTimeout); + return { ...state, rtrTimeout: undefined }; + } + case 'TOGGLE_RTR_MODAL': { + return { ...state, rtrModalIsVisible: action.isVisible }; + } + default: return state; } diff --git a/src/okapiReducer.test.js b/src/okapiReducer.test.js index de9cd2827..d1c5ffe6d 100644 --- a/src/okapiReducer.test.js +++ b/src/okapiReducer.test.js @@ -1,10 +1,25 @@ import okapiReducer from './okapiReducer'; describe('okapiReducer', () => { - it('SET_IS_AUTHENTICATED', () => { - const isAuthenticated = true; - const o = okapiReducer({}, { type: 'SET_IS_AUTHENTICATED', isAuthenticated: true }); - expect(o).toMatchObject({ isAuthenticated }); + describe('SET_IS_AUTHENTICATED', () => { + it('sets isAuthenticated to true', () => { + const isAuthenticated = true; + const o = okapiReducer({}, { type: 'SET_IS_AUTHENTICATED', isAuthenticated: true }); + expect(o).toMatchObject({ isAuthenticated }); + }); + + it('if isAuthenticated is false, clears rtr state', () => { + const state = { + rtrModalIsVisible: true, + rtrTimeout: 123, + }; + const ct = jest.spyOn(window, 'clearTimeout') + const o = okapiReducer(state, { type: 'SET_IS_AUTHENTICATED', isAuthenticated: false }); + expect(o.isAuthenticated).toBe(false); + expect(o.rtrModalIsVisible).toBe(false); + expect(o.rtrTimeout).toBe(undefined); + expect(ct).toHaveBeenCalled(); + }); }); it('SET_LOGIN_DATA', () => { @@ -45,4 +60,37 @@ describe('okapiReducer', () => { currentPerms: perms, }); }); + + it('SET_RTR_TIMEOUT', () => { + const ct = jest.spyOn(window, 'clearTimeout'); + + const state = { + rtrTimeout: 991, + }; + + const newState = { rtrTimeout: 997 }; + + const o = okapiReducer(state, { type: 'SET_RTR_TIMEOUT', rtrTimeout: newState.rtrTimeout }); + expect(o).toMatchObject(newState); + + expect(ct).toHaveBeenCalledWith(state.rtrTimeout); + }); + + it('CLEAR_RTR_TIMEOUT', () => { + const ct = jest.spyOn(window, 'clearTimeout'); + + const state = { + rtrTimeout: 991, + }; + + const o = okapiReducer(state, { type: 'CLEAR_RTR_TIMEOUT' }); + expect(o).toMatchObject({}); + expect(ct).toHaveBeenCalledWith(state.rtrTimeout); + }); + + it('TOGGLE_RTR_MODAL', () => { + const rtrModalIsVisible = true; + const o = okapiReducer({}, { type: 'TOGGLE_RTR_MODAL', isVisible: true }); + expect(o).toMatchObject({ rtrModalIsVisible }); + }); }); diff --git a/test/jest/__mock__/BroadcastChannel.mock.js b/test/jest/__mock__/BroadcastChannel.mock.js new file mode 100644 index 000000000..1803d14b5 --- /dev/null +++ b/test/jest/__mock__/BroadcastChannel.mock.js @@ -0,0 +1,6 @@ +window.BroadcastChannel = jest.fn().mockImplementation(() => ({ + addEventListener: jest.fn(), + close: jest.fn(), + postMessage: jest.fn(), + removeEventListener: jest.fn(), +})); diff --git a/test/jest/__mock__/index.js b/test/jest/__mock__/index.js index 6f2097269..98e4a984a 100644 --- a/test/jest/__mock__/index.js +++ b/test/jest/__mock__/index.js @@ -3,3 +3,5 @@ import './intl.mock'; import './stripesIcon.mock'; import './stripesComponents.mock'; import './currencies.mock'; +import './BroadcastChannel.mock'; + diff --git a/translations/stripes-core/en.json b/translations/stripes-core/en.json index 5fbbffc1f..b08b58fc5 100644 --- a/translations/stripes-core/en.json +++ b/translations/stripes-core/en.json @@ -89,6 +89,7 @@ "currentServicePoint": "Service point: {name}", "currentServicePointNotSelected": "Service point: None", "logout": "Log out", + "logoutPending": "Log out in process...", "logoutKeepSso": "Log out from FOLIO, keep SSO session", "login": "Log in", "username": "Username", @@ -161,5 +162,11 @@ "routeErrorBoundary.goToModuleSettingsHomeLabel": "Return to {name} settings", "stale.warning": "The application has changed on the server and needs to be refreshed.", - "stale.reload": "Click here to reload." + "stale.reload": "Click here to reload.", + + "rtr.idleSession.modalHeader": "Your session will expire soon!", + "rtr.idleSession.timeRemaining": "Time remaining", + "rtr.idleSession.keepWorking": "Keep working", + "rtr.idleSession.sessionExpiredSoSad": "Your session expired due to inactivity.", + "rtr.idleSession.logInAgain": "Log in again" } diff --git a/translations/stripes-core/en_GB.json b/translations/stripes-core/en_GB.json index fb9b20dca..b62d72420 100644 --- a/translations/stripes-core/en_GB.json +++ b/translations/stripes-core/en_GB.json @@ -146,5 +146,11 @@ "stale.reload": "Click here to reload.", "placeholder.forgotPassword": "Enter email or phone", "placeholder.forgotUsername": "Enter email or phone", - "title.cookieEnabled": "Cookies are required to login. Please enable cookies and try again." -} \ No newline at end of file + "title.cookieEnabled": "Cookies are required to login. Please enable cookies and try again.", + "errors.sso.session.failed": "SSO Login failed. Please try again", + "rtr.idleSession.modalHeader": "Your session will expire soon!", + "rtr.idleSession.timeRemaining": "Time remaining", + "rtr.idleSession.keepWorking": "Keep working", + "rtr.idleSession.sessionExpiredSoSad": "Your session expired due to inactivity.", + "rtr.idleSession.logInAgain": "Log in again" +} diff --git a/translations/stripes-core/en_SE.json b/translations/stripes-core/en_SE.json index fb9b20dca..b62d72420 100644 --- a/translations/stripes-core/en_SE.json +++ b/translations/stripes-core/en_SE.json @@ -146,5 +146,11 @@ "stale.reload": "Click here to reload.", "placeholder.forgotPassword": "Enter email or phone", "placeholder.forgotUsername": "Enter email or phone", - "title.cookieEnabled": "Cookies are required to login. Please enable cookies and try again." -} \ No newline at end of file + "title.cookieEnabled": "Cookies are required to login. Please enable cookies and try again.", + "errors.sso.session.failed": "SSO Login failed. Please try again", + "rtr.idleSession.modalHeader": "Your session will expire soon!", + "rtr.idleSession.timeRemaining": "Time remaining", + "rtr.idleSession.keepWorking": "Keep working", + "rtr.idleSession.sessionExpiredSoSad": "Your session expired due to inactivity.", + "rtr.idleSession.logInAgain": "Log in again" +} diff --git a/translations/stripes-core/en_US.json b/translations/stripes-core/en_US.json index 29dc20d1f..4873ac4e4 100644 --- a/translations/stripes-core/en_US.json +++ b/translations/stripes-core/en_US.json @@ -152,5 +152,11 @@ "stale.reload": "Click here to reload.", "placeholder.forgotPassword": "Enter email or phone", "placeholder.forgotUsername": "Enter email or phone", - "title.cookieEnabled": "Cookies are required to login. Please enable cookies and try again." + "title.cookieEnabled": "Cookies are required to login. Please enable cookies and try again.", + "errors.sso.session.failed": "SSO Login failed. Please try again", + "rtr.idleSession.modalHeader": "Your session will expire soon!", + "rtr.idleSession.timeRemaining": "Time remaining", + "rtr.idleSession.keepWorking": "Keep working", + "rtr.idleSession.sessionExpiredSoSad": "Your session expired due to inactivity.", + "rtr.idleSession.logInAgain": "Log in again" } From b5fe81497f9bb190c6c1e97f03f8133c280cef00 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Fri, 17 May 2024 13:21:51 -0400 Subject: [PATCH 02/17] STCOR-776 use correct logout-timeout translation IDs (#1473) Whoops, missed this in #1463 Refs STCOR-776 (cherry picked from commit be7f076bb07635f51fc2a756b38b7f05ccd8364d) --- src/components/LogoutTimeout/LogoutTimeout.js | 4 ++-- src/components/LogoutTimeout/LogoutTimeout.test.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/LogoutTimeout/LogoutTimeout.js b/src/components/LogoutTimeout/LogoutTimeout.js index af9467329..2eda11421 100644 --- a/src/components/LogoutTimeout/LogoutTimeout.js +++ b/src/components/LogoutTimeout/LogoutTimeout.js @@ -44,12 +44,12 @@ const LogoutTimeout = () => { - + - + diff --git a/src/components/LogoutTimeout/LogoutTimeout.test.js b/src/components/LogoutTimeout/LogoutTimeout.test.js index 068285092..55c9c9b5b 100644 --- a/src/components/LogoutTimeout/LogoutTimeout.test.js +++ b/src/components/LogoutTimeout/LogoutTimeout.test.js @@ -16,7 +16,7 @@ describe('LogoutTimeout', () => { mockUseStripes.mockReturnValue({ okapi: { isAuthenticated: false } }); render(); - screen.getByText('rtr.idleSession.sessionExpiredSoSad'); + screen.getByText('stripes-core.rtr.idleSession.sessionExpiredSoSad'); }); it('if authenticated, renders a redirect', async () => { From 01f7ee2dc7ed70765a3becc4cf66202ca4a21df3 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Tue, 28 May 2024 10:36:32 -0400 Subject: [PATCH 03/17] STCOR-776 always populate stripes.config.rtr.activityEvents (#1483) * Always populate `stripes.config.rtr.activityEvents`; if it isn't defined at build-time via `stripes.config.js`, populate it with values from `RTR_ACTIVITY_EVENTS`. Previously, it was omitted if not defined in `stripes.config.js`, causing the KeepWorkingModal callback to dispatch an empty event, dismissing the modal but failing to actually prolong the session since no "activity" event was fired. Whoops. Related, this means we must supply `activityEvents` on `stripes` in tests of `` where it is rendered directly, without `` as a parent to call `configureRtr()`. * Populate `stripes.config.rtr.idleSessionTTL` and `stripes.config.rtr.idleModalTTL` from their corresponding constants instead of hard-coding magic strings in multiple places like an idiot. * Minor test clean up. We don't need to reassign `console` methods when we can just run jest with `--silent`. Refs STCOR-776 (cherry picked from commit 99b894870183fe3a1ba43ef8d6c68e4df668b7f9) --- src/components/Root/token-util.js | 21 ++++++++++++++----- .../SessionEventContainer.js | 6 ++---- .../SessionEventContainer.test.js | 5 +++-- src/loginServices.test.js | 17 --------------- 4 files changed, 21 insertions(+), 28 deletions(-) diff --git a/src/components/Root/token-util.js b/src/components/Root/token-util.js index ea24f0c98..b622101a6 100644 --- a/src/components/Root/token-util.js +++ b/src/components/Root/token-util.js @@ -1,8 +1,15 @@ +import { isEmpty } from 'lodash'; import { okapi } from 'stripes-config'; import { getTokenExpiry, setTokenExpiry } from '../../loginServices'; import { RTRError, UnexpectedResourceError } from './Errors'; -import { RTR_ERROR_EVENT, RTR_SUCCESS_EVENT } from './constants'; +import { + RTR_ACTIVITY_EVENTS, + RTR_ERROR_EVENT, + RTR_IDLE_MODAL_TTL, + RTR_IDLE_SESSION_TTL, + RTR_SUCCESS_EVENT, +} from './constants'; /** localstorage flag indicating whether an RTR request is already under way. */ export const RTR_IS_ROTATING = '@folio/stripes/core::rtrIsRotating'; @@ -294,7 +301,7 @@ export const getPromise = async (logger) => { /** * configureRtr * Provide default values necessary for RTR. They may be overriden by setting - * config.rtr in stripes.config.js. + * config.rtr... in stripes.config.js. * * @param {object} config */ @@ -303,15 +310,19 @@ export const configureRtr = (config = {}) => { // how long does an idle session last before being killed? if (!conf.idleSessionTTL) { - conf.idleSessionTTL = '60m'; + conf.idleSessionTTL = RTR_IDLE_SESSION_TTL; } // how long is the "warning, session is idle!" modal shown // before the session is killed? if (!conf.idleModalTTL) { - conf.idleModalTTL = '1m'; + conf.idleModalTTL = RTR_IDLE_MODAL_TTL; + } + + // what events constitute activity? + if (isEmpty(conf.activityEvents)) { + conf.activityEvents = RTR_ACTIVITY_EVENTS; } return conf; }; - diff --git a/src/components/SessionEventContainer/SessionEventContainer.js b/src/components/SessionEventContainer/SessionEventContainer.js index fa65bdcf6..ff1707764 100644 --- a/src/components/SessionEventContainer/SessionEventContainer.js +++ b/src/components/SessionEventContainer/SessionEventContainer.js @@ -8,7 +8,6 @@ import KeepWorkingModal from './KeepWorkingModal'; import { useStripes } from '../../StripesContext'; import { RTR_ACTIVITY_CHANNEL, - RTR_ACTIVITY_EVENTS, RTR_ERROR_EVENT, RTR_TIMEOUT_EVENT } from '../Root/constants'; @@ -21,7 +20,7 @@ import { toggleRtrModal } from '../../okapiActions'; // RTR error in this window: logout export const thisWindowRtrError = (_e, stripes, history) => { - console.warn('rtr error; logging out', history); // eslint-disable-line no-console + console.warn('rtr error; logging out'); // eslint-disable-line no-console return logout(stripes.okapi.url, stripes.store) .then(() => { history.push('/logout-timeout'); @@ -177,7 +176,7 @@ const SessionEventContainer = ({ history }) => { const channelListeners = { window, bc }; if (stripes.config.useSecureTokens) { - const { idleModalTTL, idleSessionTTL } = stripes.config.rtr; + const { idleModalTTL, idleSessionTTL, activityEvents } = stripes.config.rtr; // inactive timer: show the "keep working?" modal const showModalIT = createInactivityTimer(ms(idleSessionTTL) - ms(idleModalTTL), () => { @@ -213,7 +212,6 @@ const SessionEventContainer = ({ history }) => { channels.bc.message = (message) => otherWindowActivity(message, stripes, timers, setIsVisible); // activity in this window: ping idle-timers and BroadcastChannel - const activityEvents = stripes.config.rtr?.activityEvents ?? RTR_ACTIVITY_EVENTS; activityEvents.forEach(eventName => { channels.window[eventName] = (e) => thisWindowActivity(e, stripes, timers, bc); }); diff --git a/src/components/SessionEventContainer/SessionEventContainer.test.js b/src/components/SessionEventContainer/SessionEventContainer.test.js index 269a2f98f..09b68f19a 100644 --- a/src/components/SessionEventContainer/SessionEventContainer.test.js +++ b/src/components/SessionEventContainer/SessionEventContainer.test.js @@ -23,6 +23,7 @@ const stripes = { rtr: { idleModalTTL: '3s', idleSessionTTL: '3s', + activityEvents: ['right thing', 'hustle', 'hand jive'] } }, okapi: { @@ -34,12 +35,12 @@ const stripes = { describe('SessionEventContainer', () => { it('Renders nothing if useSecureTokens is false', async () => { - const inSecureStripes = { + const insecureStripes = { config: { useSecureTokens: false, }, }; - render(); + render(); expect(screen.queryByText('KeepWorkingModal')).toBe(null); }); diff --git a/src/loginServices.test.js b/src/loginServices.test.js index 21f2c75e5..1421efdf5 100644 --- a/src/loginServices.test.js +++ b/src/loginServices.test.js @@ -43,23 +43,6 @@ import { import { defaultErrors } from './constants'; -// reassign console.log to keep things quiet -const consoleInterruptor = {}; -beforeAll(() => { - consoleInterruptor.log = global.console.log; - consoleInterruptor.error = global.console.error; - consoleInterruptor.warn = global.console.warn; - console.log = () => { }; - console.error = () => { }; - console.warn = () => { }; -}); - -afterAll(() => { - global.console.log = consoleInterruptor.log; - global.console.error = consoleInterruptor.error; - global.console.warn = consoleInterruptor.warn; -}); - jest.mock('localforage', () => ({ getItem: jest.fn(() => Promise.resolve({ user: {} })), setItem: jest.fn(() => Promise.resolve()), From e2b6a09ddc12224dd0dcc1c4632b84dbbfe8768e Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Mon, 10 Jun 2024 15:21:33 -0400 Subject: [PATCH 04/17] STCOR-776 optionally schedule RTR based on session data (#1488) Not all authentication-related responses are created equal. If the response contains AT expiration data, i.e. it is a response from `/bl-users/login-with-expiry`, use that data to configure RTR. If the response does not contain such data, i.e. it is a response from `/bl-users/_self`, pull AT expiration data from the session and use that. If we still don't have any AT expiration data, cross your fingers and try rotating in 10 seconds. Previously, we did not ever check the session, so RTR always fired 10 seconds into a resumed, which is exactly the situation faced in e2e tests. This doesn't precisely handle the issue faced by e2e tests that stall when the rtr requests fires, but leveraging the session data should push the rtr request far enough into the future that most e2e tests will now avoid it. We should still try to understand that problem and solve it, but in the mean time this may enable us to work around it. Refs STCOR-776 (cherry picked from commit e93a5af762786e53e9f4bc0aa55a8d47ff6b91cf) --- src/components/Root/FFetch.js | 59 ++++++++--- src/components/Root/FFetch.test.js | 163 +++++++++++++++++++++++++++++ src/components/Root/constants.js | 14 +++ src/loginServices.js | 6 +- 4 files changed, 224 insertions(+), 18 deletions(-) diff --git a/src/components/Root/FFetch.js b/src/components/Root/FFetch.js index 5312445c0..ef84814b8 100644 --- a/src/components/Root/FFetch.js +++ b/src/components/Root/FFetch.js @@ -47,6 +47,7 @@ import { setRtrTimeout } from '../../okapiActions'; +import { getTokenExpiry } from '../../loginServices'; import { getPromise, isAuthenticationRequest, @@ -58,10 +59,10 @@ import { RTRError, } from './Errors'; import { + RTR_AT_EXPIRY_IF_UNKNOWN, RTR_AT_TTL_FRACTION, RTR_ERROR_EVENT, } from './constants'; - import FXHR from './FXHR'; const OKAPI_FETCH_OPTIONS = { @@ -107,19 +108,42 @@ export class FFetch { rotateCallback = (res) => { this.logger.log('rtr', 'rotation callback setup'); - // set a short rotation interval by default, then inspect the response for - // token-expiration data to use instead if available. not all responses - // (e.g. those from _self) contain token-expiration values, so it is - // necessary to provide a default. - let rotationInterval = 10 * 1000; + const scheduleRotation = (rotationP) => { + rotationP.then((rotationInterval) => { + this.logger.log('rtr', `rotation fired from rotateCallback; next callback in ${ms(rotationInterval)}`); + this.store.dispatch(setRtrTimeout(setTimeout(() => { + rtr(this.nativeFetch, this.logger, this.rotateCallback); + }, rotationInterval))); + }); + }; + + // When starting a new session, the response from /bl-users/login-with-expiry + // will contain AT expiration info, but when restarting an existing session, + // the response from /bl-users/_self will NOT, although that information will + // 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, otherwise the session. Default to 10 seconds. if (res?.accessTokenExpiration) { - rotationInterval = (new Date(res.accessTokenExpiration).getTime() - Date.now()) * RTR_AT_TTL_FRACTION; + 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); + + 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; + } + + // default: 10 seconds + this.logger.log('rtr', 'rotation scheduled with default value'); + return ms(RTR_AT_EXPIRY_IF_UNKNOWN); + }); + + scheduleRotation(rotationPromise); } - - this.logger.log('rtr', `rotation fired from rotateCallback; next callback in ${ms(rotationInterval)}`); - this.store.dispatch(setRtrTimeout(setTimeout(() => { - rtr(this.nativeFetch, this.logger, this.rotateCallback); - }, rotationInterval))); } /** @@ -158,13 +182,16 @@ export class FFetch { this.logger.log('rtr', 'authn request'); return this.nativeFetch.apply(global, [resource, options && { ...options, ...OKAPI_FETCH_OPTIONS }]) .then(res => { - this.logger.log('rtr', 'authn success!'); // a response can only be read once, so we clone it to grab the // tokenExpiration in order to kick of the rtr cycle, then return // the original - res.clone().json().then(json => { - this.rotateCallback(json.tokenExpiration); - }); + const clone = res.clone(); + if (clone.ok) { + this.logger.log('rtr', 'authn success!'); + clone.json().then(json => { + this.rotateCallback(json.tokenExpiration); + }); + } return res; }); diff --git a/src/components/Root/FFetch.test.js b/src/components/Root/FFetch.test.js index 8013b93ff..8ada9a7c4 100644 --- a/src/components/Root/FFetch.test.js +++ b/src/components/Root/FFetch.test.js @@ -2,9 +2,15 @@ // FFetch for the reassign globals side-effect in its constructor. /* eslint-disable no-unused-vars */ +import ms from 'ms'; + import { getTokenExpiry } from '../../loginServices'; import { FFetch } from './FFetch'; import { RTRError, UnexpectedResourceError } from './Errors'; +import { + RTR_AT_EXPIRY_IF_UNKNOWN, + RTR_AT_TTL_FRACTION, +} from './constants'; jest.mock('../../loginServices', () => ({ ...(jest.requireActual('../../loginServices')), @@ -145,6 +151,163 @@ describe('FFetch class', () => { }); }); + describe('calling authentication resources', () => { + it('handles RTR data in the response', 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 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 } }) + }); + + mockFetch.mockResolvedValueOnce({ + ok: false, + clone, + }); + + mockFetch.mockResolvedValueOnce('okapi success'); + const testFfetch = new FFetch({ + logger: { log }, + store: { + dispatch: jest.fn(), + } + }); + 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); + expect(st).toHaveBeenCalledWith(expect.any(Function), (accessTokenExpiration - whatTimeIsItMrFox) * RTR_AT_TTL_FRACTION); + }); + + it('handles RTR data in the session', 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 retrieved from local storage via getTokenExpiry + const atExpires = whatTimeIsItMrFox + 5000; + + const st = jest.spyOn(window, 'setTimeout'); + + getTokenExpiry.mockResolvedValue({ atExpires }); + Date.now = () => whatTimeIsItMrFox; + + const cloneJson = jest.fn(); + const clone = () => ({ + ok: true, + json: () => Promise.resolve({}), + }); + + mockFetch.mockResolvedValueOnce({ + ok: false, + clone, + }); + + mockFetch.mockResolvedValueOnce('okapi success'); + const testFfetch = new FFetch({ + logger: { log }, + store: { + dispatch: jest.fn(), + } + }); + 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); + expect(st).toHaveBeenCalledWith(expect.any(Function), (atExpires - whatTimeIsItMrFox) * RTR_AT_TTL_FRACTION); + }); + + it('handles missing RTR data', async () => { + const st = jest.spyOn(window, 'setTimeout'); + getTokenExpiry.mockResolvedValue({}); + + const cloneJson = jest.fn(); + const clone = () => ({ + ok: true, + json: () => Promise.resolve({}), + }); + + mockFetch.mockResolvedValueOnce({ + ok: false, + clone, + }); + + mockFetch.mockResolvedValueOnce('okapi success'); + const testFfetch = new FFetch({ + logger: { log }, + store: { + dispatch: jest.fn(), + } + }); + 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); + + expect(st).toHaveBeenCalledWith(expect.any(Function), ms(RTR_AT_EXPIRY_IF_UNKNOWN)); + }); + + it('handles unsuccessful responses', async () => { + jest.spyOn(window, 'dispatchEvent'); + jest.spyOn(console, 'error'); + + const cloneJson = jest.fn(); + const clone = () => ({ + ok: false, + json: cloneJson, + }); + + mockFetch.mockResolvedValueOnce({ + ok: false, + clone, + }); + + mockFetch.mockResolvedValueOnce('okapi success'); + const testFfetch = new FFetch({ + logger: { log }, + store: { + dispatch: jest.fn(), + } + }); + testFfetch.replaceFetch(); + testFfetch.replaceXMLHttpRequest(); + + const response = await global.fetch('okapiUrl/bl-users/_self', { testOption: 'test' }); + expect(mockFetch.mock.calls).toHaveLength(1); + expect(cloneJson).not.toHaveBeenCalled(); + }); + }); + + describe('Calling an okapi fetch with missing token...', () => { it('returns the error', async () => { mockFetch.mockResolvedValue('success') diff --git a/src/components/Root/constants.js b/src/components/Root/constants.js index 58ddc16db..771467234 100644 --- a/src/components/Root/constants.js +++ b/src/components/Root/constants.js @@ -47,3 +47,17 @@ export const RTR_IDLE_SESSION_TTL = '60m'; * value must be a string parsable by ms() */ export const RTR_IDLE_MODAL_TTL = '1m'; + +/** + * When resuming an existing session but there is no token-expiration + * data in the session, we can't properly schedule RTR. + * 1. the real expiration data is in the cookie, but it's HTTPOnly + * 2. the resume-session API endpoint, _self, doesn't include + * token-expiration data in its response + * 3. the session _should_ contain a value, but maybe the session + * was corrupt. + * Given the resume-session API call succeeded, we know the AT must have been + * valid at the time, so we punt and schedule rotation in the future by this + * (relatively short) interval. + */ +export const RTR_AT_EXPIRY_IF_UNKNOWN = '10s'; diff --git a/src/loginServices.js b/src/loginServices.js index 58774458c..841bd4ea4 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -762,7 +762,9 @@ export function validateUser(okapiUrl, store, tenant, session) { /** * checkOkapiSession - * 1. Pull the session from local storage; if non-empty validate it, dispatching load-resources actions. + * 1. Pull the session from local storage; if it contains a user id, + * validate it by fetching /_self to verify that it is still active, + * dispatching load-resources actions. * 2. Check if SSO (SAML) is enabled, dispatching check-sso actions * 3. dispatch set-okapi-ready. * @@ -773,7 +775,7 @@ export function validateUser(okapiUrl, store, tenant, session) { export function checkOkapiSession(okapiUrl, store, tenant) { getOkapiSession() .then((sess) => { - return sess !== null ? validateUser(okapiUrl, store, tenant, sess) : null; + return sess?.user?.id ? validateUser(okapiUrl, store, tenant, sess) : null; }) .then(() => { if (store.getState().discovery?.interfaces?.['login-saml']) { From d15fe3a5cbe764ef5595cade7d4192ae55f0481f Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Fri, 17 May 2024 16:56:47 -0400 Subject: [PATCH 05/17] rebase-cleanup: the STCOR-776 rebase was a doozy We attempted to rebase onto master just after STCOR-776 merged (#1463). It didn't go smoothly but the results got pushed anyway, which made clean up tricky too. I think the changes here resolve the conflicts. One outstanding issue I am aware of is that the `/logout-timeout` redirect does not work correctly. When the session terminates, `` redirects to keycloak no matter what. It's like the routing switch statement is falling through instead of stopping with ``. That's no good, but it's less no good than the current tip-of-branch, which doesn't redirect ever, making it impossible to authenticate. (cherry picked from commit a9b860dfe591f7e7b236640fc1ceb6d25401b30c) --- CHANGELOG.md | 2 +- src/RootWithIntl.js | 11 +---------- src/RootWithIntl.test.js | 20 ++++++++++---------- src/components/Root/token-util.js | 1 + src/discoverServices.js | 3 +++ src/loginServices.js | 24 ++++++++++++------------ src/loginServices.test.js | 1 - yarn.lock | 16 ---------------- 8 files changed, 28 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fafeb1b0b..ef5f9f11a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,12 @@ # Change history for stripes-core +<<<<<<< HEAD ## [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) * Utilize the `tenant` procured through the SSO login process. Refs STCOR-769. * Use keycloak URLs in place of users-bl for tenant-switch. Refs US1153537. * Idle-session timeout and "Keep working?" modal. Refs STCOR-776. -======= ## [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) diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index 5e4db3ae7..5c4c2f749 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -28,7 +28,6 @@ import { Settings, HandlerManager, TitleManager, - Login, Logout, LogoutTimeout, OverlayContainer, @@ -44,10 +43,6 @@ import { StripesContext } from './StripesContext'; import { CalloutContext } from './CalloutContext'; import AuthnLogin from './components/AuthnLogin'; -export const renderLogoutComponent = () => { - return ; -}; - const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAuth, history = {} }) => { const connect = connectFor('@folio/core', stripes.epics, stripes.logger); const connectedStripes = stripes.clone({ connect }); @@ -156,11 +151,7 @@ const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAut /> } + component={} /> } diff --git a/src/RootWithIntl.test.js b/src/RootWithIntl.test.js index b3bb2636e..11f6634e0 100644 --- a/src/RootWithIntl.test.js +++ b/src/RootWithIntl.test.js @@ -8,9 +8,9 @@ import Redirect from './components/Redirect'; import { Login } from './components'; import PreLoginLanding from './components/PreLoginLanding'; -import { - renderLogoutComponent -} from './RootWithIntl'; +// import { +// renderLogoutComponent +// } from './RootWithIntl'; import AuthnLogin from './components/AuthnLogin'; @@ -54,12 +54,12 @@ describe('RootWithIntl', () => { }); }); - describe('renderLogoutComponent', () => { - it('handles legacy logout', () => { - const stripes = { okapi: {}, config: {} }; - render(renderLogoutComponent(stripes)); + // describe('renderLogoutComponent', () => { + // it('handles legacy logout', () => { + // const stripes = { okapi: {}, config: {} }; + // render(renderLogoutComponent(stripes)); - expect(screen.getByText(//)).toBeInTheDocument(); - }); - }); + // expect(screen.getByText(//)).toBeInTheDocument(); + // }); + // }); }); diff --git a/src/components/Root/token-util.js b/src/components/Root/token-util.js index b622101a6..c1a147024 100644 --- a/src/components/Root/token-util.js +++ b/src/components/Root/token-util.js @@ -72,6 +72,7 @@ export const resourceMapper = (resource, fx) => { export const isAuthenticationRequest = (resource, oUrl) => { const isPermissibleResource = (string) => { const permissible = [ + '/authn/token', '/bl-users/login-with-expiry', '/bl-users/_self', ]; diff --git a/src/discoverServices.js b/src/discoverServices.js index 1a221631a..04a888423 100644 --- a/src/discoverServices.js +++ b/src/discoverServices.js @@ -114,17 +114,20 @@ function fetchApplicationDetails(store) { return Promise.all(list); } + // eslint-disable-next-line no-console console.error(`>>> NO APPLICATIONS AVAILABLE FOR ${okapi.tenant}`, json); store.dispatch({ type: 'DISCOVERY_FAILURE', code: response.status }); throw response; }); } else { + // eslint-disable-next-line no-console console.error(`>>> COULD NOT RETRIEVE APPLICATIONS FOR ${okapi.tenant}`, response); store.dispatch({ type: 'DISCOVERY_FAILURE', code: response.status }); throw response; } }) .catch(reason => { + // eslint-disable-next-line no-console console.error(`@@ COULD NOT RETRIEVE APPLICATIONS FOR ${okapi.tenant}`, reason); store.dispatch({ type: 'DISCOVERY_FAILURE', message: reason }); }); diff --git a/src/loginServices.js b/src/loginServices.js index 841bd4ea4..52237ec0e 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -112,15 +112,16 @@ export const setTokenExpiry = async (te) => { /** * removeUnauthorizedPathFromSession, setUnauthorizedPathToSession, getUnauthorizedPathFromSession - * Add/remove/get unauthorized_path to/from session storage; - * Used to restore path if user is unauthorized. - * @see OIDCRedirect + * remove/set/get unauthorized_path to/from session storage. + * Used to restore path on returning from login if user accessed a bookmarked + * URL while unauthenticated and was redirected to login. + * + * @see components/OIDCRedirect */ - -export const removeUnauthorizedPathFromSession = () => sessionStorage.removeItem('unauthorized_path'); -export const setUnauthorizedPathToSession = (pathname) => sessionStorage.setItem('unauthorized_path', pathname); -export const getUnauthorizedPathFromSession = () => sessionStorage.getItem('unauthorized_path'); - +const UNAUTHORIZED_PATH = 'unauthorized_path'; +export const removeUnauthorizedPathFromSession = () => sessionStorage.removeItem(UNAUTHORIZED_PATH); +export const setUnauthorizedPathToSession = (pathname) => sessionStorage.setItem(UNAUTHORIZED_PATH, pathname); +export const getUnauthorizedPathFromSession = () => sessionStorage.getItem(UNAUTHORIZED_PATH); // export config values for storing user locale export const userLocaleConfig = { @@ -475,6 +476,7 @@ export async function logout(okapiUrl, store) { }) : Promise.resolve(); + return logoutPromise // clear private-storage .then(() => { @@ -528,8 +530,6 @@ export async function logout(okapiUrl, store) { * @returns {Promise} */ export function createOkapiSession(store, tenant, token, data) { - // @@ new StripesSession(store, data); - // clear any auth-n errors store.dispatch(setAuthError(null)); @@ -921,9 +921,9 @@ export function updateUser(store, data) { * * @returns {Promise} */ -export async function updateTenant(okapi, tenant) { +export async function updateTenant(okapiConfig, tenant) { const okapiSess = await getOkapiSession(); - const userWithPermsResponse = await fetchUserWithPerms(okapi.url, tenant, okapi.token); + const userWithPermsResponse = await fetchUserWithPerms(okapiConfig.url, tenant, okapiConfig.token); const userWithPerms = await userWithPermsResponse.json(); await localforage.setItem(SESSION_NAME, { ...okapiSess, tenant, ...spreadUserWithPerms(userWithPerms) }); } diff --git a/src/loginServices.test.js b/src/loginServices.test.js index 1421efdf5..3ccd7c106 100644 --- a/src/loginServices.test.js +++ b/src/loginServices.test.js @@ -1,5 +1,4 @@ import localforage from 'localforage'; -import { config } from 'stripes-config'; import { createOkapiSession, diff --git a/yarn.lock b/yarn.lock index 65c9805bc..4319bea9a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9494,22 +9494,6 @@ possible-typed-array-names@^1.0.0: resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f" integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q== -postcss-calc@^9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-9.0.1.tgz#a744fd592438a93d6de0f1434c572670361eb6c6" - integrity sha512-TipgjGyzP5QzEhsOZUaIkeO5mKeMFpebWzRogWG/ysonUlnHcq5aJe0jOjpfzUU8PeSaBQnrE8ehR0QA5vs8PQ== - dependencies: - postcss-selector-parser "^6.0.11" - postcss-value-parser "^4.2.0" - -postcss-color-function@folio-org/postcss-color-function: - version "4.1.0" - resolved "https://codeload.github.com/folio-org/postcss-color-function/tar.gz/c128aad740ae740fb571c4b6493f467dd51efe85" - dependencies: - css-color-function "~1.3.3" - postcss-message-helpers "^2.0.0" - postcss-value-parser "^4.1.0" - postcss-custom-media@^9.0.1: version "9.1.5" resolved "https://registry.yarnpkg.com/postcss-custom-media/-/postcss-custom-media-9.1.5.tgz#20c5822dd15155d768f8dd84e07a6ffd5d01b054" From adcf437c09139320a6fa5bac2a8ae2ab7cf97a41 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Mon, 20 May 2024 07:20:47 -0400 Subject: [PATCH 06/17] rebase-cleanup: restore logout The `/authn/logout` request requires the `X-Okapi-Tenant` header to succeed. (cherry picked from commit eeaa34acfec17d95e955e615e09d153a444a06b6) --- src/loginServices.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/loginServices.js b/src/loginServices.js index 52237ec0e..4ec77f3af 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -472,7 +472,8 @@ export async function logout(okapiUrl, store) { fetch(`${okapiUrl}/authn/logout`, { method: 'POST', mode: 'cors', - credentials: 'include' + credentials: 'include', + headers: getHeaders(store.getState()?.okapi?.tenant), }) : Promise.resolve(); From 17d3f7fe546761b83c9095169e5214192b058aa6 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Thu, 23 May 2024 16:31:41 -0400 Subject: [PATCH 07/17] rebase-cleanup: restore logout AND ITS TESTS (#1479) The previous commit re-enabled logout by correctly passing the `x-okapi-tenant` header in the `/authn/logout` request. It turns out that if you want read the tenant from the store in a test, you have to mock the store in your test. WHO KNEW??? (cherry picked from commit 5bc64cebd6fa57347570aaca3301545ed21a6e8b) --- src/loginServices.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/loginServices.test.js b/src/loginServices.test.js index 3ccd7c106..7a00d9a37 100644 --- a/src/loginServices.test.js +++ b/src/loginServices.test.js @@ -458,6 +458,7 @@ describe('logout', () => { global.fetch = jest.fn().mockImplementation(() => Promise.resolve()); const store = { dispatch: jest.fn(), + getState: jest.fn(), }; window.sessionStorage.clear(); @@ -479,6 +480,7 @@ describe('logout', () => { localStorage.setItem(SESSION_NAME, 'true'); const store = { dispatch: jest.fn(), + getState: jest.fn(), }; window.sessionStorage.clear(); From b17e9ad23169f689a6ce550b21772784c8a0e1ee Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Tue, 11 Jun 2024 13:30:55 -0400 Subject: [PATCH 08/17] STCOR-776 RTR adjustments for keycloak (#1490) There are many small differences in how keycloak and okapi respond to authentication related requests. * permissions are structured differently in Okapi between `login` and `_self` requests and depending on whether `expandPermissions=true` is present on the request; keycloak always responds with a flattened list. * token expiration data is nested in the login-response in Okapi but is a root-level element in the `/authn/token` response from keycloak. STCOR-776, STCOR-846 (cherry picked from commit 2e162f618287cd59f4c4937fbd04ac107aeb15e5) --- src/components/Root/FFetch.js | 13 +++++++++---- src/loginServices.js | 18 +++++++++++++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/components/Root/FFetch.js b/src/components/Root/FFetch.js index ef84814b8..21c5b4c73 100644 --- a/src/components/Root/FFetch.js +++ b/src/components/Root/FFetch.js @@ -119,12 +119,12 @@ export class FFetch { // When starting a new session, the response from /bl-users/login-with-expiry // will contain AT expiration info, but when restarting an existing session, - // the response from /bl-users/_self will NOT, although that information will + // 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, otherwise the session. Default to 10 seconds. + // the response, then the session, then default to 10 seconds. 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); @@ -132,7 +132,7 @@ export class FFetch { scheduleRotation(rotationPromise); } else { const rotationPromise = getTokenExpiry().then((expiry) => { - if (expiry.atExpires) { + 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; } @@ -189,7 +189,12 @@ export class FFetch { if (clone.ok) { this.logger.log('rtr', 'authn success!'); clone.json().then(json => { - this.rotateCallback(json.tokenExpiration); + // we want accessTokenExpiration. do we need to destructure? + // in community-folio, a /login-with-expiry response is shaped like + // { ..., tokenExpiration: { accessTokenExpiration, refreshTokenExpiration } } + // in eureka-folio, a /authn/token response is shaped like + // { accessTokenExpiration, refreshTokenExpiration } + this.rotateCallback(json.tokenExpiration ?? json); }); } diff --git a/src/loginServices.js b/src/loginServices.js index 4ec77f3af..ba966b3f0 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -413,15 +413,23 @@ export function spreadUserWithPerms(userWithPerms) { // remap data's array of permission-names to set with // permission-names for keys and `true` for values. // - // userWithPerms is shaped differently depending on whether - // it comes from a login call or a `.../_self` call, which - // is just totally totally awesome. :| + // userWithPerms is shaped differently depending on the API call + // that generated it. + // in community-folio, /login sends data like [{ "permissionName": "foo" }] + // and includes both directly and indirectly assigned permissions + // in community-folio, /_self sends data like ["foo", "bar", "bat"] + // but only includes directly assigned permissions + // in community-folio, /_self?expandPermissions=true sends data like [{ "permissionName": "foo" }] + // and includes both directly and indirectly assigned permissions + // in eureka-folio, /_self sends data like ["foo", "bar", "bat"] + // and includes both directly and indirectly assigned permissions + // // we'll parse it differently depending on what it looks like. let perms = {}; const list = userWithPerms?.permissions?.permissions; if (list && Array.isArray(list) && list.length > 0) { - // _self sends data like ["foo", "bar", "bat"] - // login sends data like [{ "permissionName": "foo" }] + // shaped like this ["foo", "bar", "bat"] or + // shaped like that [{ "permissionName": "foo" }]? if (typeof list[0] === 'string') { perms = Object.assign({}, ...list.map(p => ({ [p]: true }))); } else { From 2d712ea46011d4ffdec4bc8d4c19cac91aac3544 Mon Sep 17 00:00:00 2001 From: Ryan Berger Date: Mon, 10 Jun 2024 08:16:13 -0400 Subject: [PATCH 09/17] [STCOR-787] Always retrieve clientId and tenant values from config.tenantOptions in stripes.config.js (#1487) * Retrieve clientId and tenant values from config.tenantOptions before login * Fix tenant gathering * Remove isSingleTenant param which is redundant * If user object not returned from local storage, then default user from /_self response * Update CHANGELOG.md * Revert PreLoginLanding which uses okapi values * Remove space * Rework flow to immediately set config to okapi for compatibility. * Lint fix * Fix unit test (cherry picked from commit e738a2fce16d3b69dc286ba41ce62f8e320cd757) --- CHANGELOG.md | 2 +- src/RootWithIntl.test.js | 30 ++++++++- src/Stripes.js | 1 + src/components/AuthnLogin/AuthnLogin.js | 27 +++++--- src/components/OIDCLanding.js | 1 - src/components/OIDCLanding.test.js | 85 +++++++++++++++++++++++++ src/components/OIDCRedirect.test.js | 2 +- 7 files changed, 134 insertions(+), 14 deletions(-) create mode 100644 src/components/OIDCLanding.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ef5f9f11a..053b042e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,12 @@ # Change history for stripes-core -<<<<<<< HEAD ## [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) * Utilize the `tenant` procured through the SSO login process. Refs STCOR-769. * Use keycloak URLs in place of users-bl for tenant-switch. Refs US1153537. * Idle-session timeout and "Keep working?" modal. Refs STCOR-776. +* Always retrieve `clientId` and `tenant` values from `config.tenantOptions` in stripes.config.js. Retires `okapi.tenant`, `okapi.clientId`, and `config.isSingleTenant`. Refs STCOR-787. ## [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) diff --git a/src/RootWithIntl.test.js b/src/RootWithIntl.test.js index 11f6634e0..4da7f32b9 100644 --- a/src/RootWithIntl.test.js +++ b/src/RootWithIntl.test.js @@ -22,10 +22,21 @@ jest.mock('./components/Redirect', () => () => ''); jest.mock('./components/Login', () => () => ''); jest.mock('./components/PreLoginLanding', () => () => ''); +const store = { + getState: () => ({ + okapi: { + token: '123', + }, + }), + dispatch: () => {}, + subscribe: () => {}, + replaceReducer: () => {}, +}; + describe('RootWithIntl', () => { describe('AuthnLogin', () => { it('handles legacy login', () => { - const stripes = { okapi: {}, config: {} }; + const stripes = { okapi: {}, config: {}, store }; render(); expect(screen.getByText(//)).toBeInTheDocument(); @@ -35,7 +46,13 @@ describe('RootWithIntl', () => { it('handles single-tenant', () => { const stripes = { okapi: { authnUrl: 'https://barbie.com' }, - config: { isSingleTenant: true } + config: { + isSingleTenant: true, + tenantOptions: { + diku: { name: 'diku', clientId: 'diku-application' } + } + }, + store }; render(); @@ -45,7 +62,14 @@ describe('RootWithIntl', () => { it('handles multi-tenant', () => { const stripes = { okapi: { authnUrl: 'https://oppie.com' }, - config: { }, + config: { + isSingleTenant: false, + tenantOptions: { + diku: { name: 'diku', clientId: 'diku-application' }, + diku2: { name: 'diku2', clientId: 'diku2-application' } + } + }, + store }; render(); diff --git a/src/Stripes.js b/src/Stripes.js index 3d7413354..5f91fe238 100644 --- a/src/Stripes.js +++ b/src/Stripes.js @@ -23,6 +23,7 @@ export const stripesShape = PropTypes.shape({ logTimestamp: PropTypes.bool, showHomeLink: PropTypes.bool, showPerms: PropTypes.bool, + tenantOptions: PropTypes.object, }).isRequired, connect: PropTypes.func.isRequired, currency: PropTypes.string, diff --git a/src/components/AuthnLogin/AuthnLogin.js b/src/components/AuthnLogin/AuthnLogin.js index 136ea8136..4ae5a020d 100644 --- a/src/components/AuthnLogin/AuthnLogin.js +++ b/src/components/AuthnLogin/AuthnLogin.js @@ -9,6 +9,14 @@ import { setUnauthorizedPathToSession } from '../../loginServices'; const AuthnLogin = ({ stripes }) => { const { config, okapi } = stripes; + // If config.tenantOptions is not defined, default to classic okapi.tenant and okapi.clientId + const { tenantOptions = [{ name: okapi.tenant, clientId: okapi.clientId }] } = config; + const tenants = Object.values(tenantOptions); + + const setTenant = (tenant, clientId) => { + localStorage.setItem('tenant', JSON.stringify({ tenantName: tenant, clientId })); + stripes.store.dispatch(setOkapiTenant({ tenant, clientId })); + }; useEffect(() => { if (okapi.authnUrl) { @@ -17,23 +25,26 @@ const AuthnLogin = ({ stripes }) => { */ setUnauthorizedPathToSession(window.location.pathname); } + + // If only 1 tenant is defined in config (in either okapi or config.tenantOptions) set to okapi to be accessed there + // in the rest of the application for compatibity across existing modules. + if (tenants.length === 1) { + const loginTenant = tenants[0]; + setTenant(loginTenant.name, loginTenant.clientId); + } // we only want to run this effect once, on load. - // okapi.authnUrl are defined in stripes.config.js + // okapi.authnUrl tenant values are defined in stripes.config.js }, []); // eslint-disable-line react-hooks/exhaustive-deps if (okapi.authnUrl) { - if (config.isSingleTenant) { + // If only 1 tenant is defined in config, skip the tenant selection screen. + if (tenants.length === 1) { const redirectUri = `${window.location.protocol}//${window.location.host}/oidc-landing`; const authnUri = `${okapi.authnUrl}/realms/${okapi.tenant}/protocol/openid-connect/auth?client_id=${okapi.clientId}&response_type=code&redirect_uri=${redirectUri}&scope=openid`; return ; } - const handleSelectTenant = (tenant, clientId) => { - localStorage.setItem('tenant', JSON.stringify({ tenantName: tenant, clientId })); - stripes.store.dispatch(setOkapiTenant({ tenant, clientId })); - }; - - return ; + return ; } return { const store = useStore(); // const samlError = useRef(); const { okapi } = useStripes(); - const [potp, setPotp] = useState(); const [samlError, setSamlError] = useState(); diff --git a/src/components/OIDCLanding.test.js b/src/components/OIDCLanding.test.js new file mode 100644 index 000000000..f7e199c50 --- /dev/null +++ b/src/components/OIDCLanding.test.js @@ -0,0 +1,85 @@ +import { render, screen, waitFor } from '@folio/jest-config-stripes/testing-library/react'; + +import OIDCLanding from './OIDCLanding'; + +jest.mock('react-router-dom', () => ({ + useLocation: () => ({ + search: 'session_state=dead-beef&code=c0ffee' + }), + Redirect: () => <>Redirect, +})); + +jest.mock('react-redux', () => ({ + useStore: () => { }, +})); + +jest.mock('../StripesContext', () => ({ + useStripes: () => ({ + okapi: { url: 'https://whaterver' }, + config: { tenantOptions: { diku: { name: 'diku', clientId: 'diku-application' } } }, + }), +})); + +// jest.mock('../loginServices'); + + +const mockSetTokenExpiry = jest.fn(); +const mockRequestUserWithPerms = jest.fn(); +const mockFoo = jest.fn(); +jest.mock('../loginServices', () => ({ + setTokenExpiry: () => mockSetTokenExpiry(), + requestUserWithPerms: () => mockRequestUserWithPerms(), + foo: () => mockFoo(), +})); + + +// fetch success: resolve promise with ok == true and $data in json() +const mockFetchSuccess = (data) => { + global.fetch = jest.fn().mockImplementation(() => ( + Promise.resolve({ + ok: true, + json: () => Promise.resolve(data), + headers: new Map(), + }) + )); +}; + +// fetch failure: resolve promise with ok == false and $error in json() +const mockFetchError = (error) => { + global.fetch = jest.fn().mockImplementation(() => ( + Promise.resolve({ + ok: false, + json: () => Promise.resolve(error), + headers: new Map(), + }) + )); +}; + +// restore default fetch impl +const mockFetchCleanUp = () => { + global.fetch.mockClear(); + delete global.fetch; +}; + +describe('OIDCLanding', () => { + it('calls requestUserWithPerms, setTokenExpiry on success', async () => { + mockFetchSuccess({ + accessTokenExpiration: '2024-05-23T09:47:17.000-04:00', + refreshTokenExpiration: '2024-05-23T10:07:17.000-04:00', + }); + + await render(); + screen.getByText('Loading'); + await waitFor(() => expect(mockSetTokenExpiry).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mockRequestUserWithPerms).toHaveBeenCalledTimes(1)); + mockFetchCleanUp(); + }); + + it('displays an error on failure', async () => { + mockFetchError('barf'); + + await render(); + await screen.findByText('errors.saml.missingToken'); + mockFetchCleanUp(); + }); +}); diff --git a/src/components/OIDCRedirect.test.js b/src/components/OIDCRedirect.test.js index 3b9c83c3a..48c8a0c4d 100644 --- a/src/components/OIDCRedirect.test.js +++ b/src/components/OIDCRedirect.test.js @@ -25,7 +25,7 @@ describe('OIDCRedirect', () => { afterAll(() => sessionStorage.removeItem('unauthorized_path')); it('redirects to value from session storage under unauthorized_path key', () => { - useStripes.mockReturnValue({ okapi:{ authnUrl: 'http://example.com/authn' } }); + useStripes.mockReturnValue({ okapi: { authnUrl: 'http://example.com/authn' } }); render(); expect(screen.getByText(/internalredirect/)).toBeInTheDocument(); From df755ea813ddda40357c19a1e9e9103aab224e91 Mon Sep 17 00:00:00 2001 From: Ryan Berger Date: Mon, 24 Jun 2024 09:11:14 -0400 Subject: [PATCH 10/17] STCOR-787 Fix tenant and clientId references (#1492) * Ensure okapi is being read from store after pulling from tenantOptions in AuthLogin (cherry picked from commit eed1ba5c25e6591eb298a98d4d91beea2c4d29f3) --- src/components/AuthnLogin/AuthnLogin.js | 3 ++- src/components/Root/FFetch.js | 11 ++++++----- src/components/Root/token-util.js | 4 ++-- src/components/Root/token-util.test.js | 15 ++++++++++----- src/components/SSOLanding/useSSOSession.js | 6 +++--- src/components/SSOLanding/useSSOSession.test.js | 9 ++++++++- src/discoverServices.js | 8 ++++---- 7 files changed, 35 insertions(+), 21 deletions(-) diff --git a/src/components/AuthnLogin/AuthnLogin.js b/src/components/AuthnLogin/AuthnLogin.js index 4ae5a020d..0b005de14 100644 --- a/src/components/AuthnLogin/AuthnLogin.js +++ b/src/components/AuthnLogin/AuthnLogin.js @@ -39,8 +39,9 @@ const AuthnLogin = ({ stripes }) => { if (okapi.authnUrl) { // If only 1 tenant is defined in config, skip the tenant selection screen. if (tenants.length === 1) { + const loginTenant = tenants[0]; const redirectUri = `${window.location.protocol}//${window.location.host}/oidc-landing`; - const authnUri = `${okapi.authnUrl}/realms/${okapi.tenant}/protocol/openid-connect/auth?client_id=${okapi.clientId}&response_type=code&redirect_uri=${redirectUri}&scope=openid`; + const authnUri = `${okapi.authnUrl}/realms/${loginTenant.name}/protocol/openid-connect/auth?client_id=${loginTenant.clientId}&response_type=code&redirect_uri=${redirectUri}&scope=openid`; return ; } diff --git a/src/components/Root/FFetch.js b/src/components/Root/FFetch.js index 21c5b4c73..90eade94e 100644 --- a/src/components/Root/FFetch.js +++ b/src/components/Root/FFetch.js @@ -42,7 +42,7 @@ */ import ms from 'ms'; -import { okapi } from 'stripes-config'; +import { okapi as okapiConfig } from 'stripes-config'; import { setRtrTimeout } from '../../okapiActions'; @@ -112,7 +112,8 @@ export class FFetch { rotationP.then((rotationInterval) => { this.logger.log('rtr', `rotation fired from rotateCallback; next callback in ${ms(rotationInterval)}`); this.store.dispatch(setRtrTimeout(setTimeout(() => { - rtr(this.nativeFetch, this.logger, this.rotateCallback); + const { okapi } = this.store.getState(); + rtr(this.nativeFetch, this.logger, this.rotateCallback, okapi); }, rotationInterval))); }); }; @@ -173,12 +174,12 @@ export class FFetch { */ ffetch = async (resource, options = {}) => { // FOLIO API requests are subject to RTR - if (isFolioApiRequest(resource, okapi.url)) { + if (isFolioApiRequest(resource, okapiConfig.url)) { this.logger.log('rtrv', 'will fetch', resource); // on authentication, grab the response to kick of the rotation cycle, // then return the response - if (isAuthenticationRequest(resource, okapi.url)) { + if (isAuthenticationRequest(resource, okapiConfig.url)) { this.logger.log('rtr', 'authn request'); return this.nativeFetch.apply(global, [resource, options && { ...options, ...OKAPI_FETCH_OPTIONS }]) .then(res => { @@ -207,7 +208,7 @@ export class FFetch { // tries to logout, the logout request will fail. And that's fine, just // fine. We will let them fail, capturing the response and swallowing it // to avoid getting stuck in an error loop. - if (isLogoutRequest(resource, okapi.url)) { + if (isLogoutRequest(resource, okapiConfig.url)) { this.logger.log('rtr', 'logout request'); return this.nativeFetch.apply(global, [resource, options && { ...options, ...OKAPI_FETCH_OPTIONS }]) diff --git a/src/components/Root/token-util.js b/src/components/Root/token-util.js index c1a147024..32ede2604 100644 --- a/src/components/Root/token-util.js +++ b/src/components/Root/token-util.js @@ -1,5 +1,4 @@ import { isEmpty } from 'lodash'; -import { okapi } from 'stripes-config'; import { getTokenExpiry, setTokenExpiry } from '../../loginServices'; import { RTRError, UnexpectedResourceError } from './Errors'; @@ -192,9 +191,10 @@ export const isRotating = () => { * @param {function} fetchfx native fetch function * @param {@folio/stripes/logger} logger * @param {function} callback + * @param {object} okapi * @returns void */ -export const rtr = (fetchfx, logger, callback) => { +export const rtr = (fetchfx, logger, callback, okapi) => { logger.log('rtr', '** RTR ...'); // rotation is already in progress, maybe in this window, diff --git a/src/components/Root/token-util.test.js b/src/components/Root/token-util.test.js index c103df9c1..b64cfac73 100644 --- a/src/components/Root/token-util.test.js +++ b/src/components/Root/token-util.test.js @@ -14,6 +14,11 @@ import { } from './token-util'; import { RTR_SUCCESS_EVENT } from './constants'; +const okapi = { + tenant: 'diku', + url: 'http://test' +}; + describe('isFolioApiRequest', () => { it('accepts requests whose origin matches okapi\'s', () => { const oUrl = 'https://millicent-sounds-kinda-like-malificent.edu'; @@ -127,7 +132,7 @@ describe('rtr', () => { let ex = null; // const callback = () => { console.log('HOLA!!!')}; // jest.fn(); try { - await rtr(fetchfx, logger, callback); + await rtr(fetchfx, logger, callback, okapi); expect(callback).toHaveBeenCalled(); } catch (e) { ex = e; @@ -169,7 +174,7 @@ describe('rtr', () => { let ex = null; try { - await rtr(nativeFetch, logger, jest.fn()); + await rtr(nativeFetch, logger, jest.fn(), okapi); } catch (e) { ex = e; } @@ -203,7 +208,7 @@ describe('rtr', () => { let ex = null; try { - await rtr(nativeFetch, logger, jest.fn()); + await rtr(nativeFetch, logger, jest.fn(), okapi); } catch (e) { ex = e; } @@ -233,7 +238,7 @@ describe('rtr', () => { let ex = null; try { - await rtr(nativeFetch, logger, jest.fn()); + await rtr(nativeFetch, logger, jest.fn(), okapi); } catch (e) { ex = e; } @@ -263,7 +268,7 @@ describe('rtr', () => { let ex = null; try { - await rtr(nativeFetch, logger, jest.fn()); + await rtr(nativeFetch, logger, jest.fn(), okapi); } catch (e) { ex = e; } diff --git a/src/components/SSOLanding/useSSOSession.js b/src/components/SSOLanding/useSSOSession.js index 8242f111f..8e71b01c4 100644 --- a/src/components/SSOLanding/useSSOSession.js +++ b/src/components/SSOLanding/useSSOSession.js @@ -23,12 +23,12 @@ const getToken = (cookies, params) => { return cookies?.ssoToken || params?.ssoToken; }; -const getTenant = (params, token) => { +const getTenant = (params, token, store) => { const tenant = config.useSecureTokens ? params?.tenantId : parseJWT(token)?.tenant; - return tenant || okapi.tenant; + return tenant || store.getState()?.okapi?.tenant; }; const useSSOSession = () => { @@ -42,7 +42,7 @@ const useSSOSession = () => { const params = getParams(location); const token = getToken(cookies, params); - const tenant = getTenant(params, token); + const tenant = getTenant(params, token, store); useEffect(() => { requestUserWithPerms(okapi.url, store, tenant, token) diff --git a/src/components/SSOLanding/useSSOSession.test.js b/src/components/SSOLanding/useSSOSession.test.js index 2a0e4cd0c..06eac936e 100644 --- a/src/components/SSOLanding/useSSOSession.test.js +++ b/src/components/SSOLanding/useSSOSession.test.js @@ -51,7 +51,14 @@ describe('SSOLanding', () => { useLocation.mockReturnValue({ search: '' }); useCookies.mockReturnValue([]); - useStore.mockReturnValue({ getState: jest.fn() }); + useStore.mockReturnValue({ + getState: jest.fn().mockReturnValue({ + okapi: { + url: 'okapiUrl', + tenant: 'okapiTenant' + } + }) + }); requestUserWithPerms.mockReturnValue(Promise.resolve()); }); diff --git a/src/discoverServices.js b/src/discoverServices.js index 04a888423..1a3d5d847 100644 --- a/src/discoverServices.js +++ b/src/discoverServices.js @@ -96,7 +96,7 @@ function parseApplicationDescriptor(store, descriptor) { const APP_MAX_COUNT = 500; function fetchApplicationDetails(store) { - const okapi = store.getState().okapi; + const { okapi } = store.getState(); return fetch(`${okapi.url}/entitlements/${okapi.tenant}/applications?limit=${APP_MAX_COUNT}`, { credentials: 'include', @@ -146,7 +146,7 @@ function fetchApplicationDetails(store) { */ function fetchGatewayVersion(store) { - const okapi = store.getState().okapi; + const { okapi } = store.getState(); return fetch(`${okapi.url}/version`, { credentials: 'include', @@ -167,7 +167,7 @@ function fetchGatewayVersion(store) { } function fetchOkapiVersion(store) { - const okapi = store.getState().okapi; + const { okapi } = store.getState(); return fetch(`${okapi.url}/_/version`, { credentials: 'include', @@ -188,7 +188,7 @@ function fetchOkapiVersion(store) { } function fetchModules(store) { - const okapi = store.getState().okapi; + const { okapi } = store.getState(); return fetch(`${okapi.url}/_/proxy/tenants/${okapi.tenant}/modules?full=true`, { credentials: 'include', From dede048536aea078d3e59674ab022eaf26d3d02c Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Tue, 25 Jun 2024 16:55:39 -0400 Subject: [PATCH 11/17] STCOR-864 correctly evaluate typeof stripes.okapi (#1498) Stripes should render `` either when discovery is complete or when okapi isn't present at all, i.e. when `stripes.config.js` doesn't even contain an `okapi` entry. What's most amazing about this bug is not the bug, which is a relatively simple typo, but that it didn't bite us for more than six years. BTOG init never conducted discovery, but _did_ pass an okapi object during application setup, which is another way of saying that our application didn't have anything that relied on the presence of this bug, but our test suite did. :| Ignore the "new" AuthnLogin test file; those tests were previously stashed in `RootWithIntl.test.js` for some reason and have just been relocated. Refs STCOR-864 (cherry picked from commit 6201292a2d851082a794122ddec607489e71a966) --- CHANGELOG.md | 1 + src/RootWithIntl.js | 2 +- src/RootWithIntl.test.js | 126 ++++++++++--------- src/components/AuthnLogin/AuthnLogin.test.js | 76 +++++++++++ test/bigtest/helpers/setup-application.js | 3 + test/jest/__mock__/stripesComponents.mock.js | 1 + 6 files changed, 148 insertions(+), 61 deletions(-) create mode 100644 src/components/AuthnLogin/AuthnLogin.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 053b042e1..7784399c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * Use keycloak URLs in place of users-bl for tenant-switch. Refs US1153537. * Idle-session timeout and "Keep working?" modal. Refs STCOR-776. * 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 ``. Refs STCOR-864. ## [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) diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index 5c4c2f749..0a1850e98 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -73,7 +73,7 @@ const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAut event={events.LOGIN} stripes={connectedStripes} /> - { (connectedStripes.okapi !== 'object' || connectedStripes.discovery.isFinished) && ( + { (typeof connectedStripes.okapi !== 'object' || connectedStripes.discovery.isFinished) && ( {connectedStripes.config.useSecureTokens && } diff --git a/src/RootWithIntl.test.js b/src/RootWithIntl.test.js index 4da7f32b9..6aa1babad 100644 --- a/src/RootWithIntl.test.js +++ b/src/RootWithIntl.test.js @@ -2,25 +2,34 @@ /* eslint-disable no-unused-vars */ import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import { Router as DefaultRouter } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; -import { Redirect as InternalRedirect } from 'react-router-dom'; -import Redirect from './components/Redirect'; -import { Login } from './components'; -import PreLoginLanding from './components/PreLoginLanding'; +import AuthnLogin from './components/AuthnLogin'; +import MainNav from './components/MainNav'; +import MainContainer from './components/MainContainer'; +import ModuleContainer from './components/ModuleContainer'; +import RootWithIntl from './RootWithIntl'; +import Stripes from './Stripes'; -// import { -// renderLogoutComponent -// } from './RootWithIntl'; +jest.mock('./components/AuthnLogin', () => () => ''); +jest.mock('./components/MainNav', () => () => ''); +jest.mock('./components/ModuleContainer', () => () => ''); +jest.mock('./components/MainContainer', () => ({ children }) => children); -import AuthnLogin from './components/AuthnLogin'; +const defaultHistory = createMemoryHistory(); -jest.mock('react-router-dom', () => ({ - Redirect: () => '', - withRouter: (Component) => Component, -})); -jest.mock('./components/Redirect', () => () => ''); -jest.mock('./components/Login', () => () => ''); -jest.mock('./components/PreLoginLanding', () => () => ''); +const Harness = ({ + Router = DefaultRouter, + children, + history = defaultHistory, +}) => { + return ( + + {children} + + ); +}; const store = { getState: () => ({ @@ -34,56 +43,53 @@ const store = { }; describe('RootWithIntl', () => { - describe('AuthnLogin', () => { - it('handles legacy login', () => { - const stripes = { okapi: {}, config: {}, store }; - render(); + it('renders login without one of (isAuthenticated, token, disableAuth)', async () => { + const stripes = new Stripes({ epics: {}, logger: {}, bindings: {}, config: {}, store, discovery: { isFinished: false } }); + await render(); + + expect(screen.getByText(//)).toBeInTheDocument(); + expect(screen.queryByText(//)).toBeNull(); + }); + + describe('renders MainNav', () => { + it('given isAuthenticated', async () => { + const stripes = new Stripes({ epics: {}, logger: {}, bindings: {}, config: {}, store, discovery: { isFinished: false } }); + await render(); - expect(screen.getByText(//)).toBeInTheDocument(); + expect(screen.queryByText(//)).toBeNull(); + expect(screen.queryByText(//)).toBeInTheDocument(); }); - describe('handles third-party login', () => { - it('handles single-tenant', () => { - const stripes = { - okapi: { authnUrl: 'https://barbie.com' }, - config: { - isSingleTenant: true, - tenantOptions: { - diku: { name: 'diku', clientId: 'diku-application' } - } - }, - store - }; - render(); - - expect(screen.getByText(//)).toBeInTheDocument(); - }); - - it('handles multi-tenant', () => { - const stripes = { - okapi: { authnUrl: 'https://oppie.com' }, - config: { - isSingleTenant: false, - tenantOptions: { - diku: { name: 'diku', clientId: 'diku-application' }, - diku2: { name: 'diku2', clientId: 'diku2-application' } - } - }, - store - }; - render(); - - expect(screen.getByText(//)).toBeInTheDocument(); - }); + it('given token', async () => { + const stripes = new Stripes({ epics: {}, logger: {}, bindings: {}, config: {}, store, discovery: { isFinished: false } }); + await render(); + + expect(screen.queryByText(//)).toBeNull(); + expect(screen.queryByText(//)).toBeInTheDocument(); + }); + + it('given disableAuth', async () => { + const stripes = new Stripes({ epics: {}, logger: {}, bindings: {}, config: {}, store, discovery: { isFinished: false } }); + await render(); + + expect(screen.queryByText(//)).toBeNull(); + expect(screen.queryByText(//)).toBeInTheDocument(); }); }); - // describe('renderLogoutComponent', () => { - // it('handles legacy logout', () => { - // const stripes = { okapi: {}, config: {} }; - // render(renderLogoutComponent(stripes)); + describe('renders ModuleContainer', () => { + it('if config.okapi is not an object', async () => { + const stripes = new Stripes({ epics: {}, logger: {}, bindings: {}, config: {}, store, discovery: { isFinished: true } }); + await render(); + + expect(screen.getByText(//)).toBeInTheDocument(); + }); - // expect(screen.getByText(//)).toBeInTheDocument(); - // }); - // }); + it('if discovery is finished', async () => { + const stripes = new Stripes({ epics: {}, logger: {}, bindings: {}, config: {}, store, okapi: {}, discovery: { isFinished: true } }); + await render(); + + expect(screen.getByText(//)).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/AuthnLogin/AuthnLogin.test.js b/src/components/AuthnLogin/AuthnLogin.test.js new file mode 100644 index 000000000..e8aa7ffdb --- /dev/null +++ b/src/components/AuthnLogin/AuthnLogin.test.js @@ -0,0 +1,76 @@ +/* shhhh, eslint, it's ok. we need "unused" imports for mocks */ +/* eslint-disable no-unused-vars */ + +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; + +import { Redirect as InternalRedirect } from 'react-router-dom'; +import Redirect from '../Redirect'; +import Login from '../Login'; +import PreLoginLanding from '../PreLoginLanding'; + +import AuthnLogin from './AuthnLogin'; + +jest.mock('react-router-dom', () => ({ + Redirect: () => '', + withRouter: (Component) => Component, +})); +jest.mock('../Redirect', () => () => ''); +jest.mock('../Login', () => () => ''); +jest.mock('../PreLoginLanding', () => () => ''); + +const store = { + getState: () => ({ + okapi: { + token: '123', + }, + }), + dispatch: () => {}, + subscribe: () => {}, + replaceReducer: () => {}, +}; + +describe('RootWithIntl', () => { + describe('AuthnLogin', () => { + it('handles legacy login', () => { + const stripes = { okapi: {}, config: {}, store }; + render(); + + expect(screen.getByText(//)).toBeInTheDocument(); + }); + + describe('handles third-party login', () => { + it('handles single-tenant', () => { + const stripes = { + okapi: { authnUrl: 'https://barbie.com' }, + config: { + isSingleTenant: true, + tenantOptions: { + diku: { name: 'diku', clientId: 'diku-application' } + } + }, + store + }; + render(); + + expect(screen.getByText(//)).toBeInTheDocument(); + }); + + it('handles multi-tenant', () => { + const stripes = { + okapi: { authnUrl: 'https://oppie.com' }, + config: { + isSingleTenant: false, + tenantOptions: { + diku: { name: 'diku', clientId: 'diku-application' }, + diku2: { name: 'diku2', clientId: 'diku2-application' } + } + }, + store + }; + render(); + + expect(screen.getByText(//)).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/test/bigtest/helpers/setup-application.js b/test/bigtest/helpers/setup-application.js index a1b15d139..488a2f570 100644 --- a/test/bigtest/helpers/setup-application.js +++ b/test/bigtest/helpers/setup-application.js @@ -53,6 +53,9 @@ export default function setupApplication({ currentPerms: permissions, isAuthenticated: true, }; + initialState.discovery = { + isFinished: true, + }; } else { initialState.okapi = { ssoEnabled: true, diff --git a/test/jest/__mock__/stripesComponents.mock.js b/test/jest/__mock__/stripesComponents.mock.js index 105990a4a..d24089613 100644 --- a/test/jest/__mock__/stripesComponents.mock.js +++ b/test/jest/__mock__/stripesComponents.mock.js @@ -48,6 +48,7 @@ jest.mock('@folio/stripes-components', () => ({ {children} )), Headline: jest.fn(({ children }) =>
{ children }
), + HotKeys: jest.fn(({ children }) => <>{ children }), Icon: jest.fn((props) => (props && props.children ? props.children : )), IconButton: jest.fn(({ buttonProps, From aa9b1d3eba8253d006dd84718ca26dd32ea34b4a Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Mon, 8 Jul 2024 08:22:23 -0400 Subject: [PATCH 12/17] STCOR-865 call logout() exclusively from logout-* routes (#1500) Two things happen when idle-session-timeout kicks in: 1. the redux store is updated to clear out the session 2. the URL is updated to `/logout-timeout` It sounds simple, but it gets messy when `` re-renders when the store updates because that's where routes are defined. Previously, with event-handlers separately calling `logout()` to update the store and `history.push()` to update the URL, you could end up in an unexpected situation such as being logged-out before the URL updated to `/logout-timeout`, causing the default route-match handler to kick in and redirect to the login screen. The changes here consolidate calls to `logout()` into the components bound to `/logout` (``) and `/logout-timeout` (``). Event handlers that previously did things like ``` return logout(...) // update redux and other storage .then(history.push(...)) // update URL ``` are now limited to updating the URL. This means directly accessing the routes `/logout` and `/logout-timeout` always terminates a session, and the logic around logout is both simpler and better contained within components whose purpose, by dint of their names, is blindingly clear. The minor changes in `` are just clean-up work, removing cruft that is no longer in use. Refs STCOR-865 (cherry picked from commit 8daa26711c1435f9853a53f2603d285e011c3398) --- src/components/LogoutTimeout/LogoutTimeout.js | 33 +++++++++++++++---- .../LogoutTimeout/LogoutTimeout.test.js | 11 +++++-- src/components/MainNav/MainNav.js | 15 +-------- .../SessionEventContainer.js | 22 +++---------- .../SessionEventContainer.test.js | 20 ++++------- test/jest/__mock__/stripesComponents.mock.js | 1 + 6 files changed, 48 insertions(+), 54 deletions(-) diff --git a/src/components/LogoutTimeout/LogoutTimeout.js b/src/components/LogoutTimeout/LogoutTimeout.js index 2eda11421..e0af76f17 100644 --- a/src/components/LogoutTimeout/LogoutTimeout.js +++ b/src/components/LogoutTimeout/LogoutTimeout.js @@ -1,36 +1,55 @@ +import { useEffect, useState } from 'react'; import { FormattedMessage } from 'react-intl'; import { branding } from 'stripes-config'; -import { Redirect } from 'react-router'; import { Button, Col, Headline, + LoadingView, Row, } from '@folio/stripes-components'; import OrganizationLogo from '../OrganizationLogo'; import { useStripes } from '../../StripesContext'; +import { logout } from '../../loginServices'; import styles from './LogoutTimeout.css'; /** * LogoutTimeout - * For unauthenticated users, show a "sorry, your session timed out" message - * with a link to login page. For authenticated users, redirect to / since - * showing such a message would be a misleading lie. + * Show a "sorry, your session timed out message"; if the session is still + * active, call logout() to end it. * * Having a static route to this page allows the logout handler to choose * between redirecting straight to the login page (if the user chose to - * logout) or to this page (if the session timeout out). + * logout) or to this page (if the session timed out). * * This corresponds to the '/logout-timeout' route. */ const LogoutTimeout = () => { const stripes = useStripes(); + const [didLogout, setDidLogout] = useState(false); - if (stripes.okapi.isAuthenticated) { - return ; + useEffect( + () => { + if (stripes.okapi.isAuthenticated) { + // returns a promise, which we ignore + logout(stripes.okapi.url, stripes.store) + .then(setDidLogout(true)); + } else { + setDidLogout(true); + } + }, + // no dependencies because we only want to start the logout process once. + // we don't care about changes to stripes; certainly it'll be updated as + // part of the logout process + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + if (!didLogout) { + return ; } return ( diff --git a/src/components/LogoutTimeout/LogoutTimeout.test.js b/src/components/LogoutTimeout/LogoutTimeout.test.js index 55c9c9b5b..6efff5e0c 100644 --- a/src/components/LogoutTimeout/LogoutTimeout.test.js +++ b/src/components/LogoutTimeout/LogoutTimeout.test.js @@ -2,6 +2,8 @@ import { render, screen } from '@folio/jest-config-stripes/testing-library/react import LogoutTimeout from './LogoutTimeout'; import { useStripes } from '../../StripesContext'; +import { logout } from '../../loginServices'; + jest.mock('../OrganizationLogo'); @@ -10,6 +12,10 @@ jest.mock('react-router', () => ({ Redirect: () =>
Redirect
, })); +jest.mock('../../loginServices', () => ({ + logout: jest.fn(() => Promise.resolve()), +})); + describe('LogoutTimeout', () => { it('if not authenticated, renders a timeout message', async () => { const mockUseStripes = useStripes; @@ -19,11 +25,12 @@ describe('LogoutTimeout', () => { screen.getByText('stripes-core.rtr.idleSession.sessionExpiredSoSad'); }); - it('if authenticated, renders a redirect', async () => { + it('if authenticated, calls logout then renders a timeout message', async () => { const mockUseStripes = useStripes; mockUseStripes.mockReturnValue({ okapi: { isAuthenticated: true } }); render(); - screen.getByText('Redirect'); + expect(logout).toHaveBeenCalled(); + screen.getByText('stripes-core.rtr.idleSession.sessionExpiredSoSad'); }); }); diff --git a/src/components/MainNav/MainNav.js b/src/components/MainNav/MainNav.js index 856f82a44..85f1dfb57 100644 --- a/src/components/MainNav/MainNav.js +++ b/src/components/MainNav/MainNav.js @@ -11,7 +11,6 @@ import { Icon } from '@folio/stripes-components'; import { withModules } from '../Modules'; import { LastVisitedContext } from '../LastVisited'; -import { getLocale, logout as sessionLogout } from '../../loginServices'; import { updateQueryResource, getLocationQuery, @@ -65,7 +64,6 @@ class MainNav extends Component { userMenuOpen: false, }; this.store = props.stripes.store; - this.logout = this.logout.bind(this); this.getAppList = this.getAppList.bind(this); } @@ -116,14 +114,6 @@ class MainNav extends Component { }); } - // return the user to the login screen, but after logging in they will be brought to the default screen. - logout() { - const { okapi } = this.store.getState(); - - return getLocale(okapi.url, this.store, okapi.tenant) - .then(sessionLogout(okapi.url, this.store, this.props.history)); - } - getAppList(lastVisited) { const { stripes, location: { pathname }, modules, intl: { formatMessage } } = this.props; @@ -213,10 +203,7 @@ class MainNav extends Component { target="_blank" /> - + ); diff --git a/src/components/SessionEventContainer/SessionEventContainer.js b/src/components/SessionEventContainer/SessionEventContainer.js index ff1707764..21ca8f4e1 100644 --- a/src/components/SessionEventContainer/SessionEventContainer.js +++ b/src/components/SessionEventContainer/SessionEventContainer.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import createInactivityTimer from 'inactivity-timer'; import ms from 'ms'; -import { logout, SESSION_NAME } from '../../loginServices'; +import { SESSION_NAME } from '../../loginServices'; import KeepWorkingModal from './KeepWorkingModal'; import { useStripes } from '../../StripesContext'; import { @@ -21,19 +21,13 @@ import { toggleRtrModal } from '../../okapiActions'; // RTR error in this window: logout export const thisWindowRtrError = (_e, stripes, history) => { console.warn('rtr error; logging out'); // eslint-disable-line no-console - return logout(stripes.okapi.url, stripes.store) - .then(() => { - history.push('/logout-timeout'); - }); + history.push('/logout-timeout'); }; // idle session timeout in this window: logout export const thisWindowRtrTimeout = (_e, stripes, history) => { stripes.logger.log('rtr', 'idle session timeout; logging out'); - return logout(stripes.okapi.url, stripes.store) - .then(() => { - history.push('/logout-timeout'); - }); + history.push('/logout-timeout'); }; // localstorage change in another window: logout? @@ -43,16 +37,10 @@ export const thisWindowRtrTimeout = (_e, stripes, history) => { export const otherWindowStorage = (e, stripes, history) => { if (e.key === RTR_TIMEOUT_EVENT) { stripes.logger.log('rtr', 'idle session timeout; logging out'); - return logout(stripes.okapi.url, stripes.store) - .then(() => { - history.push('/logout-timeout'); - }); + history.push('/logout-timeout'); } else if (!localStorage.getItem(SESSION_NAME)) { stripes.logger.log('rtr', 'external localstorage change; logging out'); - return logout(stripes.okapi.url, stripes.store) - .then(() => { - history.push('/'); - }); + history.push('/logout'); } return Promise.resolve(); }; diff --git a/src/components/SessionEventContainer/SessionEventContainer.test.js b/src/components/SessionEventContainer/SessionEventContainer.test.js index 09b68f19a..24d9fd04c 100644 --- a/src/components/SessionEventContainer/SessionEventContainer.test.js +++ b/src/components/SessionEventContainer/SessionEventContainer.test.js @@ -9,7 +9,7 @@ import SessionEventContainer, { thisWindowRtrError, thisWindowRtrTimeout, } from './SessionEventContainer'; -import { logout, SESSION_NAME } from '../../loginServices'; +import { SESSION_NAME } from '../../loginServices'; import { RTR_TIMEOUT_EVENT } from '../Root/constants'; import { toggleRtrModal } from '../../okapiActions'; @@ -69,11 +69,8 @@ describe('SessionEventContainer', () => { describe('SessionEventContainer event listeners', () => { it('thisWindowRtrError', async () => { const history = { push: jest.fn() }; - const logoutMock = logout; - logoutMock.mockReturnValue(Promise.resolve()); - await thisWindowRtrError(null, { okapi: { url: 'http' } }, history); - expect(logout).toHaveBeenCalled(); + thisWindowRtrError(null, { okapi: { url: 'http' } }, history); expect(history.push).toHaveBeenCalledWith('/logout-timeout'); }); @@ -89,11 +86,8 @@ describe('SessionEventContainer event listeners', () => { }; const history = { push: jest.fn() }; - const logoutMock = logout; - await logoutMock.mockReturnValue(Promise.resolve()); - await thisWindowRtrTimeout(null, s, history); - expect(logout).toHaveBeenCalled(); + thisWindowRtrTimeout(null, s, history); expect(history.push).toHaveBeenCalledWith('/logout-timeout'); }); @@ -115,8 +109,7 @@ describe('SessionEventContainer event listeners', () => { }; const history = { push: jest.fn() }; - await otherWindowStorage(e, s, history); - expect(logout).toHaveBeenCalledWith(s.okapi.url, s.store); + otherWindowStorage(e, s, history); expect(history.push).toHaveBeenCalledWith('/logout-timeout'); }); @@ -133,9 +126,8 @@ describe('SessionEventContainer event listeners', () => { }; const history = { push: jest.fn() }; - await otherWindowStorage(e, s, history); - expect(logout).toHaveBeenCalledWith(s.okapi.url, s.store); - expect(history.push).toHaveBeenCalledWith('/'); + otherWindowStorage(e, s, history); + expect(history.push).toHaveBeenCalledWith('/logout'); }); }); diff --git a/test/jest/__mock__/stripesComponents.mock.js b/test/jest/__mock__/stripesComponents.mock.js index d24089613..0747a52a4 100644 --- a/test/jest/__mock__/stripesComponents.mock.js +++ b/test/jest/__mock__/stripesComponents.mock.js @@ -75,6 +75,7 @@ jest.mock('@folio/stripes-components', () => ({ )), Loading: () =>
Loading
, + LoadingView: () =>
LoadingView
, MessageBanner: jest.fn(({ show, children }) => { return show ? <>{children} : <>; }), // oy, dismissible. we need to pull it out of props so it doesn't From 3cc5d0429779385af00fb3575cc2906e2eed4dac Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Mon, 15 Jul 2024 23:15:16 -0400 Subject: [PATCH 13/17] STCOR-866 include `/users-keycloak/_self` in auth-n requests (#1502) The RTR cycle is kicked off when processing the response from an authentication-related request. `/users-keycloak/_self` was missing from the list, which meant that RTR would never kick off when a new tab was opened for an existing session. Refs STCOR-866 (cherry picked from commit f93f21d68c8c4b30a5a748ab5b1bf6db16339417) --- CHANGELOG.md | 2 ++ src/components/Root/token-util.js | 1 + 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7784399c9..528c5811f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # 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) @@ -8,6 +9,7 @@ * Idle-session timeout and "Keep working?" modal. Refs STCOR-776. * 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 ``. Refs STCOR-864. +* `/users-keycloak/_self` is an authentication request. Refs STCOR-866. ## [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) diff --git a/src/components/Root/token-util.js b/src/components/Root/token-util.js index 32ede2604..0e70ba012 100644 --- a/src/components/Root/token-util.js +++ b/src/components/Root/token-util.js @@ -74,6 +74,7 @@ export const isAuthenticationRequest = (resource, oUrl) => { '/authn/token', '/bl-users/login-with-expiry', '/bl-users/_self', + '/users-keycloak/_self', ]; return !!permissible.find(i => string.startsWith(`${oUrl}${i}`)); From 9a7b8d499844dd2abb8c60d65d6f432bc274f209 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Mon, 22 Jul 2024 16:50:04 -0400 Subject: [PATCH 14/17] STCOR-862 terminate session when fixed-length session expires (#1503) 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 8b5274e7a2070ec54a3fd30cfc9f375b024cd29e) --- CHANGELOG.md | 2 +- src/components/AuthnLogin/AuthnLogin.js | 21 +++- src/components/Root/FFetch.js | 110 +++++++++++++----- src/components/Root/FFetch.test.js | 96 +++++++++++++-- src/components/Root/Root.js | 10 ++ src/components/Root/constants.js | 34 +++++- src/components/Root/token-util.js | 7 ++ .../FixedLengthSessionWarning.js | 54 +++++++++ .../FixedLengthSessionWarning.test.js | 59 ++++++++++ .../SessionEventContainer.js | 50 ++++++-- .../SessionEventContainer.test.js | 6 +- src/loginServices.js | 7 +- src/okapiActions.js | 68 +++++++---- src/okapiReducer.js | 97 ++++++++++----- src/okapiReducer.test.js | 49 ++++++-- translations/stripes-core/en.json | 5 +- translations/stripes-core/en_US.json | 4 +- 17 files changed, 553 insertions(+), 126 deletions(-) create mode 100644 src/components/SessionEventContainer/FixedLengthSessionWarning.js create mode 100644 src/components/SessionEventContainer/FixedLengthSessionWarning.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 528c5811f..1ecadea6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) @@ -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 ``. 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) diff --git a/src/components/AuthnLogin/AuthnLogin.js b/src/components/AuthnLogin/AuthnLogin.js index 0b005de14..093c3db68 100644 --- a/src/components/AuthnLogin/AuthnLogin.js +++ b/src/components/AuthnLogin/AuthnLogin.js @@ -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 + * but if the user was de-authenticated due to a session timeout, they + * will have a history something like + * /some-interesting-path + * /logout + * / + * 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); } diff --git a/src/components/Root/FFetch.js b/src/components/Root/FFetch.js index 90eade94e..a689523f8 100644 --- a/src/components/Root/FFetch.js +++ b/src/components/Root/FFetch.js @@ -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'; @@ -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'; @@ -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; } /** @@ -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 @@ -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); } } @@ -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 diff --git a/src/components/Root/FFetch.test.js b/src/components/Root/FFetch.test.js index 8ada9a7c4..048dce81b 100644 --- a/src/components/Root/FFetch.test.js +++ b/src/components/Root/FFetch.test.js @@ -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', () => ({ @@ -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'); @@ -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({ @@ -181,7 +183,10 @@ describe('FFetch class', () => { logger: { log }, store: { dispatch: jest.fn(), - } + }, + rtrConfig: { + fixedLengthSessionWarningTTL: '1m', + }, }); testFfetch.replaceFetch(); testFfetch.replaceXMLHttpRequest(); @@ -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 () => { @@ -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(); @@ -225,7 +239,10 @@ describe('FFetch class', () => { logger: { log }, store: { dispatch: jest.fn(), - } + }, + rtrConfig: { + fixedLengthSessionWarningTTL: '1m', + }, }); testFfetch.replaceFetch(); testFfetch.replaceXMLHttpRequest(); @@ -260,7 +277,10 @@ describe('FFetch class', () => { logger: { log }, store: { dispatch: jest.fn(), - } + }, + rtrConfig: { + fixedLengthSessionWarningTTL: '1m', + }, }); testFfetch.replaceFetch(); testFfetch.replaceXMLHttpRequest(); @@ -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 () => { @@ -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)); + }); }); @@ -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', } @@ -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', } @@ -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', } diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index cddcaedae..52094d172 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -71,9 +71,12 @@ class Root extends Component { // * 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); + this.ffetch = new FFetch({ logger: this.props.logger, store, + rtrConfig, }); this.ffetch.replaceFetch(); this.ffetch.replaceXMLHttpRequest(); @@ -124,6 +127,7 @@ class Root extends Component { const { logger, store, epics, config, okapi, actionNames, token, isAuthenticated, disableAuth, currentUser, currentPerms, locale, defaultTranslations, timezone, currency, plugins, bindings, discovery, translations, history, serverDown } = this.props; if (serverDown) { + // note: this isn't i18n'ed because we haven't rendered an IntlProvider yet. return
Error: server is down.
; } @@ -133,6 +137,12 @@ class Root extends Component { } // 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({ diff --git a/src/components/Root/constants.js b/src/components/Root/constants.js index 771467234..1ec4b5623 100644 --- a/src/components/Root/constants.js +++ b/src/components/Root/constants.js @@ -9,10 +9,34 @@ export const RTR_ERROR_EVENT = '@folio/stripes/core::RTRError'; */ export const RTR_TIMEOUT_EVENT = '@folio/stripes/core::RTRIdleSessionTimeout'; +/** dispatched when the fixed-length session is about to end */ +export const RTR_FLS_WARNING_EVENT = '@folio/stripes/core::RTRFLSWarning'; + +/** dispatched when the fixed-length session ends */ +export const RTR_FLS_TIMEOUT_EVENT = '@folio/stripes/core::RTRFLSTimeout'; + +/** + * how long is the FLS warning visible? + * When a fixed-length session expires, the session ends immediately and the + * user is forcibly logged out. This interval describes how much warning they + * get before the session ends. + * + * overridden in stripes.configs.js::config.rtr.fixedLengthSessionWarningTTL + * value must be a string parsable by ms() + */ +export const RTR_FLS_WARNING_TTL = '1m'; + /** BroadcastChannel for cross-window activity pings */ export const RTR_ACTIVITY_CHANNEL = '@folio/stripes/core::RTRActivityChannel'; -/** how much of an AT's lifespan can elapse before it is considered expired */ +/** + * how much of a token's lifespan can elapse before it is considered expired? + * For the AT, we want a very safe margin because we don't ever want to fall + * off the end of the AT since it would be a very misleading failure given + * 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. + */ export const RTR_AT_TTL_FRACTION = 0.8; /** @@ -56,8 +80,10 @@ export const RTR_IDLE_MODAL_TTL = '1m'; * token-expiration data in its response * 3. the session _should_ contain a value, but maybe the session * was corrupt. - * Given the resume-session API call succeeded, we know the AT must have been - * valid at the time, so we punt and schedule rotation in the future by this - * (relatively short) interval. + * Given the resume-session API call succeeded, we know the tokens were valid + * at the time so we punt and schedule rotation in the very near future because + * the rotation-response _will_ contain token-expiration values we can use to + * replace these. */ export const RTR_AT_EXPIRY_IF_UNKNOWN = '10s'; +export const RTR_RT_EXPIRY_IF_UNKNOWN = '10m'; diff --git a/src/components/Root/token-util.js b/src/components/Root/token-util.js index 0e70ba012..91abf3400 100644 --- a/src/components/Root/token-util.js +++ b/src/components/Root/token-util.js @@ -5,6 +5,7 @@ import { RTRError, UnexpectedResourceError } from './Errors'; import { RTR_ACTIVITY_EVENTS, RTR_ERROR_EVENT, + RTR_FLS_WARNING_TTL, RTR_IDLE_MODAL_TTL, RTR_IDLE_SESSION_TTL, RTR_SUCCESS_EVENT, @@ -326,5 +327,11 @@ export const configureRtr = (config = {}) => { conf.activityEvents = RTR_ACTIVITY_EVENTS; } + // how long is the "your session is gonna die!" warning shown + // before the session is, in fact, killed? + if (!conf.fixedLengthSessionWarningTTL) { + conf.fixedLengthSessionWarningTTL = RTR_FLS_WARNING_TTL; + } + return conf; }; diff --git a/src/components/SessionEventContainer/FixedLengthSessionWarning.js b/src/components/SessionEventContainer/FixedLengthSessionWarning.js new file mode 100644 index 000000000..09456ca79 --- /dev/null +++ b/src/components/SessionEventContainer/FixedLengthSessionWarning.js @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import ms from 'ms'; + +import { + MessageBanner +} from '@folio/stripes-components'; + +import { useStripes } from '../../StripesContext'; + +/** + * FixedLengthSessionWarning + * Show a callout with a countdown timer representing the number of seconds + * remaining until the session expires. + * + * @param {function} callback function to call when clicking "Keep working" button + */ +const FixedLengthSessionWarning = () => { + const stripes = useStripes(); + const [remainingMillis, setRemainingMillis] = useState(ms(stripes.config.rtr.fixedLengthSessionWarningTTL)); + + // configure an interval timer that sets state each second, + // counting down to 0. + useEffect(() => { + const interval = setInterval(() => { + setRemainingMillis(i => i - 1000); + }, 1000); + + // cleanup: clear the timer + return () => { + clearInterval(interval); + }; + }, []); + + /** + * timestampFormatter + * convert time-remaining to mm:ss. Given the remaining time can easily be + * represented as elapsed-time since the JSDate epoch, convert to a + * Date object, format it, and extract the minutes and seconds. + * That is, given we have 99 seconds left, that converts to a Date + * like `1970-01-01T00:01:39.000Z`; extract the `01:39`. + */ + const timestampFormatter = () => { + if (remainingMillis >= 1000) { + return new Date(remainingMillis).toISOString().substring(14, 19); + } + + return '00:00'; + }; + + return {timestampFormatter()}; +}; + +export default FixedLengthSessionWarning; diff --git a/src/components/SessionEventContainer/FixedLengthSessionWarning.test.js b/src/components/SessionEventContainer/FixedLengthSessionWarning.test.js new file mode 100644 index 000000000..d20719c9c --- /dev/null +++ b/src/components/SessionEventContainer/FixedLengthSessionWarning.test.js @@ -0,0 +1,59 @@ +import { render, screen, waitFor } from '@folio/jest-config-stripes/testing-library/react'; + +import Harness from '../../../test/jest/helpers/harness'; +import FixedLengthSessionWarning from './FixedLengthSessionWarning'; + +jest.mock('../Root/token-util'); + +const stripes = { + config: { + rtr: { + fixedLengthSessionWarningTTL: '99s' + } + } +}; + +describe('FixedLengthSessionWarning', () => { + it('renders a warning with seconds remaining', async () => { + render(); + screen.getByText(/stripes-core.rtr.fixedLengthSession.timeRemaining/); + screen.getByText(/01:39/); + }); + + it('renders 0:00 when time expires', async () => { + const zeroSecondsStripes = { + config: { + rtr: { + fixedLengthSessionWarningTTL: '0s' + } + } + }; + + render(); + screen.getByText(/stripes-core.rtr.fixedLengthSession.timeRemaining/); + screen.getByText(/0:00/); + }); + + // I've never had great luck with jest's fake timers, https://jestjs.io/docs/timer-mocks + // The modal counts down one second at a time so this test just waits for + // two seconds. Great? Nope. Good enough? Sure is. + describe('uses timers', () => { + it('"like sand through an hourglass, so are the elapsed seconds of this warning" -- Soh Kraits', async () => { + jest.spyOn(global, 'setInterval'); + const zeroSecondsStripes = { + config: { + rtr: { + fixedLengthSessionWarningTTL: '10s' + } + } + }; + + render(); + + expect(setInterval).toHaveBeenCalledTimes(1); + expect(setInterval).toHaveBeenLastCalledWith(expect.any(Function), 1000); + + await waitFor(() => screen.getByText(/00:09/), { timeout: 2000 }); + }); + }); +}); diff --git a/src/components/SessionEventContainer/SessionEventContainer.js b/src/components/SessionEventContainer/SessionEventContainer.js index 21ca8f4e1..fa162d666 100644 --- a/src/components/SessionEventContainer/SessionEventContainer.js +++ b/src/components/SessionEventContainer/SessionEventContainer.js @@ -3,15 +3,18 @@ import PropTypes from 'prop-types'; import createInactivityTimer from 'inactivity-timer'; import ms from 'ms'; -import { SESSION_NAME } from '../../loginServices'; +import { SESSION_NAME, setUnauthorizedPathToSession } from '../../loginServices'; import KeepWorkingModal from './KeepWorkingModal'; import { useStripes } from '../../StripesContext'; import { RTR_ACTIVITY_CHANNEL, RTR_ERROR_EVENT, + RTR_FLS_TIMEOUT_EVENT, + RTR_FLS_WARNING_EVENT, RTR_TIMEOUT_EVENT } from '../Root/constants'; import { toggleRtrModal } from '../../okapiActions'; +import FixedLengthSessionWarning from './FixedLengthSessionWarning'; // // event listeners @@ -21,15 +24,30 @@ import { toggleRtrModal } from '../../okapiActions'; // RTR error in this window: logout export const thisWindowRtrError = (_e, stripes, history) => { console.warn('rtr error; logging out'); // eslint-disable-line no-console + setUnauthorizedPathToSession(); history.push('/logout-timeout'); }; // idle session timeout in this window: logout -export const thisWindowRtrTimeout = (_e, stripes, history) => { +export const thisWindowRtrIstTimeout = (_e, stripes, history) => { stripes.logger.log('rtr', 'idle session timeout; logging out'); + setUnauthorizedPathToSession(); history.push('/logout-timeout'); }; +// fixed-length session warning in this window: logout +export const thisWindowRtrFlsWarning = (_e, stripes, setIsFlsVisible) => { + stripes.logger.log('rtr', 'fixed-length session warning'); + setIsFlsVisible(true); +}; + +// fixed-length session timeout in this window: logout +export const thisWindowRtrFlsTimeout = (_e, stripes, history) => { + stripes.logger.log('rtr', 'fixed-length session timeout; logging out'); + setUnauthorizedPathToSession(); + history.push('/logout'); +}; + // localstorage change in another window: logout? // logout if it was a timeout event or if SESSION_NAME is being // removed from localStorage, an indicator that logout is in-progress @@ -37,9 +55,11 @@ export const thisWindowRtrTimeout = (_e, stripes, history) => { export const otherWindowStorage = (e, stripes, history) => { if (e.key === RTR_TIMEOUT_EVENT) { stripes.logger.log('rtr', 'idle session timeout; logging out'); + setUnauthorizedPathToSession(); history.push('/logout-timeout'); } else if (!localStorage.getItem(SESSION_NAME)) { stripes.logger.log('rtr', 'external localstorage change; logging out'); + setUnauthorizedPathToSession(); history.push('/logout'); } return Promise.resolve(); @@ -113,6 +133,9 @@ const SessionEventContainer = ({ history }) => { // is the "keep working?" modal visible? const [isVisible, setIsVisible] = useState(false); + // is the fixed-length-session warning visible? + const [isFlsVisible, setIsFlsVisible] = useState(false); + // inactivity timers const timers = useRef(); const stripes = useStripes(); @@ -191,7 +214,7 @@ const SessionEventContainer = ({ history }) => { channels.window[RTR_ERROR_EVENT] = (e) => thisWindowRtrError(e, stripes, history); // idle session timeout in this window: logout - channels.window[RTR_TIMEOUT_EVENT] = (e) => thisWindowRtrTimeout(e, stripes, history); + channels.window[RTR_TIMEOUT_EVENT] = (e) => thisWindowRtrIstTimeout(e, stripes, history); // localstorage change in another window: logout? channels.window.storage = (e) => otherWindowStorage(e, stripes, history); @@ -204,6 +227,13 @@ const SessionEventContainer = ({ history }) => { channels.window[eventName] = (e) => thisWindowActivity(e, stripes, timers, bc); }); + // fixed-length session: show session-is-ending warning + channels.window[RTR_FLS_WARNING_EVENT] = (e) => thisWindowRtrFlsWarning(e, stripes, setIsFlsVisible); + + // fixed-length session: terminate session + channels.window[RTR_FLS_TIMEOUT_EVENT] = (e) => thisWindowRtrFlsTimeout(e, stripes, history); + + // add listeners Object.entries(channels).forEach(([k, channel]) => { Object.entries(channel).forEach(([e, h]) => { @@ -236,13 +266,19 @@ const SessionEventContainer = ({ history }) => { // array. }, []); // eslint-disable-line react-hooks/exhaustive-deps - // show the idle-session warning modal if necessary; - // otherwise return null + const renderList = []; + + // show the idle-session warning modal? if (isVisible) { - return ; + renderList.push(); + } + + // show the fixed-length session warning? + if (isFlsVisible) { + renderList.push(); } - return null; + return renderList.length ? renderList : null; }; SessionEventContainer.propTypes = { diff --git a/src/components/SessionEventContainer/SessionEventContainer.test.js b/src/components/SessionEventContainer/SessionEventContainer.test.js index 24d9fd04c..06f9c2a02 100644 --- a/src/components/SessionEventContainer/SessionEventContainer.test.js +++ b/src/components/SessionEventContainer/SessionEventContainer.test.js @@ -7,7 +7,7 @@ import SessionEventContainer, { otherWindowStorage, thisWindowActivity, thisWindowRtrError, - thisWindowRtrTimeout, + thisWindowRtrIstTimeout, } from './SessionEventContainer'; import { SESSION_NAME } from '../../loginServices'; import { RTR_TIMEOUT_EVENT } from '../Root/constants'; @@ -74,7 +74,7 @@ describe('SessionEventContainer event listeners', () => { expect(history.push).toHaveBeenCalledWith('/logout-timeout'); }); - it('thisWindowRtrTimeout', async () => { + it('thisWindowRtrIstTimeout', async () => { const s = { okapi: { url: 'http' @@ -87,7 +87,7 @@ describe('SessionEventContainer event listeners', () => { const history = { push: jest.fn() }; - thisWindowRtrTimeout(null, s, history); + thisWindowRtrIstTimeout(null, s, history); expect(history.push).toHaveBeenCalledWith('/logout-timeout'); }); diff --git a/src/loginServices.js b/src/loginServices.js index ba966b3f0..f50b2a4f9 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -114,13 +114,16 @@ export const setTokenExpiry = async (te) => { * removeUnauthorizedPathFromSession, setUnauthorizedPathToSession, getUnauthorizedPathFromSession * remove/set/get unauthorized_path to/from session storage. * Used to restore path on returning from login if user accessed a bookmarked - * URL while unauthenticated and was redirected to login. + * URL while unauthenticated and was redirected to login, and when a session + * times out, forcing the user to re-authenticate. * * @see components/OIDCRedirect */ const UNAUTHORIZED_PATH = 'unauthorized_path'; export const removeUnauthorizedPathFromSession = () => sessionStorage.removeItem(UNAUTHORIZED_PATH); -export const setUnauthorizedPathToSession = (pathname) => sessionStorage.setItem(UNAUTHORIZED_PATH, pathname); +export const setUnauthorizedPathToSession = (pathname) => { + sessionStorage.setItem(UNAUTHORIZED_PATH, pathname ?? `${window.location.pathname}${window.location.search}`); +}; export const getUnauthorizedPathFromSession = () => sessionStorage.getItem(UNAUTHORIZED_PATH); // export config values for storing user locale diff --git a/src/okapiActions.js b/src/okapiActions.js index b6ff9554f..c9d3795ab 100644 --- a/src/okapiActions.js +++ b/src/okapiActions.js @@ -1,54 +1,56 @@ +import { OKAPI_REDUCER_ACTIONS } from './okapiReducer'; + function setCurrentUser(currentUser) { return { - type: 'SET_CURRENT_USER', + type: OKAPI_REDUCER_ACTIONS.SET_CURRENT_USER, currentUser, }; } function clearCurrentUser() { return { - type: 'CLEAR_CURRENT_USER', + type: OKAPI_REDUCER_ACTIONS.CLEAR_CURRENT_USER, }; } function setCurrentPerms(currentPerms) { return { - type: 'SET_CURRENT_PERMS', + type: OKAPI_REDUCER_ACTIONS.SET_CURRENT_PERMS, currentPerms, }; } function setLocale(locale) { return { - type: 'SET_LOCALE', + type: OKAPI_REDUCER_ACTIONS.SET_LOCALE, locale, }; } function setTimezone(timezone) { return { - type: 'SET_TIMEZONE', + type: OKAPI_REDUCER_ACTIONS.SET_TIMEZONE, timezone, }; } function setCurrency(currency) { return { - type: 'SET_CURRENCY', + type: OKAPI_REDUCER_ACTIONS.SET_CURRENCY, currency, }; } function setPlugins(plugins) { return { - type: 'SET_PLUGINS', + type: OKAPI_REDUCER_ACTIONS.SET_PLUGINS, plugins, }; } function setSinglePlugin(name, value) { return { - type: 'SET_SINGLE_PLUGIN', + type: OKAPI_REDUCER_ACTIONS.SET_SINGLE_PLUGIN, name, value, }; @@ -56,115 +58,129 @@ function setSinglePlugin(name, value) { function setBindings(bindings) { return { - type: 'SET_BINDINGS', + type: OKAPI_REDUCER_ACTIONS.SET_BINDINGS, bindings, }; } function setOkapiToken(token) { return { - type: 'SET_OKAPI_TOKEN', + type: OKAPI_REDUCER_ACTIONS.SET_OKAPI_TOKEN, token, }; } function clearOkapiToken() { return { - type: 'CLEAR_OKAPI_TOKEN', + type: OKAPI_REDUCER_ACTIONS.CLEAR_OKAPI_TOKEN, }; } function setIsAuthenticated(b) { return { - type: 'SET_IS_AUTHENTICATED', + type: OKAPI_REDUCER_ACTIONS.SET_IS_AUTHENTICATED, isAuthenticated: Boolean(b), }; } function setAuthError(message) { return { - type: 'SET_AUTH_FAILURE', + type: OKAPI_REDUCER_ACTIONS.SET_AUTH_FAILURE, message, }; } function setTranslations(translations) { return { - type: 'SET_TRANSLATIONS', + type: OKAPI_REDUCER_ACTIONS.SET_TRANSLATIONS, translations, }; } function checkSSO(ssoEnabled) { return { - type: 'CHECK_SSO', + type: OKAPI_REDUCER_ACTIONS.CHECK_SSO, ssoEnabled, }; } function setOkapiReady() { return { - type: 'OKAPI_READY', + type: OKAPI_REDUCER_ACTIONS.OKAPI_READY, }; } function setServerDown() { return { - type: 'SERVER_DOWN', + type: OKAPI_REDUCER_ACTIONS.SERVER_DOWN, }; } function setSessionData(session) { return { - type: 'SET_SESSION_DATA', + type: OKAPI_REDUCER_ACTIONS.SET_SESSION_DATA, session, }; } function setLoginData(loginData) { return { - type: 'SET_LOGIN_DATA', + type: OKAPI_REDUCER_ACTIONS.SET_LOGIN_DATA, loginData, }; } function updateCurrentUser(data) { return { - type: 'UPDATE_CURRENT_USER', + type: OKAPI_REDUCER_ACTIONS.UPDATE_CURRENT_USER, data, }; } function setOkapiTenant(payload) { return { - type: 'SET_OKAPI_TENANT', + type: OKAPI_REDUCER_ACTIONS.SET_OKAPI_TENANT, payload }; } function setTokenExpiration(tokenExpiration) { return { - type: 'SET_TOKEN_EXPIRATION', + type: OKAPI_REDUCER_ACTIONS.SET_TOKEN_EXPIRATION, tokenExpiration, }; } function setRtrTimeout(rtrTimeout) { return { - type: 'SET_RTR_TIMEOUT', + type: OKAPI_REDUCER_ACTIONS.SET_RTR_TIMEOUT, rtrTimeout, }; } function clearRtrTimeout() { return { - type: 'CLEAR_RTR_TIMEOUT', + type: OKAPI_REDUCER_ACTIONS.CLEAR_RTR_TIMEOUT, + }; +} + +function setRtrFlsTimeout(rtrFlsTimeout) { + return { + type: OKAPI_REDUCER_ACTIONS.SET_RTR_FLS_TIMEOUT, + rtrFlsTimeout, }; } +function clearRtrFlsTimeout() { + return { + type: OKAPI_REDUCER_ACTIONS.CLEAR_RTR_FLS_TIMEOUT, + }; +} + + function toggleRtrModal(isVisible) { return { - type: 'TOGGLE_RTR_MODAL', + type: OKAPI_REDUCER_ACTIONS.TOGGLE_RTR_MODAL, isVisible, }; } @@ -173,6 +189,7 @@ export { checkSSO, clearCurrentUser, clearOkapiToken, + clearRtrFlsTimeout, clearRtrTimeout, setAuthError, setBindings, @@ -185,6 +202,7 @@ export { setOkapiReady, setOkapiToken, setPlugins, + setRtrFlsTimeout, setRtrTimeout, setServerDown, setSessionData, diff --git a/src/okapiReducer.js b/src/okapiReducer.js index 6c4f1f475..2fc52174b 100644 --- a/src/okapiReducer.js +++ b/src/okapiReducer.js @@ -1,78 +1,119 @@ +export const OKAPI_REDUCER_ACTIONS = { + CHECK_SSO: 'CHECK_SSO', + CLEAR_CURRENT_USER: 'CLEAR_CURRENT_USER', + CLEAR_OKAPI_TOKEN: 'CLEAR_OKAPI_TOKEN', + CLEAR_RTR_FLS_TIMEOUT: 'CLEAR_RTR_FLS_TIMEOUT', + CLEAR_RTR_TIMEOUT: 'CLEAR_RTR_TIMEOUT', + OKAPI_READY: 'OKAPI_READY', + SERVER_DOWN: 'SERVER_DOWN', + SET_AUTH_FAILURE: 'SET_AUTH_FAILURE', + SET_BINDINGS: 'SET_BINDINGS', + SET_CURRENCY: 'SET_CURRENCY', + SET_CURRENT_PERMS: 'SET_CURRENT_PERMS', + SET_CURRENT_USER: 'SET_CURRENT_USER', + SET_IS_AUTHENTICATED: 'SET_IS_AUTHENTICATED', + SET_LOCALE: 'SET_LOCALE', + SET_LOGIN_DATA: 'SET_LOGIN_DATA', + SET_OKAPI_TENANT: 'SET_OKAPI_TENANT', + SET_OKAPI_TOKEN: 'SET_OKAPI_TOKEN', + SET_PLUGINS: 'SET_PLUGINS', + SET_RTR_FLS_TIMEOUT: 'SET_RTR_FLS_TIMEOUT', + SET_RTR_TIMEOUT: 'SET_RTR_TIMEOUT', + SET_SESSION_DATA: 'SET_SESSION_DATA', + SET_SINGLE_PLUGIN: 'SET_SINGLE_PLUGIN', + SET_TIMEZONE: 'SET_TIMEZONE', + SET_TOKEN_EXPIRATION: 'SET_TOKEN_EXPIRATION', + SET_TRANSLATIONS: 'SET_TRANSLATIONS', + TOGGLE_RTR_MODAL: 'TOGGLE_RTR_MODAL', + UPDATE_CURRENT_USER: 'UPDATE_CURRENT_USER', +}; + export default function okapiReducer(state = {}, action) { switch (action.type) { - case 'SET_OKAPI_TENANT': { + case OKAPI_REDUCER_ACTIONS.SET_OKAPI_TENANT: { const { tenant, clientId } = action.payload; return Object.assign({}, state, { tenant, clientId }); } - case 'SET_OKAPI_TOKEN': + case OKAPI_REDUCER_ACTIONS.SET_OKAPI_TOKEN: return Object.assign({}, state, { token: action.token }); - case 'CLEAR_OKAPI_TOKEN': + case OKAPI_REDUCER_ACTIONS.CLEAR_OKAPI_TOKEN: return Object.assign({}, state, { token: null }); - case 'SET_CURRENT_USER': + case OKAPI_REDUCER_ACTIONS.SET_CURRENT_USER: return Object.assign({}, state, { currentUser: action.currentUser }); - case 'SET_IS_AUTHENTICATED': { + case OKAPI_REDUCER_ACTIONS.SET_IS_AUTHENTICATED: { const newState = { isAuthenticated: action.isAuthenticated, }; - // if we're logging out, clear the RTR timeout - // and other rtr-related values + // if we're logging out, clear the RTR timeouts and related values if (!action.isAuthenticated) { clearTimeout(state.rtrTimeout); + clearTimeout(state.rtrFlsTimeout); newState.rtrModalIsVisible = false; newState.rtrTimeout = undefined; + newState.rtrFlsTimeout = undefined; } return { ...state, ...newState }; } - case 'SET_LOCALE': + case OKAPI_REDUCER_ACTIONS.SET_LOCALE: return Object.assign({}, state, { locale: action.locale }); - case 'SET_TIMEZONE': + case OKAPI_REDUCER_ACTIONS.SET_TIMEZONE: return Object.assign({}, state, { timezone: action.timezone }); - case 'SET_CURRENCY': + case OKAPI_REDUCER_ACTIONS.SET_CURRENCY: return Object.assign({}, state, { currency: action.currency }); - case 'SET_PLUGINS': + case OKAPI_REDUCER_ACTIONS.SET_PLUGINS: return Object.assign({}, state, { plugins: action.plugins }); - case 'SET_SINGLE_PLUGIN': + case OKAPI_REDUCER_ACTIONS.SET_SINGLE_PLUGIN: return Object.assign({}, state, { plugins: Object.assign({}, state.plugins, { [action.name]: action.value }) }); - case 'SET_BINDINGS': + case OKAPI_REDUCER_ACTIONS.SET_BINDINGS: return Object.assign({}, state, { bindings: action.bindings }); - case 'SET_CURRENT_PERMS': + case OKAPI_REDUCER_ACTIONS.SET_CURRENT_PERMS: return Object.assign({}, state, { currentPerms: action.currentPerms }); - case 'SET_LOGIN_DATA': + case OKAPI_REDUCER_ACTIONS.SET_LOGIN_DATA: return Object.assign({}, state, { loginData: action.loginData }); - case 'SET_TOKEN_EXPIRATION': + case OKAPI_REDUCER_ACTIONS.SET_TOKEN_EXPIRATION: return Object.assign({}, state, { loginData: { ...state.loginData, tokenExpiration: action.tokenExpiration } }); - case 'CLEAR_CURRENT_USER': + case OKAPI_REDUCER_ACTIONS.CLEAR_CURRENT_USER: return Object.assign({}, state, { currentUser: {}, currentPerms: {} }); - case 'SET_SESSION_DATA': { + case OKAPI_REDUCER_ACTIONS.SET_SESSION_DATA: { const { isAuthenticated, perms, tenant, token, user } = action.session; const sessionTenant = tenant || state.tenant; return { ...state, currentUser: user, currentPerms: perms, isAuthenticated, tenant: sessionTenant, token }; } - case 'SET_AUTH_FAILURE': + case OKAPI_REDUCER_ACTIONS.SET_AUTH_FAILURE: return Object.assign({}, state, { authFailure: action.message }); - case 'SET_TRANSLATIONS': + case OKAPI_REDUCER_ACTIONS.SET_TRANSLATIONS: return Object.assign({}, state, { translations: action.translations }); - case 'CHECK_SSO': + case OKAPI_REDUCER_ACTIONS.CHECK_SSO: return Object.assign({}, state, { ssoEnabled: action.ssoEnabled }); - case 'OKAPI_READY': + case OKAPI_REDUCER_ACTIONS.OKAPI_READY: return Object.assign({}, state, { okapiReady: true }); - case 'SERVER_DOWN': + case OKAPI_REDUCER_ACTIONS.SERVER_DOWN: return Object.assign({}, state, { serverDown: true }); - case 'UPDATE_CURRENT_USER': + case OKAPI_REDUCER_ACTIONS.UPDATE_CURRENT_USER: return { ...state, currentUser: { ...state.currentUser, ...action.data } }; - // clear existing timeout and set a new one - case 'SET_RTR_TIMEOUT': { + // clear existing AT rotation timeout and set a new one + case OKAPI_REDUCER_ACTIONS.SET_RTR_TIMEOUT: { clearTimeout(state.rtrTimeout); return { ...state, rtrTimeout: action.rtrTimeout }; } - case 'CLEAR_RTR_TIMEOUT': { + case OKAPI_REDUCER_ACTIONS.CLEAR_RTR_TIMEOUT: { clearTimeout(state.rtrTimeout); return { ...state, rtrTimeout: undefined }; } - case 'TOGGLE_RTR_MODAL': { + // clear existing FLS timeout and set a new one + case OKAPI_REDUCER_ACTIONS.SET_RTR_FLS_TIMEOUT: { + clearTimeout(state.rtrFlsTimeout); + return { ...state, rtrFlsTimeout: action.rtrFlsTimeout }; + } + case OKAPI_REDUCER_ACTIONS.CLEAR_RTR_FLS_TIMEOUT: { + clearTimeout(state.rtrFlsTimeout); + return { ...state, rtrFlsTimeout: undefined }; + } + + case OKAPI_REDUCER_ACTIONS.TOGGLE_RTR_MODAL: { return { ...state, rtrModalIsVisible: action.isVisible }; } diff --git a/src/okapiReducer.test.js b/src/okapiReducer.test.js index d1c5ffe6d..ce366491e 100644 --- a/src/okapiReducer.test.js +++ b/src/okapiReducer.test.js @@ -1,10 +1,11 @@ -import okapiReducer from './okapiReducer'; +import okapiReducer, { OKAPI_REDUCER_ACTIONS } from './okapiReducer'; + describe('okapiReducer', () => { describe('SET_IS_AUTHENTICATED', () => { it('sets isAuthenticated to true', () => { const isAuthenticated = true; - const o = okapiReducer({}, { type: 'SET_IS_AUTHENTICATED', isAuthenticated: true }); + const o = okapiReducer({}, { type: OKAPI_REDUCER_ACTIONS.SET_IS_AUTHENTICATED, isAuthenticated: true }); expect(o).toMatchObject({ isAuthenticated }); }); @@ -13,25 +14,26 @@ describe('okapiReducer', () => { rtrModalIsVisible: true, rtrTimeout: 123, }; - const ct = jest.spyOn(window, 'clearTimeout') - const o = okapiReducer(state, { type: 'SET_IS_AUTHENTICATED', isAuthenticated: false }); + const ct = jest.spyOn(window, 'clearTimeout'); + const o = okapiReducer(state, { type: OKAPI_REDUCER_ACTIONS.SET_IS_AUTHENTICATED, isAuthenticated: false }); expect(o.isAuthenticated).toBe(false); expect(o.rtrModalIsVisible).toBe(false); expect(o.rtrTimeout).toBe(undefined); + expect(o.rtrFlsTimeout).toBe(undefined); expect(ct).toHaveBeenCalled(); }); }); it('SET_LOGIN_DATA', () => { const loginData = 'loginData'; - const o = okapiReducer({}, { type: 'SET_LOGIN_DATA', loginData }); + const o = okapiReducer({}, { type: OKAPI_REDUCER_ACTIONS.SET_LOGIN_DATA, loginData }); expect(o).toMatchObject({ loginData }); }); it('UPDATE_CURRENT_USER', () => { const initialState = { funky: 'chicken' }; const data = { monkey: 'bagel' }; - const o = okapiReducer(initialState, { type: 'UPDATE_CURRENT_USER', data }); + const o = okapiReducer(initialState, { type: OKAPI_REDUCER_ACTIONS.UPDATE_CURRENT_USER, data }); expect(o).toMatchObject({ ...initialState, currentUser: { ...data } }); }); @@ -51,7 +53,7 @@ describe('okapiReducer', () => { }, tenant: 'institutional', }; - const o = okapiReducer(initialState, { type: 'SET_SESSION_DATA', session }); + const o = okapiReducer(initialState, { type: OKAPI_REDUCER_ACTIONS.SET_SESSION_DATA, session }); const { user, perms, ...rest } = session; expect(o).toMatchObject({ ...initialState, @@ -70,7 +72,7 @@ describe('okapiReducer', () => { const newState = { rtrTimeout: 997 }; - const o = okapiReducer(state, { type: 'SET_RTR_TIMEOUT', rtrTimeout: newState.rtrTimeout }); + const o = okapiReducer(state, { type: OKAPI_REDUCER_ACTIONS.SET_RTR_TIMEOUT, rtrTimeout: newState.rtrTimeout }); expect(o).toMatchObject(newState); expect(ct).toHaveBeenCalledWith(state.rtrTimeout); @@ -83,14 +85,41 @@ describe('okapiReducer', () => { rtrTimeout: 991, }; - const o = okapiReducer(state, { type: 'CLEAR_RTR_TIMEOUT' }); + const o = okapiReducer(state, { type: OKAPI_REDUCER_ACTIONS.CLEAR_RTR_TIMEOUT }); expect(o).toMatchObject({}); expect(ct).toHaveBeenCalledWith(state.rtrTimeout); }); it('TOGGLE_RTR_MODAL', () => { const rtrModalIsVisible = true; - const o = okapiReducer({}, { type: 'TOGGLE_RTR_MODAL', isVisible: true }); + const o = okapiReducer({}, { type: OKAPI_REDUCER_ACTIONS.TOGGLE_RTR_MODAL, isVisible: true }); expect(o).toMatchObject({ rtrModalIsVisible }); }); + + it('SET_RTR_FLS_TIMEOUT', () => { + const ct = jest.spyOn(window, 'clearTimeout'); + + const state = { + rtrFlsTimeout: 991, + }; + + const newState = { rtrFlsTimeout: 997 }; + + const o = okapiReducer(state, { type: OKAPI_REDUCER_ACTIONS.SET_RTR_FLS_TIMEOUT, rtrFlsTimeout: newState.rtrFlsTimeout }); + expect(o).toMatchObject(newState); + + expect(ct).toHaveBeenCalledWith(state.rtrFlsTimeout); + }); + + it('CLEAR_RTR_FLS_TIMEOUT', () => { + const ct = jest.spyOn(window, 'clearTimeout'); + + const state = { + rtrFlsTimeout: 991, + }; + + const o = okapiReducer(state, { type: OKAPI_REDUCER_ACTIONS.CLEAR_RTR_FLS_TIMEOUT }); + expect(o).toMatchObject({}); + expect(ct).toHaveBeenCalledWith(state.rtrFlsTimeout); + }); }); diff --git a/translations/stripes-core/en.json b/translations/stripes-core/en.json index b08b58fc5..27953d60d 100644 --- a/translations/stripes-core/en.json +++ b/translations/stripes-core/en.json @@ -13,7 +13,6 @@ "title.noPermission": "No permission", "title.cookieEnabled": "Cookies are required to login. Please enable cookies and try again.", "title.logout": "Log out", - "title.cookieEnabled": "Cookies are required to login. Please enable cookies and try again.", "front.welcome": "Welcome, the Future Of Libraries Is OPEN!", "front.home": "Home", "front.about": "Software versions", @@ -168,5 +167,7 @@ "rtr.idleSession.timeRemaining": "Time remaining", "rtr.idleSession.keepWorking": "Keep working", "rtr.idleSession.sessionExpiredSoSad": "Your session expired due to inactivity.", - "rtr.idleSession.logInAgain": "Log in again" + "rtr.idleSession.logInAgain": "Log in again", + "rtr.fixedLengthSession.timeRemaining": "Your session will end soon! Time remaining:" + } diff --git a/translations/stripes-core/en_US.json b/translations/stripes-core/en_US.json index 4873ac4e4..a5bdc815d 100644 --- a/translations/stripes-core/en_US.json +++ b/translations/stripes-core/en_US.json @@ -152,11 +152,11 @@ "stale.reload": "Click here to reload.", "placeholder.forgotPassword": "Enter email or phone", "placeholder.forgotUsername": "Enter email or phone", - "title.cookieEnabled": "Cookies are required to login. Please enable cookies and try again.", "errors.sso.session.failed": "SSO Login failed. Please try again", "rtr.idleSession.modalHeader": "Your session will expire soon!", "rtr.idleSession.timeRemaining": "Time remaining", "rtr.idleSession.keepWorking": "Keep working", "rtr.idleSession.sessionExpiredSoSad": "Your session expired due to inactivity.", - "rtr.idleSession.logInAgain": "Log in again" + "rtr.idleSession.logInAgain": "Log in again", + "rtr.fixedLengthSession.timeRemaining": "Your session will end soon! Time remaining:" } From 8ef05ced1577a6ced159194d437fcf9fc64e5f6a Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Fri, 8 Dec 2023 14:22:12 -0500 Subject: [PATCH 15/17] STCOR-868 IST/FLST backport cleanup Some of the commits being cherry-picked on this branch relied on other work that wasn't directly related to idle-session timeout or fixed-length-session timeout, and hence weren't picked onto the branch but were, in fact necessary, for this work to function correctly. * Restore the `/oidc-landing` route * Pass the correct arguments to `loadResources()` Refs STCOR-868 --- CHANGELOG.md | 1 - src/App.js | 2 +- src/RootWithIntl.js | 13 +++++++++++++ src/components/Login/Login.js | 3 ++- src/components/Login/index.js | 2 +- src/components/OIDCLanding.js | 5 +---- src/discoverServices.js | 8 -------- src/loginServices.js | 2 +- 8 files changed, 19 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ecadea6e..def64009b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,6 @@ ## [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) -* Utilize the `tenant` procured through the SSO login process. Refs STCOR-769. * Use keycloak URLs in place of users-bl for tenant-switch. Refs US1153537. * Idle-session timeout and "Keep working?" modal. Refs STCOR-776. * Always retrieve `clientId` and `tenant` values from `config.tenantOptions` in stripes.config.js. Retires `okapi.tenant`, `okapi.clientId`, and `config.isSingleTenant`. Refs STCOR-787. diff --git a/src/App.js b/src/App.js index ee2857a9a..226750189 100644 --- a/src/App.js +++ b/src/App.js @@ -25,7 +25,7 @@ export default class StripesCore extends Component { const parsedTenant = storedTenant ? JSON.parse(storedTenant) : undefined; const okapi = (typeof okapiConfig === 'object' && Object.keys(okapiConfig).length > 0) - ? { ...okapiConfig, tenant: parsedTenant?.tenantName || okapiConfig.tenant, clientId: parsedTenant?.clientId || okapiConfig.clientId } : { withoutOkapi: true }; + ? { ...okapiConfig, tenant: parsedTenant?.tenantName || okapiConfig.tenant, clientId: parsedTenant?.clientId } : { withoutOkapi: true }; const initialState = merge({}, { okapi }, props.initialState); diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index 0a1850e98..941d979ca 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -91,6 +91,12 @@ const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAut key="sso-landing" component={} /> + } + /> } key="sso-landing" /> + } + key="oidc-landing" + /> -
+
@@ -160,6 +160,7 @@ class Login extends Component { validationEnabled={false} hasClearIcon={false} autoComplete="current-password" + required /> diff --git a/src/components/Login/index.js b/src/components/Login/index.js index 44e798405..2a741cdbd 100644 --- a/src/components/Login/index.js +++ b/src/components/Login/index.js @@ -1 +1 @@ -export { default } from './LoginCtrl'; +export { default } from './Login'; diff --git a/src/components/OIDCLanding.js b/src/components/OIDCLanding.js index 9c164979f..d72e0805a 100644 --- a/src/components/OIDCLanding.js +++ b/src/components/OIDCLanding.js @@ -100,11 +100,8 @@ const OIDCLanding = () => {
-

code

- {potp} -

error

- {JSON.stringify(samlError, null, 2)} + {JSON.stringify(samlError.current, null, 2)}
diff --git a/src/discoverServices.js b/src/discoverServices.js index 1a3d5d847..dfb719617 100644 --- a/src/discoverServices.js +++ b/src/discoverServices.js @@ -214,13 +214,6 @@ function fetchModules(store) { }); } -/* - * This function probes Okapi to discover what versions of what - * interfaces are supported by the services that it is proxying - * for. This information can be used to configure the UI at run-time - * (e.g. not attempting to fetch loan information for a - * non-circulating library that doesn't provide the circ interface) - */ export function discoverServices(store) { const promises = []; if (config.tenantOptions) { @@ -236,7 +229,6 @@ export function discoverServices(store) { }); } - export function discoveryReducer(state = {}, action) { switch (action.type) { case 'DISCOVERY_APPLICATIONS': diff --git a/src/loginServices.js b/src/loginServices.js index f50b2a4f9..b55bf3111 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -757,7 +757,7 @@ export function validateUser(okapiUrl, store, tenant, session) { tokenExpiration, })); - return loadResources(okapiUrl, store, sessionTenant, user.id); + return loadResources(store, sessionTenant, user.id); }); } else { store.dispatch(clearCurrentUser()); From 69bb5610a6c4c6974217c15306d8c41e2c38fb16 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Thu, 25 Jul 2024 17:37:28 -0400 Subject: [PATCH 16/17] restore src/components/Login/index.js import whoops; didn't mean to include this in the previous commit --- src/components/Login/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Login/index.js b/src/components/Login/index.js index 2a741cdbd..44e798405 100644 --- a/src/components/Login/index.js +++ b/src/components/Login/index.js @@ -1 +1 @@ -export { default } from './Login'; +export { default } from './LoginCtrl'; From 8c5b31ae1e688c260801ebde086feb713e9fed0a Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Fri, 26 Jul 2024 10:12:25 -0400 Subject: [PATCH 17/17] lint --- src/RootWithIntl.js | 1 - src/components/OIDCLanding.js | 2 +- src/components/Root/FFetch.js | 2 +- src/components/Root/FXHR.test.js | 1 - src/components/Root/token-util.test.js | 1 - 5 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index 941d979ca..bbd8af687 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import { Router, Switch, - Redirect as InternalRedirect } from 'react-router-dom'; import { Provider } from 'react-redux'; import { CookiesProvider } from 'react-cookie'; diff --git a/src/components/OIDCLanding.js b/src/components/OIDCLanding.js index d72e0805a..8eaf05708 100644 --- a/src/components/OIDCLanding.js +++ b/src/components/OIDCLanding.js @@ -26,7 +26,7 @@ const OIDCLanding = () => { const store = useStore(); // const samlError = useRef(); const { okapi } = useStripes(); - const [potp, setPotp] = useState(); + const [, setPotp] = useState(); const [samlError, setSamlError] = useState(); diff --git a/src/components/Root/FFetch.js b/src/components/Root/FFetch.js index a689523f8..b795b8447 100644 --- a/src/components/Root/FFetch.js +++ b/src/components/Root/FFetch.js @@ -100,7 +100,7 @@ export class FFetch { /** * scheduleRotation * Given a promise that resolves with timestamps for the AT's and RT's - * expiration, configure relevant corresponding timers: + * 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" diff --git a/src/components/Root/FXHR.test.js b/src/components/Root/FXHR.test.js index c5d5efed9..5c52ee602 100644 --- a/src/components/Root/FXHR.test.js +++ b/src/components/Root/FXHR.test.js @@ -1,6 +1,5 @@ import { rtr } from './token-util'; import { getTokenExpiry } from '../../loginServices'; -import { RTRError } from './Errors'; import FXHR from './FXHR'; jest.mock('./token-util', () => ({ diff --git a/src/components/Root/token-util.test.js b/src/components/Root/token-util.test.js index b64cfac73..0ed8cc7fc 100644 --- a/src/components/Root/token-util.test.js +++ b/src/components/Root/token-util.test.js @@ -1,4 +1,3 @@ -import { waitFor } from '@folio/jest-config-stripes/testing-library/react'; import { RTRError, UnexpectedResourceError } from './Errors'; import { configureRtr,