Skip to content

Commit

Permalink
STCOR-671 handle access-control via cookies (#1346)
Browse files Browse the repository at this point in the history
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, and proxy all requests through a service worker that performs
Refresh Token Rotation as needed to make sure the access-token remains
fresh.

Notable changes:

* Fetch requests are proxied by a Service Worker that intercepts them,
  validates that the Access Token is still valid, and performs token
  rotation (if it is not) before completing the original fetch.
* Sessions automatically end (i.e. the user is automatically logged out)
  when the Refresh Token expires.
* Access control is managed by including an HTTP-only cookie with all
  requests. This means the Access Token formerly available in the
  response-header as `X-Okapi-Token` is never accessible to JS code.

* Requires folio-org/stripes-connect/pull/223
* Requires folio-org/stripes-smart-components/pull/1397
* Requires folio-org/stripes-webpack/pull/125

Replaces #1340. It was gross and I really don't want to talk about it.
Let us never mention it again. 

Refs STCOR-671, FOLIO-3627
  • Loading branch information
zburke authored Oct 31, 2023
1 parent bbc722d commit 27d2948
Show file tree
Hide file tree
Showing 37 changed files with 1,672 additions and 160 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
* *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)
Expand Down
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ export { userLocaleConfig } from './src/loginServices';
export * from './src/consortiaServices';
export { default as queryLimit } from './src/queryLimit';
export { default as init } from './src/init';
export { registerServiceWorker, unregisterServiceWorker } from './src/serviceWorkerRegistration';
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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 = {
Expand All @@ -30,6 +31,12 @@ 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() {
Expand Down
8 changes: 4 additions & 4 deletions src/RootWithIntl.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
};

Expand All @@ -66,7 +66,7 @@ class RootWithIntl extends React.Component {

render() {
const {
token,
isAuthenticated,
disableAuth,
history,
} = this.props;
Expand All @@ -85,7 +85,7 @@ class RootWithIntl extends React.Component {
>
<Provider store={stripes.store}>
<Router history={history}>
{ token || disableAuth ?
{ isAuthenticated || disableAuth ?
<>
<MainContainer>
<AppCtxMenuProvider>
Expand Down
4 changes: 2 additions & 2 deletions src/Stripes.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,18 @@ 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,
}).isRequired,
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,
Expand Down
13 changes: 3 additions & 10 deletions src/components/MainNav/MainNav.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,14 @@ 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';

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,
Expand Down Expand Up @@ -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(okapi.url, this.store));
}

// return the user to the login screen, but after logging in they will be brought to the default screen.
Expand Down
19 changes: 11 additions & 8 deletions src/components/Root/Root.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 { addServiceWorkerListeners, loadTranslations, checkOkapiSession } from '../../loginServices';
import { getQueryResourceKey, getCurrentModule } from '../../locationService';
import Stripes from '../../Stripes';
import RootWithIntl from '../../RootWithIntl';
Expand All @@ -40,7 +40,7 @@ class Root extends Component {
constructor(...args) {
super(...args);

const { modules, history, okapi } = this.props;
const { modules, history, okapi, store } = this.props;

this.reducers = { ...initialReducers };
this.epics = {};
Expand All @@ -64,6 +64,9 @@ class Root extends Component {

this.apolloClient = createApolloClient(okapi);
this.reactQueryClient = createReactQueryClient();

// service-worker message listeners
addServiceWorkerListeners(okapi, store);
}

getChildContext() {
Expand Down Expand Up @@ -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 <div>Error: server is down.</div>;
Expand All @@ -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,
Expand Down Expand Up @@ -166,7 +169,7 @@ class Root extends Component {
>
<RootWithIntl
stripes={stripes}
token={token}
isAuthenticated={isAuthenticated}
disableAuth={disableAuth}
history={history}
/>
Expand All @@ -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,
Expand Down Expand Up @@ -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,
};
}
Expand Down
4 changes: 2 additions & 2 deletions src/createApolloClient.js
Original file line number Diff line number Diff line change
@@ -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(),
}));
Expand Down
11 changes: 7 additions & 4 deletions src/discoverServices.js
Original file line number Diff line number Diff line change
@@ -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'
};
}
Expand All @@ -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 });
Expand All @@ -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 });
Expand Down
Loading

0 comments on commit 27d2948

Please sign in to comment.