diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a5506993..90b1da5a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ``, `` 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) @@ -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) diff --git a/src/components/Login/Login.js b/src/components/Login/Login.js index 8bb12bf33..48bad369a 100644 --- a/src/components/Login/Login.js +++ b/src/components/Login/Login.js @@ -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 ? '' : @@ -83,7 +56,6 @@ class LoginCtrl extends Component { ); return ( -<<<<<<< HEAD
({ - authFailure: state.okapi.authFailure, - ssoEnabled: state.okapi.ssoEnabled, -}); -const mapDispatchToProps = dispatch => ({ - clearAuthErrors: () => dispatch(setAuthError([])), -}); - -export default reduxConnect(mapStateToProps, mapDispatchToProps)(withRouter(LoginCtrl)); +export default Login; diff --git a/src/components/Login/index.js b/src/components/Login/index.js index 2a741cdbd..44e798405 100644 --- a/src/components/Login/index.js +++ b/src/components/Login/index.js @@ -1 +1 @@ -export { default } from './Login'; +export { default } from './LoginCtrl'; diff --git a/src/components/MainNav/MainNav.js b/src/components/MainNav/MainNav.js index 1b4ac4780..fc418e8a1 100644 --- a/src/components/MainNav/MainNav.js +++ b/src/components/MainNav/MainNav.js @@ -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. diff --git a/src/components/OIDCLanding.js b/src/components/OIDCLanding.js index 82ae09c1c..18960f8f9 100644 --- a/src/components/OIDCLanding.js +++ b/src/components/OIDCLanding.js @@ -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 @@ -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) => { diff --git a/src/components/Root/FFetch.js b/src/components/Root/FFetch.js index 545bcfd5a..f28c08e32 100644 --- a/src/components/Root/FFetch.js +++ b/src/components/Root/FFetch.js @@ -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() diff --git a/src/components/Root/FFetch.test.js b/src/components/Root/FFetch.test.js index 6b8372ccd..bd96a9449 100644 --- a/src/components/Root/FFetch.test.js +++ b/src/components/Root/FFetch.test.js @@ -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...', () => { diff --git a/src/loginServices.js b/src/loginServices.js index 11d92b601..d99894dcc 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -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) => { @@ -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 * @@ -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} @@ -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). @@ -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', @@ -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. * @@ -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', @@ -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); diff --git a/src/loginServices.test.js b/src/loginServices.test.js index 6a25a859b..537a4bdcd 100644 --- a/src/loginServices.test.js +++ b/src/loginServices.test.js @@ -222,7 +222,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()); @@ -239,7 +239,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])); diff --git a/translations/stripes-core/en.json b/translations/stripes-core/en.json index 0b1779a2e..5fbbffc1f 100644 --- a/translations/stripes-core/en.json +++ b/translations/stripes-core/en.json @@ -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", diff --git a/translations/stripes-core/en_GB.json b/translations/stripes-core/en_GB.json index d338d5ab7..fb9b20dca 100644 --- a/translations/stripes-core/en_GB.json +++ b/translations/stripes-core/en_GB.json @@ -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?", diff --git a/translations/stripes-core/en_SE.json b/translations/stripes-core/en_SE.json index d338d5ab7..fb9b20dca 100644 --- a/translations/stripes-core/en_SE.json +++ b/translations/stripes-core/en_SE.json @@ -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?", diff --git a/translations/stripes-core/en_US.json b/translations/stripes-core/en_US.json index b118eb8b8..29dc20d1f 100644 --- a/translations/stripes-core/en_US.json +++ b/translations/stripes-core/en_US.json @@ -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?", diff --git a/yarn.lock b/yarn.lock index 37aa47545..65c9805bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9502,7 +9502,7 @@ postcss-calc@^9.0.1: postcss-selector-parser "^6.0.11" postcss-value-parser "^4.2.0" -"postcss-color-function@github:folio-org/postcss-color-function": +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: