From b2065cc1cc6e54eab54a783ef6dbf9da815410ec Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Wed, 4 Oct 2023 11:03:34 -0400 Subject: [PATCH] STCOR-671 handle access-control via cookies Handle access-control via HTTP-only cookies instead of storing the JWT in local storage and providing it in the `X-Okapi-Token` header of fetch requests. The `login-with-expiry` endpoint returns an access-token and refresh-token in HTTP-only cookies, along with information about when those cookies expire in the response body. Stripes-core sets up a service worker to track the AT's expiration timestamp and transparently request a replacement by intercepting the fetch request, replacing (i.e. rotating) both the AT and the RT before passing along the original request. Notable changes: * Sessions now timeout after a period of inactivity, determined by the lifespan of the RT, instead of remaining valid indefinitely. * Authentication requests are sent to `/bl-users/login-with-expiry` instead of `/bl-users/login`. * "Activity" is tracked by a document-level event handler that listens for mouse-down and key-down events. Refs STCOR-671, FOLIO-3627 --- CHANGELOG.md | 1 + package.json | 1 + src/RootWithIntl.js | 8 +- src/Stripes.js | 4 +- src/components/MainNav/MainNav.js | 13 +- src/components/Root/Root.js | 17 +- src/createApolloClient.js | 4 +- src/discoverServices.js | 11 +- src/init.js | 5 + src/loginServices.js | 264 ++++++++++++++++++++++-------- src/mainActions.js | 4 - src/okapiActions.js | 15 +- src/okapiActions.test.js | 15 ++ src/okapiReducer.js | 10 +- src/okapiReducer.test.js | 6 + src/service-worker.js | 228 ++++++++++++++++++++++++++ src/serviceWorkerRegistration.js | 90 ++++++++++ src/useOkapiKy.js | 7 +- src/withOkapiKy.js | 4 +- 19 files changed, 587 insertions(+), 120 deletions(-) create mode 100644 src/service-worker.js create mode 100644 src/serviceWorkerRegistration.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b1bfba040..f505d5e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ * Forgot password and Forgot username : add placeholder to input box. Refs STCOR-728. * Include `yarn.lock`. Refs STCOR-679. * *BREAKING* bump `react-intl` to `v6.4.4`. Refs STCOR-744. +* *BREAKING* use cookies and RTR instead of directly handling the JWT. Refs STCOR-671. ## [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/package.json b/package.json index 9cdaa4893..c19924e47 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "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/RootWithIntl.js b/src/RootWithIntl.js index 1f67251b5..aa54a9e9d 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, - token: PropTypes.string, + isAuthenticated: PropTypes.bool, disableAuth: PropTypes.bool.isRequired, history: PropTypes.shape({}), }; static defaultProps = { - token: '', + isAuthenticated: false, history: {}, }; @@ -66,7 +66,7 @@ class RootWithIntl extends React.Component { render() { const { - token, + isAuthenticated, disableAuth, history, } = this.props; @@ -85,7 +85,7 @@ class RootWithIntl extends React.Component { > - { token || disableAuth ? + { isAuthenticated || disableAuth ? <> diff --git a/src/Stripes.js b/src/Stripes.js index 560397a39..db4b4913d 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, - token: PropTypes.string, + isAuthenticated: PropTypes.bool, 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 fa26ba0fe..a87096ead 100644 --- a/src/components/MainNav/MainNav.js +++ b/src/components/MainNav/MainNav.js @@ -4,7 +4,6 @@ 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 } from 'stripes-config'; @@ -12,9 +11,7 @@ import { Icon } from '@folio/stripes-components'; import { withModules } from '../Modules'; import { LastVisitedContext } from '../LastVisited'; -import { clearOkapiToken, clearCurrentUser } from '../../okapiActions'; -import { resetStore } from '../../mainActions'; -import { getLocale } from '../../loginServices'; +import { getLocale, logout as sessionLogout } from '../../loginServices'; import { updateQueryResource, getLocationQuery, @@ -123,12 +120,8 @@ class MainNav extends Component { returnToLogin() { const { okapi } = this.store.getState(); - 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 getLocale(okapi.url, this.store, okapi.tenant) + .then(sessionLogout(this.store)); } // 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 b4b549cc6..9d23ec88c 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, setOkapiToken, setTimezone, setCurrency, updateCurrentUser } from '../../okapiActions'; -import { loadTranslations, checkOkapiSession } from '../../loginServices'; +import { setSinglePlugin, setBindings, setIsAuthenticated, setTimezone, setCurrency, updateCurrentUser } from '../../okapiActions'; +import { addDocumentListeners, loadTranslations, checkOkapiSession } from '../../loginServices'; import { getQueryResourceKey, getCurrentModule } from '../../locationService'; import Stripes from '../../Stripes'; import RootWithIntl from '../../RootWithIntl'; @@ -64,6 +64,9 @@ class Root extends Component { this.apolloClient = createApolloClient(okapi); this.reactQueryClient = createReactQueryClient(); + + // document-level event handlers + addDocumentListeners(); } getChildContext() { @@ -107,7 +110,7 @@ class Root extends Component { } render() { - const { logger, store, epics, config, okapi, actionNames, token, disableAuth, currentUser, currentPerms, locale, defaultTranslations, timezone, currency, plugins, bindings, discovery, translations, history, serverDown } = this.props; + const { logger, store, epics, config, okapi, actionNames, isAuthenticated, disableAuth, currentUser, currentPerms, locale, defaultTranslations, timezone, currency, plugins, bindings, discovery, translations, history, serverDown } = this.props; if (serverDown) { return
Error: server is down.
; @@ -125,7 +128,7 @@ class Root extends Component { config, okapi, withOkapi: this.withOkapi, - setToken: (val) => { store.dispatch(setOkapiToken(val)); }, + setIsAuthenticated: (val) => { store.dispatch(setIsAuthenticated(val)); }, actionNames, locale, timezone, @@ -166,7 +169,7 @@ class Root extends Component { > @@ -191,7 +194,7 @@ Root.propTypes = { getState: PropTypes.func.isRequired, replaceReducer: PropTypes.func.isRequired, }), - token: PropTypes.string, + isAuthenticated: PropTypes.bool, disableAuth: PropTypes.bool.isRequired, logger: PropTypes.object.isRequired, currentPerms: PropTypes.object, @@ -249,13 +252,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 9819bda6c..393afb8bb 100644 --- a/src/createApolloClient.js +++ b/src/createApolloClient.js @@ -1,10 +1,10 @@ import { InMemoryCache, ApolloClient } from '@apollo/client'; -const createClient = ({ url, tenant, token }) => (new ApolloClient({ +const createClient = ({ url, tenant }) => (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 7c5e33812..29360f498 100644 --- a/src/discoverServices.js +++ b/src/discoverServices.js @@ -1,9 +1,8 @@ import { some } from 'lodash'; -function getHeaders(tenant, token) { +function getHeaders(tenant) { return { 'X-Okapi-Tenant': tenant, - 'X-Okapi-Token': token, 'Content-Type': 'application/json' }; } @@ -12,7 +11,9 @@ function fetchOkapiVersion(store) { const okapi = store.getState().okapi; return fetch(`${okapi.url}/_/version`, { - headers: getHeaders(okapi.tenant, okapi.token) + headers: getHeaders(okapi.tenant), + credentials: 'include', + mode: 'cors', }).then((response) => { // eslint-disable-line consistent-return if (response.status >= 400) { store.dispatch({ type: 'DISCOVERY_FAILURE', code: response.status }); @@ -31,7 +32,9 @@ function fetchModules(store) { const okapi = store.getState().okapi; return fetch(`${okapi.url}/_/proxy/tenants/${okapi.tenant}/modules?full=true`, { - headers: getHeaders(okapi.tenant, okapi.token) + headers: getHeaders(okapi.tenant), + credentials: 'include', + mode: 'cors', }).then((response) => { // eslint-disable-line consistent-return if (response.status >= 400) { store.dispatch({ type: 'DISCOVERY_FAILURE', code: response.status }); diff --git a/src/init.js b/src/init.js index ebf891cce..d3ae437c8 100644 --- a/src/init.js +++ b/src/init.js @@ -2,11 +2,16 @@ import 'core-js/stable'; import 'regenerator-runtime/runtime'; import React from 'react'; import { createRoot } from 'react-dom/client'; +import localforage from 'localforage'; +import { okapi as okapiConfig } from 'stripes-config'; + +import { registerServiceWorker } from './serviceWorkerRegistration'; import App from './App'; export default function init() { const container = document.getElementById('root'); const root = createRoot(container); root.render(); + registerServiceWorker(okapiConfig.url, localforage); } diff --git a/src/loginServices.js b/src/loginServices.js index d2acceae8..423b636f4 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -2,8 +2,10 @@ import localforage from 'localforage'; import { translations } from 'stripes-config'; import rtlDetect from 'rtl-detect'; import moment from 'moment'; +import createInactivityTimer from 'inactivity-timer'; import { discoverServices } from './discoverServices'; +import { resetStore } from './mainActions'; import { clearCurrentUser, @@ -14,7 +16,7 @@ import { setPlugins, setBindings, setTranslations, - clearOkapiToken, + setIsAuthenticated, setAuthError, checkSSO, setOkapiReady, @@ -63,16 +65,19 @@ export const supportedNumberingSystems = [ 'arab', // Arabic-Hindi (٠ ١ ٢ ٣ ٤ ٥ ٦ ٧ ٨ ٩) ]; +const SESSION_NAME = 'okapiSess'; +/** session length in milliseconds */ +const SESSION_LENGTH = 10 * 1000; + // export config values for storing user locale export const userLocaleConfig = { 'configName': 'localeSettings', 'module': '@folio/stripes-core', }; -function getHeaders(tenant, token) { +function getHeaders(tenant) { return { 'X-Okapi-Tenant': tenant, - 'X-Okapi-Token': token, 'Content-Type': 'application/json', }; } @@ -164,8 +169,11 @@ export function loadTranslations(store, locale, defaultTranslations = {}) { * @returns {Promise} */ function dispatchLocale(url, store, tenant) { - return fetch(url, - { headers: getHeaders(tenant, store.getState().okapi.token) }) + return fetch(url, { + headers: getHeaders(tenant), + credentials: 'include', + mode: 'cors', + }) .then((response) => { if (response.status === 200) { response.json().then((json) => { @@ -240,8 +248,11 @@ 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, store.getState().okapi.token) }) + return fetch(`${okapiUrl}/configurations/entries?query=(module==PLUGINS)`, { + headers: getHeaders(tenant), + credentials: 'include', + mode: 'cors', + }) .then((response) => { if (response.status < 400) { response.json().then((json) => { @@ -266,8 +277,11 @@ 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, store.getState().okapi.token) }) + return fetch(`${okapiUrl}/configurations/entries?query=(module==ORG and configName==bindings)`, { + headers: getHeaders(tenant), + credentials: 'include', + mode: 'cors', + }) .then((response) => { let bindings = {}; if (response.status >= 400) { @@ -347,13 +361,88 @@ 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 localforage.removeItem(SESSION_NAME) + .then(() => { + return fetch(`${okapiUrl}/authn/logout`, { + method: 'POST', + mode: 'cors', + }); + }); +} + +/** + * startIdleTimer + * Start a timer that should last the length of the session, + * calling the timeout-handler if/when it expires. This function + * should be called by event-listener that tracks activity: each + * time the event-listener pings the existing timer will be cancelled + * and a new one started to keep the session alive. + * + * @param {redux-store} store + */ +let idleTimer = null; +let lastActive = Date.now(); +const startIdleTimer = (okapiUrl, store) => { + // @@ in reality, + // @@ idleTimer = SESSION_LENGTH is rtExpires - Date.now() + idleTimer = createInactivityTimer(SESSION_LENGTH, () => { + // @@ + console.log(`logging out; no activity since ${new Date(lastActive).toISOString()}`); + logout(okapiUrl, store); + }); + + // ### + // if (idleTimer) { + // clearTimeout(idleTimer); + // } + + // // @@ in reality, + // // @@ idleTimer = setTimeout(logout, rtExpires - Date.now()); + // idleTimer = setTimeout(() => { + // console.log(`logging out; no activity since ${new Date(lastActive).toISOString()}`); + // logout(); + // }, SESSION_LENGTH); +}; + +/** + * dispatchTokenExpiration + * send SW a TOKEN_EXPIRATION message + */ +const dispatchTokenExpiration = (tokenExpiration) => { + navigator.serviceWorker.ready + .then((reg) => { + const sw = reg.active; + if (sw) { + const message = { type: 'TOKEN_EXPIRATION', tokenExpiration }; + console.log('<= sending', message); console.trace(); + sw.postMessage(message); + } else { + console.warn('could not dispatch message; no active registration'); + } + + }); +}; + /** * createOkapiSession * Remap the given data into a session object shaped like: * { * user: { id, username, personal } * perms: { permNameA: true, permNameB: true, ... } - * token: token + * isAuthenticated: boolean, + * tokenExpiration: { atExpires, rtExpires } * } * Dispatch the session object, then return a Promise that fetches * and dispatches tenant resources. @@ -361,12 +450,11 @@ export function spreadUserWithPerms(userWithPerms) { * @param {*} okapiUrl * @param {*} store * @param {*} tenant - * @param {*} token * @param {*} data * * @returns {Promise} */ -export function createOkapiSession(okapiUrl, store, tenant, token, data) { +export function createOkapiSession(okapiUrl, store, tenant, data) { // clear any auth-n errors store.dispatch(setAuthError(null)); @@ -378,54 +466,65 @@ export function createOkapiSession(okapiUrl, store, tenant, token, data) { store.dispatch(setCurrentPerms(perms)); + const tokenExpiration = { + atExpires: new Date(data.tokenExpiration.accessTokenExpiration).getTime(), + rtExpires: new Date(data.tokenExpiration.refreshTokenExpiration).getTime(), + }; + const sessionTenant = data.tenant || tenant; const okapiSess = { - token, + isAuthenticated: true, user, perms, tenant: sessionTenant, + tokenExpiration, }; + // provide token-expiration info to the service worker + dispatchTokenExpiration(tokenExpiration); + startIdleTimer(okapiUrl, store); + return localforage.setItem('loginResponse', data) - .then(() => localforage.setItem('okapiSess', okapiSess)) + .then(() => localforage.setItem(SESSION_NAME, okapiSess)) .then(() => { + store.dispatch(setIsAuthenticated(true)); store.dispatch(setSessionData(okapiSess)); return loadResources(okapiUrl, store, sessionTenant, user.id); }); } /** - * 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} + * addDocumentListeners + * Attach document-level event handlers for keydown and mousedown in order to + * track when the session is idle */ -export function validateUser(okapiUrl, store, tenant, session) { - const { token, user, perms, tenant: sessionTenant = tenant } = session; - - 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(clearOkapiToken()); - return localforage.removeItem('okapiSess'); - } - }).catch((error) => { - store.dispatch(setServerDown()); - return error; +export function addDocumentListeners() { + // on any event, set the last-access timestamp and restart the inactivity timer. + // if the access token has expired, renew it. + ['keydown', 'mousedown'].forEach((event) => { + document.addEventListener(event, () => { + localforage.getItem(SESSION_NAME) + .then(session => { + if (session?.isAuthenticated && idleTimer) { + idleTimer.signal(); + // @@ remove this; it's just for debugging + lastActive = Date.now(); + // @@ startIdleTimer(); + } + }); + }); }); + + // document.addEventListener(event, () => { + // this.setLastAccess(); + // if (this.inactivityTimer) { + // this.inactivityTimer.signal(); + // } + + // if (!this.accessTokenIsValid()) { + // this.exchangeRefresh(); + // } + // }); } /** @@ -502,7 +601,7 @@ function processSSOLoginResponse(resp) { * @returns {Promise} resolving to the response's JSON */ export function handleLoginError(dispatch, resp) { - return localforage.removeItem('okapiSess') + return localforage.removeItem(SESSION_NAME) .then(() => processBadResponse(dispatch, resp)) .then(responseBody => { dispatch(setOkapiReady()); @@ -518,18 +617,16 @@ 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, ssoToken) { - const token = resp.headers.get('X-Okapi-Token') || ssoToken; +export function processOkapiSession(okapiUrl, store, tenant, resp) { const { dispatch } = store; if (resp.ok) { return resp.json() .then(json => { - return createOkapiSession(okapiUrl, store, tenant, token, json) + return createOkapiSession(okapiUrl, store, tenant, json) .then(() => json); }) .then((json) => { @@ -541,6 +638,45 @@ export function processOkapiSession(okapiUrl, store, tenant, resp, ssoToken) { } } +/** + * 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 { tenant: sessionTenant = tenant } = session; + + return fetch(`${okapiUrl}/bl-users/_self`, { + headers: getHeaders(sessionTenant), + credentials: 'include', + mode: 'cors', + }).then((resp) => { + if (resp.ok) { + console.log('>>> validateUser::resp.ok'); + return resp.json().then((data) => { + console.log('session', session); + createOkapiSession(okapiUrl, store, tenant, data); + }); + } else { + console.error('>>> validateUser !resp.ok'); + return logout(store); + } + }).catch((error) => { + console.error('validateUser', error); + store.dispatch(setServerDown()); + return error; + }); +} + /** * checkOkapiSession * 1. Pull the session from local storage; if non-empty validate it, dispatching load-resources actions. @@ -552,7 +688,7 @@ export function processOkapiSession(okapiUrl, store, tenant, resp, ssoToken) { * @param {string} tenant */ export function checkOkapiSession(okapiUrl, store, tenant) { - localforage.getItem('okapiSess') + localforage.getItem(SESSION_NAME) .then((sess) => { return sess !== null ? validateUser(okapiUrl, store, tenant, sess) : null; }) @@ -576,10 +712,12 @@ export function checkOkapiSession(okapiUrl, store, tenant) { * @returns {Promise} */ export function requestLogin(okapiUrl, store, tenant, data) { - return fetch(`${okapiUrl}/bl-users/login?expandPermissions=true&fullPermissions=true`, { - method: 'POST', - headers: { 'X-Okapi-Tenant': tenant, 'Content-Type': 'application/json' }, + 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' }, + method: 'POST', + mode: 'cors', }) .then(resp => processOkapiSession(okapiUrl, store, tenant, resp)); } @@ -589,14 +727,13 @@ 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, token) { +function fetchUserWithPerms(okapiUrl, tenant) { return fetch( `${okapiUrl}/bl-users/_self?expandPermissions=true&fullPermissions=true`, - { headers: getHeaders(tenant, token) }, + { headers: getHeaders(tenant) }, ); } @@ -606,13 +743,12 @@ function fetchUserWithPerms(okapiUrl, tenant, token) { * @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, token) { - return fetchUserWithPerms(okapiUrl, tenant, token) - .then(resp => processOkapiSession(okapiUrl, store, tenant, resp, token)); +export function requestUserWithPerms(okapiUrl, store, tenant) { + return fetchUserWithPerms(okapiUrl, tenant) + .then(resp => processOkapiSession(okapiUrl, store, tenant, resp)); } /** @@ -648,10 +784,10 @@ export function requestSSOLogin(okapiUrl, tenant) { * @returns {Promise} */ export function updateUser(store, data) { - return localforage.getItem('okapiSess') + return localforage.getItem(SESSION_NAME) .then((sess) => { sess.user = { ...sess.user, ...data }; - return localforage.setItem('okapiSess', sess); + return localforage.setItem(SESSION_NAME, sess); }) .then(() => { store.dispatch(updateCurrentUser(data)); @@ -668,9 +804,9 @@ export function updateUser(store, data) { * @returns {Promise} */ export async function updateTenant(okapi, tenant) { - const okapiSess = await localforage.getItem('okapiSess'); - const userWithPermsResponse = await fetchUserWithPerms(okapi.url, tenant, okapi.token); + const okapiSess = await localforage.getItem(SESSION_NAME); + const userWithPermsResponse = await fetchUserWithPerms(okapi.url, tenant); const userWithPerms = await userWithPermsResponse.json(); - await localforage.setItem('okapiSess', { ...okapiSess, tenant, ...spreadUserWithPerms(userWithPerms) }); + await localforage.setItem(SESSION_NAME, { ...okapiSess, tenant, ...spreadUserWithPerms(userWithPerms) }); } diff --git a/src/mainActions.js b/src/mainActions.js index 91f063fbb..bc41df8d2 100644 --- a/src/mainActions.js +++ b/src/mainActions.js @@ -18,10 +18,6 @@ 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 fe3bed7a1..432284cbe 100644 --- a/src/okapiActions.js +++ b/src/okapiActions.js @@ -61,16 +61,10 @@ function setBindings(bindings) { }; } -function setOkapiToken(token) { +function setIsAuthenticated(b) { return { - type: 'SET_OKAPI_TOKEN', - token, - }; -} - -function clearOkapiToken() { - return { - type: 'CLEAR_OKAPI_TOKEN', + type: 'SET_IS_AUTHENTICATED', + isAuthenticated: Boolean(b), }; } @@ -131,16 +125,15 @@ function updateCurrentUser(data) { export { checkSSO, clearCurrentUser, - clearOkapiToken, setAuthError, setBindings, setCurrency, setCurrentPerms, setCurrentUser, + setIsAuthenticated, setLocale, setLoginData, setOkapiReady, - setOkapiToken, setPlugins, setServerDown, setSessionData, diff --git a/src/okapiActions.test.js b/src/okapiActions.test.js index 2376aed7e..9ac82f56d 100644 --- a/src/okapiActions.test.js +++ b/src/okapiActions.test.js @@ -1,8 +1,23 @@ 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 aaa34563f..596ce6612 100644 --- a/src/okapiReducer.js +++ b/src/okapiReducer.js @@ -1,11 +1,9 @@ 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': @@ -25,10 +23,10 @@ export default function okapiReducer(state = {}, action) { case 'CLEAR_CURRENT_USER': return Object.assign({}, state, { currentUser: {}, currentPerms: {} }); case 'SET_SESSION_DATA': { - const { perms, user, token, tenant } = action.session; + const { isAuthenticated, perms, tenant, user } = action.session; const sessionTenant = tenant || state.tenant; - return { ...state, currentUser: user, currentPerms: perms, token, tenant: sessionTenant }; + return { ...state, currentUser: user, currentPerms: perms, isAuthenticated, 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 fc67ace6e..94c1daf31 100644 --- a/src/okapiReducer.test.js +++ b/src/okapiReducer.test.js @@ -1,6 +1,12 @@ 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 }); diff --git a/src/service-worker.js b/src/service-worker.js new file mode 100644 index 000000000..9527d0370 --- /dev/null +++ b/src/service-worker.js @@ -0,0 +1,228 @@ +/* eslint no-console: 0 */ + +/* eslint no-restricted-globals: ["off", "self"] */ + + +/** { atExpires, rtExpires } both are JS millisecond timestamps */ +let tokenExpiration = null; + +/** string FQDN including protocol, e.g. https://some-okapi.somewhere.org */ +let okapiUrl = null; + +/** + * isValidAT + * return true if tokenExpiration.atExpires is in the future + * @returns boolean + */ +const isValidAT = () => { + console.log(`=> at expires ${new Date(tokenExpiration?.atExpires).getTime()}`); + return !!(tokenExpiration?.atExpires > Date.now()); +}; + +/** + * isValidRT + * return true if tokenExpiration.rtExpires is in the future + * @returns boolean + */ +const isValidRT = () => { + console.log(`=> rt expires ${new Date(tokenExpiration?.rtExpires).getTime()}`); + return !!(tokenExpiration?.rtExpires > Date.now()); +}; + +/** + * messageToClient + * Send a message to clients of this service worker + * @param {Event} event + * @param {*} message + * @returns void + */ +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) { + console.log('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) { + console.log('PASSTHROUGH: no client'); + return; + } + + // Send a message to the client. + console.log('=> sending', message); + client.postMessage(message); +}; + +/** + * 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 + */ +const rtr = (event) => { + console.log('** RTR ...'); + return fetch(`${okapiUrl}/authn/refresh`, { + 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 => { + if (json.errors[0]) { + throw new Error(`${json.errors[0].message} (${json.errors[0].code})`); + } else { + throw new Error('RTR response failure'); + } + }); + }) + .then(json => { + console.log('** success!'); + tokenExpiration = { + atExpires: new Date(json.accessTokenExpiration).getTime(), + rtExpires: new Date(json.refreshTokenExpiration).getTime(), + }; + // console.log('REFRESH BODY', { tokenExpiration }) + messageToClient(event, { type: 'TOKEN_EXPIRATION', tokenExpiration }); + }); +}; + + +const isLoginRequest = (request) => { + return request.url.includes('login-with-expiry'); +}; + +const isRefreshRequest = (request) => { + return request.url.includes('authn/refresh'); +}; + +/** + * isPermissibleRequest + * Some requests are always permissible, e.g. auth-n and token-rotation. + * Others are only permissible if the Access Token is still valid. + * @param {} req + * @returns + */ +const isPermissibleRequest = (req) => { + return isLoginRequest(req) || isRefreshRequest(req) || isValidAT(); +}; + +const isOkapiRequest = (req) => { + return new URL(req.url).origin === okapiUrl; +}; + +/** + * 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. + * @param {Event} event + * @returns Promise + * @throws if any fetch fails + */ +const passThrough = async (event) => { + const req = event.request.clone(); + + // okapi requests are subject to RTR + if (isOkapiRequest(req)) { + console.log('=> fetch', req.url); + if (isPermissibleRequest(req)) { + console.log(' (valid AT or authn request)'); + return fetch(event.request, { credentials: 'include' }) + .catch(e => { + console.error(e); + return Promise.reject(e); + }); + } + + if (isValidRT()) { + console.log('=> valid RT'); + try { + // we don't need the response from RTR, but we do need to await it + // to make sure the AT included with the fetch has been refreshed + await rtr(event); + return fetch(event.request, { credentials: 'include' }); + } catch (e) { + // console.error('passThrough fail', e) + return Promise.reject(e); + } + } + + return Promise.reject(new Error('Invalid RT')); + } + + // default: pass requests through to the network + // console.log('passThrough NON-OKAPI', req.url) + return fetch(event.request, { credentials: 'include' }) + .catch(e => { + console.error(e); + return Promise.reject(e); + }); +}; + +/** + * install + * on install, force this SW to be the active SW + */ +self.addEventListener('install', (event) => { + console.info('=> 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', async (event) => { + event.waitUntil(self.clients.claim()); +}); + +// self.addEventListener('activate', async (event) => { +// console.info('=> activate', event); +// clients.claim(); +// // event.waitUntil(clients.claim()); +// }); + +/** + * eventListener: message + * listen for messages from clients and dispatch them accordingly. + * OKAPI_URL: store + */ +self.addEventListener('message', async (event) => { + console.info('=> reading', event.data); + if (event.data.type === 'OKAPI_URL') { + okapiUrl = event.data.value; + } + + if (event.data.type === 'TOKEN_EXPIRATION') { + tokenExpiration = event.data.tokenExpiration; + } +}); + +/** + * eventListener: fetch + * intercept fetches + */ +self.addEventListener('fetch', async (event) => { + // const clone = event.request.clone(); + // console.log('=> fetch', clone.url) + + // console.log('=> fetch') // , clone.url) + event.respondWith(passThrough(event)); +}); + diff --git a/src/serviceWorkerRegistration.js b/src/serviceWorkerRegistration.js new file mode 100644 index 000000000..34df4882e --- /dev/null +++ b/src/serviceWorkerRegistration.js @@ -0,0 +1,90 @@ +/* eslint no-console: 0 */ + +/** + * registerSW + * * register SW + * * send SW the Okapi URL. + * * listen for messages sent from SW + * 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 {string} okapiUrl + * @param {object} store any object that supports the localforage API + * @return void + */ +export const registerServiceWorker = async (okapiUrl, store) => { + if ('serviceWorker' in navigator) { + try { + let sw = null; + + // + // register + // + const registration = await navigator.serviceWorker.register('/service-worker.js', { scope: './' }) + .then(reg => { + return reg.update(); + }); + if (registration.installing) { + sw = registration.installing; + console.log('=> Service worker installing'); + } else if (registration.waiting) { + sw = registration.waiting; + console.log('=> Service worker installed'); + } else if (registration.active) { + sw = registration.active; + console.log('=> Service worker active'); + } + + // + // send SW an OKAPI_URL message + // + if (sw) { + sw.postMessage({ type: 'OKAPI_URL', value: okapiUrl }); + } else { + console.error('SW NOT AVAILABLE'); + } + } catch (error) { + console.error(`=> Registration failed with ${error}`); + } + + // + // listen for messages + // the only message we expect to receive tells us that RTR happened + // so we need to update our expiration timestamps + // + navigator.serviceWorker.addEventListener('message', (e) => { + console.info('<= reading', e.data); + if (e.data.type === 'TOKEN_EXPIRATION') { + // @@ store.setItem is async but we don't care about the response + store.setItem('tokenExpiration', e.data.tokenExpiration); + console.log(`atExpires ${e.data.tokenExpiration.atExpires}`); + console.log(`rtExpires ${e.data.tokenExpiration.rtExpires}`); + } + }); + + // talk to me, goose + if (navigator.serviceWorker.controller) { + console.log(`This page is currently controlled by: ${navigator.serviceWorker.controller}`); + } + navigator.serviceWorker.oncontrollerchange = () => { + console.log(`This page is now controlled by ${navigator.serviceWorker.controller}`); + }; + } +}; + +export const unregisterServiceWorker = async () => { + console.log('unregister'); + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then((reg) => { + reg.unregister(); + }) + .catch((error) => { + console.error(error.message); + }); + } +}; + +registerServiceWorker(); diff --git a/src/useOkapiKy.js b/src/useOkapiKy.js index ff2126fab..7f7a0ab39 100644 --- a/src/useOkapiKy.js +++ b/src/useOkapiKy.js @@ -2,18 +2,19 @@ import ky from 'ky'; import { useStripes } from './StripesContext'; export default () => { - const { locale = 'en', tenant, token, url } = useStripes().okapi; + const { locale = 'en', tenant, url } = useStripes().okapi; return ky.create({ - prefixUrl: url, + credentials: 'include', hooks: { beforeRequest: [ request => { request.headers.set('Accept-Language', locale); request.headers.set('X-Okapi-Tenant', tenant); - request.headers.set('X-Okapi-Token', token); } ] }, + mode: 'cors', + prefix: url, retry: 0, timeout: 30000, }); diff --git a/src/withOkapiKy.js b/src/withOkapiKy.js index 522ab6056..bd692c916 100644 --- a/src/withOkapiKy.js +++ b/src/withOkapiKy.js @@ -9,7 +9,6 @@ const withOkapiKy = (WrappedComponent) => { stripes: PropTypes.shape({ okapi: PropTypes.shape({ tenant: PropTypes.string.isRequired, - token: PropTypes.string.isRequired, url: PropTypes.string.isRequired, }).isRequired, }).isRequired, @@ -17,14 +16,13 @@ const withOkapiKy = (WrappedComponent) => { constructor(props) { super(); - const { tenant, token, url } = props.stripes.okapi; + const { tenant, 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); } ] }