From ccefa283dff611fd7fecb8a2ea51bdfdf85f2a22 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Fri, 10 Nov 2023 15:34:10 -0500 Subject: [PATCH] Revert RTR (#1371) Revert all PRs related to STCOR-571, which implemented RTR in a service worker. This includes the following: * Revert "STCOR-759 return non-okapi request fetches as-is (#1369)" This reverts commit a323456fb14760b0c5fcb5558039ac62013cd40e. * Revert "STCOR-759 read okapi config from micro-stripes-config (#1366)" This reverts commit 6c6a85e34122656758976e625da06fbfc0b8dd69. * Revert "STCOR-756 correctly evaluate token lifespan (#1363)" This reverts commit 607dc5c5d78fc5d7eb3d8b083b6122ad53181d78. * Revert "STCOR-574 rotate tokens after 80% of their TTL (#1361)" This reverts commit dd718191f9a23572c5e085ab7355408a09bf1b63. * Revert "STCOR-671 handle access-control via cookies (#1346)" This reverts commit 27d29484c907f059c090aaa3a8fda2af753e8049. Also, add a dummy service-worker.js file to appease stripes-webpack. This removes the need to do extra clean-up work in that repository. --- CHANGELOG.md | 4 - index.js | 4 - package.json | 1 - src/App.js | 7 - src/RootWithIntl.js | 8 +- src/Stripes.js | 4 +- src/components/MainNav/MainNav.js | 13 +- src/components/Root/Root.js | 19 +- src/createApolloClient.js | 4 +- src/discoverServices.js | 11 +- src/loginServices.js | 279 ++------- src/loginServices.test.js | 202 +------ src/mainActions.js | 4 + src/okapiActions.js | 23 +- src/okapiActions.test.js | 15 - src/okapiReducer.js | 12 +- src/okapiReducer.test.js | 8 +- src/queries/useConfigurations.test.js | 14 - src/queries/useOkapiEnv.test.js | 14 - src/service-worker.js | 476 +-------------- src/service-worker.test.js | 569 ------------------ src/serviceWorkerRegistration.js | 79 --- src/serviceWorkerRegistration.test.js | 132 ---- src/useOkapiKy.js | 7 +- src/useOkapiKy.test.js | 3 + src/withOkapiKy.js | 4 +- test/bigtest/helpers/setup-application.js | 11 +- test/bigtest/network/config.js | 14 +- .../network/scenarios/fifthAttemptToLogin.js | 2 +- .../network/scenarios/invalidResponseBody.js | 2 +- .../network/scenarios/lockedAccount.js | 2 +- .../network/scenarios/multipleErrors.js | 2 +- test/bigtest/network/scenarios/serverError.js | 2 +- .../network/scenarios/thirdAttemptToLogin.js | 2 +- .../network/scenarios/wrongPassword.js | 2 +- .../network/scenarios/wrongUsername.js | 2 +- test/bigtest/tests/login-test.js | 8 - test/bigtest/tests/session-timeout-test.js | 2 +- test/jest/__mock__/index.js | 1 - test/jest/__mock__/microStripesConfig.mock.js | 5 - 40 files changed, 165 insertions(+), 1808 deletions(-) delete mode 100644 src/service-worker.test.js delete mode 100644 src/serviceWorkerRegistration.js delete mode 100644 src/serviceWorkerRegistration.test.js delete mode 100644 test/jest/__mock__/microStripesConfig.mock.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7284f3e22..b20c91ba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,7 @@ * Avoid private path when import `validateUser` function. Refs STCOR-749. * Ensure `` is not cut off when app name is long. Refs STCOR-752. * Use cookies and RTR instead of directly handling the JWT. Refs STCOR-671, FOLIO-3627. -* Shrink the token lifespan so we are less likely to use an expired one. Refs STCOR-754. -* Correctly evaluate token lifespan; use consistent protocol for service worker messages. Refs STCOR-756. * Allow console to be preserved on logout. STCOR-761. -* Do not catch and reject non-okapi request errors. Refs STCOR-759, UILDP-129. ## [10.0.0](https://github.com/folio-org/stripes-core/tree/v10.0.0) (2023-10-11) [Full Changelog](https://github.com/folio-org/stripes-core/compare/v9.0.0...v10.0.0) @@ -35,7 +32,6 @@ * *BREAKING* bump `react-intl` to `v6.4.4`. Refs STCOR-744. * Bump `stylelint` to `v15` and `stylelint-config-standard` to `v34`. Refs STCOR-745. * Read ky timeout from stripes-config value. Refs STCOR-594. -* *BREAKING* use cookies and RTR instead of directly handling the JWT. Refs STCOR-671, FOLIO-3627. ## [9.0.0](https://github.com/folio-org/stripes-core/tree/v9.0.0) (2023-01-30) [Full Changelog](https://github.com/folio-org/stripes-core/compare/v8.3.0...v9.0.0) diff --git a/index.js b/index.js index 877ba3222..f9598cb04 100644 --- a/index.js +++ b/index.js @@ -45,7 +45,3 @@ export { userLocaleConfig } from './src/loginServices'; export * from './src/consortiaServices'; export { default as queryLimit } from './src/queryLimit'; export { default as init } from './src/init'; - -/* RTR and service worker */ -export { postTokenExpiration } from './src/loginServices'; -export { registerServiceWorker, unregisterServiceWorker } from './src/serviceWorkerRegistration'; diff --git a/package.json b/package.json index 36631f51c..1c171d274 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,6 @@ "graphql": "^16.0.0", "history": "^4.6.3", "hoist-non-react-statics": "^3.3.0", - "inactivity-timer": "^1.0.0", "jwt-decode": "^3.1.2", "ky": "^0.23.0", "localforage": "^1.5.6", diff --git a/src/App.js b/src/App.js index 8d7503b59..437037235 100644 --- a/src/App.js +++ b/src/App.js @@ -11,7 +11,6 @@ import gatherActions from './gatherActions'; import { destroyStore } from './mainActions'; import Root from './components/Root'; -import { registerServiceWorker } from './serviceWorkerRegistration'; export default class StripesCore extends Component { static propTypes = { @@ -31,12 +30,6 @@ export default class StripesCore extends Component { this.epics = configureEpics(connectErrorEpic); this.store = configureStore(initialState, this.logger, this.epics); this.actionNames = gatherActions(); - - // register a service worker, providing okapi and stripes config details. - // the service worker functions as a proxy between between the browser - // and the network, intercepting ALL fetch requests to make sure they - // are accompanied by a valid access-token. - registerServiceWorker(okapiConfig, config, this.logger); } componentWillUnmount() { diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index aa54a9e9d..1f67251b5 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -46,13 +46,13 @@ class RootWithIntl extends React.Component { logger: PropTypes.object.isRequired, clone: PropTypes.func.isRequired, }).isRequired, - isAuthenticated: PropTypes.bool, + token: PropTypes.string, disableAuth: PropTypes.bool.isRequired, history: PropTypes.shape({}), }; static defaultProps = { - isAuthenticated: false, + token: '', history: {}, }; @@ -66,7 +66,7 @@ class RootWithIntl extends React.Component { render() { const { - isAuthenticated, + token, disableAuth, history, } = this.props; @@ -85,7 +85,7 @@ class RootWithIntl extends React.Component { > - { isAuthenticated || disableAuth ? + { token || disableAuth ? <> diff --git a/src/Stripes.js b/src/Stripes.js index db4b4913d..560397a39 100644 --- a/src/Stripes.js +++ b/src/Stripes.js @@ -49,7 +49,7 @@ export const stripesShape = PropTypes.shape({ ]), okapiReady: PropTypes.bool, tenant: PropTypes.string.isRequired, - isAuthenticated: PropTypes.bool, + token: PropTypes.string, translations: PropTypes.object, url: PropTypes.string.isRequired, withoutOkapi: PropTypes.bool, @@ -57,10 +57,10 @@ export const stripesShape = PropTypes.shape({ plugins: PropTypes.object, setBindings: PropTypes.func.isRequired, setCurrency: PropTypes.func.isRequired, - setIsAuthenticated: PropTypes.func.isRequired, setLocale: PropTypes.func.isRequired, setSinglePlugin: PropTypes.func.isRequired, setTimezone: PropTypes.func.isRequired, + setToken: PropTypes.func.isRequired, store: PropTypes.shape({ dispatch: PropTypes.func.isRequired, getState: PropTypes.func.isRequired, diff --git a/src/components/MainNav/MainNav.js b/src/components/MainNav/MainNav.js index d1b7a6e07..86a886fa6 100644 --- a/src/components/MainNav/MainNav.js +++ b/src/components/MainNav/MainNav.js @@ -4,6 +4,7 @@ import { isEqual, find } from 'lodash'; import { compose } from 'redux'; import { injectIntl } from 'react-intl'; import { withRouter } from 'react-router'; +import localforage from 'localforage'; import { branding, config } from 'stripes-config'; @@ -11,7 +12,9 @@ import { Icon } from '@folio/stripes-components'; import { withModules } from '../Modules'; import { LastVisitedContext } from '../LastVisited'; -import { getLocale, logout as sessionLogout } from '../../loginServices'; +import { clearOkapiToken, clearCurrentUser } from '../../okapiActions'; +import { resetStore } from '../../mainActions'; +import { getLocale } from '../../loginServices'; import { updateQueryResource, getLocationQuery, @@ -120,8 +123,12 @@ class MainNav extends Component { returnToLogin() { const { okapi } = this.store.getState(); - return getLocale(okapi.url, this.store, okapi.tenant) - .then(sessionLogout(okapi.url, this.store)); + return getLocale(okapi.url, this.store, okapi.tenant).then(() => { + this.store.dispatch(clearOkapiToken()); + this.store.dispatch(clearCurrentUser()); + this.store.dispatch(resetStore()); + localforage.removeItem('okapiSess'); + }); } // return the user to the login screen, but after logging in they will be brought to the default screen. diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index 75725d37a..b4b549cc6 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -20,8 +20,8 @@ import initialReducers from '../../initialReducers'; import enhanceReducer from '../../enhanceReducer'; import createApolloClient from '../../createApolloClient'; import createReactQueryClient from '../../createReactQueryClient'; -import { setSinglePlugin, setBindings, setIsAuthenticated, setTimezone, setCurrency, updateCurrentUser } from '../../okapiActions'; -import { addServiceWorkerListeners, loadTranslations, checkOkapiSession } from '../../loginServices'; +import { setSinglePlugin, setBindings, setOkapiToken, setTimezone, setCurrency, updateCurrentUser } from '../../okapiActions'; +import { loadTranslations, checkOkapiSession } from '../../loginServices'; import { getQueryResourceKey, getCurrentModule } from '../../locationService'; import Stripes from '../../Stripes'; import RootWithIntl from '../../RootWithIntl'; @@ -40,7 +40,7 @@ class Root extends Component { constructor(...args) { super(...args); - const { modules, history, okapi, store } = this.props; + const { modules, history, okapi } = this.props; this.reducers = { ...initialReducers }; this.epics = {}; @@ -64,9 +64,6 @@ class Root extends Component { this.apolloClient = createApolloClient(okapi); this.reactQueryClient = createReactQueryClient(); - - // service-worker message listeners - addServiceWorkerListeners(okapi, store); } getChildContext() { @@ -110,7 +107,7 @@ class Root extends Component { } render() { - const { logger, store, epics, config, okapi, actionNames, isAuthenticated, disableAuth, currentUser, currentPerms, locale, defaultTranslations, timezone, currency, plugins, bindings, discovery, translations, history, serverDown } = this.props; + const { logger, store, epics, config, okapi, actionNames, token, disableAuth, currentUser, currentPerms, locale, defaultTranslations, timezone, currency, plugins, bindings, discovery, translations, history, serverDown } = this.props; if (serverDown) { return
Error: server is down.
; @@ -128,7 +125,7 @@ class Root extends Component { config, okapi, withOkapi: this.withOkapi, - setIsAuthenticated: (val) => { store.dispatch(setIsAuthenticated(val)); }, + setToken: (val) => { store.dispatch(setOkapiToken(val)); }, actionNames, locale, timezone, @@ -169,7 +166,7 @@ class Root extends Component { > @@ -194,7 +191,7 @@ Root.propTypes = { getState: PropTypes.func.isRequired, replaceReducer: PropTypes.func.isRequired, }), - isAuthenticated: PropTypes.bool, + token: PropTypes.string, disableAuth: PropTypes.bool.isRequired, logger: PropTypes.object.isRequired, currentPerms: PropTypes.object, @@ -252,13 +249,13 @@ function mapStateToProps(state) { currentPerms: state.okapi.currentPerms, currentUser: state.okapi.currentUser, discovery: state.discovery, - isAuthenticated: state.okapi.isAuthenticated, locale: state.okapi.locale, okapi: state.okapi, okapiReady: state.okapi.okapiReady, plugins: state.okapi.plugins, serverDown: state.okapi.serverDown, timezone: state.okapi.timezone, + token: state.okapi.token, translations: state.okapi.translations, }; } diff --git a/src/createApolloClient.js b/src/createApolloClient.js index 393afb8bb..9819bda6c 100644 --- a/src/createApolloClient.js +++ b/src/createApolloClient.js @@ -1,10 +1,10 @@ import { InMemoryCache, ApolloClient } from '@apollo/client'; -const createClient = ({ url, tenant }) => (new ApolloClient({ +const createClient = ({ url, tenant, token }) => (new ApolloClient({ uri: `${url}/graphql`, - credentials: 'include', headers: { 'X-Okapi-Tenant': tenant, + 'X-Okapi-Token': token, }, cache: new InMemoryCache(), })); diff --git a/src/discoverServices.js b/src/discoverServices.js index 29360f498..7c5e33812 100644 --- a/src/discoverServices.js +++ b/src/discoverServices.js @@ -1,8 +1,9 @@ import { some } from 'lodash'; -function getHeaders(tenant) { +function getHeaders(tenant, token) { return { 'X-Okapi-Tenant': tenant, + 'X-Okapi-Token': token, 'Content-Type': 'application/json' }; } @@ -11,9 +12,7 @@ function fetchOkapiVersion(store) { const okapi = store.getState().okapi; return fetch(`${okapi.url}/_/version`, { - headers: getHeaders(okapi.tenant), - credentials: 'include', - mode: 'cors', + headers: getHeaders(okapi.tenant, okapi.token) }).then((response) => { // eslint-disable-line consistent-return if (response.status >= 400) { store.dispatch({ type: 'DISCOVERY_FAILURE', code: response.status }); @@ -32,9 +31,7 @@ function fetchModules(store) { const okapi = store.getState().okapi; return fetch(`${okapi.url}/_/proxy/tenants/${okapi.tenant}/modules?full=true`, { - headers: getHeaders(okapi.tenant), - credentials: 'include', - mode: 'cors', + headers: getHeaders(okapi.tenant, okapi.token) }).then((response) => { // eslint-disable-line consistent-return if (response.status >= 400) { store.dispatch({ type: 'DISCOVERY_FAILURE', code: response.status }); diff --git a/src/loginServices.js b/src/loginServices.js index b99107f93..d2acceae8 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -1,10 +1,9 @@ import localforage from 'localforage'; -import { config, translations } from 'stripes-config'; +import { translations } from 'stripes-config'; import rtlDetect from 'rtl-detect'; import moment from 'moment'; import { discoverServices } from './discoverServices'; -import { resetStore } from './mainActions'; import { clearCurrentUser, @@ -15,18 +14,16 @@ import { setPlugins, setBindings, setTranslations, - setIsAuthenticated, + clearOkapiToken, setAuthError, checkSSO, setOkapiReady, setServerDown, setSessionData, - setTokenExpiration, setLoginData, updateCurrentUser, } from './okapiActions'; import processBadResponse from './processBadResponse'; -import configureLogger from './configureLogger'; // export supported locales, i.e. the languages we provide translations for export const supportedLocales = [ @@ -66,20 +63,16 @@ export const supportedNumberingSystems = [ 'arab', // Arabic-Hindi (٠ ١ ٢ ٣ ٤ ٥ ٦ ٧ ٨ ٩) ]; -/** name for the session key in local storage */ -const SESSION_NAME = 'okapiSess'; - // export config values for storing user locale export const userLocaleConfig = { 'configName': 'localeSettings', 'module': '@folio/stripes-core', }; -const logger = configureLogger(config); - -function getHeaders(tenant) { +function getHeaders(tenant, token) { return { 'X-Okapi-Tenant': tenant, + 'X-Okapi-Token': token, 'Content-Type': 'application/json', }; } @@ -171,15 +164,12 @@ export function loadTranslations(store, locale, defaultTranslations = {}) { * @returns {Promise} */ function dispatchLocale(url, store, tenant) { - return fetch(url, { - headers: getHeaders(tenant), - credentials: 'include', - mode: 'cors', - }) + return fetch(url, + { headers: getHeaders(tenant, store.getState().okapi.token) }) .then((response) => { if (response.status === 200) { response.json().then((json) => { - if (json.configs?.length) { + if (json.configs.length) { const localeValues = JSON.parse(json.configs[0].value); const { locale, timezone, currency } = localeValues; if (locale) { @@ -250,15 +240,12 @@ export function getUserLocale(okapiUrl, store, tenant, userId) { * @returns {Promise} */ export function getPlugins(okapiUrl, store, tenant) { - return fetch(`${okapiUrl}/configurations/entries?query=(module==PLUGINS)`, { - headers: getHeaders(tenant), - credentials: 'include', - mode: 'cors', - }) + return fetch(`${okapiUrl}/configurations/entries?query=(module==PLUGINS)`, + { headers: getHeaders(tenant, store.getState().okapi.token) }) .then((response) => { if (response.status < 400) { response.json().then((json) => { - const configs = json.configs?.reduce((acc, val) => ({ + const configs = json.configs.reduce((acc, val) => ({ ...acc, [val.configName]: val.value, }), {}); @@ -279,11 +266,8 @@ export function getPlugins(okapiUrl, store, tenant) { * @returns {Promise} */ export function getBindings(okapiUrl, store, tenant) { - return fetch(`${okapiUrl}/configurations/entries?query=(module==ORG and configName==bindings)`, { - headers: getHeaders(tenant), - credentials: 'include', - mode: 'cors', - }) + return fetch(`${okapiUrl}/configurations/entries?query=(module==ORG and configName==bindings)`, + { headers: getHeaders(tenant, store.getState().okapi.token) }) .then((response) => { let bindings = {}; if (response.status >= 400) { @@ -291,7 +275,7 @@ export function getBindings(okapiUrl, store, tenant) { } else { response.json().then((json) => { const configs = json.configs; - if (Array.isArray(configs) && configs.length > 0) { + if (configs.length > 0) { const string = configs[0].value; try { const tmp = JSON.parse(string); @@ -363,62 +347,13 @@ export function spreadUserWithPerms(userWithPerms) { return { user, perms }; } -/** - * logout - * dispatch events to clear the store, then clear the session too. - * - * @param {object} redux store - * - * @returns {Promise} - */ -export async function logout(okapiUrl, store) { - store.dispatch(setIsAuthenticated(false)); - store.dispatch(clearCurrentUser()); - store.dispatch(resetStore()); - return fetch(`${okapiUrl}/authn/logout`, { - method: 'POST', - mode: 'cors', - credentials: 'include' - }) - .then(localforage.removeItem(SESSION_NAME)) - .then(localforage.removeItem('loginResponse')); -} - -/** - * postTokenExpiration - * send SW a TOKEN_EXPIRATION message - * @param {object} tokenExpiration shaped like { atExpires, rtExpires} where both are millisecond timestamps - * - * @returns {Promise} - */ -export const postTokenExpiration = (tokenExpiration) => { - if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { - return navigator.serviceWorker.ready - .then((reg) => { - const sw = reg.active; - if (sw) { - const message = { source: '@folio/stripes-core', type: 'TOKEN_EXPIRATION', value: { tokenExpiration } }; - logger.log('rtr', '<= sending', message); - sw.postMessage(message); - } else { - logger.log('rtr', 'error, could not send TOKEN_EXPIRATION message; no ServiceWorker is active'); - } - }); - } - - logger.log('rtr', 'error, could not send TOKEN_EXPIRATION message; navigator.serviceWorker is empty'); - return Promise.resolve(); -}; - /** * createOkapiSession * Remap the given data into a session object shaped like: * { * user: { id, username, personal } - * tenant: string, * perms: { permNameA: true, permNameB: true, ... } - * isAuthenticated: boolean, - * tokenExpiration: { atExpires, rtExpires } + * token: token * } * Dispatch the session object, then return a Promise that fetches * and dispatches tenant resources. @@ -426,11 +361,12 @@ export const postTokenExpiration = (tokenExpiration) => { * @param {*} okapiUrl * @param {*} store * @param {*} tenant + * @param {*} token * @param {*} data * * @returns {Promise} */ -export function createOkapiSession(okapiUrl, store, tenant, data) { +export function createOkapiSession(okapiUrl, store, tenant, token, data) { // clear any auth-n errors store.dispatch(setAuthError(null)); @@ -442,81 +378,54 @@ export function createOkapiSession(okapiUrl, store, tenant, data) { store.dispatch(setCurrentPerms(perms)); - // if we can't parse tokenExpiration data, e.g. because data comes from `/bl-users/_self` - // which doesn't provide it, then set an invalid AT value and a near-future (+10 minutes) RT value. - // the invalid AT will prompt an RTR cycle which will either give us new AT/RT values - // (if the RT was valid) or throw an RTR_ERROR (if the RT was not valid). - const tokenExpiration = { - atExpires: data.tokenExpiration?.accessTokenExpiration ? new Date(data.tokenExpiration.accessTokenExpiration).getTime() : -1, - rtExpires: data.tokenExpiration?.refreshTokenExpiration ? new Date(data.tokenExpiration.refreshTokenExpiration).getTime() : Date.now() + (10 * 60 * 1000), - }; - const sessionTenant = data.tenant || tenant; const okapiSess = { - isAuthenticated: true, + token, user, perms, tenant: sessionTenant, - tokenExpiration, }; - // provide token-expiration info to the service worker - return postTokenExpiration(tokenExpiration) - .then(localforage.setItem('loginResponse', data)) - .then(() => localforage.setItem(SESSION_NAME, okapiSess)) + return localforage.setItem('loginResponse', data) + .then(() => localforage.setItem('okapiSess', okapiSess)) .then(() => { - store.dispatch(setIsAuthenticated(true)); store.dispatch(setSessionData(okapiSess)); return loadResources(okapiUrl, store, sessionTenant, user.id); }); } /** - * handleServiceWorkerMessage - * Handle messages posted by service workers - * * TOKEN_EXPIRATION: update the redux store - * * RTR_ERROR: logout + * validateUser + * 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. * - * @param {Event} event - * @param {object} store redux-store + * @param {string} okapiUrl + * @param {redux store} store + * @param {string} tenant + * @param {object} session + * + * @returns {Promise} */ -export const handleServiceWorkerMessage = (event, store) => { - // only accept events whose origin matches this window's origin, - // i.e. if this is a same-origin event. Browsers allow cross-origin - // message exchange, but we're only interested in the events we control. - if ((!event.origin) || (event.origin !== window.location.origin)) { - return; - } - - if (event.data.source === '@folio/stripes-core') { - // RTR happened: update token expiration timestamps in our store - if (event.data.type === 'TOKEN_EXPIRATION') { - store.dispatch(setTokenExpiration({ - atExpires: new Date(event.data.value.tokenExpiration.atExpires).toISOString(), - rtExpires: new Date(event.data.value.tokenExpiration.rtExpires).toISOString(), - })); - } +export function validateUser(okapiUrl, store, tenant, session) { + const { token, user, perms, tenant: sessionTenant = tenant } = session; - // RTR failed: we have no cookies; logout - if (event.data.type === 'RTR_ERROR') { - logger.log('rtr', 'rtr error; logging out', event.data.error); - store.dispatch(setIsAuthenticated(false)); + return fetch(`${okapiUrl}/bl-users/_self`, { headers: getHeaders(sessionTenant, token) }).then((resp) => { + if (resp.ok) { + return resp.json().then((data) => { + store.dispatch(setLoginData(data)); + store.dispatch(setSessionData({ token, user, perms, tenant: sessionTenant })); + return loadResources(okapiUrl, store, sessionTenant, user.id); + }); + } else { store.dispatch(clearCurrentUser()); - store.dispatch(resetStore()); - localforage.removeItem(SESSION_NAME) - .then(localforage.removeItem('loginResponse')); + store.dispatch(clearOkapiToken()); + return localforage.removeItem('okapiSess'); } - } -}; - -export function addServiceWorkerListeners(okapiConfig, store) { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.addEventListener('message', (e) => { - handleServiceWorkerMessage(e, store); - }); - } else { - logger.log('rtr', 'error; navigator.serviceWorker is empty'); - } + }).catch((error) => { + store.dispatch(setServerDown()); + return error; + }); } /** @@ -593,7 +502,7 @@ function processSSOLoginResponse(resp) { * @returns {Promise} resolving to the response's JSON */ export function handleLoginError(dispatch, resp) { - return localforage.removeItem(SESSION_NAME) + return localforage.removeItem('okapiSess') .then(() => processBadResponse(dispatch, resp)) .then(responseBody => { dispatch(setOkapiReady()); @@ -609,16 +518,18 @@ export function handleLoginError(dispatch, resp) { * @param {redux store} store * @param {string} tenant * @param {Response} resp HTTP response + * @param {string} ssoToken * * @returns {Promise} resolving with login response body, rejecting with, ummmmm */ -export function processOkapiSession(okapiUrl, store, tenant, resp) { +export function processOkapiSession(okapiUrl, store, tenant, resp, ssoToken) { + const token = resp.headers.get('X-Okapi-Token') || ssoToken; const { dispatch } = store; if (resp.ok) { return resp.json() .then(json => { - return createOkapiSession(okapiUrl, store, tenant, json) + return createOkapiSession(okapiUrl, store, tenant, token, json) .then(() => json); }) .then((json) => { @@ -630,64 +541,6 @@ export function processOkapiSession(okapiUrl, store, tenant, resp) { } } -/** - * validateUser - * 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. - * - * @param {string} okapiUrl - * @param {redux store} store - * @param {string} tenant - * @param {object} session - * - * @returns {Promise} - */ -export function validateUser(okapiUrl, store, tenant, session) { - const { user, perms, tenant: sessionTenant = tenant } = session; - return fetch(`${okapiUrl}/bl-users/_self`, { - headers: getHeaders(sessionTenant), - credentials: 'include', - mode: 'cors', - }).then((resp) => { - if (resp.ok) { - return resp.json().then((data) => { - // clear any auth-n errors - store.dispatch(setAuthError(null)); - store.dispatch(setLoginData(data)); - - // If the request succeeded, we know the AT must be valid, but the - // response body from this endpoint doesn't include token-expiration - // data. So ... we set a near-future RT and an already-expired AT. - // On the next request, the expired AT will prompt an RTR cycle and - // we'll get real expiration values then. - const tokenExpiration = { - atExpires: -1, - rtExpires: Date.now() + (10 * 60 * 1000), - }; - // provide token-expiration info to the service-worker - return postTokenExpiration(tokenExpiration) - .then(() => { - store.dispatch(setSessionData({ - isAuthenticated: true, - user, - perms, - tenant: sessionTenant, - tokenExpiration, - })); - return loadResources(okapiUrl, store, sessionTenant, user.id); - }); - }); - } else { - return logout(okapiUrl, store); - } - }).catch((error) => { - console.error(error); // eslint-disable-line no-console - store.dispatch(setServerDown()); - return error; - }); -} - /** * checkOkapiSession * 1. Pull the session from local storage; if non-empty validate it, dispatching load-resources actions. @@ -699,7 +552,7 @@ export function validateUser(okapiUrl, store, tenant, session) { * @param {string} tenant */ export function checkOkapiSession(okapiUrl, store, tenant) { - localforage.getItem(SESSION_NAME) + localforage.getItem('okapiSess') .then((sess) => { return sess !== null ? validateUser(okapiUrl, store, tenant, sess) : null; }) @@ -723,12 +576,10 @@ export function checkOkapiSession(okapiUrl, store, tenant) { * @returns {Promise} */ export function requestLogin(okapiUrl, store, tenant, data) { - return fetch(`${okapiUrl}/bl-users/login-with-expiry?expandPermissions=true&fullPermissions=true`, { - body: JSON.stringify(data), - credentials: 'include', - headers: { 'X-Okapi-Tenant': tenant, 'Content-Type': 'application/json' }, + return fetch(`${okapiUrl}/bl-users/login?expandPermissions=true&fullPermissions=true`, { method: 'POST', - mode: 'cors', + headers: { 'X-Okapi-Tenant': tenant, 'Content-Type': 'application/json' }, + body: JSON.stringify(data), }) .then(resp => processOkapiSession(okapiUrl, store, tenant, resp)); } @@ -738,13 +589,14 @@ export function requestLogin(okapiUrl, store, tenant, data) { * retrieve currently-authenticated user * @param {string} okapiUrl * @param {string} tenant + * @param {string} token * * @returns {Promise} Promise resolving to the response of the request */ -function fetchUserWithPerms(okapiUrl, tenant) { +function fetchUserWithPerms(okapiUrl, tenant, token) { return fetch( `${okapiUrl}/bl-users/_self?expandPermissions=true&fullPermissions=true`, - { headers: getHeaders(tenant) }, + { headers: getHeaders(tenant, token) }, ); } @@ -754,12 +606,13 @@ function fetchUserWithPerms(okapiUrl, tenant) { * @param {string} okapiUrl * @param {redux store} store * @param {string} tenant + * @param {string} token * * @returns {Promise} Promise resolving to the response-body (JSON) of the request */ -export function requestUserWithPerms(okapiUrl, store, tenant) { - return fetchUserWithPerms(okapiUrl, tenant) - .then(resp => processOkapiSession(okapiUrl, store, tenant, resp)); +export function requestUserWithPerms(okapiUrl, store, tenant, token) { + return fetchUserWithPerms(okapiUrl, tenant, token) + .then(resp => processOkapiSession(okapiUrl, store, tenant, resp, token)); } /** @@ -795,10 +648,10 @@ export function requestSSOLogin(okapiUrl, tenant) { * @returns {Promise} */ export function updateUser(store, data) { - return localforage.getItem(SESSION_NAME) + return localforage.getItem('okapiSess') .then((sess) => { sess.user = { ...sess.user, ...data }; - return localforage.setItem(SESSION_NAME, sess); + return localforage.setItem('okapiSess', sess); }) .then(() => { store.dispatch(updateCurrentUser(data)); @@ -815,9 +668,9 @@ export function updateUser(store, data) { * @returns {Promise} */ export async function updateTenant(okapi, tenant) { - const okapiSess = await localforage.getItem(SESSION_NAME); - const userWithPermsResponse = await fetchUserWithPerms(okapi.url, tenant); + const okapiSess = await localforage.getItem('okapiSess'); + const userWithPermsResponse = await fetchUserWithPerms(okapi.url, tenant, okapi.token); const userWithPerms = await userWithPermsResponse.json(); - await localforage.setItem(SESSION_NAME, { ...okapiSess, tenant, ...spreadUserWithPerms(userWithPerms) }); + await localforage.setItem('okapiSess', { ...okapiSess, tenant, ...spreadUserWithPerms(userWithPerms) }); } diff --git a/src/loginServices.test.js b/src/loginServices.test.js index d46935ac9..1c7f6a6f0 100644 --- a/src/loginServices.test.js +++ b/src/loginServices.test.js @@ -1,21 +1,18 @@ import localforage from 'localforage'; import { + spreadUserWithPerms, createOkapiSession, handleLoginError, - handleServiceWorkerMessage, loadTranslations, processOkapiSession, - spreadUserWithPerms, supportedLocales, supportedNumberingSystems, - updateTenant, updateUser, + updateTenant, validateUser, } from './loginServices'; -import { resetStore } from './mainActions'; - import { clearCurrentUser, setCurrentPerms, @@ -25,35 +22,19 @@ import { // setPlugins, // setBindings, // setTranslations, + clearOkapiToken, setAuthError, // checkSSO, - setIsAuthenticated, setOkapiReady, setServerDown, - // setSessionData, - setTokenExpiration, + setSessionData, setLoginData, updateCurrentUser, } from './okapiActions'; 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: {} })), @@ -83,8 +64,9 @@ const mockFetchCleanUp = () => { delete global.fetch; }; + describe('createOkapiSession', () => { - it('clears authentication errors and sends a TOKEN_EXPIRATION message', async () => { + it('clears authentication errors', async () => { const store = { dispatch: jest.fn(), getState: () => ({ @@ -94,50 +76,23 @@ describe('createOkapiSession', () => { }), }; - const postMessage = jest.fn(); - navigator.serviceWorker = { - controller: true, - ready: Promise.resolve({ - active: { - postMessage, - } - }) - }; - - const te = { - accessTokenExpiration: '2023-11-06T18:05:33Z', - refreshTokenExpiration: '2023-10-30T18:15:33Z', - }; - const data = { user: { id: 'user-id', }, permissions: { permissions: [{ permissionName: 'a' }, { permissionName: 'b' }] - }, - tokenExpiration: te, + } }; const permissionsMap = { a: true, b: true }; + mockFetchSuccess([]); - await createOkapiSession('url', store, 'tenant', data); + await createOkapiSession('url', store, 'tenant', 'token', data); expect(store.dispatch).toHaveBeenCalledWith(setAuthError(null)); expect(store.dispatch).toHaveBeenCalledWith(setLoginData(data)); expect(store.dispatch).toHaveBeenCalledWith(setCurrentPerms(permissionsMap)); - const message = { - source: '@folio/stripes-core', - type: 'TOKEN_EXPIRATION', - value: { - tokenExpiration: { - atExpires: new Date('2023-11-06T18:05:33Z').getTime(), - rtExpires: new Date('2023-10-30T18:15:33Z').getTime(), - }, - } - }; - expect(postMessage).toHaveBeenCalledWith(message); - mockFetchCleanUp(); }); }); @@ -241,7 +196,7 @@ describe('processOkapiSession', () => { mockFetchSuccess(); - await processOkapiSession('url', store, 'tenant', resp); + await processOkapiSession('url', store, 'tenant', resp, 'token'); expect(store.dispatch).toHaveBeenCalledWith(setAuthError(null)); expect(store.dispatch).toHaveBeenCalledWith(setOkapiReady()); @@ -258,7 +213,7 @@ describe('processOkapiSession', () => { } }; - await processOkapiSession('url', store, 'tenant', resp); + await processOkapiSession('url', store, 'tenant', resp, 'token'); expect(store.dispatch).toHaveBeenCalledWith(setOkapiReady()); expect(store.dispatch).toHaveBeenCalledWith(setAuthError([defaultErrors.DEFAULT_LOGIN_CLIENT_ERROR])); @@ -299,45 +254,20 @@ describe('validateUser', () => { const tenant = 'tenant'; const data = { monkey: 'bagel' }; + const token = 'token'; const user = { id: 'id' }; const perms = []; const session = { + token, user, perms, }; mockFetchSuccess(data); - const postMessage = jest.fn(); - navigator.serviceWorker = { - controller: true, - ready: Promise.resolve({ - active: { - postMessage, - } - }) - }; - - // set a fixed system time so date math is stable - const now = new Date('2023-10-30T19:34:56.000Z'); - jest.useFakeTimers().setSystemTime(now); - await validateUser('url', store, tenant, session); - - expect(store.dispatch).nthCalledWith(1, setAuthError(null)); - expect(store.dispatch).nthCalledWith(2, setLoginData(data)); - - const message = { - source: '@folio/stripes-core', - type: 'TOKEN_EXPIRATION', - value: { - tokenExpiration: { - atExpires: -1, - rtExpires: new Date(now).getTime() + (10 * 60 * 1000), - }, - }, - }; - expect(postMessage).toHaveBeenCalledWith(message); + expect(store.dispatch).toHaveBeenCalledWith(setLoginData(data)); + expect(store.dispatch).toHaveBeenCalledWith(setSessionData({ token, user, perms, tenant })); mockFetchCleanUp(); }); @@ -350,22 +280,21 @@ describe('validateUser', () => { const tenant = 'tenant'; const sessionTenant = 'sessionTenant'; const data = { monkey: 'bagel' }; + const token = 'token'; const user = { id: 'id' }; const perms = []; const session = { + token, user, perms, tenant: sessionTenant, }; mockFetchSuccess(data); - navigator.serviceWorker = { - ready: Promise.resolve({}) - }; await validateUser('url', store, tenant, session); - expect(store.dispatch).nthCalledWith(1, setAuthError(null)); - expect(store.dispatch).nthCalledWith(2, setLoginData(data)); + expect(store.dispatch).toHaveBeenCalledWith(setLoginData(data)); + expect(store.dispatch).toHaveBeenCalledWith(setSessionData({ token, user, perms, tenant: sessionTenant })); mockFetchCleanUp(); }); @@ -381,6 +310,7 @@ describe('validateUser', () => { await validateUser('url', store, 'tenant', {}); expect(store.dispatch).toHaveBeenCalledWith(clearCurrentUser()); + expect(store.dispatch).toHaveBeenCalledWith(clearOkapiToken()); mockFetchCleanUp(); }); }); @@ -426,95 +356,3 @@ describe('updateTenant', () => { }); }); }); - - -describe('handleServiceWorkerMessage', () => { - const store = { - dispatch: jest.fn(), - getState: () => ({ - okapi: { - currentPerms: [], - } - }), - }; - - beforeEach(() => { - delete window.location; - }); - - describe('ignores cross-origin events', () => { - it('mismatched event origin', () => { - window.location = new URL('https://www.barbie.com'); - const event = { origin: '' }; - - handleServiceWorkerMessage(event, store); - expect(store.dispatch).not.toHaveBeenCalled(); - }); - - it('missing event origin', () => { - window.location = new URL('https://www.barbie.com'); - const event = { origin: 'https://www.openheimer.com' }; - - handleServiceWorkerMessage(event, store); - expect(store.dispatch).not.toHaveBeenCalled(); - }); - }); - - describe('handles same-origin events', () => { - it('only handles events if data.source is "@folio/stripes-core"', () => { - window.location = new URL('https://www.barbie.com'); - const event = { - origin: 'https://www.barbie.com', - data: { - source: 'monkey-bagel' - } - }; - - handleServiceWorkerMessage(event, store); - expect(store.dispatch).not.toHaveBeenCalled(); - }); - - it('on RTR, dispatches new token-expiration data', () => { - window.location = new URL('https://www.barbie.com'); - const tokenExpiration = { - atExpires: '2023-11-06T18:05:33.000Z', - rtExpires: '2023-10-30T18:15:33.000Z', - }; - - const event = { - origin: 'https://www.barbie.com', - data: { - source: '@folio/stripes-core', - type: 'TOKEN_EXPIRATION', - value: { tokenExpiration }, - } - }; - - handleServiceWorkerMessage(event, store); - expect(store.dispatch).toHaveBeenCalledWith(setTokenExpiration({ ...tokenExpiration })); - }); - - it('on RTR error, ends session', () => { - window.location = new URL('https://www.oppenheimer.com'); - const tokenExpiration = { - atExpires: '2023-11-06T18:05:33.000Z', - rtExpires: '2023-10-30T18:15:33.000Z', - }; - - const event = { - origin: 'https://www.oppenheimer.com', - data: { - source: '@folio/stripes-core', - type: 'RTR_ERROR', - tokenExpiration, - } - }; - - handleServiceWorkerMessage(event, store); - expect(store.dispatch).toHaveBeenCalledWith(setIsAuthenticated(false)); - expect(store.dispatch).toHaveBeenCalledWith(clearCurrentUser()); - expect(store.dispatch).toHaveBeenCalledWith(resetStore()); - }); - }); -}); - diff --git a/src/mainActions.js b/src/mainActions.js index bc41df8d2..91f063fbb 100644 --- a/src/mainActions.js +++ b/src/mainActions.js @@ -18,6 +18,10 @@ function destroyStore() { }; } +// We export a single named function rather than using a default +// export, to remain consistent with okapiActions.js +// +// eslint-disable-next-line import/prefer-default-export export { resetStore, destroyStore, diff --git a/src/okapiActions.js b/src/okapiActions.js index 77834a767..fe3bed7a1 100644 --- a/src/okapiActions.js +++ b/src/okapiActions.js @@ -61,10 +61,16 @@ function setBindings(bindings) { }; } -function setIsAuthenticated(b) { +function setOkapiToken(token) { return { - type: 'SET_IS_AUTHENTICATED', - isAuthenticated: Boolean(b), + type: 'SET_OKAPI_TOKEN', + token, + }; +} + +function clearOkapiToken() { + return { + type: 'CLEAR_OKAPI_TOKEN', }; } @@ -122,31 +128,24 @@ function updateCurrentUser(data) { }; } -function setTokenExpiration(tokenExpiration) { - return { - type: 'SET_TOKEN_EXPIRATION', - tokenExpiration, - }; -} - export { checkSSO, clearCurrentUser, + clearOkapiToken, setAuthError, setBindings, setCurrency, setCurrentPerms, setCurrentUser, - setIsAuthenticated, setLocale, setLoginData, setOkapiReady, + setOkapiToken, setPlugins, setServerDown, setSessionData, setSinglePlugin, setTimezone, - setTokenExpiration, setTranslations, updateCurrentUser, }; diff --git a/src/okapiActions.test.js b/src/okapiActions.test.js index 9ac82f56d..2376aed7e 100644 --- a/src/okapiActions.test.js +++ b/src/okapiActions.test.js @@ -1,23 +1,8 @@ import { - setIsAuthenticated, setLoginData, updateCurrentUser, } from './okapiActions'; -describe('setIsAuthenticated', () => { - it('handles truthy values', () => { - expect(setIsAuthenticated('truthy').isAuthenticated).toBe(true); - expect(setIsAuthenticated(1).isAuthenticated).toBe(true); - expect(setIsAuthenticated(true).isAuthenticated).toBe(true); - }); - - it('handles falsey values', () => { - expect(setIsAuthenticated('').isAuthenticated).toBe(false); - expect(setIsAuthenticated(0).isAuthenticated).toBe(false); - expect(setIsAuthenticated(false).isAuthenticated).toBe(false); - }); -}); - describe('setLoginData', () => { it('receives given data in "loginData"', () => { const av = { monkey: 'bagel' }; diff --git a/src/okapiReducer.js b/src/okapiReducer.js index a5f581192..aaa34563f 100644 --- a/src/okapiReducer.js +++ b/src/okapiReducer.js @@ -1,9 +1,11 @@ export default function okapiReducer(state = {}, action) { switch (action.type) { + case 'SET_OKAPI_TOKEN': + return Object.assign({}, state, { token: action.token }); + case 'CLEAR_OKAPI_TOKEN': + return Object.assign({}, state, { token: null }); case 'SET_CURRENT_USER': return Object.assign({}, state, { currentUser: action.currentUser }); - case 'SET_IS_AUTHENTICATED': - return Object.assign({}, state, { isAuthenticated: action.isAuthenticated }); case 'SET_LOCALE': return Object.assign({}, state, { locale: action.locale }); case 'SET_TIMEZONE': @@ -20,15 +22,13 @@ export default function okapiReducer(state = {}, action) { return Object.assign({}, state, { currentPerms: action.currentPerms }); case 'SET_LOGIN_DATA': return Object.assign({}, state, { loginData: action.loginData }); - case 'SET_TOKEN_EXPIRATION': - return Object.assign({}, state, { loginData: { ...state.loginData, tokenExpiration: action.tokenExpiration } }); case 'CLEAR_CURRENT_USER': return Object.assign({}, state, { currentUser: {}, currentPerms: {} }); case 'SET_SESSION_DATA': { - const { isAuthenticated, perms, tenant, user } = action.session; + const { perms, user, token, tenant } = action.session; const sessionTenant = tenant || state.tenant; - return { ...state, currentUser: user, currentPerms: perms, isAuthenticated, tenant: sessionTenant }; + return { ...state, currentUser: user, currentPerms: perms, token, tenant: sessionTenant }; } case 'SET_AUTH_FAILURE': return Object.assign({}, state, { authFailure: action.message }); diff --git a/src/okapiReducer.test.js b/src/okapiReducer.test.js index de9cd2827..fc67ace6e 100644 --- a/src/okapiReducer.test.js +++ b/src/okapiReducer.test.js @@ -1,12 +1,6 @@ import okapiReducer from './okapiReducer'; describe('okapiReducer', () => { - it('SET_IS_AUTHENTICATED', () => { - const isAuthenticated = true; - const o = okapiReducer({}, { type: 'SET_IS_AUTHENTICATED', isAuthenticated: true }); - expect(o).toMatchObject({ isAuthenticated }); - }); - it('SET_LOGIN_DATA', () => { const loginData = 'loginData'; const o = okapiReducer({}, { type: 'SET_LOGIN_DATA', loginData }); @@ -24,6 +18,7 @@ describe('okapiReducer', () => { const initialState = { perms: [], user: {}, + token: 'qwerty', tenant: 'central', }; const session = { @@ -34,6 +29,7 @@ describe('okapiReducer', () => { username: 'admin', } }, + token: 'ytrewq', tenant: 'institutional', }; const o = okapiReducer(initialState, { type: 'SET_SESSION_DATA', session }); diff --git a/src/queries/useConfigurations.test.js b/src/queries/useConfigurations.test.js index a40725cff..83baeef4b 100644 --- a/src/queries/useConfigurations.test.js +++ b/src/queries/useConfigurations.test.js @@ -11,20 +11,6 @@ import useOkapiKy from '../useOkapiKy'; jest.mock('../useOkapiKy'); jest.mock('../StripesContext'); -// reassign console.log to keep things quiet -const consoleInterruptor = {}; -beforeAll(() => { - consoleInterruptor.log = global.console.log; - consoleInterruptor.error = global.console.error; - console.log = () => { }; - console.error = () => { }; -}); - -afterAll(() => { - global.console.log = consoleInterruptor.log; - global.console.error = consoleInterruptor.error; -}); - // set query retries to false. otherwise, react-query will thoughtfully // (but unhelpfully, in the context of testing) retry a failed query // several times causing the test to timeout when what we really want diff --git a/src/queries/useOkapiEnv.test.js b/src/queries/useOkapiEnv.test.js index 28efc91cf..3101a000c 100644 --- a/src/queries/useOkapiEnv.test.js +++ b/src/queries/useOkapiEnv.test.js @@ -11,20 +11,6 @@ import useOkapiKy from '../useOkapiKy'; jest.mock('../useOkapiKy'); jest.mock('../StripesContext'); -// reassign console.log to keep things quiet -const consoleInterruptor = {}; -beforeAll(() => { - consoleInterruptor.log = global.console.log; - consoleInterruptor.error = global.console.error; - console.log = () => { }; - console.error = () => { }; -}); - -afterAll(() => { - global.console.log = consoleInterruptor.log; - global.console.error = consoleInterruptor.error; -}); - // set query retries to false. otherwise, react-query will thoughtfully // (but unhelpfully, in the context of testing) retry a failed query // several times causing the test to timeout when what we really want diff --git a/src/service-worker.js b/src/service-worker.js index 0bff7a9bc..305cc2cff 100644 --- a/src/service-worker.js +++ b/src/service-worker.js @@ -1,475 +1 @@ -/* eslint no-console: 0 */ -/* eslint no-restricted-globals: ["off", "self"] */ - -/** - * TLDR: perform refresh-token-rotation for Okapi-bound requests. - * - * Critical reading: - * @see https://web.dev/articles/service-worker-mindset#watch_out_for_global_state - * @see https://web.dev/articles/service-worker-lifecycle#shift-reload - * - * The (rather opaque) specification: - * @see https://www.w3.org/TR/service-workers/ - * - * The gory details: - * This service worker acts as a proxy betwen the browser and the network, - * intercepting all fetch requests. Those not bound for Okapi are simply - * passed along; the rest are intercepted in an attempt to make sure the - * accompanying access-token (provided in an http-only cookie) is valid. - * - * The install and activate listeners are configured to cause this worker - * to activate immediately and begin controlling all clients. - * - * The message listener receives config values and changes, such as - * setting the okapi URL and tenant, as well as resetting the timeouts - * for the AT and RT, which can be used to force RTR. Only messages with - * a data.source attribute === @folio/stripes-core are read. Likewise, - * messages sent via client.postMessage() use the same data.source attribute. - * - * The fetch listener and the function it delegates to, passThrough, is - * where things get interesting. The basic workflow is to check whether - * a request is bound for Okapi an intercept it in order to perform RTR - * if necessary, or to let the request pass through. - * - * Although JS cannot read the _actual_ timeouts for the AT and RT, - * those timeouts are also returned in the request-body of the login - * and refresh endpoints, and those are the values used here to - * determine whether the AT and RT are expected to be valid. If a request's - * AT appears valid, or if the request is destined for an endpoint that - * does not require authorization, the request is passed through. If the - * AT has expired, an RTR request executes first and then the original - * request executes after the RTR promise has resolved. - * - * When RTR succeeds, a new message with type === TOKEN_EXPIRATION is - * sent to clients with timeouts from the rotation request in the attribute - * 'tokenExpiration'. The response is a resolved Promise. - * - * When RTR fails, a new message with type === RTR_ERROR is sent to clients - * with additional details in the attribute 'error'. The response is a - * rejected Promise. - * - */ - -import { okapiUrl, okapiTenant } from 'micro-stripes-config'; - -/** { atExpires, rtExpires } both are JS millisecond timestamps */ -let tokenExpiration = null; - -/** whether to emit console logs */ -let shouldLog = false; - -/** lock to indicate whether a rotation request is already in progress */ -let isRotating = false; -/** how many times to check the lock before giving up */ -const IS_ROTATING_RETRIES = 100; -/** how long to wait before rechecking the lock, in milliseconds (100 * 100) === 10 seconds */ -const IS_ROTATING_INTERVAL = 100; - -/** - * TTL_WINDOW - * 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. - * - * Value is a float, 0 to 1, inclusive. Closer to 0 means more frequent - * rotation; 1 means a token is valid up the very last moment 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 TTL_WINDOW = 0.8; - -/** - * isValidAT - * return true if tokenExpiration.atExpires is in the future - * @param {object} te tokenExpiration shaped like { atExpires, rtExpires } - * @returns boolean - */ -export const isValidAT = (te) => { - const isValid = !!(te?.atExpires > Date.now()); - if (shouldLog) console.log(`-- (rtr-sw) => at isValid? ${isValid}; expires ${new Date(te?.atExpires || null).toISOString()}`); - return isValid; -}; - -/** - * isValidRT - * return true if tokenExpiration.rtExpires is in the future - * @param {object} te tokenExpiration shaped like { atExpires, rtExpires } - * @returns boolean - */ -export const isValidRT = (te) => { - const isValid = !!(te?.rtExpires > Date.now()); - if (shouldLog) console.log(`-- (rtr-sw) => rt isValid? ${isValid}; expires ${new Date(te?.rtExpires || null).toISOString()}`); - return isValid; -}; - -/** - * messageToClient - * Send a message to clients of this service worker - * @param {Event} event - * @param {*} message - * @returns void - */ -export const messageToClient = async (event, message) => { - // Exit early if we don't have access to the client. - // Eg, if it's cross-origin. - if (!event.clientId) { - if (shouldLog) console.log('-- (rtr-sw) PASSTHROUGH: no clientId'); - return; - } - - // Get the client. - const client = await self.clients.get(event.clientId); - // Exit early if we don't get the client. - // Eg, if it closed. - if (!client) { - if (shouldLog) console.log('-- (rtr-sw) PASSTHROUGH: no client'); - return; - } - - // Send a message to the client. - if (shouldLog) console.log('-- (rtr-sw) => sending', message); - client.postMessage({ ...message, source: '@folio/stripes-core' }); -}; - -/** - * handleTokenExpiration - * Set the AT and RT token expirations to the fraction of their TTL given by - * TTL_WINDOW. e.g. if a token should be valid for 100 more seconds and 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 - * @returns { tokenExpiration: { atExpires, rtExpires }} both are millisecond timestamps - */ -export const handleTokenExpiration = (value) => ({ - atExpires: Date.now() + ((value.tokenExpiration.atExpires - Date.now()) * TTL_WINDOW), - rtExpires: Date.now() + ((value.tokenExpiration.rtExpires - Date.now()) * TTL_WINDOW), -}); - -/** - * 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. - * @param {Event} event - * @returns Promise - * @throws if RTR fails - */ -export const rtr = async (event) => { - if (shouldLog) console.log('-- (rtr-sw) ** RTR ...'); - - // if several fetches trigger rtr in a short window, all but the first will - // fail because the RT will be stale after the first request rotates it. - // the sentinel isRotating indicates that rtr has already started and therefore - // should not start again; instead, we just need to wait until it finishes. - // waiting happens in a for-loop that waits a few milliseconds and then rechecks - // isRotating. hopefully, that process goes smoothly, but we'll give up after - // IS_ROTATING_RETRIES * IS_ROTATING_INTERVAL milliseconds and return failure. - if (isRotating) { - for (let i = 0; i < IS_ROTATING_RETRIES; i++) { - if (shouldLog) console.log(`-- (rtr-sw) ** is rotating; waiting ${IS_ROTATING_INTERVAL}ms`); - await new Promise(resolve => setTimeout(resolve, IS_ROTATING_INTERVAL)); - if (!isRotating) { - return Promise.resolve(); - } - } - // all is lost - return Promise.reject(new Error('in-process RTR timed out')); - } - - isRotating = true; - return fetch(`${okapiUrl}/authn/refresh`, { - headers: { - 'content-type': 'application/json', - 'x-okapi-tenant': okapiTenant, - }, - 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 => { - isRotating = false; - - if (Array.isArray(json.errors) && json.errors[0]) { - throw new Error(`${json.errors[0].message} (${json.errors[0].code})`); - } else { - throw new Error('RTR response failure'); - } - }); - }) - .then(json => { - if (shouldLog) console.log('-- (rtr-sw) ** success!'); - isRotating = false; - tokenExpiration = handleTokenExpiration({ - tokenExpiration: { - atExpires: new Date(json.accessTokenExpiration).getTime(), - rtExpires: new Date(json.refreshTokenExpiration).getTime(), - } - }); - - messageToClient(event, { type: 'TOKEN_EXPIRATION', value: { tokenExpiration } }); - }); -}; - -/** - * isPermissibleRequest - * Some requests are always permissible, e.g. auth-n and forgot-password. - * Others are only permissible if the Access Token is still valid. - * - * @param {Request} req clone of the original event.request object - * @param {object} te token expiration shaped like { atExpires, rtExpires } - * @param {string} oUrl Okapi URL - * @returns boolean true if the AT is valid or the request is always permissible - */ -export const isPermissibleRequest = (req, te, oUrl) => { - if (isValidAT(te)) { - return true; - } - - const permissible = [ - '/bl-users/forgotten/password', - '/bl-users/forgotten/username', - '/bl-users/login-with-expiry', - '/bl-users/password-reset', - '/saml/check', - ]; - - if (shouldLog) console.log(`-- (rtr-sw) AT invalid for ${req.url}`); - return !!permissible.find(i => req.url.startsWith(`${oUrl}${i}`)); -}; - -/** - * isLogoutRequest - * Logout requests are always permissible but need special handling - * because they should never fail. - * - * @param {Request} req clone of the original event.request object - * @param {string} oUrl okapi URL - * @returns boolean true if the request URL matches a logout URL - */ -export const isLogoutRequest = (req, oUrl) => { - const permissible = [ - '/authn/logout', - ]; - - return !!permissible.find(i => req.url.startsWith(`${oUrl}${i}`)); -}; - -/** - * isOkapiRequest - * Return true if the request origin matches our okapi URL, i.e. if this is a - * request that needs to include a valid AT. - * @param {Request} req - * @param {string} oUrl okapi URL - * @returns boolean - */ -export const isOkapiRequest = (req, oUrl) => { - if (shouldLog) console.log(`-- (rtr-sw) isOkapiRequest: ${new URL(req.url).origin} === ${okapiUrl}`); - return new URL(req.url).origin === oUrl; -}; - -/** - * passThroughWithRT - * Perform RTR then return the original fetch. on error, post an RTR_ERROR - * message to clients and return an empty response in a resolving promise. - * - * @param {Event} event - * @returns Promise - */ -const passThroughWithRT = (event) => { - return rtr(event) - .then(() => { - const req = event.request.clone(); - if (shouldLog) console.log('-- (rtr-sw) => post-rtr-fetch', req.url); - return fetch(event.request, { credentials: 'include' }); - }) - .catch((rtre) => { - // kill me softly: send an empty response body, which allows the fetch - // to return without error while the clients catch up, read the RTR_ERROR - // and handle it, hopefully by logging out. - // Promise.reject() here would result in every single fetch in every - // single application needing to thoughtfully handle RTR_ERROR responses. - messageToClient(event, { type: 'RTR_ERROR', error: rtre }); - return Promise.resolve(new Response(JSON.stringify({}))); - }); -}; - -/** - * 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, - * 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're still throwing an Error. - * @param {Event} event - * @returns Promise - * @throws if any fetch fails - */ -const passThroughWithAT = (event) => { - if (shouldLog) console.log('-- (rtr-sw) (valid AT or authn request)'); - return fetch(event.request, { credentials: 'include' }) - .then(response => { - // Handle three different situations: - // 1. 403: AT was expired (try RTR) - // 2. 403: AT was valid but corresponding permissions were insufficent (return response) - // 3. *: Anything else (return response) - if (response.status === 403 && response.headers['content-type'] === 'text/plain') { - return response.clone().text() - .then(text => { - // we thought the AT was valid but it wasn't, so try again. - // if we fail this time, we're done. - if (text.startsWith('Token missing')) { - if (shouldLog) console.log('-- (rtr-sw) (whoops, invalid AT; retrying)'); - return passThroughWithRT(event); - } - - // we got a 403 but not related to RTR; just pass it along - return response; - }); - } - - // any other response should just be returned as-is - return response; - }); -}; - -/** - * 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 {Event} event - * @returns Promise - */ -export const passThroughLogout = (event) => { - if (shouldLog) console.log('-- (rtr-sw) (logout request)'); - return fetch(event.request, { credentials: 'include' }) - .catch(e => { - // kill me softly: return an empty response to allow graceful failure - console.error('-- (rtr-sw) logout failure', e); // eslint-disable-line no-console - return Promise.resolve(new Response(JSON.stringify({}))); - }); -}; - -/** - * passThrough - * Inspect event.request to determine whether it's an okapi request. - * If it is, make sure its AT is valid or perform RTR before executing it. - * If it isn't, execute it immediately. If RTR fails catastrophically, - * post an RTR_ERROR message to clients and return an empty Response in a - * resolving promise in order to let the top-level error-handler pick it up. - * @param {Event} event - * @param {object} te tokenExpiration shaped like { atExpires, rtExpires } - * @param {string} oUrl okapiUrl - * @returns Promise - * @throws if any fetch fails - */ -export const passThrough = (event, te, oUrl) => { - const req = event.request.clone(); - - // okapi requests are subject to RTR - if (isOkapiRequest(req, oUrl)) { - if (shouldLog) console.log('-- (rtr-sw) => will fetch', req.url); - if (isLogoutRequest(req, oUrl)) { - return passThroughLogout(event); - } - - if (isPermissibleRequest(req, te, oUrl)) { - return passThroughWithAT(event); - } - - if (isValidRT(te)) { - if (shouldLog) console.log('-- (rtr-sw) => valid RT'); - return passThroughWithRT(event); - } - - // kill me softly: send an empty response body, which allows the fetch - // to return without error while the clients catch up, read the RTR_ERROR - // and handle it, hopefully by logging out. - // Promise.reject() here would result in every single fetch in every - // single application needing to thoughtfully handle RTR_ERROR responses. - messageToClient(event, { type: 'RTR_ERROR', error: `AT/RT failure accessing ${req.url}` }); - return Promise.resolve(new Response(JSON.stringify({}))); - } - - // default: pass requests through to the network - // console.log('-- (rtr-sw) passThrough NON-OKAPI', req.url) - return fetch(event.request); -}; - -/** - * install - * on install, force this SW to be the active SW - */ -self.addEventListener('install', (event) => { - if (shouldLog) console.log('-- (rtr-sw) => install', event); - return self.skipWaiting(); -}); - -/** - * activate - * on activate, force this SW to control all in-scope clients, - * even those that loaded before this SW was registered. - */ -self.addEventListener('activate', (event) => { - if (shouldLog) console.log('-- (rtr-sw) => activate', event); - event.waitUntil(self.clients.claim()); -}); - -/** - * eventListener: message - * listen for messages from @folio/stripes-core clients and dispatch them accordingly. - */ -self.addEventListener('message', (event) => { - // only accept events whose origin matches this window's origin, - // i.e. if this is a same-origin event. Browsers allow cross-origin - // message exchange, but we're only interested in the events we control. - if ((!event.origin) || (event.origin !== self.location.origin)) { - return; - } - - if (event.data.source === '@folio/stripes-core') { - if (shouldLog) console.info('-- (rtr-sw) reading', event.data); - - // LOGGER_CONFIG - if (event.data.type === 'LOGGER_CONFIG') { - shouldLog = !!event.data.value.categories?.split(',').some(cat => cat === 'rtr-sw'); - } - - // TOKEN_EXPIRATION - if (event.data.type === 'TOKEN_EXPIRATION') { - tokenExpiration = handleTokenExpiration(event.data.value); - } - } -}); - -/** - * eventListener: fetch - * intercept fetches - */ -self.addEventListener('fetch', (event) => { - event.respondWith(passThrough(event, tokenExpiration, okapiUrl)); -}); +const funky = 'chicken'; diff --git a/src/service-worker.test.js b/src/service-worker.test.js deleted file mode 100644 index 79dd26e5b..000000000 --- a/src/service-worker.test.js +++ /dev/null @@ -1,569 +0,0 @@ -import { - handleTokenExpiration, - isLogoutRequest, - isOkapiRequest, - isPermissibleRequest, - isValidAT, - isValidRT, - messageToClient, - passThrough, - passThroughLogout, - rtr, - TTL_WINDOW, -} from './service-worker'; - -// reassign console.log to keep things quiet -const consoleInterruptor = {}; -beforeAll(() => { - consoleInterruptor.log = global.console.log; - consoleInterruptor.error = global.console.error; - console.log = () => { }; - console.error = () => { }; -}); - -afterAll(() => { - global.console.log = consoleInterruptor.log; - global.console.error = consoleInterruptor.error; -}); - -describe('isValidAT', () => { - it('returns true for valid ATs', () => { - expect(isValidAT({ atExpires: Date.now() + 1000 })).toBe(true); - }); - - it('returns false for expired ATs', () => { - expect(isValidAT({ atExpires: Date.now() - 1000 })).toBe(false); - }); - - it('returns false when AT info is missing', () => { - expect(isValidAT({ monkey: 'bagel' })).toBe(false); - }); -}); - -describe('isValidRT', () => { - it('returns true for valid RTs', () => { - expect(isValidRT({ rtExpires: Date.now() + 1000 })).toBe(true); - }); - - it('returns false for expired RTs', () => { - expect(isValidRT({ rtExpires: Date.now() - 1000 })).toBe(false); - }); - - it('returns false when RT info is missing', () => { - expect(isValidRT({ monkey: 'bagel' })).toBe(false); - }); -}); - -describe('messageToClient', () => { - let self = null; - const client = { - postMessage: jest.fn(), - }; - - describe('when clients are absent, ignores events', () => { - beforeEach(() => { - ({ self } = window); - delete window.self; - - window.self = { - clients: { - get: jest.fn().mockReturnValue(Promise.resolve(undefined)), - }, - }; - }); - - afterEach(() => { - window.self = self; - }); - - it('event.clientId is absent', async () => { - messageToClient({}); - expect(window.self.clients.get).not.toHaveBeenCalled(); - }); - - it('self.clients.get(event.clientId) is empty', async () => { - const event = { clientId: 'monkey' }; - messageToClient(event, 'message'); - expect(window.self.clients.get).toHaveBeenCalledWith(event.clientId); - expect(client.postMessage).not.toHaveBeenCalled(); - }); - }); - - describe('when clients are present, posts a message', () => { - beforeEach(() => { - ({ self } = window); - delete window.self; - - window.self = { - clients: { - get: jest.fn().mockReturnValue(Promise.resolve(client)), - }, - }; - }); - - afterEach(() => { - window.self = self; - }); - - it('posts a message', async () => { - const event = { clientId: 'monkey' }; - const message = { thunder: 'chicken' }; - - await messageToClient(event, message); - expect(window.self.clients.get).toHaveBeenCalledWith(event.clientId); - expect(client.postMessage).toBeCalledWith({ ...message, source: '@folio/stripes-core' }); - }); - }); -}); - -describe('isPermissibleRequest', () => { - describe('when AT is valid', () => { - it('when AT is valid, accepts any endpoint', () => { - const req = { url: 'monkey' }; - const te = { atExpires: (Date.now() / TTL_WINDOW) + 1000, rtExpires: (Date.now() / TTL_WINDOW) + 1000 }; - expect(isPermissibleRequest(req, te, '')).toBe(true); - }); - }); - - describe('when AT is invalid or missing', () => { - describe('accepts known endpoints that do not require authorization', () => { - it('/bl-users/forgotten/password', () => { - const req = { url: '/bl-users/forgotten/password' }; - const te = {}; - - expect(isPermissibleRequest(req, te, '')).toBe(true); - }); - - it('/bl-users/forgotten/username', () => { - const req = { url: '/bl-users/forgotten/username' }; - const te = {}; - - expect(isPermissibleRequest(req, te, '')).toBe(true); - }); - - it('/bl-users/login-with-expiry', () => { - const req = { url: '/bl-users/login-with-expiry' }; - const te = {}; - - expect(isPermissibleRequest(req, te, '')).toBe(true); - }); - - it('/bl-users/password-reset', () => { - const req = { url: '/bl-users/password-reset' }; - const te = {}; - - expect(isPermissibleRequest(req, te, '')).toBe(true); - }); - - it('/saml/check', () => { - const req = { url: '/saml/check' }; - const te = {}; - - expect(isPermissibleRequest(req, te, '')).toBe(true); - }); - }); - - it('rejects unknown endpoints', () => { - const req = { url: '/monkey/bagel/is/not/known/to/stripes/at/least/i/hope/not' }; - const te = {}; - - expect(isPermissibleRequest(req, te, '')).toBe(false); - }); - }); -}); - -describe('isLogoutRequest', () => { - describe('accepts logout endpoints', () => { - it('/authn/logout', () => { - const req = { url: '/authn/logout' }; - - expect(isLogoutRequest(req, '')).toBe(true); - }); - }); - - it('rejects unknown endpoints', () => { - const req = { url: '/monkey/bagel/is/not/known/to/stripes/at/least/i/hope/not' }; - const te = {}; - - expect(isLogoutRequest(req, te, '')).toBe(false); - }); -}); - -describe('isOkapiRequest', () => { - it('accepts requests whose origin matches okapi\'s', () => { - const oUrl = 'https://domain.edu'; - const req = { url: `${oUrl}/some/endpoint` }; - expect(isOkapiRequest(req, oUrl)).toBe(true); - }); - - it('rejects requests whose origin does not match okapi\'s', () => { - const req = { url: 'https://foo.edu/some/endpoint' }; - expect(isOkapiRequest(req, 'https://bar.edu')).toBe(false); - }); -}); - -describe('passThroughLogout', () => { - it('resolves on success', async () => { - const val = { monkey: 'bagel' }; - global.fetch = jest.fn(() => ( - Promise.resolve({ - json: () => Promise.resolve(val), - }) - )); - const event = { request: 'monkey' }; - const res = await passThroughLogout(event); - expect(await res.json()).toMatchObject(val); - }); - - it('rejects on failure', async () => { - window.Response = jest.fn(); - const val = {}; - global.fetch = jest.fn(() => Promise.reject(Promise.resolve(new Response(JSON.stringify({}))))); - - const event = { request: 'monkey' }; - try { - await passThroughLogout(event); - } catch (e) { - expect(e).toMatchObject(val); - } - }); -}); - -describe('passThrough', () => { - describe('non-okapi requests break on through, break on through, break on through to the other side', () => { - it('successful requests receive a response', async () => { - const req = { - url: 'https://barbie-is-the-greatest-action-movie-of-all-time.fight.me' - }; - const event = { - request: { - clone: () => req, - } - }; - const tokenExpiration = {}; - const oUrl = 'https://okapi.edu'; - - const response = 'kenough'; - global.fetch = jest.fn(() => Promise.resolve(response)); - - const res = await passThrough(event, tokenExpiration, oUrl); - expect(res).toBe(response); - }); - - it('failed requests receive a rejection', async () => { - const req = { - url: 'https://barbie-is-the-greatest-action-movie-of-all-time.fight.me' - }; - const event = { - request: { - clone: () => req, - } - }; - const tokenExpiration = {}; - const oUrl = 'https://okapi.edu'; - - const error = 'not kenough'; - global.fetch = jest.fn(() => Promise.reject(error)); - - try { - await passThrough(event, tokenExpiration, oUrl); - } catch (e) { - expect(e).toEqual(error); - } - }); - }); - - describe('okapi requests are subject to RTR', () => { - it('requests to logout succeed', async () => { - const oUrl = 'https://trinity.edu'; - const req = { url: `${oUrl}/authn/logout` }; - const event = { - request: { - clone: () => req, - } - }; - const tokenExpiration = {}; - - const response = 'oppenheimer'; - global.fetch = jest.fn(() => Promise.resolve(response)); - - const res = await passThrough(event, tokenExpiration, oUrl); - expect(res).toEqual(response); - }); - - // request was valid, response is success; we should receive response - it('requests with valid ATs succeed with success response', async () => { - const oUrl = 'https://trinity.edu'; - const req = { url: `${oUrl}/manhattan` }; - const event = { - request: { - clone: () => req, - } - }; - const tokenExpiration = { atExpires: (Date.now() / TTL_WINDOW) + 10000 }; - - const response = { ok: true }; - global.fetch = jest.fn(() => Promise.resolve(response)); - - const res = await passThrough(event, tokenExpiration, oUrl); - expect(res).toEqual(response); - }); - - // request was valid, response is error; we should receive response - it('requests with valid ATs succeed with error response', async () => { - const oUrl = 'https://trinity.edu'; - const req = { url: `${oUrl}/manhattan` }; - const event = { - request: { - clone: () => req, - } - }; - const tokenExpiration = { atExpires: (Date.now() / TTL_WINDOW) + 10000 }; - - const response = { - ok: false, - status: 403, - headers: { 'content-type': 'text/plain' }, - clone: () => ({ - text: () => Promise.resolve('Access for user \'barbie\' (c0ffeeee-dead-beef-dead-coffeecoffee) requires permission: pink.is.the.new.black') - }), - }; - global.fetch = jest.fn(() => Promise.resolve(response)); - - const res = await passThrough(event, tokenExpiration, oUrl); - expect(res).toEqual(response); - }); - - it('requests with false-valid AT data succeed via RTR', async () => { - const oUrl = 'https://trinity.edu'; - const req = { url: `${oUrl}/manhattan` }; - const event = { - request: { - clone: () => req, - } - }; - const tokenExpiration = { - atExpires: (Date.now() / TTL_WINDOW) + 1000, // at says it's valid, but ok == false - rtExpires: (Date.now() / TTL_WINDOW) + 1000 - }; - - const response = 'los alamos'; - global.fetch = jest.fn() - .mockReturnValueOnce(Promise.resolve({ - status: 403, - headers: { 'content-type': 'text/plain' }, - clone: () => ({ - text: () => Promise.resolve('Token missing, access requires permission:'), - }), - })) - .mockReturnValueOnce(Promise.resolve({ - ok: true, - json: () => Promise.resolve({ - accessTokenExpiration: Date.now(), - refreshTokenExpiration: Date.now(), - }) - })) - .mockReturnValueOnce(Promise.resolve(response)); - const res = await passThrough(event, tokenExpiration, oUrl); - expect(res).toEqual(response); - }); - - it('requests with valid RTs succeed', async () => { - const oUrl = 'https://trinity.edu'; - const req = { url: `${oUrl}/manhattan` }; - const event = { - request: { - clone: () => req, - } - }; - const tokenExpiration = { - atExpires: Date.now() - 1000, - rtExpires: (Date.now() / TTL_WINDOW) + 1000 - }; - - const response = 'los alamos'; - - global.fetch = jest.fn() - .mockReturnValueOnce(Promise.resolve({ - ok: true, - json: () => Promise.resolve({ - accessTokenExpiration: Date.now(), - refreshTokenExpiration: Date.now(), - }) - })) - .mockReturnValueOnce(Promise.resolve(response)); - const res = await passThrough(event, tokenExpiration, oUrl); - expect(res).toEqual(response); - }); - - it('requests with false-valid RTs fail softly', async () => { - const oUrl = 'https://trinity.edu'; - const req = { url: `${oUrl}/manhattan` }; - const event = { - request: { - clone: () => req, - } - }; - const tokenExpiration = { - atExpires: Date.now() - 1000, - rtExpires: Date.now() + 1000 // rt says it's valid but ok == false - }; - - const error = {}; - window.Response = jest.fn(); - - global.fetch = jest.fn() - .mockReturnValueOnce(Promise.resolve({ - ok: false, - json: () => Promise.resolve('RTR response failure') - })) - .mockReturnValueOnce(Promise.resolve(error)); - - try { - await passThrough(event, tokenExpiration, oUrl); - } catch (e) { - expect(e).toMatchObject(error); - } - }); - - it('requests with invalid RTs fail softly', async () => { - const oUrl = 'https://trinity.edu'; - const req = { url: `${oUrl}/manhattan` }; - const event = { - request: { - clone: () => req, - } - }; - const tokenExpiration = { - atExpires: Date.now() - 1000, - rtExpires: Date.now() - 1000 - }; - - const error = {}; - window.Response = jest.fn(); - - global.fetch = jest.fn() - .mockReturnValueOnce(Promise.resolve({ - ok: false, - json: () => Promise.reject(new Error('RTR response failure')), - })) - .mockReturnValueOnce(Promise.resolve(error)); - - try { - await passThrough(event, tokenExpiration, oUrl); - } catch (e) { - expect(e).toMatchObject(error); - } - }); - }); -}); - -describe('rtr', () => { - it('on error with JSON, returns it', async () => { - const oUrl = 'https://trinity.edu'; - const req = { url: `${oUrl}/manhattan` }; - const event = { - request: { - clone: () => req, - } - }; - - const error = { message: 'los', code: 'alamos' }; - window.Response = jest.fn(); - - global.fetch = jest.fn() - .mockReturnValueOnce(Promise.resolve({ - ok: false, - json: () => Promise.resolve({ errors: [error] }) - })); - - try { - await rtr(event); - } catch (e) { - expect(e.message).toMatch(error.message); - expect(e.message).toMatch(error.code); - } - }); - - it('on unknown error, throws a generic error', async () => { - const oUrl = 'https://trinity.edu'; - const req = { url: `${oUrl}/manhattan` }; - const event = { - request: { - clone: () => req, - } - }; - - const error = 'RTR response failure'; - window.Response = jest.fn(); - - global.fetch = jest.fn() - .mockReturnValueOnce(Promise.resolve({ - ok: false, - json: () => Promise.resolve(error) - })); - - try { - await rtr(event); - } catch (e) { - expect(e.message).toMatch(error); - } - }); - - it.skip('foo', async () => { - const foo = handleTokenExpiration; - const bar = messageToClient; - - handleTokenExpiration = jest.fn(); - messageToClient = jest.fn(); - - const oUrl = 'https://trinity.edu'; - const req = { url: `${oUrl}/manhattan` }; - const event = { - request: { - clone: () => req, - } - }; - - window.Response = jest.fn(); - - global.fetch = jest.fn() - .mockReturnValueOnce(Promise.resolve({ - ok: true, - json: () => Promise.resolve({ - accessTokenExpiration: Date.now(), - refreshTokenExpiration: Date.now(), - }) - })); - - await rtr(event); - expect(handleTokenExpiration).toHaveBeenCalled(); - expect(messageToClient).toHaveBeenCalled(); - - handleTokenExpiration = foo; - messageToClient = bar; - }); -}); - -describe('handleTokenExpiration', () => { - const testWindow = (token) => { - const now = Date.now(); - const window = 1000; - const data = { - tokenExpiration: { - [token]: now + window, - }, - }; - - const result = handleTokenExpiration(data); - expect(parseFloat(result[token] - now).toPrecision(2)).toEqual(parseFloat(TTL_WINDOW * window).toPrecision(2)); - }; - - it(`shrinks AT's validity window to ${parseFloat(TTL_WINDOW * 100).toPrecision(2)}% of original size`, () => { - testWindow('atExpires'); - }); - - it(`shrinks RT's validity window to ${parseFloat(TTL_WINDOW * 100).toPrecision(2)}% of original size`, () => { - testWindow('rtExpires'); - }); -}); diff --git a/src/serviceWorkerRegistration.js b/src/serviceWorkerRegistration.js deleted file mode 100644 index a2128ba4b..000000000 --- a/src/serviceWorkerRegistration.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * registerSW - * * register SW - * * send SW okapi details via an OKAPI_CONFIG message. - * * send SW log category details via a LOGGER_CONFIG message. - * Note that although normally a page must be reloaded after a service worker - * has been installed in order for the page to be controlled, this one - * immediately claims control. Otherwise, no RTR would occur until after a - * reload. - * - * @param {object} okapiConfig okapi object from stripes.config.js - * @param {object} config config object from stripes.config.js - * @param {object} logger stripes logger - * @return void - */ -export const registerServiceWorker = async (okapiConfig, config, logger) => { - if ('serviceWorker' in navigator) { - try { - let sw = null; - // - // register - // - const registration = await navigator.serviceWorker.register(new URL('./service-worker.js', window.location.origin), { scope: '/' }) - .then(reg => { - return reg.update(); - }); - if (registration.installing) { - sw = registration.installing; - logger.log('rtr', 'Service worker installing'); - } else if (registration.waiting) { - sw = registration.waiting; - logger.log('rtr', 'Service worker installed'); - } else if (registration.active) { - sw = registration.active; - logger.log('rtr', 'Service worker active'); - } - - // - // send SW okapi config details and a logger. - // the corresponding listener is configured in App.js in order for it - // to recieve some additional config values (i.e. the redux store) - // which are necessary for processing failures (so we can clear out - // said store on logout). - // - if (sw) { - logger.log('rtr', 'sending OKAPI_CONFIG'); - sw.postMessage({ source: '@folio/stripes-core', type: 'OKAPI_CONFIG', value: okapiConfig }); - logger.log('rtr', 'sending LOGGER', logger); - sw.postMessage({ source: '@folio/stripes-core', type: 'LOGGER_CONFIG', value: { categories: config.logCategories } }); - } else { - console.error('(rtr) service worker not available'); - } - } catch (error) { - console.error(`(rtr) service worker registration failed with ${error}`); - } - - // talk to me, goose - navigator.serviceWorker.oncontrollerchange = () => { - if (navigator.serviceWorker.controller) { - logger.log('rtr', 'This page is currently controlled by: ', navigator.serviceWorker.controller); - } else { - logger.log('rtr', 'SERVICE WORKER NOT ACTIVE'); - } - }; - } -}; - -export const unregisterServiceWorker = async () => { - console.log('unregister'); - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready - .then((reg) => { - reg.unregister(); - }) - .catch((error) => { - console.error(error.message); - }); - } -}; diff --git a/src/serviceWorkerRegistration.test.js b/src/serviceWorkerRegistration.test.js deleted file mode 100644 index 3a78d4f36..000000000 --- a/src/serviceWorkerRegistration.test.js +++ /dev/null @@ -1,132 +0,0 @@ -import { - registerServiceWorker, - unregisterServiceWorker -} from './serviceWorkerRegistration'; - -describe('registerServiceWorker', () => { - describe('on success', () => { - const stateTest = (state) => { - it(state, async () => { - const sw = { - postMessage: jest.fn(), - }; - - navigator.serviceWorker = { - register: () => Promise.resolve({ - update: () => ({ [state]: sw }) - }), - controller: 'malibu-trinity', - }; - - const l = { - log: jest.fn(), - }; - - const okapiConfig = { 'barbie': 'oppenheimer' }; - const config = { logCategories: 'kenough,trinity' }; - - await registerServiceWorker(okapiConfig, config, l); - - const lConfig = { source: '@folio/stripes-core', type: 'LOGGER_CONFIG', value: { categories: config.logCategories } }; - - expect(sw.postMessage).toHaveBeenCalledWith(lConfig); - expect(typeof navigator.serviceWorker.oncontrollerchange).toBe('function'); - expect(l.log).toHaveBeenCalledTimes(3); - }); - }; - - const states = ['installing', 'waiting', 'active']; - states.forEach((state) => stateTest(state)); - }); - - describe('on failure', () => { - const consoleInterruptor = {}; - beforeAll(() => { - consoleInterruptor.error = global.console.error; - console.error = jest.fn(); - }); - - afterAll(() => { - global.console.error = consoleInterruptor.error; - }); - - it('registration is not in expected state', async () => { - navigator.serviceWorker = { - register: () => Promise.resolve({ - update: () => ({ }) - }), - }; - - const l = { - log: jest.fn(), - }; - - const okapiConfig = { 'barbie': 'oppenheimer' }; - const config = { logCategories: 'kenough,trinity' }; - - await registerServiceWorker(okapiConfig, config, l); - expect(console.error).toHaveBeenCalledWith('(rtr) service worker not available'); - }); - - it('registration throws', async () => { - const error = Error('Trinity Ken has a nice tan. Oh. Wait.'); - navigator.serviceWorker = { - register: () => { - throw error; - } - }; - - const l = { - log: jest.fn(), - }; - - const okapiConfig = { 'barbie': 'oppenheimer' }; - const config = { logCategories: 'kenough,trinity' }; - - await registerServiceWorker(okapiConfig, config, l); - expect(console.error).toHaveBeenCalledWith(`(rtr) service worker registration failed with ${error}`); - }); - }); -}); - -describe('unregisterServiceWorker', () => { - const consoleInterruptor = {}; - beforeEach(() => { - consoleInterruptor.log = global.console.log; - consoleInterruptor.error = global.console.error; - console.log = jest.fn(); - console.error = jest.fn(); - }); - - afterEach(() => { - global.console.log = consoleInterruptor.log; - global.console.error = consoleInterruptor.error; - }); - - it('on success', async () => { - const unregister = jest.fn(); - navigator.serviceWorker = { - ready: Promise.resolve({ - unregister, - }) - }; - - await unregisterServiceWorker(); - expect(unregister).toHaveBeenCalled(); - }); - - it('on failure', async () => { - const error = 'Los Alamos Ken has a nice tan. Oh. Wait.'; - const unregister = jest.fn(); - navigator.serviceWorker = { - ready: Promise.reject(new Error(error)) - }; - - await unregisterServiceWorker(); - expect(unregister).not.toHaveBeenCalled(); - - // logging will show that console.error _is_ called, - // yet jest always says there are 0 calls here. wha...? - // expect(console.error).toHaveBeenCalled(); - }); -}); diff --git a/src/useOkapiKy.js b/src/useOkapiKy.js index 921530cbb..22fdef09e 100644 --- a/src/useOkapiKy.js +++ b/src/useOkapiKy.js @@ -2,20 +2,19 @@ import ky from 'ky'; import { useStripes } from './StripesContext'; export default ({ tenant } = {}) => { - const { locale = 'en', timeout = 30000, tenant: currentTenant, url } = useStripes().okapi; + const { locale = 'en', timeout = 30000, tenant: currentTenant, token, url } = useStripes().okapi; return ky.create({ - credentials: 'include', + prefixUrl: url, hooks: { beforeRequest: [ request => { request.headers.set('Accept-Language', locale); request.headers.set('X-Okapi-Tenant', tenant ?? currentTenant); + request.headers.set('X-Okapi-Token', token); } ] }, - mode: 'cors', - prefixUrl: url, retry: 0, timeout, }); diff --git a/src/useOkapiKy.test.js b/src/useOkapiKy.test.js index 5959efdc8..1a58e3208 100644 --- a/src/useOkapiKy.test.js +++ b/src/useOkapiKy.test.js @@ -15,6 +15,7 @@ describe('useOkapiKy', () => { locale: 'klingon', tenant: 'tenant', timeout: 271828, + token: 'token', url: 'https://whatever.com' }; @@ -35,6 +36,7 @@ describe('useOkapiKy', () => { expect(r.headers.set).toHaveBeenCalledWith('Accept-Language', okapi.locale); expect(r.headers.set).toHaveBeenCalledWith('X-Okapi-Tenant', okapi.tenant); + expect(r.headers.set).toHaveBeenCalledWith('X-Okapi-Token', okapi.token); }); it('provides default values if stripes lacks them', async () => { @@ -61,6 +63,7 @@ describe('useOkapiKy', () => { const okapi = { tenant: 'tenant', timeout: 271828, + token: 'token', url: 'https://whatever.com' }; diff --git a/src/withOkapiKy.js b/src/withOkapiKy.js index bd692c916..522ab6056 100644 --- a/src/withOkapiKy.js +++ b/src/withOkapiKy.js @@ -9,6 +9,7 @@ const withOkapiKy = (WrappedComponent) => { stripes: PropTypes.shape({ okapi: PropTypes.shape({ tenant: PropTypes.string.isRequired, + token: PropTypes.string.isRequired, url: PropTypes.string.isRequired, }).isRequired, }).isRequired, @@ -16,13 +17,14 @@ const withOkapiKy = (WrappedComponent) => { constructor(props) { super(); - const { tenant, url } = props.stripes.okapi; + const { tenant, token, url } = props.stripes.okapi; this.okapiKy = ky.create({ prefixUrl: url, hooks: { beforeRequest: [ request => { request.headers.set('X-Okapi-Tenant', tenant); + request.headers.set('X-Okapi-Token', token); } ] } diff --git a/test/bigtest/helpers/setup-application.js b/test/bigtest/helpers/setup-application.js index d2dd67a4e..3f2121b9d 100644 --- a/test/bigtest/helpers/setup-application.js +++ b/test/bigtest/helpers/setup-application.js @@ -41,6 +41,7 @@ export default function setupApplication({ // when auth is disabled, add a fake user to the store if (disableAuth) { initialState.okapi = { + token: 'test', currentUser: assign({ id: 'test', username: 'testuser', @@ -50,8 +51,7 @@ export default function setupApplication({ addresses: [], servicePoints: [] }, currentUser), - currentPerms: permissions, - isAuthenticated: true, + currentPerms: permissions }; } else { initialState.okapi = { @@ -74,14 +74,9 @@ export default function setupApplication({ if (userLoggedIn) { localforage.setItem('okapiSess', { - isAuthenticated: true, + token: initialState.okapi.token, user: initialState.okapi.currentUser, perms: initialState.okapi.currentPerms, - tenant: 'tenant', - tokenExpiration: { - atExpires: Date.now() + (10 * 60 * 1000), - rtExpires: Date.now() + (10 * 60 * 1000), - }, }); } diff --git a/test/bigtest/network/config.js b/test/bigtest/network/config.js index 5229d629d..82e58f915 100644 --- a/test/bigtest/network/config.js +++ b/test/bigtest/network/config.js @@ -29,13 +29,6 @@ export default function configure() { launchDescriptor : {} }]); - this.get('/service-worker.js', { - monkey: 'bagel' - }); - this.get('/_/env', { - monkey: 'bagel' - }); - this.get('/saml/check', { ssoEnabled: false }); @@ -50,10 +43,11 @@ export default function configure() { }); this.post('/bl-users/password-reset/reset', {}, 401); - this.post('/authn/logout', {}, 204); - this.post('/bl-users/login-with-expiry', () => { - return new Response(201, {}, { + this.post('/bl-users/login', () => { + return new Response(201, { + 'X-Okapi-Token': `myOkapiToken:${Date.now()}` + }, { user: { id: 'test', username: 'testuser', diff --git a/test/bigtest/network/scenarios/fifthAttemptToLogin.js b/test/bigtest/network/scenarios/fifthAttemptToLogin.js index 32e72aa49..def93a69f 100644 --- a/test/bigtest/network/scenarios/fifthAttemptToLogin.js +++ b/test/bigtest/network/scenarios/fifthAttemptToLogin.js @@ -1,5 +1,5 @@ export default (server) => { - server.post('bl-users/login-with-expiry', { + server.post('bl-users/login', { errorMessage: JSON.stringify( { errors: [ { diff --git a/test/bigtest/network/scenarios/invalidResponseBody.js b/test/bigtest/network/scenarios/invalidResponseBody.js index 65908f776..6f821cf84 100644 --- a/test/bigtest/network/scenarios/invalidResponseBody.js +++ b/test/bigtest/network/scenarios/invalidResponseBody.js @@ -1,5 +1,5 @@ export default (server) => { - server.post('bl-users/login-with-expiry', { + server.post('bl-users/login', { errorMessage: JSON.stringify(['test']) }, 422); }; diff --git a/test/bigtest/network/scenarios/lockedAccount.js b/test/bigtest/network/scenarios/lockedAccount.js index 7d73b1257..498b91c5d 100644 --- a/test/bigtest/network/scenarios/lockedAccount.js +++ b/test/bigtest/network/scenarios/lockedAccount.js @@ -1,5 +1,5 @@ export default (server) => { - server.post('bl-users/login-with-expiry', { + server.post('bl-users/login', { errorMessage: JSON.stringify( { errors: [ { diff --git a/test/bigtest/network/scenarios/multipleErrors.js b/test/bigtest/network/scenarios/multipleErrors.js index a0a512553..b70f89628 100644 --- a/test/bigtest/network/scenarios/multipleErrors.js +++ b/test/bigtest/network/scenarios/multipleErrors.js @@ -1,5 +1,5 @@ export default (server) => { - server.post('bl-users/login-with-expiry', { + server.post('bl-users/login', { errorMessage: JSON.stringify( { errors: [ { diff --git a/test/bigtest/network/scenarios/serverError.js b/test/bigtest/network/scenarios/serverError.js index 43160f128..f9902294d 100644 --- a/test/bigtest/network/scenarios/serverError.js +++ b/test/bigtest/network/scenarios/serverError.js @@ -1,3 +1,3 @@ export default (server) => { - server.post('bl-users/login-with-expiry', {}, 500); + server.post('bl-users/login', {}, 500); }; diff --git a/test/bigtest/network/scenarios/thirdAttemptToLogin.js b/test/bigtest/network/scenarios/thirdAttemptToLogin.js index 8cd063303..3d005ce0a 100644 --- a/test/bigtest/network/scenarios/thirdAttemptToLogin.js +++ b/test/bigtest/network/scenarios/thirdAttemptToLogin.js @@ -1,5 +1,5 @@ export default (server) => { - server.post('bl-users/login-with-expiry', { + server.post('bl-users/login', { errorMessage: JSON.stringify( { errors: [ { diff --git a/test/bigtest/network/scenarios/wrongPassword.js b/test/bigtest/network/scenarios/wrongPassword.js index 02282ba49..c0529673b 100644 --- a/test/bigtest/network/scenarios/wrongPassword.js +++ b/test/bigtest/network/scenarios/wrongPassword.js @@ -1,5 +1,5 @@ export default (server) => { - server.post('bl-users/login-with-expiry', { + server.post('bl-users/login', { errorMessage: JSON.stringify( { errors: [ { diff --git a/test/bigtest/network/scenarios/wrongUsername.js b/test/bigtest/network/scenarios/wrongUsername.js index 8e3015bee..993ee8253 100644 --- a/test/bigtest/network/scenarios/wrongUsername.js +++ b/test/bigtest/network/scenarios/wrongUsername.js @@ -1,5 +1,5 @@ export default (server) => { - server.post('bl-users/login-with-expiry', { + server.post('bl-users/login', { errorMessage: JSON.stringify( { errors: [ { diff --git a/test/bigtest/tests/login-test.js b/test/bigtest/tests/login-test.js index b0f218612..126c6d829 100644 --- a/test/bigtest/tests/login-test.js +++ b/test/bigtest/tests/login-test.js @@ -340,14 +340,6 @@ describe('Login', () => { }); }); - // the login workflow invokes navigator.serviceWorker.ready, - // a browser property that returns a Promise that waits until - // the service worker resolves, but in Karma-land we don't - // configure the service-worker. hence, this will time out, - // every time. - // - // we'll need to cover these components with jest/RTL tests - // eventually. describe('with valid credentials', () => { beforeEach(async () => { const { username, password, submit } = login; diff --git a/test/bigtest/tests/session-timeout-test.js b/test/bigtest/tests/session-timeout-test.js index f6e8046e4..702f2a1b5 100644 --- a/test/bigtest/tests/session-timeout-test.js +++ b/test/bigtest/tests/session-timeout-test.js @@ -5,7 +5,7 @@ import setupApplication from '../helpers/setup-core-application'; import LoginInteractor from '../interactors/login'; import translations from '../../../translations/stripes-core/en'; -describe.skip('Session timeout test', () => { +describe('Session timeout test', () => { const login = new LoginInteractor('form[class^="form--"]'); setupApplication({ diff --git a/test/jest/__mock__/index.js b/test/jest/__mock__/index.js index 1b511c30a..15fc172e2 100644 --- a/test/jest/__mock__/index.js +++ b/test/jest/__mock__/index.js @@ -1,4 +1,3 @@ -import './microStripesConfig.mock'; import './stripesConfig.mock'; import './intl.mock'; import './stripesIcon.mock'; diff --git a/test/jest/__mock__/microStripesConfig.mock.js b/test/jest/__mock__/microStripesConfig.mock.js deleted file mode 100644 index ace72c7b7..000000000 --- a/test/jest/__mock__/microStripesConfig.mock.js +++ /dev/null @@ -1,5 +0,0 @@ -jest.mock('micro-stripes-config', () => ({ - okapiUrl: 'https://los-alamos-barbie-has-a-nice-tan.oh-wa.it', - okapiTenant: 'kenough', -}), -{ virtual: true });