diff --git a/CHANGELOG.md b/CHANGELOG.md index 492b0fe96..37538e117 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ * Idle-session timeout and "Keep working?" modal. Refs STCOR-776. * Use keycloak URLs in place of users-bl for tenant-switch. Refs US1153537. * Fix 404 error page when logging in after changing password in Eureka. Refs STCOR-845. +* Always retrieve `clientId` and `tenant` values from `config.tenantOptions` in stripes.config.js. Retires `okapi.tenant`, `okapi.clientId`, and `config.isSingleTenant`. Refs STCOR-787. ## [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) diff --git a/src/RootWithIntl.test.js b/src/RootWithIntl.test.js index 11f6634e0..4da7f32b9 100644 --- a/src/RootWithIntl.test.js +++ b/src/RootWithIntl.test.js @@ -22,10 +22,21 @@ jest.mock('./components/Redirect', () => () => ''); jest.mock('./components/Login', () => () => ''); jest.mock('./components/PreLoginLanding', () => () => ''); +const store = { + getState: () => ({ + okapi: { + token: '123', + }, + }), + dispatch: () => {}, + subscribe: () => {}, + replaceReducer: () => {}, +}; + describe('RootWithIntl', () => { describe('AuthnLogin', () => { it('handles legacy login', () => { - const stripes = { okapi: {}, config: {} }; + const stripes = { okapi: {}, config: {}, store }; render(); expect(screen.getByText(//)).toBeInTheDocument(); @@ -35,7 +46,13 @@ describe('RootWithIntl', () => { it('handles single-tenant', () => { const stripes = { okapi: { authnUrl: 'https://barbie.com' }, - config: { isSingleTenant: true } + config: { + isSingleTenant: true, + tenantOptions: { + diku: { name: 'diku', clientId: 'diku-application' } + } + }, + store }; render(); @@ -45,7 +62,14 @@ describe('RootWithIntl', () => { it('handles multi-tenant', () => { const stripes = { okapi: { authnUrl: 'https://oppie.com' }, - config: { }, + config: { + isSingleTenant: false, + tenantOptions: { + diku: { name: 'diku', clientId: 'diku-application' }, + diku2: { name: 'diku2', clientId: 'diku2-application' } + } + }, + store }; render(); diff --git a/src/Stripes.js b/src/Stripes.js index 3d7413354..5f91fe238 100644 --- a/src/Stripes.js +++ b/src/Stripes.js @@ -23,6 +23,7 @@ export const stripesShape = PropTypes.shape({ logTimestamp: PropTypes.bool, showHomeLink: PropTypes.bool, showPerms: PropTypes.bool, + tenantOptions: PropTypes.object, }).isRequired, connect: PropTypes.func.isRequired, currency: PropTypes.string, diff --git a/src/components/AuthnLogin/AuthnLogin.js b/src/components/AuthnLogin/AuthnLogin.js index 136ea8136..4ae5a020d 100644 --- a/src/components/AuthnLogin/AuthnLogin.js +++ b/src/components/AuthnLogin/AuthnLogin.js @@ -9,6 +9,14 @@ import { setUnauthorizedPathToSession } from '../../loginServices'; const AuthnLogin = ({ stripes }) => { const { config, okapi } = stripes; + // If config.tenantOptions is not defined, default to classic okapi.tenant and okapi.clientId + const { tenantOptions = [{ name: okapi.tenant, clientId: okapi.clientId }] } = config; + const tenants = Object.values(tenantOptions); + + const setTenant = (tenant, clientId) => { + localStorage.setItem('tenant', JSON.stringify({ tenantName: tenant, clientId })); + stripes.store.dispatch(setOkapiTenant({ tenant, clientId })); + }; useEffect(() => { if (okapi.authnUrl) { @@ -17,23 +25,26 @@ const AuthnLogin = ({ stripes }) => { */ setUnauthorizedPathToSession(window.location.pathname); } + + // If only 1 tenant is defined in config (in either okapi or config.tenantOptions) set to okapi to be accessed there + // in the rest of the application for compatibity across existing modules. + if (tenants.length === 1) { + const loginTenant = tenants[0]; + setTenant(loginTenant.name, loginTenant.clientId); + } // we only want to run this effect once, on load. - // okapi.authnUrl are defined in stripes.config.js + // okapi.authnUrl tenant values are defined in stripes.config.js }, []); // eslint-disable-line react-hooks/exhaustive-deps if (okapi.authnUrl) { - if (config.isSingleTenant) { + // If only 1 tenant is defined in config, skip the tenant selection screen. + if (tenants.length === 1) { const redirectUri = `${window.location.protocol}//${window.location.host}/oidc-landing`; const authnUri = `${okapi.authnUrl}/realms/${okapi.tenant}/protocol/openid-connect/auth?client_id=${okapi.clientId}&response_type=code&redirect_uri=${redirectUri}&scope=openid`; return ; } - const handleSelectTenant = (tenant, clientId) => { - localStorage.setItem('tenant', JSON.stringify({ tenantName: tenant, clientId })); - stripes.store.dispatch(setOkapiTenant({ tenant, clientId })); - }; - - return ; + return ; } return { const store = useStore(); // const samlError = useRef(); const { okapi } = useStripes(); - const [potp, setPotp] = useState(); const [samlError, setSamlError] = useState(); diff --git a/src/components/OIDCLanding.test.js b/src/components/OIDCLanding.test.js new file mode 100644 index 000000000..f7e199c50 --- /dev/null +++ b/src/components/OIDCLanding.test.js @@ -0,0 +1,85 @@ +import { render, screen, waitFor } from '@folio/jest-config-stripes/testing-library/react'; + +import OIDCLanding from './OIDCLanding'; + +jest.mock('react-router-dom', () => ({ + useLocation: () => ({ + search: 'session_state=dead-beef&code=c0ffee' + }), + Redirect: () => <>Redirect, +})); + +jest.mock('react-redux', () => ({ + useStore: () => { }, +})); + +jest.mock('../StripesContext', () => ({ + useStripes: () => ({ + okapi: { url: 'https://whaterver' }, + config: { tenantOptions: { diku: { name: 'diku', clientId: 'diku-application' } } }, + }), +})); + +// jest.mock('../loginServices'); + + +const mockSetTokenExpiry = jest.fn(); +const mockRequestUserWithPerms = jest.fn(); +const mockFoo = jest.fn(); +jest.mock('../loginServices', () => ({ + setTokenExpiry: () => mockSetTokenExpiry(), + requestUserWithPerms: () => mockRequestUserWithPerms(), + foo: () => mockFoo(), +})); + + +// fetch success: resolve promise with ok == true and $data in json() +const mockFetchSuccess = (data) => { + global.fetch = jest.fn().mockImplementation(() => ( + Promise.resolve({ + ok: true, + json: () => Promise.resolve(data), + headers: new Map(), + }) + )); +}; + +// fetch failure: resolve promise with ok == false and $error in json() +const mockFetchError = (error) => { + global.fetch = jest.fn().mockImplementation(() => ( + Promise.resolve({ + ok: false, + json: () => Promise.resolve(error), + headers: new Map(), + }) + )); +}; + +// restore default fetch impl +const mockFetchCleanUp = () => { + global.fetch.mockClear(); + delete global.fetch; +}; + +describe('OIDCLanding', () => { + it('calls requestUserWithPerms, setTokenExpiry on success', async () => { + mockFetchSuccess({ + accessTokenExpiration: '2024-05-23T09:47:17.000-04:00', + refreshTokenExpiration: '2024-05-23T10:07:17.000-04:00', + }); + + await render(); + screen.getByText('Loading'); + await waitFor(() => expect(mockSetTokenExpiry).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mockRequestUserWithPerms).toHaveBeenCalledTimes(1)); + mockFetchCleanUp(); + }); + + it('displays an error on failure', async () => { + mockFetchError('barf'); + + await render(); + await screen.findByText('errors.saml.missingToken'); + mockFetchCleanUp(); + }); +}); diff --git a/src/components/OIDCRedirect.test.js b/src/components/OIDCRedirect.test.js index 3b9c83c3a..48c8a0c4d 100644 --- a/src/components/OIDCRedirect.test.js +++ b/src/components/OIDCRedirect.test.js @@ -25,7 +25,7 @@ describe('OIDCRedirect', () => { afterAll(() => sessionStorage.removeItem('unauthorized_path')); it('redirects to value from session storage under unauthorized_path key', () => { - useStripes.mockReturnValue({ okapi:{ authnUrl: 'http://example.com/authn' } }); + useStripes.mockReturnValue({ okapi: { authnUrl: 'http://example.com/authn' } }); render(); expect(screen.getByText(/internalredirect/)).toBeInTheDocument(); diff --git a/src/loginServices.js b/src/loginServices.js index d3bb2261e..35fa559c1 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -778,7 +778,7 @@ export function validateUser(okapiUrl, store, tenant, session) { token, tokenExpiration, })); - return loadResources(store, sessionTenant, user.id); + return loadResources(store, sessionTenant, user?.id ?? data.user?.id); }); } else { store.dispatch(clearCurrentUser());