From bedbe00e0871eb53a356b84853b759bc689098f2 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Sat, 29 Aug 2026 21:44:12 -0300 Subject: [PATCH 1/2] feat(auth): let a consumer replace initLogOut and the 401/403 handling setAuthHandlers registers optional replacements: initLogOut for the IDP end-session redirect, authErrorHandler for the 401 login / 403 logout paths. The two keys are independent, and without handlers uicore keeps its built-in behavior. The handlers live on globalThis under Symbol.for('openstack-uicore-foundation.authHandlers'), so every copy of the module shares them. --- .../security/__tests__/methods.test.js | 66 +++++++++++++++- src/components/security/methods.js | 30 +++++++ src/utils/__tests__/actions.test.js | 79 +++++++++++++++++++ src/utils/actions.js | 8 +- 4 files changed, 181 insertions(+), 2 deletions(-) diff --git a/src/components/security/__tests__/methods.test.js b/src/components/security/__tests__/methods.test.js index 3fef8ea0..0a00d16d 100644 --- a/src/components/security/__tests__/methods.test.js +++ b/src/components/security/__tests__/methods.test.js @@ -3,7 +3,7 @@ import { AUTH_ERROR_REFRESH_TOKEN_NETWORK_ERROR, } from '../constants'; -import { refreshAccessToken, retryWithBackoff, getAccessToken, setAccessTokenResolver } from '../methods'; +import { refreshAccessToken, retryWithBackoff, getAccessToken, setAccessTokenResolver, setAuthHandlers, getAuthHandlers, initLogOut } from '../methods'; // Mock utils/methods imports used by security/methods jest.mock('../../../utils/methods', () => ({ @@ -412,3 +412,67 @@ describe('setAccessTokenResolver / getAccessToken', () => { expect(resolver).toHaveBeenCalledTimes(1); }); }); + +describe('setAuthHandlers / initLogOut', () => { + afterEach(() => setAuthHandlers()); + + it('initLogOut calls the injected initLogOut and does not redirect', () => { + const injected = jest.fn(); + const { getCurrentLocation } = require('../../../utils/methods'); + const replace = jest.fn(); + getCurrentLocation.mockReturnValue({ replace }); + setAuthHandlers({ initLogOut: injected }); + + initLogOut(); + + expect(injected).toHaveBeenCalledTimes(1); + expect(replace).not.toHaveBeenCalled(); + }); + + it('without an injected initLogOut the built-in redirect runs', () => { + const { getCurrentLocation } = require('../../../utils/methods'); + const replace = jest.fn(); + getCurrentLocation.mockReturnValue({ replace }); + + initLogOut(); + + expect(replace).toHaveBeenCalledTimes(1); + }); + + it('injecting only authErrorHandler leaves initLogOut on the built-in redirect', () => { + const { getCurrentLocation } = require('../../../utils/methods'); + const replace = jest.fn(); + getCurrentLocation.mockReturnValue({ replace }); + setAuthHandlers({ authErrorHandler: jest.fn() }); + + initLogOut(); + + expect(replace).toHaveBeenCalledTimes(1); + }); + + it('getAuthHandlers returns a copy', () => { + setAuthHandlers({ initLogOut: jest.fn() }); + getAuthHandlers().initLogOut = 'mutated'; + expect(typeof getAuthHandlers().initLogOut).toBe('function'); + }); + + it('handlers registered on one module copy are visible to a second copy', () => { + // The handlers ride globalThis under Symbol.for, so duplicate installs + // of the package share them. + setAuthHandlers({ initLogOut: jest.fn() }); + let secondCopy; + jest.isolateModules(() => { + secondCopy = require('../methods'); + }); + expect(typeof secondCopy.getAuthHandlers().initLogOut).toBe('function'); + expect(secondCopy.getAuthHandlers().authErrorHandler).toBe(null); + }); + + it('non-function values are ignored and setAuthHandlers() clears both', () => { + setAuthHandlers({ initLogOut: 'nope', authErrorHandler: jest.fn() }); + expect(getAuthHandlers().initLogOut).toBe(null); + expect(typeof getAuthHandlers().authErrorHandler).toBe('function'); + setAuthHandlers(); + expect(getAuthHandlers()).toEqual({ initLogOut: null, authErrorHandler: null }); + }); +}); diff --git a/src/components/security/methods.js b/src/components/security/methods.js index 729a2772..2ea04c02 100644 --- a/src/components/security/methods.js +++ b/src/components/security/methods.js @@ -373,6 +373,31 @@ export const setAccessTokenResolver = (resolver) => { _global[ACCESS_TOKEN_RESOLVER_KEY] = typeof resolver === 'function' ? resolver : null; }; +/** + * Optional handlers set via setAuthHandlers. When present, initLogOut and + * authErrorHandler defer to them instead of redirecting the browser: + * initLogOut() replaces the IDP end-session redirect + * authErrorHandler({ status }) replaces the 401 login / 403 logout paths + * The two keys are independent: with only initLogOut set, a 403 still shows + * the built-in dialog and then calls the injected logout. Without handlers + * uicore keeps its built-in behavior. Call with no argument to clear both. + * The handlers live on globalThis under a Symbol.for key so every copy of + * this module shares them. + */ +const AUTH_HANDLERS_KEY = Symbol.for('openstack-uicore-foundation.authHandlers'); + +const readAuthHandlers = () => + globalThis[AUTH_HANDLERS_KEY] || { initLogOut: null, authErrorHandler: null }; + +export const setAuthHandlers = ({ initLogOut, authErrorHandler } = {}) => { + globalThis[AUTH_HANDLERS_KEY] = { + initLogOut: typeof initLogOut === 'function' ? initLogOut : null, + authErrorHandler: typeof authErrorHandler === 'function' ? authErrorHandler : null, + }; +}; + +export const getAuthHandlers = () => ({ ...readAuthHandlers() }); + /** * @returns {Promise<*|undefined>} */ @@ -605,6 +630,11 @@ export const getOAuth2Scopes = () => { }; export const initLogOut = () => { + const { initLogOut: injectedLogOut } = readAuthHandlers(); + if (injectedLogOut) { + injectedLogOut(); + return; + } let location = getCurrentLocation(); location.replace(getLogoutUrl(getIdToken()).toString()); } diff --git a/src/utils/__tests__/actions.test.js b/src/utils/__tests__/actions.test.js index 581ed715..5dd17eb4 100644 --- a/src/utils/__tests__/actions.test.js +++ b/src/utils/__tests__/actions.test.js @@ -1,4 +1,5 @@ import {authErrorHandler} from "../actions"; +import {setAuthHandlers} from "../../components/security/methods"; import configureMockStore from 'redux-mock-store'; import request from 'superagent/lib/client'; import Swal from 'sweetalert2'; @@ -85,5 +86,83 @@ describe('Utils Actions', () => { store.dispatch(authErrorHandler({ status:403 })); expect(Swal.fire).toBeCalled(); }); + + describe('with injected auth handlers', () => { + beforeEach(() => jest.clearAllMocks()); + afterEach(() => setAuthHandlers()); + + test('401 calls the injected handler and does not start a login', () => { + console.log = jest.fn(); + const injected = jest.fn(); + setAuthHandlers({ authErrorHandler: injected }); + + store.dispatch(authErrorHandler({ status:401 })); + + expect(injected).toHaveBeenCalledWith({ status: 401 }); + expect(console.log).not.toHaveBeenCalledWith("authErrorHandler 401 - re login"); + expect(store.getActions()).toEqual([{ type: 'STOP_LOADING' }]); + }); + + test('403 calls the injected handler and does not show the logout dialog', () => { + const injected = jest.fn(); + setAuthHandlers({ authErrorHandler: injected }); + + store.dispatch(authErrorHandler({ status:403 })); + + expect(injected).toHaveBeenCalledWith({ status: 403 }); + expect(injected).toHaveBeenCalledTimes(1); + expect(Swal.fire).not.toBeCalled(); + expect(store.getActions()).toEqual([{ type: 'STOP_LOADING' }]); + }); + + test('401 with a custom notifier still goes to the injected handler, not the notifier', () => { + const injected = jest.fn(); + const notifier = jest.fn(() => () => {}); + setAuthHandlers({ authErrorHandler: injected }); + + store.dispatch(authErrorHandler({ status:401 }, {}, notifier)); + + expect(injected).toHaveBeenCalledWith({ status: 401 }); + expect(notifier).not.toHaveBeenCalled(); + }); + + test('other status codes keep the built-in path while handlers are set', () => { + const injected = jest.fn(); + setAuthHandlers({ authErrorHandler: injected, initLogOut: jest.fn() }); + + store.dispatch(authErrorHandler({ status:404, response: { body: { message: 'gone' } } })); + store.dispatch(authErrorHandler({ status:500, response: {} })); + + expect(injected).not.toHaveBeenCalled(); + expect(Swal.fire).toHaveBeenCalledTimes(2); + }); + + test('403 with only initLogOut injected shows the built-in dialog, then calls the injected logout', async () => { + const injectedLogout = jest.fn(); + setAuthHandlers({ initLogOut: injectedLogout }); + windowSpy.mockImplementation(() => ({ + location: { href: 'https://example.com', pathname:'/', replace: () => {} }, + localStorage: localStorageMock + })); + + store.dispatch(authErrorHandler({ status:403 })); + await Promise.resolve(); + + expect(Swal.fire).toHaveBeenCalledTimes(1); + expect(injectedLogout).toHaveBeenCalledTimes(1); + }); + + test('setAuthHandlers() restores the built-in 403 dialog', () => { + setAuthHandlers({ authErrorHandler: jest.fn() }); + setAuthHandlers(); + windowSpy.mockImplementation(() => ({ + location: { href: 'https://example.com', pathname:'/', replace: () => {} }, + localStorage: localStorageMock + })); + + store.dispatch(authErrorHandler({ status:403 })); + expect(Swal.fire).toBeCalled(); + }); + }); }); }); diff --git a/src/utils/actions.js b/src/utils/actions.js index 1d7ea724..98479909 100644 --- a/src/utils/actions.js +++ b/src/utils/actions.js @@ -20,7 +20,7 @@ import Swal from 'sweetalert2'; import T from "i18n-react/dist/i18n-react"; import { isClearingSessionState, setSessionClearingState, getCurrentPathName } from './methods'; import { CLEAR_SESSION_STATE } from '../components/security/actions'; -import { doLogin, initLogOut } from '../components/security/methods'; +import { doLogin, initLogOut, getAuthHandlers } from '../components/security/methods'; export const GENERIC_ERROR = "Yikes. Something seems to be broken. Our web team has been notified, and we apologize for the inconvenience."; export const RESET_LOADING = 'RESET_LOADING'; @@ -104,6 +104,12 @@ export const authErrorHandler = ( dispatch(stopLoading()); + const { authErrorHandler: injected } = getAuthHandlers(); + if ((code === 401 || code === 403) && injected) { + injected({ status: code }); + return; + } + switch (code) { case 401: if (notifyErrorHandler !== showMessage) { From d0e9d690a1de0f9a9286dbe93a9f64ecbecf0bc4 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Wed, 7 Oct 2026 00:01:23 -0300 Subject: [PATCH 2/2] fix(auth): read auth handlers through the guarded global accessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setAuthHandlers and readAuthHandlers used bare globalThis. They now use the _global fallback (globalThis → window → {}) that the resolver already uses, so initLogOut keeps its built-in redirect on a runtime without globalThis. --- src/components/security/methods.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/security/methods.js b/src/components/security/methods.js index 2ea04c02..defd807a 100644 --- a/src/components/security/methods.js +++ b/src/components/security/methods.js @@ -381,16 +381,16 @@ export const setAccessTokenResolver = (resolver) => { * The two keys are independent: with only initLogOut set, a 403 still shows * the built-in dialog and then calls the injected logout. Without handlers * uicore keeps its built-in behavior. Call with no argument to clear both. - * The handlers live on globalThis under a Symbol.for key so every copy of + * The handlers live on a global under a Symbol.for key so every copy of * this module shares them. */ const AUTH_HANDLERS_KEY = Symbol.for('openstack-uicore-foundation.authHandlers'); const readAuthHandlers = () => - globalThis[AUTH_HANDLERS_KEY] || { initLogOut: null, authErrorHandler: null }; + _global[AUTH_HANDLERS_KEY] || { initLogOut: null, authErrorHandler: null }; export const setAuthHandlers = ({ initLogOut, authErrorHandler } = {}) => { - globalThis[AUTH_HANDLERS_KEY] = { + _global[AUTH_HANDLERS_KEY] = { initLogOut: typeof initLogOut === 'function' ? initLogOut : null, authErrorHandler: typeof authErrorHandler === 'function' ? authErrorHandler : null, };