Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion src/components/security/__tests__/methods.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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 });
});
});
30 changes: 30 additions & 0 deletions src/components/security/methods.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 = () =>
_global[AUTH_HANDLERS_KEY] || { initLogOut: null, authErrorHandler: null };

export const setAuthHandlers = ({ initLogOut, authErrorHandler } = {}) => {
_global[AUTH_HANDLERS_KEY] = {
initLogOut: typeof initLogOut === 'function' ? initLogOut : null,
authErrorHandler: typeof authErrorHandler === 'function' ? authErrorHandler : null,
};
};

export const getAuthHandlers = () => ({ ...readAuthHandlers() });

/**
* @returns {Promise<*|undefined>}
*/
Expand Down Expand Up @@ -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());
}
Expand Down
79 changes: 79 additions & 0 deletions src/utils/__tests__/actions.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
});
});
});
});
8 changes: 7 additions & 1 deletion src/utils/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
Loading