From cfd20c2ce84c42500d92b9964f4980bf05c94593 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Wed, 13 Mar 2024 00:01:30 -0400 Subject: [PATCH 01/48] Release v10.1.0 (#1441) --- CHANGELOG.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86479e6a4..895ead742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Change history for stripes-core -## 10.1.0 IN PROGRESS +## [10.1.0](https://github.com/folio-org/stripes-core/tree/v10.1.0) (2024-03-12) +[Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.0.0...v10.1.0) * Provide optional tenant argument to `useOkapiKy` hook. Refs STCOR-747. * Avoid private path when import `validateUser` function. Refs STCOR-749. @@ -25,6 +26,22 @@ * Check for valid token before rotating during XHR send. Refs STCOR-817. * Remove `autoComplete` from ``, `` fields. Refs STCOR-742. +## [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) + +* Revert "Use cookies and RTR" until further notice. Refs FOLIO-3627. +* Ensure `` is not cut off when app name is long. Refs STCOR-752. + +## [10.0.2](https://github.com/folio-org/stripes-core/tree/v10.0.2) (2023-11-06) +[Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.0.1...v10.0.2) + +* Use cookies and RTR instead of directly handling the JWT. Refs STCOR-671, STCOR-754, STCOR-756, FOLIO-3627. + +## [10.0.1](https://github.com/folio-org/stripes-core/tree/v10.0.1) (2023-10-25) +[Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.0.0...v10.0.1) + +* Export `validateUser`. Refs STCOR-749. + ## [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) From 33d2165d517c46ae81ff8e80d508ad9f0c7cbc03 Mon Sep 17 00:00:00 2001 From: Yury Saukou Date: Thu, 14 Mar 2024 12:35:20 +0400 Subject: [PATCH 02/48] STCOR-769 Utilize the 'tenant' procured through the SSO login process (#1415) To support Single Sign-On (SSO) authorization in consortium mode, it's necessary to explicitly pass the tenant in the request header to load data. (cherry picked from commit d8db8b79589f7522a4ba0bc756350708139606f9) --- CHANGELOG.md | 5 + src/components/Root/FFetch.js | 7 +- src/components/SSOLanding.js | 54 -------- src/components/SSOLanding.test.js | 65 --------- src/components/SSOLanding/SSOLanding.css | 7 + src/components/SSOLanding/SSOLanding.js | 27 ++++ src/components/SSOLanding/SSOLanding.test.js | 50 +++++++ src/components/SSOLanding/index.js | 1 + src/components/SSOLanding/useSSOSession.js | 73 ++++++++++ .../SSOLanding/useSSOSession.test.js | 125 ++++++++++++++++++ src/constants/defaultErrors/defaultErrors.js | 7 +- src/constants/index.js | 1 + src/constants/ssoErrorCodes.js | 5 + src/loginServices.js | 11 +- translations/stripes-core/en.json | 1 + 15 files changed, 314 insertions(+), 125 deletions(-) delete mode 100644 src/components/SSOLanding.js delete mode 100644 src/components/SSOLanding.test.js create mode 100644 src/components/SSOLanding/SSOLanding.css create mode 100644 src/components/SSOLanding/SSOLanding.js create mode 100644 src/components/SSOLanding/SSOLanding.test.js create mode 100644 src/components/SSOLanding/index.js create mode 100644 src/components/SSOLanding/useSSOSession.js create mode 100644 src/components/SSOLanding/useSSOSession.test.js create mode 100644 src/constants/ssoErrorCodes.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 895ead742..594d6ac4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Change history for stripes-core +## [10.1.1](https://github.com/folio-org/stripes-core/tree/v10.1.1) (2024-03-25) +[Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.1.0...v10.1.1) + +* Utilize the `tenant` procured through the SSO login process. Refs STCOR-769. + ## [10.1.0](https://github.com/folio-org/stripes-core/tree/v10.1.0) (2024-03-12) [Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.0.0...v10.1.0) diff --git a/src/components/Root/FFetch.js b/src/components/Root/FFetch.js index adc69bfdd..545bcfd5a 100644 --- a/src/components/Root/FFetch.js +++ b/src/components/Root/FFetch.js @@ -100,6 +100,7 @@ export class FFetch { '/bl-users/login-with-expiry', '/bl-users/password-reset', '/saml/check', + `/_/invoke/tenant/${okapi.tenant}/saml/login` ]; this.logger.log('rtr', `AT invalid for ${resource}`); @@ -230,7 +231,9 @@ export class FFetch { * @returns Promise * @throws if any fetch fails */ - ffetch = async (resource, options) => { + ffetch = async (resource, ffOptions = {}) => { + const { rtrIgnore = false, ...options } = ffOptions; + // FOLIO API requests are subject to RTR if (isFolioApiRequest(resource, okapi.url)) { this.logger.log('rtr', 'will fetch', resource); @@ -248,7 +251,7 @@ export class FFetch { } // AT is valid or unnecessary; execute the fetch - if (this.isPermissibleRequest(resource, this.tokenExpiration, okapi.url)) { + if (rtrIgnore || this.isPermissibleRequest(resource, this.tokenExpiration, okapi.url)) { return this.passThroughWithAT(resource, options); } diff --git a/src/components/SSOLanding.js b/src/components/SSOLanding.js deleted file mode 100644 index 655976178..000000000 --- a/src/components/SSOLanding.js +++ /dev/null @@ -1,54 +0,0 @@ -import _ from 'lodash'; -import React, { useEffect } from 'react'; -import { useLocation } from 'react-router-dom'; -import { useCookies } from 'react-cookie'; -import queryString from 'query-string'; -import { useStore } from 'react-redux'; -import { okapi } from 'stripes-config'; - -import { requestUserWithPerms } from '../loginServices'; - -const requestUserWithPermsDeb = _.debounce(requestUserWithPerms, 5000, { leading: true, trailing: false }); - -const SSOLanding = () => { - const location = useLocation(); - const [cookies] = useCookies(['ssoToken']); - const store = useStore(); - - const getParams = () => { - const search = location.search; - if (!search) return undefined; - return queryString.parse(search) || {}; - }; - - const getToken = () => { - const params = getParams(); - return cookies?.ssoToken || params?.ssoToken; - }; - - const token = getToken(); - - useEffect(() => { - if (token) { - requestUserWithPermsDeb(okapi.url, store, okapi.tenant, token); - } - }, [token, store]); - - if (!token) { - return ( -
- No ssoToken cookie or query parameter -
- ); - } - - return ( -
-

- Logged in with token {token} from {getParams()?.ssoToken ? 'param' : 'cookie'}. -

-
- ); -}; - -export default SSOLanding; diff --git a/src/components/SSOLanding.test.js b/src/components/SSOLanding.test.js deleted file mode 100644 index 9d9dd452e..000000000 --- a/src/components/SSOLanding.test.js +++ /dev/null @@ -1,65 +0,0 @@ -import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; -import { useLocation } from 'react-router-dom'; -import { useCookies } from 'react-cookie'; -import { requestUserWithPerms } from '../loginServices'; - -import SSOLanding from './SSOLanding'; - -jest.mock('lodash', () => ({ - debounce: jest.fn(fn => fn) -})); - -jest.mock('../loginServices', () => ({ - requestUserWithPerms: jest.fn(() => {}) -})); - -jest.mock('react-router-dom', () => ({ - useLocation: jest.fn() -})); - -jest.mock('stripes-config', () => ({ - okapi: { - url: 'okapiUrl', - tenant: 'okapiTenant' - } -}), -{ virtual: true }); - -jest.mock('react-cookie', () => ({ - useCookies: jest.fn() -})); - -jest.mock('react-redux', () => ({ - useStore: jest.fn() -})); - -describe('SSOLanding', () => { - beforeEach(() => { - useLocation.mockImplementation(() => ({ search: '' })); - useCookies.mockImplementation(() => [{}]); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('handles token within query parameters', () => { - useLocation.mockImplementation(() => ({ search: 'ssoToken=c0ffee' })); - render(); - expect(requestUserWithPerms.mock.calls).toHaveLength(1); - expect(screen.getByText(/Logged in with token.*param\.$/)).toBeInTheDocument(); - }); - - it('handles token within a cookie', () => { - useCookies.mockImplementation(() => ([{ ssoToken: 'c0ffee-c0ffee' }])); - render(); - expect(requestUserWithPerms.mock.calls).toHaveLength(1); - expect(screen.getByText(/Logged in with token.*cookie\.$/)).toBeInTheDocument(); - }); - - it('displays error with no token.', () => { - render(); - expect(requestUserWithPerms.mock.calls).toHaveLength(0); - expect(screen.getByText('No cookie or query parameter')).toBeInTheDocument(); - }); -}); diff --git a/src/components/SSOLanding/SSOLanding.css b/src/components/SSOLanding/SSOLanding.css new file mode 100644 index 000000000..4378ab72c --- /dev/null +++ b/src/components/SSOLanding/SSOLanding.css @@ -0,0 +1,7 @@ +.ssoLoading { + display: flex; + height: 100%; + width: 100%; + align-items: center; + justify-content: center; +} diff --git a/src/components/SSOLanding/SSOLanding.js b/src/components/SSOLanding/SSOLanding.js new file mode 100644 index 000000000..3254d2385 --- /dev/null +++ b/src/components/SSOLanding/SSOLanding.js @@ -0,0 +1,27 @@ +import { Redirect } from 'react-router'; + +import { + Loading, +} from '@folio/stripes-components'; + +import useSSOSession from './useSSOSession'; +import styles from './SSOLanding.css'; + +const SSOLanding = () => { + const { isSessionFailed } = useSSOSession(); + + if (isSessionFailed) { + return ; + } + + return ( +
+ +
+ ); +}; + +export default SSOLanding; diff --git a/src/components/SSOLanding/SSOLanding.test.js b/src/components/SSOLanding/SSOLanding.test.js new file mode 100644 index 000000000..014b1f829 --- /dev/null +++ b/src/components/SSOLanding/SSOLanding.test.js @@ -0,0 +1,50 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; + +import { Redirect } from 'react-router'; + +import { + Loading, +} from '@folio/stripes-components'; + +import useSSOSession from './useSSOSession'; +import SSOLanding from './SSOLanding'; + +jest.mock('react-router', () => ({ + Redirect: jest.fn(), +})); + +jest.mock('@folio/stripes-components', () => ({ + Loading: jest.fn(), +})); + +jest.mock('./useSSOSession', () => jest.fn()); + +describe('SSOLanding', () => { + beforeEach(() => { + Redirect.mockReturnValue('Redirect'); + Loading.mockReturnValue('Loading'); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('displays loader when session is set up', () => { + useSSOSession.mockReturnValue({ isSessionFailed: false }); + + render(); + + expect(screen.getByText('Loading')).toBeInTheDocument(); + expect(screen.queryByText('Redirect')).toBeNull(); + }); + + it('redirects to login page when session is not set up', () => { + useSSOSession.mockReturnValue({ isSessionFailed: true }); + + render(); + + expect(screen.queryByText('Loading')).toBeNull(); + expect(screen.getByText('Redirect')).toBeInTheDocument(); + expect(Redirect).toHaveBeenCalledWith({ to: '/' }, {}); + }); +}); diff --git a/src/components/SSOLanding/index.js b/src/components/SSOLanding/index.js new file mode 100644 index 000000000..0f1fd095d --- /dev/null +++ b/src/components/SSOLanding/index.js @@ -0,0 +1 @@ +export { default } from './SSOLanding'; diff --git a/src/components/SSOLanding/useSSOSession.js b/src/components/SSOLanding/useSSOSession.js new file mode 100644 index 000000000..8242f111f --- /dev/null +++ b/src/components/SSOLanding/useSSOSession.js @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react'; +import { useDispatch, useStore } from 'react-redux'; +import { useLocation } from 'react-router-dom'; +import { useCookies } from 'react-cookie'; +import queryString from 'query-string'; + +import { config, okapi } from 'stripes-config'; + +import { defaultErrors } from '../../constants'; +import { setAuthError } from '../../okapiActions'; +import { requestUserWithPerms } from '../../loginServices'; +import { parseJWT } from '../../helpers'; + +const getParams = (location) => { + const search = location.search; + + if (!search) return undefined; + + return queryString.parse(search) || {}; +}; + +const getToken = (cookies, params) => { + return cookies?.ssoToken || params?.ssoToken; +}; + +const getTenant = (params, token) => { + const tenant = config.useSecureTokens + ? params?.tenantId + : parseJWT(token)?.tenant; + + return tenant || okapi.tenant; +}; + +const useSSOSession = () => { + const [isFailed, setIsFailed] = useState(false); + const store = useStore(); + const dispatch = useDispatch(); + + const location = useLocation(); + const [cookies] = useCookies(['ssoToken']); + + const params = getParams(location); + + const token = getToken(cookies, params); + const tenant = getTenant(params, token); + + useEffect(() => { + requestUserWithPerms(okapi.url, store, tenant, token) + .then(() => { + if (store.getState()?.okapi?.authFailure) { + return Promise.reject(new Error('SSO Failed')); + } + + return Promise.resolve(); + }) + .catch(() => { + dispatch(setAuthError([defaultErrors.SSO_SESSION_FAILED_ERROR])); + setIsFailed(true); + }); + /* + Dependencies are not required here + as all information is provided before component is rendered (query params or cookies) + and session set up should be called only once + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + isSessionFailed: isFailed, + }; +}; + +export default useSSOSession; diff --git a/src/components/SSOLanding/useSSOSession.test.js b/src/components/SSOLanding/useSSOSession.test.js new file mode 100644 index 000000000..2a0e4cd0c --- /dev/null +++ b/src/components/SSOLanding/useSSOSession.test.js @@ -0,0 +1,125 @@ +import { renderHook, waitFor } from '@folio/jest-config-stripes/testing-library/react'; + +import { useDispatch, useStore } from 'react-redux'; +import { useLocation } from 'react-router-dom'; +import { useCookies } from 'react-cookie'; + +import { config, okapi } from 'stripes-config'; + +import { defaultErrors } from '../../constants'; +import { setAuthError } from '../../okapiActions'; +import { requestUserWithPerms } from '../../loginServices'; +import { parseJWT } from '../../helpers'; + +import useSSOSession from './useSSOSession'; + +jest.mock('react-router-dom', () => ({ + useLocation: jest.fn() +})); + +jest.mock('react-cookie', () => ({ + useCookies: jest.fn() +})); + +jest.mock('react-redux', () => ({ + useStore: jest.fn(), + useDispatch: jest.fn(), +})); + +jest.mock('stripes-config', () => ({ + config: { + useSecureTokens: true, + }, + okapi: { + url: 'okapiUrl', + tenant: 'okapiTenant' + } +}), +{ virtual: true }); + +jest.mock('../../loginServices', () => ({ + requestUserWithPerms: jest.fn() +})); +jest.mock('../../helpers', () => ({ + parseJWT: jest.fn() +})); + +describe('SSOLanding', () => { + const ssoTokenValue = 'c0ffee'; + + beforeEach(() => { + useLocation.mockReturnValue({ search: '' }); + useCookies.mockReturnValue([]); + + useStore.mockReturnValue({ getState: jest.fn() }); + + requestUserWithPerms.mockReturnValue(Promise.resolve()); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should request user session when RTR is disabled and token from query params', () => { + const store = useStore(); + + useLocation.mockReturnValue({ search: `ssoToken=${ssoTokenValue}` }); + config.useSecureTokens = false; + + renderHook(() => useSSOSession()); + + expect(requestUserWithPerms).toHaveBeenCalledWith(okapi.url, store, okapi.tenant, ssoTokenValue); + }); + + it('should request user session when RTR is disabled with token from cookies', () => { + const store = useStore(); + + useCookies.mockReturnValue([{ ssoToken: ssoTokenValue }]); + config.useSecureTokens = false; + + renderHook(() => useSSOSession()); + + expect(requestUserWithPerms).toHaveBeenCalledWith(okapi.url, store, okapi.tenant, ssoTokenValue); + }); + + it('should request user session when RTR is disabled and right tenant from ssoToken', () => { + const tokenTenant = 'tokenTenant'; + const store = useStore(); + + useLocation.mockReturnValue({ search: `ssoToken=${ssoTokenValue}` }); + parseJWT.mockReturnValue({ tenant: tokenTenant }); + config.useSecureTokens = false; + + renderHook(() => useSSOSession()); + + expect(requestUserWithPerms).toHaveBeenCalledWith(okapi.url, store, tokenTenant, ssoTokenValue); + }); + + it('should request user session when RTR is enabled and right tenant from query params', () => { + const queryTenant = 'queryTenant'; + const store = useStore(); + + useLocation.mockReturnValue({ search: `tenantId=${queryTenant}` }); + config.useSecureTokens = true; + + renderHook(() => useSSOSession()); + + expect(requestUserWithPerms).toHaveBeenCalledWith(okapi.url, store, queryTenant, undefined); + }); + + it('should display error when session request failed', async () => { + const queryTenant = 'queryTenant'; + const dispatch = jest.fn(); + + requestUserWithPerms.mockReturnValue(Promise.reject()); + useDispatch.mockReturnValue(dispatch); + useLocation.mockReturnValue({ search: `tenant=${queryTenant}` }); + config.useSecureTokens = true; + + renderHook(() => useSSOSession()); + + await waitFor(() => expect(requestUserWithPerms).toHaveBeenCalled()); + + expect(dispatch).toHaveBeenCalledWith(setAuthError([defaultErrors.SSO_SESSION_FAILED_ERROR])); + }); +}); diff --git a/src/constants/defaultErrors/defaultErrors.js b/src/constants/defaultErrors/defaultErrors.js index f8e22735a..01df3cc8d 100644 --- a/src/constants/defaultErrors/defaultErrors.js +++ b/src/constants/defaultErrors/defaultErrors.js @@ -2,6 +2,7 @@ import { defaultErrorCodes, changePasswordErrorCodes, forgotFormErrorCodes, + ssoErrorCodes, } from '../index'; const defaultErrors = { @@ -24,7 +25,11 @@ const defaultErrors = { FORGOTTEN_USERNAME_CLIENT_ERROR: { code: forgotFormErrorCodes.UNABLE_LOCATE_ACCOUNT, type: 'error', - } + }, + SSO_SESSION_FAILED_ERROR: { + code: ssoErrorCodes.SSO_SESSION_FAILED_ERROR, + type: 'error', + }, }; export default defaultErrors; diff --git a/src/constants/index.js b/src/constants/index.js index 817c65e29..a59c465d9 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -1,6 +1,7 @@ export { default as defaultErrorCodes } from './defaultErrorCodes'; export { default as forgotFormErrorCodes } from './forgotFormErrorCodes'; export { default as changePasswordErrorCodes } from './changePasswordErrorCodes'; +export { default as ssoErrorCodes } from './ssoErrorCodes'; export { default as defaultErrors } from './defaultErrors'; export { default as packageName } from './packageName'; export { default as delimiters } from './delimiters'; diff --git a/src/constants/ssoErrorCodes.js b/src/constants/ssoErrorCodes.js new file mode 100644 index 000000000..7bdcdfd48 --- /dev/null +++ b/src/constants/ssoErrorCodes.js @@ -0,0 +1,5 @@ +const ssoErrorCodes = { + SSO_SESSION_FAILED_ERROR: 'sso.session.failed', +}; + +export default ssoErrorCodes; diff --git a/src/loginServices.js b/src/loginServices.js index f6a5e1cae..baf00569a 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -757,13 +757,18 @@ export function requestLogin(okapiUrl, store, tenant, data) { * retrieve currently-authenticated user * @param {string} okapiUrl * @param {string} tenant + * @param {string} token + * @param {boolean} rtrIgnore * * @returns {Promise} Promise resolving to the response of the request */ -function fetchUserWithPerms(okapiUrl, tenant, token) { +function fetchUserWithPerms(okapiUrl, tenant, token, rtrIgnore = false) { return fetch( `${okapiUrl}/bl-users/_self?expandPermissions=true&fullPermissions=true`, - { headers: getHeaders(tenant, token) }, + { + headers: getHeaders(tenant, token), + rtrIgnore, + }, ); } @@ -777,7 +782,7 @@ function fetchUserWithPerms(okapiUrl, tenant, 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) + return fetchUserWithPerms(okapiUrl, tenant, token, !token) .then(resp => processOkapiSession(okapiUrl, store, tenant, resp, token)); } diff --git a/translations/stripes-core/en.json b/translations/stripes-core/en.json index 60a1ade17..52d040327 100644 --- a/translations/stripes-core/en.json +++ b/translations/stripes-core/en.json @@ -140,6 +140,7 @@ "errors.password.whiteSpace.invalid": "The password must not contain whitespaces.", "errors.password.consecutiveWhitespaces.invalid": "The password must not contain consecutive white space characters.", "errors.password.compromised.invalid": "The password must not be commonly-used, expected or compromised", + "errors.sso.session.failed": "SSO Login failed. Please try again", "createResetPassword.header": "Choose a password", "createResetPassword.newPassword": "New Password", From 66d475c4517289e6858fe7d3b6458af92f29ce88 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Mon, 25 Mar 2024 15:07:39 -0400 Subject: [PATCH 03/48] Release v10.1.1 (#1447) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7f7bc4e68..469578ad0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@folio/stripes-core", - "version": "10.1.0", + "version": "10.1.1", "description": "The starting point for Stripes applications", "license": "Apache-2.0", "repository": "folio-org/stripes-core", From 2b7c593fe134a0ad7177e1d5c26d39aa9d515ba9 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Fri, 8 Dec 2023 14:22:12 -0500 Subject: [PATCH 04/48] leverage keycloak (authn) and kong (discovery) endpoints There's a lot going on here, but fundamentally the changes are split into two main categories: * route authentication requests to/from keycloak * handle discovery dynamically via an API request AFTER authentication instead of reading a static module list from `stripes.config.js` --- CHANGELOG.md | 1 + package.json | 1 + src/App.js | 5 +- src/RootWithIntl.js | 74 +++++- src/components/About/About.css | 4 + src/components/About/About.js | 100 +++++--- .../ForgotPassword/ForgotPasswordCtrl.js | 7 +- .../ForgotUserName/ForgotUserNameCtrl.js | 7 +- src/components/Login/Login.js | 102 +++++--- src/components/Login/LoginForm.js | 228 ++++++++++++++++++ src/components/Login/index.js | 2 +- src/components/MainNav/MainNav.js | 11 +- src/components/OIDCLanding.js | 111 +++++++++ src/components/OIDCRedirect.js | 32 +++ .../PreLoginLanding/PreLoginLanding.js | 71 ++++++ src/components/PreLoginLanding/index.css | 13 + src/components/PreLoginLanding/index.js | 1 + src/components/Redirect/Redirect.js | 27 +++ src/components/Redirect/Redirect.test.js | 45 ++++ src/components/Redirect/index.js | 1 + src/components/TitleManager/TitleManager.js | 14 +- src/components/index.js | 2 + src/discoverServices.js | 195 +++++++++++---- src/loginServices.js | 94 +++++--- src/okapiActions.js | 8 + src/okapiReducer.js | 4 + translations/stripes-core/en.json | 8 +- translations/stripes-core/en_US.json | 10 +- 28 files changed, 1007 insertions(+), 171 deletions(-) create mode 100644 src/components/Login/LoginForm.js create mode 100644 src/components/OIDCLanding.js create mode 100644 src/components/OIDCRedirect.js create mode 100644 src/components/PreLoginLanding/PreLoginLanding.js create mode 100644 src/components/PreLoginLanding/index.css create mode 100644 src/components/PreLoginLanding/index.js create mode 100644 src/components/Redirect/Redirect.js create mode 100644 src/components/Redirect/Redirect.test.js create mode 100644 src/components/Redirect/index.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 594d6ac4c..0a5506993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ [Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.1.0...v10.1.1) * Utilize the `tenant` procured through the SSO login process. Refs STCOR-769. +* Use keycloak URLs in place of users-bl for tenant-switch. Refs US1153537. ## [10.1.0](https://github.com/folio-org/stripes-core/tree/v10.1.0) (2024-03-12) [Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.0.0...v10.1.0) diff --git a/package.json b/package.json index 469578ad0..e4a4de21b 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,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/App.js b/src/App.js index 437037235..75cc39fbb 100644 --- a/src/App.js +++ b/src/App.js @@ -21,8 +21,11 @@ export default class StripesCore extends Component { constructor(props) { super(props); + const storedTenant = localStorage.getItem('tenant'); + const parsedTenant = storedTenant ? JSON.parse(storedTenant) : undefined; + const okapi = (typeof okapiConfig === 'object' && Object.keys(okapiConfig).length > 0) - ? okapiConfig : { withoutOkapi: true }; + ? { ...okapiConfig, tenant: parsedTenant?.tenantName || okapiConfig.tenant, clientId: parsedTenant?.clientId } : { withoutOkapi: true }; const initialState = merge({}, { okapi }, props.initialState); diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index a507fff5a..34bbd3764 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -13,6 +13,7 @@ import { Callout, HotKeys } from '@folio/stripes-components'; import ModuleRoutes from './moduleRoutes'; import events from './events'; +import Redirect from './components/Redirect'; import { MainContainer, @@ -21,6 +22,8 @@ import { ModuleTranslator, TitledRoute, Front, + OIDCRedirect, + OIDCLanding, SSOLanding, SSORedirect, Settings, @@ -37,6 +40,8 @@ import { import StaleBundleWarning from './components/StaleBundleWarning'; import { StripesContext } from './StripesContext'; import { CalloutContext } from './CalloutContext'; +import PreLoginLanding from './components/PreLoginLanding'; +import { setOkapiTenant } from './okapiActions'; class RootWithIntl extends React.Component { static propTypes = { @@ -45,6 +50,9 @@ class RootWithIntl extends React.Component { epics: PropTypes.object, logger: PropTypes.object.isRequired, clone: PropTypes.func.isRequired, + config: PropTypes.object.isRequired, + okapi: PropTypes.object.isRequired, + store: PropTypes.object.isRequired }).isRequired, token: PropTypes.string, isAuthenticated: PropTypes.bool, @@ -60,12 +68,42 @@ class RootWithIntl extends React.Component { state = { callout: null }; + handleSelectTenant = (tenant, clientId) => { + localStorage.setItem('tenant', JSON.stringify({ tenantName: tenant, clientId })); + this.props.stripes.store.dispatch(setOkapiTenant({ clientId, tenant })); + } + setCalloutRef = (ref) => { this.setState({ callout: ref, }); } + singleTenantAuthnUrl = () => { + const { okapi } = this.props.stripes; + const redirectUri = `${window.location.protocol}//${window.location.host}/oidc-landing`; + + return `${okapi.authnUrl}/realms/${okapi.tenant}/protocol/openid-connect/auth?client_id=${okapi.clientId}&response_type=code&redirect_uri=${redirectUri}&scope=openid`; + } + + renderLoginComponent() { + const { config, okapi } = this.props.stripes; + + if (okapi.authnUrl) { + if (config.isSingleTenant) { + return ; + } + return ; + } + + return ; + } + render() { const { token, @@ -77,6 +115,17 @@ class RootWithIntl extends React.Component { const connect = connectFor('@folio/core', this.props.stripes.epics, this.props.stripes.logger); const stripes = this.props.stripes.clone({ connect }); + const logoutUrl = `${stripes.okapi.authnUrl}/realms/${stripes.okapi.tenant}/protocol/openid-connect/logout?client_id=${stripes.okapi.clientId}&post_logout_redirect_uri=${window.location.protocol}//${window.location.host}`; + const LoginComponent = stripes.okapi.authnUrl ? + + : + ; + return ( @@ -115,6 +164,12 @@ class RootWithIntl extends React.Component { key="sso-landing" component={} /> + } + /> } key="sso-landing" /> + } + key="oidc-landing" + /> } /> + } + /> - } + component={this.renderLoginComponent()} /> } diff --git a/src/components/About/About.css b/src/components/About/About.css index e940a85d7..782bde274 100644 --- a/src/components/About/About.css +++ b/src/components/About/About.css @@ -23,3 +23,7 @@ .incompatible { color: orange; } + +.paddingLeftOfListItems { + padding-left: 14px; +} diff --git a/src/components/About/About.js b/src/components/About/About.js index 58de039f2..95c377acc 100644 --- a/src/components/About/About.js +++ b/src/components/About/About.js @@ -12,7 +12,6 @@ import { List, Loading } from '@folio/stripes-components'; -import AboutEnabledModules from './AboutEnabledModules'; import AboutInstallMessages from './AboutInstallMessages'; import WarningBanner from './WarningBanner'; import { withModules } from '../Modules'; @@ -101,15 +100,49 @@ const About = (props) => { ); } - const modules = _.get(props.stripes, ['discovery', 'modules']) || {}; + const applications = + _.get(props.stripes, ['discovery', 'applications']) || {}; const interfaces = _.get(props.stripes, ['discovery', 'interfaces']) || {}; const isLoadingFinished = _.get(props.stripes, ['discovery', 'isFinished']); - const nm = Object.keys(modules).length; - const ni = Object.keys(interfaces).length; - const ConnectedAboutEnabledModules = props.stripes.connect(AboutEnabledModules); + const na = Object.keys(applications).length; + const unknownMsg = ; - const numModulesMsg = ; - const numInterfacesMsg = ; + const numApplicationsMsg = ( + + ); + + const renderInterfaces = (list) => { + return ( +
  • {item.name}
  • } + /> + ); + }; + const renderModules = (list) => { + return ( + { + return ( +
  • + + {item.name} + + {renderInterfaces(item.interfaces)} +
  • + ); + }} + /> + ); + }; return ( { )}
    +
    + + + + {numApplicationsMsg} + {Object.values(applications) + .map((app) => { + return ( +
      +
    • + {app.name} + {renderModules(app.modules)} +
    • +
    + ); + })} +
    +
    @@ -165,16 +216,14 @@ const About = (props) => { ]} itemFormatter={item => (
  • {item.value}
  • )} /> +
    -
    - {Object.keys(props.modules).map(key => listModules(key, props.modules[key]))} -
    -
    -
    - Okapi + + + (
  • {item}
  • )} @@ -185,31 +234,6 @@ const About = (props) => { ]} />
    - {numModulesMsg} - - - - - {chunks} - }} - /> -
    - {numInterfacesMsg} - ( -
  • - {`${key} ${interfaces[key]}`} -
  • - )} - /> -
    -
    diff --git a/src/components/ForgotPassword/ForgotPasswordCtrl.js b/src/components/ForgotPassword/ForgotPasswordCtrl.js index fbe46e7e0..7223a007f 100644 --- a/src/components/ForgotPassword/ForgotPasswordCtrl.js +++ b/src/components/ForgotPassword/ForgotPasswordCtrl.js @@ -33,7 +33,12 @@ class ForgotPasswordCtrl extends Component { static manifest = Object.freeze({ searchUsername: { type: 'okapi', - path: 'bl-users/forgotten/password', + path: (queryParams, pathComponents, resourceData, config, props) => { + if (props.stripes.okapi.authnUrl) { + return 'users-keycloak/forgotten/password'; + } + return 'bl-users/forgotten/password'; + }, headers: { 'accept': '*/*', }, diff --git a/src/components/ForgotUserName/ForgotUserNameCtrl.js b/src/components/ForgotUserName/ForgotUserNameCtrl.js index 44ddc5717..007b09246 100644 --- a/src/components/ForgotUserName/ForgotUserNameCtrl.js +++ b/src/components/ForgotUserName/ForgotUserNameCtrl.js @@ -34,7 +34,12 @@ class ForgotUserNameCtrl extends Component { static manifest = Object.freeze({ searchUsername: { type: 'okapi', - path: 'bl-users/forgotten/username', + path: (queryParams, pathComponents, resourceData, config, props) => { + if (props.stripes.okapi.authnUrl) { + return 'users-keycloak/forgotten/username'; + } + return 'bl-users/forgotten/username'; + }, headers: { 'accept': '*/*', }, diff --git a/src/components/Login/Login.js b/src/components/Login/Login.js index 48bad369a..8bb12bf33 100644 --- a/src/components/Login/Login.js +++ b/src/components/Login/Login.js @@ -1,45 +1,72 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { FormattedMessage } from 'react-intl'; -import { Field, Form } from 'react-final-form'; - -import { branding } from 'stripes-config'; - +import { connect as reduxConnect } from 'react-redux'; import { - TextField, - Button, - Row, - Col, - Headline, -} from '@folio/stripes-components'; + withRouter, + matchPath, +} from 'react-router-dom'; -import SSOLogin from '../SSOLogin'; -import OrganizationLogo from '../OrganizationLogo'; -import AuthErrorsContainer from '../AuthErrorsContainer'; -import FieldLabel from '../CreateResetPassword/components/FieldLabel'; - -import styles from './Login.css'; +import { ConnectContext } from '@folio/stripes-connect'; +import { + requestLogin, + requestSSOLogin, +} from '../../loginServices'; +import { setAuthError } from '../../okapiActions'; -class Login extends Component { +class LoginCtrl extends Component { static propTypes = { - ssoActive: PropTypes.bool, - authErrors: PropTypes.arrayOf(PropTypes.object), - onSubmit: PropTypes.func.isRequired, - handleSSOLogin: PropTypes.func.isRequired, + 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, }; - static defaultProps = { - authErrors: [], - ssoActive: false, - }; + static contextType = ConnectContext; + + 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); + } + } + + componentWillUnmount() { + this.props.clearAuthErrors(); + } + + handleSuccessfulLogin = () => { + if (matchPath(this.props.location.pathname, '/login')) { + this.props.history.push('/'); + } + } + + 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 + }); + } + + handleSSOLogin = () => { + requestSSOLogin(this.okapiUrl, this.tenant); + } render() { - const { - authErrors, - handleSSOLogin, - ssoActive, - onSubmit, - } = this.props; + const { authFailure, ssoEnabled } = this.props; const cookieMessage = navigator.cookieEnabled ? '' : @@ -56,6 +83,7 @@ class Login 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)); diff --git a/src/components/Login/LoginForm.js b/src/components/Login/LoginForm.js new file mode 100644 index 000000000..2dcab4c46 --- /dev/null +++ b/src/components/Login/LoginForm.js @@ -0,0 +1,228 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { Field, Form } from 'react-final-form'; + +import { branding } from 'stripes-config'; + +import { + TextField, + Button, + Row, + Col, + Headline, +} from '@folio/stripes-components'; + +import SSOLogin from '../SSOLogin'; +import OrganizationLogo from '../OrganizationLogo'; +import AuthErrorsContainer from '../AuthErrorsContainer'; +import FieldLabel from '../CreateResetPassword/components/FieldLabel'; + +import styles from './Login.css'; + +class LoginForm extends Component { + static propTypes = { + ssoActive: PropTypes.bool, + authErrors: PropTypes.arrayOf(PropTypes.object), + onSubmit: PropTypes.func.isRequired, + handleSSOLogin: PropTypes.func.isRequired, + }; + + static defaultProps = { + authErrors: [], + ssoActive: false, + }; + + render() { + const { + authErrors, + handleSSOLogin, + ssoActive, + onSubmit, + } = this.props; + + return ( + { + const { username } = values; + const submissionStatus = submitting || submitSucceeded; + const buttonDisabled = submissionStatus || !(username); + const buttonLabel = submissionStatus ? 'loggingIn' : 'login'; + return ( +
    +
    +
    + + + + + + + handleSubmit(data).then(() => form.change('password', undefined))} + > + + +
    + {ssoActive && } +
    + +
    + + + + + + + +
    + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + + + + + + + + + +
    + + +
    + +
    + +
    + + + + + + + + + + + + + + +
    + +
    + +
    + + {ssoActive &&
    } + +
    +
    +
    + ); + }} + /> + ); + } +} + +export default LoginForm; diff --git a/src/components/Login/index.js b/src/components/Login/index.js index 44e798405..2a741cdbd 100644 --- a/src/components/Login/index.js +++ b/src/components/Login/index.js @@ -1 +1 @@ -export { default } from './LoginCtrl'; +export { default } from './Login'; diff --git a/src/components/MainNav/MainNav.js b/src/components/MainNav/MainNav.js index d1b7a6e07..1b4ac4780 100644 --- a/src/components/MainNav/MainNav.js +++ b/src/components/MainNav/MainNav.js @@ -120,8 +120,13 @@ class MainNav extends Component { returnToLogin() { const { okapi } = this.store.getState(); - return getLocale(okapi.url, this.store, okapi.tenant) - .then(sessionLogout(okapi.url, this.store)); + 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 the user to the login screen, but after logging in they will be brought to the default screen. @@ -130,7 +135,7 @@ class MainNav extends Component { console.clear(); // eslint-disable-line no-console } this.returnToLogin().then(() => { - this.props.history.push('/'); + this.props.history.push('/logout'); }); } diff --git a/src/components/OIDCLanding.js b/src/components/OIDCLanding.js new file mode 100644 index 000000000..4f1a716ef --- /dev/null +++ b/src/components/OIDCLanding.js @@ -0,0 +1,111 @@ +import { debounce } from 'lodash'; +import React, { useEffect, useRef } from 'react'; +import { useLocation, Redirect } from 'react-router-dom'; +import queryString from 'query-string'; +import { useStore } from 'react-redux'; +import { FormattedMessage } from 'react-intl'; + +import { Loading } from '@folio/stripes-components'; + +import { requestUserWithPerms } from '../loginServices'; + +import css from './Front.css'; +import { useStripes } from '../StripesContext'; + +const requestUserWithPermsDeb = debounce(requestUserWithPerms, 5000, { leading: true, trailing: false }); + +/** + * OIDCLanding: un-authenticated route handler for /sso-landing. + * + * Reads one-time-code from URL params, exchanging it for an access_token + * and then leveraging that to retrieve a user via requestUserWithPerms, + * eventually dispatching session and Okapi-ready, resulting in a + * re-render with a token present, i.e., authenticated. + * + * @see RootWithIntl + */ +const OIDCLanding = () => { + const location = useLocation(); + const store = useStore(); + const samlError = useRef(); + const { okapi } = useStripes(); + + const getParams = () => { + const search = location.search; + if (!search) return undefined; + return queryString.parse(search) || {}; + }; + + /** + * retrieve the OTP + * @returns {string} + */ + const getOtp = () => { + return getParams()?.code; + }; + + const otp = getOtp(); + + /** + * Exchange the otp for an access token, then use it to 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 + * to exist since the workflow is the same for SSO. We can just inspect + * the response for SSO-y values or SAML-y values and act accordingly. + */ + useEffect(() => { + if (otp) { + fetch(`${okapi.url}/authn/token?code=${otp}&redirect-uri=${window.location.protocol}//${window.location.host}/oidc-landing`, { + headers: { 'X-Okapi-tenant': okapi.tenant, 'Content-Type': 'application/json' }, + }) + .then((resp) => { + if (resp.ok) { + return resp.json().then((json) => { + return requestUserWithPermsDeb(okapi.url, store, okapi.tenant, json.okapiToken); + }); + } else { + return resp.json().then((error) => { + throw error; + }); + } + }) + .catch(e => { + console.error('@@ Oh, snap, OTP exchange failed!', e); + samlError.current = e; + }); + } + }, [otp, store]); + + if (!otp) { + return ( +
    +
    + +
    +
    + + {JSON.stringify(samlError.current, null, 2)} + +
    + +
    + ); + } + + return ( +
    +
    + +
    +
    +
    +          {JSON.stringify(samlError.current, null, 2)}
    +        
    +
    +
    + ); +}; + +export default OIDCLanding; diff --git a/src/components/OIDCRedirect.js b/src/components/OIDCRedirect.js new file mode 100644 index 000000000..3e6585de2 --- /dev/null +++ b/src/components/OIDCRedirect.js @@ -0,0 +1,32 @@ +import { withRouter, Redirect, useLocation } from 'react-router'; +import queryString from 'query-string'; + +/** + * OIDCRedirect authenticated route handler for /oidc-landing. + * + * Reads `fwd` from URL params and redirects. + * + * @see RootWithIntl + * + * @returns {Redirect} + */ +const OIDCRedirect = () => { + const location = useLocation(); + + const getParams = () => { + const search = location.search; + if (!search) return undefined; + return queryString.parse(search) || {}; + }; + + const getUrl = () => { + const params = getParams(); + return params?.fwd ?? ''; + }; + + return ( + + ); +}; + +export default withRouter(OIDCRedirect); diff --git a/src/components/PreLoginLanding/PreLoginLanding.js b/src/components/PreLoginLanding/PreLoginLanding.js new file mode 100644 index 000000000..eb47719f1 --- /dev/null +++ b/src/components/PreLoginLanding/PreLoginLanding.js @@ -0,0 +1,71 @@ +import React from 'react'; +import { Button, Select, Col, Row } from '@folio/stripes-components'; +import { useIntl } from 'react-intl'; +import PropTypes from 'prop-types'; +import { OrganizationLogo } from '../index'; +import styles from './index.css'; +import { useStripes } from '../../StripesContext'; + +function PreLoginLanding({ onSelectTenant }) { + const intl = useIntl(); + const { okapi, config: { tenantOptions = {} } } = useStripes(); + + const redirectUri = `${window.location.protocol}//${window.location.host}/oidc-landing`; + const options = Object.keys(tenantOptions).map(tenantName => ({ value: tenantName, label: tenantName })); + + const getLoginUrl = () => { + if (!okapi.tenant) return ''; + if (okapi.authnUrl) { + return `${okapi.authnUrl}/realms/${okapi.tenant}/protocol/openid-connect/auth?client_id=${okapi.clientId}&response_type=code&redirect_uri=${redirectUri}&scope=openid`; + } + return ''; + }; + + const handleChangeTenant = (e) => { + const tenantName = e.target.value; + if (tenantName === '') { + onSelectTenant('', ''); + return; + } + const clientId = tenantOptions[tenantName].clientId; + onSelectTenant(tenantName, clientId); + }; + + return ( +
    +
    +
    + + + + + + + +