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 authored and ryandberger committed May 15, 2024
1 parent 602d21f commit 15fecbf
Show file tree
Hide file tree
Showing 12 changed files with 83 additions and 85 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,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 @@ -55,6 +56,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
101 changes: 33 additions & 68 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 Down Expand Up @@ -267,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';
8 changes: 8 additions & 0 deletions src/components/MainNav/MainNav.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,14 @@ class MainNav extends Component {
});
}

// Return the user to the login screen, but after logging in they will return to their previous activity.
returnToLogin() {
const { okapi } = this.store.getState();

return getLocale(okapi.url, this.store, okapi.tenant)
.then(sessionLogout(okapi.url, this.store));
}

// return the user to the login screen, but after logging in they will be brought to the default screen.
logout() {
const { okapi } = this.store.getState();
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
23 changes: 12 additions & 11 deletions src/loginServices.js
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,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 @@ -391,7 +391,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 @@ -515,9 +515,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 @@ -536,7 +536,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 @@ -655,7 +655,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 @@ -695,7 +695,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 @@ -708,7 +708,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 @@ -737,8 +739,7 @@ export function validateUser(okapiUrl, store, tenant, session) {
token,
tokenExpiration,
}));

return loadResources(okapiUrl, store, sessionTenant, user.id);
return loadResources(store, sessionTenant, user.id);
});
} else {
store.dispatch(clearCurrentUser());
Expand Down
4 changes: 2 additions & 2 deletions src/loginServices.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ describe('processOkapiSession', () => {

mockFetchSuccess();

await processOkapiSession(store, 'tenant', resp, 'token');
await processOkapiSession(store, 'tenant', resp);
expect(store.dispatch).toHaveBeenCalledWith(setAuthError(null));
expect(store.dispatch).toHaveBeenCalledWith(setOkapiReady());

Expand All @@ -243,7 +243,7 @@ describe('processOkapiSession', () => {
}
};

await processOkapiSession(store, 'tenant', resp, 'token');
await processOkapiSession(store, 'tenant', resp);

expect(store.dispatch).toHaveBeenCalledWith(setOkapiReady());
expect(store.dispatch).toHaveBeenCalledWith(setAuthError([defaultErrors.DEFAULT_LOGIN_CLIENT_ERROR]));
Expand Down
1 change: 1 addition & 0 deletions translations/stripes-core/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"title.noPermission": "No permission",
"title.cookieEnabled": "Cookies are required to login. Please enable cookies and try again.",
"title.logout": "Log out",
"title.cookieEnabled": "Cookies are required to login. Please enable cookies and try again.",
"front.welcome": "Welcome, the Future Of Libraries Is OPEN!",
"front.home": "Home",
"front.about": "Software versions",
Expand Down
1 change: 1 addition & 0 deletions translations/stripes-core/en_GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"title.forgotUsername": "Forgot username?",
"title.checkEmail": "Check your email",
"title.changePassword": "Change password",
"title.cookieEnabled": "Cookies are required to login. Please enable cookies and try again.",
"button.hidePassword": "Hide password",
"button.showPassword": "Show password",
"button.forgotPassword": "Forgot password?",
Expand Down
1 change: 1 addition & 0 deletions translations/stripes-core/en_SE.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"title.forgotUsername": "Forgot username?",
"title.checkEmail": "Check your email",
"title.changePassword": "Change password",
"title.cookieEnabled": "Cookies are required to login. Please enable cookies and try again.",
"button.hidePassword": "Hide password",
"button.showPassword": "Show password",
"button.forgotPassword": "Forgot password?",
Expand Down
1 change: 1 addition & 0 deletions translations/stripes-core/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"title.forgotUsername": "Forgot username?",
"title.checkEmail": "Check your email",
"title.changePassword": "Change password",
"title.cookieEnabled": "Cookies are required to login. Please enable cookies and try again.",
"button.hidePassword": "Hide password",
"button.showPassword": "Show password",
"button.forgotPassword": "Forgot password?",
Expand Down
16 changes: 16 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -9558,6 +9558,22 @@ possible-typed-array-names@^1.0.0:
resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f"
integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==

postcss-calc@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-9.0.1.tgz#a744fd592438a93d6de0f1434c572670361eb6c6"
integrity sha512-TipgjGyzP5QzEhsOZUaIkeO5mKeMFpebWzRogWG/ysonUlnHcq5aJe0jOjpfzUU8PeSaBQnrE8ehR0QA5vs8PQ==
dependencies:
postcss-selector-parser "^6.0.11"
postcss-value-parser "^4.2.0"

postcss-color-function@folio-org/postcss-color-function:
version "4.1.0"
resolved "https://codeload.github.com/folio-org/postcss-color-function/tar.gz/c128aad740ae740fb571c4b6493f467dd51efe85"
dependencies:
css-color-function "~1.3.3"
postcss-message-helpers "^2.0.0"
postcss-value-parser "^4.1.0"

postcss-custom-media@^9.0.1:
version "9.1.5"
resolved "https://registry.yarnpkg.com/postcss-custom-media/-/postcss-custom-media-9.1.5.tgz#20c5822dd15155d768f8dd84e07a6ffd5d01b054"
Expand Down

0 comments on commit 15fecbf

Please sign in to comment.