Skip to content

Commit

Permalink
STCOR-796 replace x-okapi-token credentials with RTR and cookies (#1410)
Browse files Browse the repository at this point in the history
Move auth tokens into HTTP-only cookies and implement refresh token
rotation (STCOR-671) by overriding global.fetch and
global.XMLHttpRequest, disabling login when cookies are disabled
(STCOR-762). This functionality is implemented behind an opt-in
feature-flag (STCOR-763).

Okapi and Keycloak do not handle the same situations in the same ways.
Changes from the original implementation in PR #1376:

* When a token is missing:
  * Okapi sends a 400 `text/plain` response
  * Keycloak sends a 401 `application/json` response
* Keycloak authentication includes the extra step of exchanging the OTP
  for the AT/RT and that request needs the `credentials` and `mode`
  options
* Some `loginServices` functions now retrieve the host the access from
  the `stripes-config` import instead of a function argument
* always permit `/authn/token` requests to go through

Refs STCOR-796, STCOR-671

(cherry picked from commit 0361353)
  • Loading branch information
zburke committed Mar 28, 2024
1 parent 72d89d4 commit 5a42507
Show file tree
Hide file tree
Showing 14 changed files with 162 additions and 96 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
* Add `idName` and `limit` as passable props to `useChunkedCQLFetch`. Refs STCOR-821.
* Check for valid token before rotating during XHR send. Refs STCOR-817.
* Remove `autoComplete` from `<ForgotPassword>`, `<ForgotUsername>` fields. Refs STCOR-742.
* Use keycloak URLs in place of users-bl for tenant-switch. Refs US1153537.

## [10.0.3](https://github.com/folio-org/stripes-core/tree/v10.0.3) (2023-11-10)
[Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.0.2...v10.0.3)
Expand All @@ -47,6 +48,8 @@
[Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.0.0...v10.0.1)

* Export `validateUser`. Refs STCOR-749.
* Opt-in: handle access-control via cookies. Refs STCOR-671.
* Opt-in: disable login when cookies are disabled. Refs STCOR-762.

## [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)
Expand Down
102 changes: 33 additions & 69 deletions src/components/Login/Login.js
Original file line number Diff line number Diff line change
@@ -1,72 +1,45 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect as reduxConnect } from 'react-redux';
import {
withRouter,
matchPath,
} from 'react-router-dom';

import { ConnectContext } from '@folio/stripes-connect';
import {
requestLogin,
requestSSOLogin,
} from '../../loginServices';
import { setAuthError } from '../../okapiActions';

class LoginCtrl extends Component {
static propTypes = {
authFailure: PropTypes.arrayOf(PropTypes.object),
ssoEnabled: PropTypes.bool,
autoLogin: PropTypes.shape({
username: PropTypes.string.isRequired,
password: PropTypes.string.isRequired,
}),
clearAuthErrors: PropTypes.func.isRequired,
history: PropTypes.shape({
push: PropTypes.func.isRequired,
}).isRequired,
location: PropTypes.shape({
pathname: PropTypes.string.isRequired,
}).isRequired,
};
import { FormattedMessage } from 'react-intl';
import { Field, Form } from 'react-final-form';

static contextType = ConnectContext;
import { branding } from 'stripes-config';

constructor(props) {
super(props);
this.sys = require('stripes-config'); // eslint-disable-line global-require
this.authnUrl = this.sys.okapi.authnUrl;
this.okapiUrl = this.sys.okapi.url;
this.tenant = this.sys.okapi.tenant;
if (props.autoLogin && props.autoLogin.username) {
this.handleSubmit(props.autoLogin);
}
}
import {
TextField,
Button,
Row,
Col,
Headline,
} from '@folio/stripes-components';

componentWillUnmount() {
this.props.clearAuthErrors();
}
import SSOLogin from '../SSOLogin';
import OrganizationLogo from '../OrganizationLogo';
import AuthErrorsContainer from '../AuthErrorsContainer';
import FieldLabel from '../CreateResetPassword/components/FieldLabel';

handleSuccessfulLogin = () => {
if (matchPath(this.props.location.pathname, '/login')) {
this.props.history.push('/');
}
}
import styles from './Login.css';

handleSubmit = (data) => {
return requestLogin({ okapi: this.sys.okapi }, this.context.store, this.tenant, data)
.then(this.handleSuccessfulLogin)
.catch(e => {
console.error(e); // eslint-disable-line no-console
});
}
class Login extends Component {
static propTypes = {
ssoActive: PropTypes.bool,
authErrors: PropTypes.arrayOf(PropTypes.object),
onSubmit: PropTypes.func.isRequired,
handleSSOLogin: PropTypes.func.isRequired,
};

handleSSOLogin = () => {
requestSSOLogin(this.okapiUrl, this.tenant);
}
static defaultProps = {
authErrors: [],
ssoActive: false,
};

render() {
const { authFailure, ssoEnabled } = this.props;
const {
authErrors,
handleSSOLogin,
ssoActive,
onSubmit,
} = this.props;

const cookieMessage = navigator.cookieEnabled ?
'' :
Expand All @@ -83,7 +56,6 @@ class LoginCtrl extends Component {
</Row>);

return (
<<<<<<< HEAD
<Form
onSubmit={onSubmit}
subscription={{ values: true }}
Expand Down Expand Up @@ -268,12 +240,4 @@ class LoginCtrl extends Component {
}
}

const mapStateToProps = state => ({
authFailure: state.okapi.authFailure,
ssoEnabled: state.okapi.ssoEnabled,
});
const mapDispatchToProps = dispatch => ({
clearAuthErrors: () => dispatch(setAuthError([])),
});

export default reduxConnect(mapStateToProps, mapDispatchToProps)(withRouter(LoginCtrl));
export default Login;
2 changes: 1 addition & 1 deletion src/components/Login/index.js
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { default } from './Login';
export { default } from './LoginCtrl';
9 changes: 2 additions & 7 deletions src/components/MainNav/MainNav.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +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());
})
.then(localforage.removeItem('okapiSess'))
.then(localforage.removeItem('loginResponse'));
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
7 changes: 4 additions & 3 deletions src/components/OIDCLanding.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@ const OIDCLanding = () => {
const otp = getOtp();

/**
* Exchange the otp for an access token, then use it to retrieve
* the user
* Exchange the otp for AT/RT cookies, then retrieve the user.
*
* See https://ebscoinddev.atlassian.net/wiki/spaces/TEUR/pages/12419306/mod-login-keycloak#mod-login-keycloak-APIs
* for additional details. May not be necessary for SAML-specific pages
Expand All @@ -58,12 +57,14 @@ const OIDCLanding = () => {
useEffect(() => {
if (otp) {
fetch(`${okapi.url}/authn/token?code=${otp}&redirect-uri=${window.location.protocol}//${window.location.host}/oidc-landing`, {
credentials: 'include',
headers: { 'X-Okapi-tenant': okapi.tenant, 'Content-Type': 'application/json' },
mode: 'cors',
})
.then((resp) => {
if (resp.ok) {
return resp.json().then((json) => {
return requestUserWithPermsDeb(okapi.url, store, okapi.tenant, json.okapiToken);
return requestUserWithPermsDeb(okapi.url, store, okapi.tenant);
});
} else {
return resp.json().then((error) => {
Expand Down
29 changes: 26 additions & 3 deletions src/components/Root/FFetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,32 @@ export class FFetch {
passThroughWithAT = (resource, options) => {
return this.nativeFetch.apply(global, [resource, options && { ...options, ...OKAPI_FETCH_OPTIONS }])
.then(response => {
// if the request failed due to a missing token, attempt RTR (which
// will then replay the original fetch if it succeeds), or die softly
// if it fails. return any other response as-is.
// certain 4xx responses indicate RTR problems (that need to be
// handled here) rather than application-specific problems (that need
// to bubble up to the applications themselves). Duplicate logic here
// is due to needing to parse different kinds of responses. Maybe it's
// JSON, maybe text. Srsly, Okapi??? :|
//
// 401/UnauthorizedException: from keycloak when the AT is missing
// 400/Token missing: from Okapi when the AT is missing
if (response.status === 401) {
const res = response.clone();
return res.json()
.then(message => {
if (Array.isArray(message.errors) && message.errors.length === 1) {
const error = message.errors[0];
if (error.type === 'UnauthorizedException' && error.code === 'authorization_error') {
this.logger.log('rtr', ' (whoops, invalid AT; retrying)');
return this.passThroughWithRT(resource, options);
}
}

// yes, it was a 401 but not a Keycloak 401:
// hand it back to the application to handle
return response;
});
}

if (response.status === 400 && response.headers.get('content-type') === 'text/plain') {
const res = response.clone();
return res.text()
Expand Down
74 changes: 74 additions & 0 deletions src/components/Root/FFetch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,80 @@ describe('FFetch class', () => {
expect(mockFetch.mock.calls[1][0]).toEqual('okapiUrl/authn/refresh');
expect(response).toEqual('success');
});

it('400 NOT token missing: bubbles failure up to the application', async () => {
mockFetch.mockResolvedValue(
new Response(
'Tolkien missing, send Frodo?',
{
status: 400,
headers: {
'content-type': 'text/plain',
},
}
));
const testFfetch = new FFetch({ logger: { log } });
const response = await global.fetch('okapiUrl', { testOption: 'test' });
expect(mockFetch.mock.calls).toHaveLength(1);
expect(response.status).toEqual(400);
});

it('401 UnauthorizedException: triggers rtr...calls fetch 3 times, failed call, token call, successful call', async () => {
mockFetch.mockResolvedValue('success')
.mockResolvedValueOnce(new Response(
JSON.stringify({
'errors': [
{
'type': 'UnauthorizedException',
'code': 'authorization_error',
'message': 'Unauthorized'
}
],
'total_records': 1
}),
{
status: 401,
headers: {
'content-type': 'application/json',
},
}
))
.mockResolvedValueOnce(new Response(JSON.stringify({
accessTokenExpiration: new Date().getTime() + 1000,
refreshTokenExpiration: new Date().getTime() + 2000,
}), { ok: true }));
const testFfetch = new FFetch({ logger: { log } });
const response = await global.fetch('okapiUrl', { testOption: 'test' });
expect(mockFetch.mock.calls).toHaveLength(3);
expect(mockFetch.mock.calls[1][0]).toEqual('okapiUrl/authn/refresh');
expect(response).toEqual('success');
});

it('401 NOT UnauthorizedException: bubbles failure up to the application', async () => {
mockFetch.mockResolvedValue(
new Response(
JSON.stringify({
'errors': [
{
'type': 'AuthorizedException',
'code': 'chuck_brown',
'message': 'Gong!'
}
],
'total_records': 1
}),
{
status: 401,
headers: {
'content-type': 'application/json',
},
}
));
const testFfetch = new FFetch({ logger: { log } });
const response = (await global.fetch('okapiUrl', { testOption: 'test' }));
expect(mockFetch.mock.calls).toHaveLength(1);
expect(response.status).toEqual(401);
});
});

describe('Calling an okapi fetch with expired AT...', () => {
Expand Down
22 changes: 12 additions & 10 deletions src/loginServices.js
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,8 @@ export function getUserLocale(okapiUrl, store, tenant, userId) {
*/
export function getPlugins(okapiUrl, store, tenant) {
return fetch(`${okapiUrl}/configurations/entries?query=(module==PLUGINS)`, {
headers: getHeaders(tenant, store.getState().okapi.token),
credentials: 'include',
headers: getHeaders(tenant, store.getState().okapi.token),
mode: 'cors',
})
.then((response) => {
Expand Down Expand Up @@ -385,7 +385,7 @@ function loadResources(store, tenant, userId) {

/**
* spreadUserWithPerms
* return an object { user, perms } based on response from bl-users/self.
* return an object { user, perms } based on response from .../_self.
*
* @param {object} userWithPerms
*
Expand Down Expand Up @@ -455,9 +455,9 @@ export async function logout(okapiUrl, store) {
* Dispatch the session object, then return a Promise that fetches
* and dispatches tenant resources.
*
* @param {object} store
* @param {string} tenant
* @param {string} token
* @param {object} store redux store
* @param {string} tenant tenant name
* @param {string} token access token [deprecated; prefer folioAccessToken cookie]
* @param {*} data
*
* @returns {Promise}
Expand All @@ -476,7 +476,7 @@ export function createOkapiSession(store, tenant, token, data) {

store.dispatch(setCurrentPerms(perms));

// if we can't parse tokenExpiration data, e.g. because data comes from `/bl-users/_self`
// if we can't parse tokenExpiration data, e.g. because data comes from `.../_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).
Expand Down Expand Up @@ -625,7 +625,7 @@ export function handleLoginError(dispatch, resp) {
/**
* processOkapiSession
* create a new okapi session with the response from either a username/password
* authentication request or a bl-users/_self request.
* authentication request or a .../_self request.
* response body is shaped like
* {
'access_token': 'SOME_STRING',
Expand Down Expand Up @@ -665,7 +665,7 @@ export function processOkapiSession(store, tenant, resp, ssoToken) {

/**
* validateUser
* return a promise that fetches from bl-users/self.
* return a promise that fetches from .../_self.
* if successful, dispatch the result to create a session
* if not, clear the session and token.
*
Expand All @@ -678,7 +678,9 @@ export function processOkapiSession(store, tenant, resp, ssoToken) {
*/
export function validateUser(okapiUrl, store, tenant, session) {
const { token, user, perms, tenant: sessionTenant = tenant } = session;
return fetch(`${okapiUrl}/bl-users/_self`, {
const usersPath = okapi.authnUrl ? 'users-keycloak' : 'bl-users';

return fetch(`${okapiUrl}/${usersPath}/_self`, {
headers: getHeaders(sessionTenant, token),
credentials: 'include',
mode: 'cors',
Expand Down Expand Up @@ -707,7 +709,7 @@ export function validateUser(okapiUrl, store, tenant, session) {
token,
tokenExpiration,
}));
return loadResources(okapiUrl, store, sessionTenant, user.id);
return loadResources(store, sessionTenant, user.id);
});
} else {
return logout(okapiUrl, store);
Expand Down
Loading

0 comments on commit 5a42507

Please sign in to comment.