diff --git a/CHANGELOG.md b/CHANGELOG.md index 96975e2b5..def64009b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,12 @@ ## [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. +* 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/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/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 a71d0b27a..bbd8af687 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -1,11 +1,9 @@ -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,175 +27,169 @@ import { Settings, HandlerManager, TitleManager, + Logout, + LogoutTimeout, OverlayContainer, CreateResetPassword, CheckEmailStatusPage, ForgotPasswordCtrl, ForgotUserNameCtrl, AppCtxMenuProvider, + SessionEventContainer, } from './components'; import StaleBundleWarning from './components/StaleBundleWarning'; 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 }); -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 [callout, setCallout] = useState(null); + const setCalloutDomRef = (ref) => { + setCallout(ref); }; - static defaultProps = { - token: '', - isAuthenticated: false, - history: {}, - }; - - 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' && } + + { (typeof 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" + /> + } + key="oidc-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/RootWithIntl.test.js b/src/RootWithIntl.test.js index b3bb2636e..6aa1babad 100644 --- a/src/RootWithIntl.test.js +++ b/src/RootWithIntl.test.js @@ -2,64 +2,94 @@ /* 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: () => ({ + okapi: { + token: '123', + }, + }), + dispatch: () => {}, + subscribe: () => {}, + replaceReducer: () => {}, +}; describe('RootWithIntl', () => { - describe('AuthnLogin', () => { - it('handles legacy login', () => { - const stripes = { okapi: {}, config: {} }; - 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.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.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 } - }; - render(); - - expect(screen.getByText(//)).toBeInTheDocument(); - }); - - it('handles multi-tenant', () => { - const stripes = { - okapi: { authnUrl: 'https://oppie.com' }, - config: { }, - }; - 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(); + }); + + 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(); + expect(screen.getByText(//)).toBeInTheDocument(); }); }); }); 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..093c3db68 100644 --- a/src/components/AuthnLogin/AuthnLogin.js +++ b/src/components/AuthnLogin/AuthnLogin.js @@ -9,31 +9,56 @@ 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) { - /** 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); } + + // 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 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 ; } - const handleSelectTenant = (tenant, clientId) => { - localStorage.setItem('tenant', JSON.stringify({ tenantName: tenant, clientId })); - stripes.store.dispatch(setOkapiTenant({ tenant, clientId })); - }; - - return ; + return ; } return ({ + 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/src/components/Login/Login.js b/src/components/Login/Login.js index 48bad369a..eda2c87e3 100644 --- a/src/components/Login/Login.js +++ b/src/components/Login/Login.js @@ -66,7 +66,7 @@ class Login extends Component { const buttonLabel = submissionStatus ? 'loggingIn' : 'login'; return (
-
+
@@ -160,6 +160,7 @@ class Login extends Component { validationEnabled={false} hasClearIcon={false} autoComplete="current-password" + required /> 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..e0af76f17 --- /dev/null +++ b/src/components/LogoutTimeout/LogoutTimeout.js @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { branding } from 'stripes-config'; + +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 + * 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 timed out). + * + * This corresponds to the '/logout-timeout' route. + */ +const LogoutTimeout = () => { + const stripes = useStripes(); + const [didLogout, setDidLogout] = useState(false); + + 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 ( +
+
+
+ + + + + + + + + + + + + + + +
+
+
+ ); +}; + +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..6efff5e0c --- /dev/null +++ b/src/components/LogoutTimeout/LogoutTimeout.test.js @@ -0,0 +1,36 @@ +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'); +jest.mock('../../StripesContext'); +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; + mockUseStripes.mockReturnValue({ okapi: { isAuthenticated: false } }); + + render(); + screen.getByText('stripes-core.rtr.idleSession.sessionExpiredSoSad'); + }); + + it('if authenticated, calls logout then renders a timeout message', async () => { + const mockUseStripes = useStripes; + mockUseStripes.mockReturnValue({ okapi: { isAuthenticated: true } }); + + render(); + expect(logout).toHaveBeenCalled(); + screen.getByText('stripes-core.rtr.idleSession.sessionExpiredSoSad'); + }); +}); 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..85f1dfb57 100644 --- a/src/components/MainNav/MainNav.js +++ b/src/components/MainNav/MainNav.js @@ -5,13 +5,12 @@ 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'; 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,24 +114,6 @@ class MainNav extends Component { }); } - // Return the user to the login screen, but after logging in they will return to their previous activity. - returnToLogin() { - 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'); - }); - } - getAppList(lastVisited) { const { stripes, location: { pathname }, modules, intl: { formatMessage } } = this.props; @@ -223,10 +203,7 @@ class MainNav extends Component { target="_blank" /> - + ); 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/OIDCLanding.js b/src/components/OIDCLanding.js index 5887c67e0..8eaf05708 100644 --- a/src/components/OIDCLanding.js +++ b/src/components/OIDCLanding.js @@ -26,8 +26,7 @@ const OIDCLanding = () => { const store = useStore(); // const samlError = useRef(); const { okapi } = useStripes(); - - const [potp, setPotp] = useState(); + const [, setPotp] = useState(); const [samlError, setSamlError] = useState(); @@ -101,11 +100,8 @@ const OIDCLanding = () => {
-

code

- {potp} -

error

- {JSON.stringify(samlError, null, 2)} + {JSON.stringify(samlError.current, null, 2)}
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(); 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..b795b8447 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,47 +12,61 @@ * 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 { okapi } from 'stripes-config'; +import ms from 'ms'; +import { okapi as okapiConfig } from 'stripes-config'; +import { + setRtrTimeout, + setRtrFlsTimeout, +} from '../../okapiActions'; + import { getTokenExpiry } from '../../loginServices'; import { + getPromise, + isAuthenticationRequest, isFolioApiRequest, isLogoutRequest, - isValidAT, - isValidRT, - resourceMapper, rtr, } from './token-util'; import { RTRError, - UnexpectedResourceError, } from './Errors'; import { + RTR_AT_EXPIRY_IF_UNKNOWN, + RTR_AT_TTL_FRACTION, RTR_ERROR_EVENT, -} from './Events'; - + RTR_FLS_TIMEOUT_EVENT, + RTR_FLS_WARNING_EVENT, + RTR_RT_EXPIRY_IF_UNKNOWN, +} from './constants'; import FXHR from './FXHR'; const OKAPI_FETCH_OPTIONS = { @@ -60,236 +75,218 @@ const OKAPI_FETCH_OPTIONS = { }; export class FFetch { - constructor({ logger }) { + constructor({ logger, store, rtrConfig }) { this.logger = logger; + this.store = store; + this.rtrConfig = rtrConfig; + } - // save a reference to fetch, and then reassign the global :scream: + /** + * save a reference to fetch, and then reassign the global :scream: + */ + replaceFetch = () => { this.nativeFetch = global.fetch; global.fetch = this.ffetch; + }; + /** + * save a reference to XMLHttpRequest, and then reassign the global :scream: + */ + replaceXMLHttpRequest = () => { this.NativeXHR = global.XMLHttpRequest; global.XMLHttpRequest = FXHR(this); - } - - /** { 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. + * 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 {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 + * @param {Promise} rotationP */ - 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}`)); - }; + 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); - try { - return resourceMapper(resource, isPermissibleResource); - } catch (rme) { - if (rme instanceof UnexpectedResourceError) { - console.warn(rme.message, resource); // eslint-disable-line no-console - return false; + // 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'); } - throw rme; - } + // 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); + }); }; /** - * passThroughWithRT - * Perform RTR then execute the original request. - * If RTR fails, dispatch RTR_ERROR_EVENT and die softly. + * 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 one of string, URL, Request - * @params {object} 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 */ - 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({}))); - } + rotateCallback = (res) => { + this.logger.log('rtr', 'rotation callback setup', res); - throw err; + // When starting a new session, the response from /bl-users/login-with-expiry + // 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. 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({ + accessTokenExpiration: new Date(res.accessTokenExpiration).getTime(), + refreshTokenExpiration: new Date(res.refreshTokenExpiration).getTime(), }); - }; - /** - * 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. - * - * @param {*} resource any resource acceptable to fetch() - * @param {*} options - * @returns Promise - */ - 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; - }); + this.scheduleRotation(rotationPromise); + } else { + const rotationPromise = getTokenExpiry().then((expiry) => { + 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(), + }; } - 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); - } + // 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'); - // 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; - }); - } - - return response; + return { + accessTokenExpiration: Date.now() + ms(RTR_AT_EXPIRY_IF_UNKNOWN), + refreshTokenExpiration: Date.now() + ms(RTR_RT_EXPIRY_IF_UNKNOWN), + }; }); - } - /** - * 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({}))); - }); - }; + this.scheduleRotation(rotationPromise); + } + } /** - * 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); + if (isFolioApiRequest(resource, okapiConfig.url)) { + this.logger.log('rtrv', 'will fetch', resource); - // logout requests must not fail - if (isLogoutRequest(resource, okapi.url)) { - return this.passThroughLogout(resource, options); - } + // 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', 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 + // tokenExpiration in order to kick of the rtr cycle, then return + // the original + const clone = res.clone(); + if (clone.ok) { + this.logger.log('rtr', 'authn success!'); + clone.json().then(json => { + // 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); + }); + } - // 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(); + return res; + }); } - // AT is valid or unnecessary; execute the fetch - if (rtrIgnore || this.isPermissibleRequest(resource, this.tokenExpiration, okapi.url)) { - return this.passThroughWithAT(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, okapiConfig.url)) { + this.logger.log('rtr', 'logout request'); - // AT was expired, but RT is valid; perform RTR then execute the fetch - if (isValidRT(this.tokenExpiration, this.logger)) { - return this.passThroughWithRT(resource, options); + 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..048dce81b 100644 --- a/src/components/Root/FFetch.test.js +++ b/src/components/Root/FFetch.test.js @@ -2,9 +2,16 @@ // 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, + RTR_FLS_WARNING_TTL, +} from './constants'; jest.mock('../../loginServices', () => ({ ...(jest.requireActual('../../loginServices')), @@ -39,20 +46,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 +120,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,126 +143,258 @@ 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'); }); }); - describe('Calling an okapi fetch with missing token...', () => { - it('triggers rtr...calls fetch 3 times, failed call, token call, successful call', 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 })); - 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'); + 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 refreshTokenExpiration = whatTimeIsItMrFox + ms('20m'); + + 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).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('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('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 rtExpires = whatTimeIsItMrFox + 15000; + + const st = jest.spyOn(window, 'setTimeout'); + + getTokenExpiry.mockResolvedValue({ atExpires, rtExpires }); + 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(), + }, + 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); + expect(st).toHaveBeenCalledWith(expect.any(Function), (atExpires - whatTimeIsItMrFox) * RTR_AT_TTL_FRACTION); }); - 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('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(), + }, + 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 not sure of a better way to handle this. + await setTimeout(Promise.resolve(), 2000); + + expect(st).toHaveBeenCalledWith(expect.any(Function), ms(RTR_AT_EXPIRY_IF_UNKNOWN) * RTR_AT_TTL_FRACTION); }); - 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' })); + 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(response.status).toEqual(401); + expect(cloneJson).not.toHaveBeenCalled(); }); - }); - 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), + 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.mockResolvedValue('token rotation success') - .mockResolvedValueOnce(new Response(JSON.stringify({ - accessTokenExpiration: new Date().getTime() + 1000, - refreshTokenExpiration: new Date().getTime() + 2000, - }), { ok: true })); + + 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)); + }); + }); + + + describe('Calling an okapi fetch with missing token...', () => { + it('returns the error', async () => { + mockFetch.mockResolvedValue('success') + .mockResolvedValueOnce('failure'); const testFfetch = new FFetch({ logger: { log } }); + testFfetch.replaceFetch(); + testFfetch.replaceXMLHttpRequest(); + 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(mockFetch.mock.calls).toHaveLength(1); + expect(response).toEqual('failure'); }); }); @@ -210,6 +411,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 +435,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) { @@ -256,13 +463,16 @@ describe('FFetch class', () => { .mockResolvedValueOnce(new Response( JSON.stringify({ errors: ['missing token-getting ability'] }), { - status: 303, + status: 403, headers: { 'content-type': 'application/json', } } )); const testFfetch = new FFetch({ logger: { log } }); + testFfetch.replaceFetch(); + testFfetch.replaceXMLHttpRequest(); + try { await global.fetch('okapiUrl', { testOption: 'test' }); } catch (e) { @@ -283,13 +493,16 @@ describe('FFetch class', () => { .mockResolvedValueOnce(new Response( JSON.stringify({ errors: ['missing token-getting ability'] }), { - status: 303, + status: 403, headers: { 'content-type': 'application/json', } } )); const testFfetch = new FFetch({ logger: { log } }); + testFfetch.replaceFetch(); + testFfetch.replaceXMLHttpRequest(); + try { await global.fetch('okapiUrl', { testOption: 'test' }); } catch (e) { @@ -310,13 +523,16 @@ describe('FFetch class', () => { .mockResolvedValueOnce(new Response( JSON.stringify({ errors: ['missing token-getting ability'] }), { - status: 303, + status: 403, headers: { 'content-type': 'application/json', } } )); 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..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', () => ({ @@ -69,36 +68,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..52094d172 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,17 @@ 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); + const rtrConfig = configureRtr(this.props.config.rtr); + + this.ffetch = new FFetch({ + logger: this.props.logger, + store, + rtrConfig, + }); + this.ffetch.replaceFetch(); + this.ffetch.replaceXMLHttpRequest(); } } @@ -119,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.
; } @@ -127,6 +136,15 @@ class Root extends Component { return (); } + // make sure RTR is configured + // gross: this overwrites whatever is currently stored at config.rtr + // gross: technically, this may be different than what is configured + // in the constructor since the constructor only runs once but + // render runs when props change. realistically, that'll never happen + // since config values are read only once from a static file at build + // time, but still, props are props so technically it's possible. + config.rtr = configureRtr(this.props.config.rtr); + const stripes = new Stripes({ logger, store, diff --git a/src/components/Root/constants.js b/src/components/Root/constants.js new file mode 100644 index 000000000..1ec4b5623 --- /dev/null +++ b/src/components/Root/constants.js @@ -0,0 +1,89 @@ +/** 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'; + +/** 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 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; + +/** + * 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'; + +/** + * 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 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 2ebd9c7f4..91abf3400 100644 --- a/src/components/Root/token-util.js +++ b/src/components/Root/token-util.js @@ -1,33 +1,15 @@ -import { okapi } from 'stripes-config'; +import { isEmpty } from 'lodash'; -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_ACTIVITY_EVENTS, + RTR_ERROR_EVENT, + RTR_FLS_WARNING_TTL, + 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'; @@ -62,7 +44,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 +59,40 @@ 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 = [ + '/authn/token', + '/bl-users/login-with-expiry', + '/bl-users/_self', + '/users-keycloak/_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 +149,189 @@ 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 - * @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 + * 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 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 + * @param {object} okapi + * @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, okapi) => { + 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 = RTR_IDLE_SESSION_TTL; + } + + // how long is the "warning, session is idle!" modal shown + // before the session is killed? + if (!conf.idleModalTTL) { + conf.idleModalTTL = RTR_IDLE_MODAL_TTL; + } + + // what events constitute activity? + if (isEmpty(conf.activityEvents)) { + 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 rtrPromise; + return conf; }; diff --git a/src/components/Root/token-util.test.js b/src/components/Root/token-util.test.js index 42f3ae2ac..0ed8cc7fc 100644 --- a/src/components/Root/token-util.test.js +++ b/src/components/Root/token-util.test.js @@ -1,16 +1,22 @@ 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'; + +const okapi = { + tenant: 'diku', + url: 'http://test' +}; describe('isFolioApiRequest', () => { it('accepts requests whose origin matches okapi\'s', () => { @@ -30,62 +36,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 +104,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, okapi); + expect(callback).toHaveBeenCalled(); } catch (e) { ex = e; } - expect(res.tokenExpiration).toBeTruthy(); expect(ex).toBe(null); }); @@ -155,19 +149,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 +173,7 @@ describe('rtr', () => { let ex = null; try { - await rtr(context); + await rtr(nativeFetch, logger, jest.fn(), okapi); } catch (e) { ex = e; } @@ -191,19 +183,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 +207,7 @@ describe('rtr', () => { let ex = null; try { - await rtr(context); + await rtr(nativeFetch, logger, jest.fn(), okapi); } catch (e) { ex = e; } @@ -227,66 +217,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(), okapi); } 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(), okapi); } 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 +287,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/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/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/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..fa162d666 --- /dev/null +++ b/src/components/SessionEventContainer/SessionEventContainer.js @@ -0,0 +1,288 @@ +import { useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import createInactivityTimer from 'inactivity-timer'; +import ms from 'ms'; + +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 +// 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'); // eslint-disable-line no-console + setUnauthorizedPathToSession(); + history.push('/logout-timeout'); +}; + +// idle session timeout in this window: logout +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 +// 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'); + 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(); +}; + +// 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); + + // is the fixed-length-session warning visible? + const [isFlsVisible, setIsFlsVisible] = 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, activityEvents } = 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) => thisWindowRtrIstTimeout(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 + activityEvents.forEach(eventName => { + 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]) => { + 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 + + const renderList = []; + + // show the idle-session warning modal? + if (isVisible) { + renderList.push(); + } + + // show the fixed-length session warning? + if (isFlsVisible) { + renderList.push(); + } + + return renderList.length ? renderList : 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..06f9c2a02 --- /dev/null +++ b/src/components/SessionEventContainer/SessionEventContainer.test.js @@ -0,0 +1,232 @@ +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, + thisWindowRtrIstTimeout, +} from './SessionEventContainer'; +import { 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', + activityEvents: ['right thing', 'hustle', 'hand jive'] + } + }, + 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() }; + + thisWindowRtrError(null, { okapi: { url: 'http' } }, history); + expect(history.push).toHaveBeenCalledWith('/logout-timeout'); + }); + + it('thisWindowRtrIstTimeout', async () => { + const s = { + okapi: { + url: 'http' + }, + store: {}, + logger: { + log: jest.fn(), + } + }; + + const history = { push: jest.fn() }; + + thisWindowRtrIstTimeout(null, s, history); + 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() }; + + otherWindowStorage(e, s, history); + 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() }; + + otherWindowStorage(e, s, history); + expect(history.push).toHaveBeenCalledWith('/logout'); + }); + }); + + 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/discoverServices.js b/src/discoverServices.js index 1a221631a..dfb719617 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', @@ -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 }); }); @@ -143,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', @@ -164,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', @@ -185,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', @@ -211,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) { @@ -233,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 4f683e846..b55bf3111 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,20 +106,25 @@ 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); }; /** * 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, and when a session + * times out, forcing the user to re-authenticate. + * + * @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 ?? `${window.location.pathname}${window.location.search}`); +}; +export const getUnauthorizedPathFromSession = () => sessionStorage.getItem(UNAUTHORIZED_PATH); // export config values for storing user locale export const userLocaleConfig = { @@ -126,8 +132,6 @@ export const userLocaleConfig = { 'module': '@folio/stripes-core', }; -const logger = configureLogger(config); - function getHeaders(tenant, token) { return { 'X-Okapi-Tenant': tenant, @@ -148,7 +152,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']; } /** @@ -412,15 +416,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 { @@ -433,34 +445,79 @@ 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', + headers: getHeaders(store.getState()?.okapi?.tenant), + }) + : + 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); }); } @@ -485,8 +542,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)); @@ -517,7 +572,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 +587,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 +712,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 +750,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); }); } else { - return logout(okapiUrl, store); + store.dispatch(clearCurrentUser()); + return resp.text((text) => { + throw text; + }); } }).catch((error) => { console.error(error); // eslint-disable-line no-console @@ -745,7 +774,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. * @@ -756,7 +787,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']) { @@ -902,10 +933,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 ca706d705..7a00d9a37 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, @@ -7,6 +6,7 @@ import { getTokenExpiry, handleLoginError, loadTranslations, + logout, processOkapiSession, setTokenExpiry, spreadUserWithPerms, @@ -15,10 +15,13 @@ import { updateTenant, updateUser, validateUser, + IS_LOGGING_OUT, + SESSION_NAME } from './loginServices'; import { clearCurrentUser, + clearOkapiToken, setCurrentPerms, setLocale, // setTimezone, @@ -39,23 +42,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()), @@ -350,6 +336,7 @@ describe('validateUser', () => { await validateUser('url', store, 'tenant', {}); expect(store.dispatch).toHaveBeenCalledWith(clearCurrentUser()); + expect(store.dispatch).toHaveBeenCalledWith(setServerDown()); mockFetchCleanUp(); }); }); @@ -442,3 +429,87 @@ 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(), + getState: 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(), + getState: 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..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,103 +58,139 @@ 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: OKAPI_REDUCER_ACTIONS.SET_RTR_TIMEOUT, + rtrTimeout, + }; +} + +function clearRtrTimeout() { + return { + 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: OKAPI_REDUCER_ACTIONS.TOGGLE_RTR_MODAL, + isVisible, + }; +} + export { checkSSO, clearCurrentUser, clearOkapiToken, + clearRtrFlsTimeout, + clearRtrTimeout, setAuthError, setBindings, setCurrency, @@ -164,12 +202,15 @@ export { setOkapiReady, setOkapiToken, setPlugins, + setRtrFlsTimeout, + setRtrTimeout, setServerDown, setSessionData, setSinglePlugin, setTimezone, setTokenExpiration, setTranslations, + toggleRtrModal, updateCurrentUser, setOkapiTenant }; diff --git a/src/okapiReducer.js b/src/okapiReducer.js index 67ef13e4d..2fc52174b 100644 --- a/src/okapiReducer.js +++ b/src/okapiReducer.js @@ -1,55 +1,122 @@ +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': - return Object.assign({}, state, { isAuthenticated: action.isAuthenticated }); - case 'SET_LOCALE': + case OKAPI_REDUCER_ACTIONS.SET_IS_AUTHENTICATED: { + const newState = { + isAuthenticated: action.isAuthenticated, + }; + // 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 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 AT rotation timeout and set a new one + case OKAPI_REDUCER_ACTIONS.SET_RTR_TIMEOUT: { + clearTimeout(state.rtrTimeout); + return { ...state, rtrTimeout: action.rtrTimeout }; + } + case OKAPI_REDUCER_ACTIONS.CLEAR_RTR_TIMEOUT: { + clearTimeout(state.rtrTimeout); + return { ...state, rtrTimeout: undefined }; + } + // 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 }; + } + default: return state; } diff --git a/src/okapiReducer.test.js b/src/okapiReducer.test.js index de9cd2827..ce366491e 100644 --- a/src/okapiReducer.test.js +++ b/src/okapiReducer.test.js @@ -1,22 +1,39 @@ -import okapiReducer from './okapiReducer'; +import okapiReducer, { OKAPI_REDUCER_ACTIONS } 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: OKAPI_REDUCER_ACTIONS.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: 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 } }); }); @@ -36,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, @@ -45,4 +62,64 @@ 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: OKAPI_REDUCER_ACTIONS.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: 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: 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/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__/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/test/jest/__mock__/stripesComponents.mock.js b/test/jest/__mock__/stripesComponents.mock.js index 105990a4a..0747a52a4 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, @@ -74,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 diff --git a/translations/stripes-core/en.json b/translations/stripes-core/en.json index 5fbbffc1f..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", @@ -89,6 +88,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 +161,13 @@ "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", + "rtr.fixedLengthSession.timeRemaining": "Your session will end soon! Time remaining:" + } 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..a5bdc815d 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." + "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.fixedLengthSession.timeRemaining": "Your session will end soon! Time remaining:" } 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"