From 0447694604763b1511a0dd4d79a632bb6d566f01 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Mon, 24 Aug 2026 14:25:54 -0300 Subject: [PATCH 1/6] feat(security): let a consumer supply the access token via setAccessTokenResolver Add setAccessTokenResolver: when a resolver is registered, getAccessToken delegates to it; otherwise the built-in flow is unchanged. Passing a non-function resets to the built-in. The resolver is module-level state, so externalize security/methods in the webpack build so every uicore lib entry shares one instance and sees the registered resolver. --- src/components/security/methods.js | 13 +++++++++++++ webpack.common.js | 25 +++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/components/security/methods.js b/src/components/security/methods.js index 95339055..b81b754a 100644 --- a/src/components/security/methods.js +++ b/src/components/security/methods.js @@ -342,10 +342,23 @@ const _getAccessToken = async () => { return accessToken; } +/** + * Optional resolver for getAccessToken, set via setAccessTokenResolver. When + * present, getAccessToken delegates to it; otherwise the built-in flow runs. + * Pass a non-function (or nothing) to reset to the built-in. + */ +let _resolveAccessToken = null; + +export const setAccessTokenResolver = (resolver) => { + _resolveAccessToken = typeof resolver === 'function' ? resolver : null; +}; + /** * @returns {Promise<*|undefined>} */ export const getAccessToken = async () => { + if (_resolveAccessToken) return _resolveAccessToken(); + if (typeof navigator !== 'undefined' && navigator.locks) { return await navigator.locks.request(GET_TOKEN_SILENTLY_LOCK_KEY, async lock => { console.log(`openstack-uicore-foundation::Security::methods::getAccessToken web lock api`, lock); diff --git a/webpack.common.js b/webpack.common.js index 550ffe4f..d73aa003 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -2,6 +2,27 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const path = require('path'); const nodeExternals = require('webpack-node-externals'); +const { name: PKG_NAME } = require('./package.json'); + +// Externalize uicore's OWN token module so every UMD entry shares one instance: +// methods.js is stateful and must be a singleton. Without this, each importer +// (e.g. query-actions) inlines its own copy with separate state. Other internal +// modules are stateless, so they stay inlined. +const METHODS_SRC = path.resolve(__dirname, 'src/components/security/methods.js'); +const METHODS_MODULE = `${PKG_NAME}/lib/security/methods`; + +// Redirect imports of methods.js to the shared lib file. The issuer guard skips +// the methods entry itself (an entry has no issuer), so it still builds its real +// implementation instead of requiring itself. +const shareSecurityMethods = ({ request, context, contextInfo }, cb) => { + if (!contextInfo || !contextInfo.issuer || !request.startsWith('.')) return cb(); + const resolved = path.resolve(context, request); + if (resolved === METHODS_SRC || `${resolved}.js` === METHODS_SRC) { + return cb(null, METHODS_MODULE); + } + cb(); +}; + module.exports = { entry: { // security @@ -98,7 +119,7 @@ module.exports = { output: { path: path.resolve(__dirname, 'lib'), filename: '[name].js', - library: 'openstack-uicore-foundation', + library: PKG_NAME, libraryTarget: 'umd', umdNamedDefine: true, globalObject: 'this', @@ -191,5 +212,5 @@ module.exports = { } ] }, - externals: [nodeExternals()] + externals: [nodeExternals(), shareSecurityMethods] }; From 63e4c514eb98bd628e35b055c81024a6a84eac3e Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Mon, 24 Aug 2026 14:33:47 -0300 Subject: [PATCH 2/6] test(security): cover setAccessTokenResolver / getAccessToken delegation Delegates to a registered resolver, a later resolver replaces the previous one, and a non-function argument clears it. --- .../security/__tests__/methods.test.js | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/components/security/__tests__/methods.test.js b/src/components/security/__tests__/methods.test.js index f1e6089a..79537f4f 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 } from '../methods'; +import { refreshAccessToken, retryWithBackoff, getAccessToken, setAccessTokenResolver } from '../methods'; // Mock utils/methods imports used by security/methods jest.mock('../../../utils/methods', () => ({ @@ -370,3 +370,30 @@ describe('retryWithBackoff', () => { setTimeoutSpy.mockRestore(); }); }); + +describe('setAccessTokenResolver / getAccessToken', () => { + afterEach(() => setAccessTokenResolver(null)); + + it('delegates to a registered resolver', async () => { + const resolver = jest.fn().mockResolvedValue('tok-A'); + setAccessTokenResolver(resolver); + await expect(getAccessToken()).resolves.toBe('tok-A'); + expect(resolver).toHaveBeenCalledTimes(1); + }); + + it('a later resolver replaces the previous one', async () => { + setAccessTokenResolver(jest.fn().mockResolvedValue('tok-A')); + const next = jest.fn().mockResolvedValue('tok-B'); + setAccessTokenResolver(next); + await expect(getAccessToken()).resolves.toBe('tok-B'); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('a non-function argument clears the resolver (built-in flow runs)', async () => { + const resolver = jest.fn().mockResolvedValue('tok-A'); + setAccessTokenResolver(resolver); + setAccessTokenResolver(undefined); + await getAccessToken().catch(() => {}); + expect(resolver).not.toHaveBeenCalled(); + }); +}); From cbd841dc8e4bbafa06e83ad603934a0d5089dc60 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Sat, 29 Aug 2026 15:26:42 -0300 Subject: [PATCH 3/6] fix(query-actions): report a token failure only through the callback _fetch rejected its promise after already calling back with the error. The query* functions never await that promise, so the rejection only surfaced as an unhandled rejection. Return instead. Also guard the 404 branch so a missing response or callback cannot throw. --- src/utils/query-actions.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/utils/query-actions.js b/src/utils/query-actions.js index 18fc0bb0..f52012b7 100644 --- a/src/utils/query-actions.js +++ b/src/utils/query-actions.js @@ -29,8 +29,8 @@ const _fetchPublic = async (endpoint, callback, options = {}) => { callback(json.data); }) .catch(response => { - const code = response.status; - if (code === 404) callback([]); + const code = response && response.status; + if (code === 404 && typeof callback === 'function') callback([]); return response; }) .catch(fetchErrorHandler); @@ -50,9 +50,12 @@ const _fetch = async (endpoint, callback, options = {}) => { try { accessToken = await getAccessToken(); } catch (e) { + // The caller is told through its callback; the query* functions do not + // await this promise, so rejecting here would only surface as an + // unhandled rejection. if(typeof callback === 'function') callback(e); - return Promise.reject(); + return; } endpoint.addQuery('access_token', accessToken); From 5ecb4c7395cdc57445b244fb4b2669aea292adbd Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Sat, 29 Aug 2026 15:26:42 -0300 Subject: [PATCH 4/6] test(security): cover the access-token resolver at its internal callers With a resolver registered, query-actions, getUserInfo and AttendanceTracker send the resolver's token, and a resolver failure in query-actions reaches the caller's callback without a request. --- .../__tests__/attendance-tracker.test.js | 56 +++++++++++++++++ .../security/__tests__/get-user-info.test.js | 50 ++++++++++++++++ src/utils/__tests__/query-actions.test.js | 60 +++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 src/components/__tests__/attendance-tracker.test.js create mode 100644 src/components/security/__tests__/get-user-info.test.js create mode 100644 src/utils/__tests__/query-actions.test.js diff --git a/src/components/__tests__/attendance-tracker.test.js b/src/components/__tests__/attendance-tracker.test.js new file mode 100644 index 00000000..e41d7b60 --- /dev/null +++ b/src/components/__tests__/attendance-tracker.test.js @@ -0,0 +1,56 @@ +/** + * AttendanceTracker reads the access token through security/methods.getAccessToken, + * so a registered resolver must be what the metrics calls send. + * superagent is mocked; the token path is real. + */ +import React from "react"; +import Enzyme, { mount } from "enzyme"; +import Adapter from "enzyme-adapter-react-16"; +import http from "superagent/lib/client"; +import { setAccessTokenResolver } from "../security/methods"; +import AttendanceTracker from "../attendance-tracker"; + +Enzyme.configure({ adapter: new Adapter() }); + +jest.mock("superagent/lib/client", () => { + const end = jest.fn(); + const send = jest.fn(() => ({ end })); + return { put: jest.fn(() => ({ send })), post: jest.fn(() => ({ send })), __send: send }; +}); + +const flush = async () => { + for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0)); +}; + +describe("AttendanceTracker with an access-token resolver", () => { + const props = { apiBaseUrl: "https://api.test", summitId: 13, sourceId: 7, sourceName: "EVENT" }; + + beforeEach(() => { + jest.clearAllMocks(); + navigator.sendBeacon = jest.fn(); + }); + + afterEach(() => setAccessTokenResolver(null)); + + test("enter, leave and the unload beacon all send the resolver's token", async () => { + const resolver = jest.fn(() => Promise.resolve("RES-TOK")); + setAccessTokenResolver(resolver); + + const w = mount(); + await flush(); + expect(http.put).toHaveBeenCalledWith("https://api.test/api/v1/summits/13/metrics/enter"); + expect(http.__send).toHaveBeenCalledWith( + expect.objectContaining({ access_token: "RES-TOK", type: "EVENT", source_id: 7 }) + ); + + await w.instance().onBeforeUnload(); + const beaconUrl = navigator.sendBeacon.mock.calls[0][0]; + expect(beaconUrl).toContain("/api/v1/summits/13/metrics/leave?access_token=RES-TOK"); + + jest.clearAllMocks(); + w.unmount(); + await flush(); + expect(http.post).toHaveBeenCalledWith("https://api.test/api/v1/summits/13/metrics/leave"); + expect(http.__send).toHaveBeenCalledWith(expect.objectContaining({ access_token: "RES-TOK" })); + }); +}); diff --git a/src/components/security/__tests__/get-user-info.test.js b/src/components/security/__tests__/get-user-info.test.js new file mode 100644 index 00000000..669fb02e --- /dev/null +++ b/src/components/security/__tests__/get-user-info.test.js @@ -0,0 +1,50 @@ +/** + * getUserInfo reads the access token through security/methods.getAccessToken, + * so a registered resolver must be what the /members/me request sends. + * The request pipeline (utils/actions) is mocked; the token path is real. + */ +jest.mock("../../../utils/actions", () => ({ + getRequest: jest.fn(), + createAction: jest.fn((t) => ({ type: t })), + authErrorHandler: jest.fn(), + showMessage: jest.fn(() => () => {}), + startLoading: jest.fn(() => ({ type: "START_LOADING" })), + stopLoading: jest.fn(() => ({ type: "STOP_LOADING" })), +})); +jest.mock("../../../utils/methods", () => ({ + buildAPIBaseUrl: jest.fn((p) => `BASE${p}`), + getAllowedUserGroups: jest.fn(() => ""), +})); + +import { getUserInfo } from "../actions"; +import { setAccessTokenResolver } from "../methods"; +import { getRequest } from "../../../utils/actions"; + +describe("getUserInfo with an access-token resolver", () => { + afterEach(() => { + setAccessTokenResolver(null); + jest.clearAllMocks(); + }); + + test("sends the resolver's token to /members/me", async () => { + const resolver = jest.fn(() => Promise.resolve("RES-TOK")); + setAccessTokenResolver(resolver); + const withParams = jest.fn(() => jest.fn(() => Promise.resolve())); + getRequest.mockReturnValue(withParams); + const dispatch = jest.fn(); + const getState = jest.fn(() => ({ loggedUserState: { member: null } })); + + await getUserInfo("groups", "", null, null, null)(dispatch, getState); + + expect(resolver).toHaveBeenCalled(); + expect(getRequest).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + "BASE/api/v1/members/me", + expect.anything() + ); + expect(withParams).toHaveBeenCalledWith( + expect.objectContaining({ access_token: "RES-TOK", expand: "groups" }) + ); + }); +}); diff --git a/src/utils/__tests__/query-actions.test.js b/src/utils/__tests__/query-actions.test.js new file mode 100644 index 00000000..59baf53a --- /dev/null +++ b/src/utils/__tests__/query-actions.test.js @@ -0,0 +1,60 @@ +/** + * query-actions reads the access token through security/methods.getAccessToken, + * so a registered resolver must be what every query* function sends. + * + * lodash/debounce is mocked to a passthrough so the query* functions run + * synchronously; the debounce delay is unrelated to the token path. + */ +import { setAccessTokenResolver } from "../../components/security/methods"; +import { queryMembers, querySummits } from "../query-actions"; + +jest.mock("lodash/debounce", () => (fn) => fn); + +const flush = async () => { + for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0)); +}; + +describe("query-actions with an access-token resolver", () => { + let origFetch; + + beforeEach(() => { + window.API_BASE_URL = "https://api.test"; + origFetch = global.fetch; + global.fetch = jest.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve({ data: [{ id: 1 }] }) }) + ); + }); + + afterEach(() => { + setAccessTokenResolver(null); + global.fetch = origFetch; + jest.clearAllMocks(); + }); + + test("sends the resolver's token as access_token", async () => { + const resolver = jest.fn(() => Promise.resolve("RES-TOK")); + setAccessTokenResolver(resolver); + const cb = jest.fn(); + + queryMembers("x", cb); + await flush(); + + expect(resolver).toHaveBeenCalled(); + expect(global.fetch).toHaveBeenCalledTimes(1); + const url = decodeURIComponent(global.fetch.mock.calls[0][0]); + expect(url).toContain("https://api.test/api/v1/members"); + expect(url).toContain("access_token=RES-TOK"); + expect(cb).toHaveBeenCalledWith([{ id: 1 }]); + }); + + test("a resolver failure calls back with the error and does not fetch", async () => { + setAccessTokenResolver(() => Promise.reject(new Error("no session"))); + const cb = jest.fn(); + + querySummits("x", cb); + await flush(); + + expect(cb).toHaveBeenCalledWith(expect.any(Error)); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); From 9612d52927d89d93f1f3bbfad2c31ea781d74171 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Sat, 29 Aug 2026 16:13:03 -0300 Subject: [PATCH 5/6] fix(security): stop security/methods from importing security/actions methods.js only needed the SET_LOGGED_USER string, but importing it from actions.js pulled the whole actions module (and utils/actions) into the methods bundle, and actions.js imports methods back. With security/methods externalized, that inlined copy resolved to the methods lib file itself. Move the string to constants.js; actions.js keeps re-exporting it. --- src/components/security/actions.js | 3 ++- src/components/security/constants.js | 2 ++ src/components/security/methods.js | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/security/actions.js b/src/components/security/actions.js index 65a84d8d..cbcfa009 100644 --- a/src/components/security/actions.js +++ b/src/components/security/actions.js @@ -19,7 +19,8 @@ import { getAccessToken, storeAuthInfo, initLogOut} from './methods'; /** * @ignore */ -export const SET_LOGGED_USER = 'SET_LOGGED_USER'; +import { SET_LOGGED_USER } from './constants'; +export { SET_LOGGED_USER }; export const LOGOUT_USER = 'LOGOUT_USER'; export const REQUEST_USER_INFO = 'REQUEST_USER_INFO'; export const RECEIVE_USER_INFO = 'RECEIVE_USER_INFO'; diff --git a/src/components/security/constants.js b/src/components/security/constants.js index 2900fa7a..d110e48c 100644 --- a/src/components/security/constants.js +++ b/src/components/security/constants.js @@ -8,3 +8,5 @@ export const AUTH_ERROR_ID_TOKEN_INVALID = 'AUTH_ERROR_ID_TOKEN_INVALID'; export const AUTH_ERROR_MISSING_OTP_PARAM = 'AUTH_ERROR_MISSING_OTP_PARAM'; export const AUTH_ERROR_MISSING_PKCE_PARAM = 'AUTH_ERROR_MISSING_PKCE_PARAM'; export const AUTH_ERROR_MISSING_NONCE_PARAM = 'AUTH_ERROR_MISSING_NONCE_PARAM'; + +export const SET_LOGGED_USER = 'SET_LOGGED_USER'; diff --git a/src/components/security/methods.js b/src/components/security/methods.js index b81b754a..c8039f00 100644 --- a/src/components/security/methods.js +++ b/src/components/security/methods.js @@ -16,10 +16,10 @@ import Cookies from 'js-cookie' let http = request; import URI from "urijs"; import IdTokenVerifier from "idtoken-verifier"; -import {SET_LOGGED_USER} from "./actions"; import {getRandomBytes, getSHA256} from "../../utils/crypto"; import { + SET_LOGGED_USER, AUTH_ERROR_ACCESS_TOKEN_EXPIRED, AUTH_ERROR_MISSING_AUTH_INFO, AUTH_ERROR_MISSING_REFRESH_TOKEN, From d312a1d01a25d27ea238ba1b2e0f459f367b6d1d Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Sat, 29 Aug 2026 16:13:38 -0300 Subject: [PATCH 6/6] refactor(security): keep the action-type strings in constants.js Move the remaining security action types and session-state statuses next to SET_LOGGED_USER and re-export them from actions.js, so existing imports keep working. reducers.js and utils/actions.js read them from constants, so they no longer inline the actions module. --- src/components/security/actions.js | 35 +++++++++++++++++++--------- src/components/security/constants.js | 9 +++++++ src/components/security/reducers.js | 2 +- src/utils/actions.js | 2 +- 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/components/security/actions.js b/src/components/security/actions.js index cbcfa009..378d49ce 100644 --- a/src/components/security/actions.js +++ b/src/components/security/actions.js @@ -19,17 +19,30 @@ import { getAccessToken, storeAuthInfo, initLogOut} from './methods'; /** * @ignore */ -import { SET_LOGGED_USER } from './constants'; -export { SET_LOGGED_USER }; -export const LOGOUT_USER = 'LOGOUT_USER'; -export const REQUEST_USER_INFO = 'REQUEST_USER_INFO'; -export const RECEIVE_USER_INFO = 'RECEIVE_USER_INFO'; -export const CLEAR_SESSION_STATE = 'CLEAR_SESSION_STATE'; -export const UPDATE_SESSION_STATE_STATUS = 'UPDATE_SESSION_STATE_STATUS'; -export const SESSION_STATE_STATUS_UNCHANGED = 'unchanged'; -export const SESSION_STATE_STATUS_CHANGED = 'changed'; -export const SESSION_STATE_STATUS_ERROR = 'error'; -export const UPDATE_USER_INFO = 'UPDATE_USER_INFO'; +import { + SET_LOGGED_USER, + LOGOUT_USER, + REQUEST_USER_INFO, + RECEIVE_USER_INFO, + CLEAR_SESSION_STATE, + UPDATE_SESSION_STATE_STATUS, + SESSION_STATE_STATUS_UNCHANGED, + SESSION_STATE_STATUS_CHANGED, + SESSION_STATE_STATUS_ERROR, + UPDATE_USER_INFO, +} from './constants'; +export { + SET_LOGGED_USER, + LOGOUT_USER, + REQUEST_USER_INFO, + RECEIVE_USER_INFO, + CLEAR_SESSION_STATE, + UPDATE_SESSION_STATE_STATUS, + SESSION_STATE_STATUS_UNCHANGED, + SESSION_STATE_STATUS_CHANGED, + SESSION_STATE_STATUS_ERROR, + UPDATE_USER_INFO, +}; export const onUserAuth = (accessToken, idToken, sessionState, expiresIn = 0, refreshToken = null) => (dispatch) => { diff --git a/src/components/security/constants.js b/src/components/security/constants.js index d110e48c..ca1a9583 100644 --- a/src/components/security/constants.js +++ b/src/components/security/constants.js @@ -10,3 +10,12 @@ export const AUTH_ERROR_MISSING_PKCE_PARAM = 'AUTH_ERROR_MISSING_PKCE_PARAM'; export const AUTH_ERROR_MISSING_NONCE_PARAM = 'AUTH_ERROR_MISSING_NONCE_PARAM'; export const SET_LOGGED_USER = 'SET_LOGGED_USER'; +export const LOGOUT_USER = 'LOGOUT_USER'; +export const REQUEST_USER_INFO = 'REQUEST_USER_INFO'; +export const RECEIVE_USER_INFO = 'RECEIVE_USER_INFO'; +export const CLEAR_SESSION_STATE = 'CLEAR_SESSION_STATE'; +export const UPDATE_SESSION_STATE_STATUS = 'UPDATE_SESSION_STATE_STATUS'; +export const SESSION_STATE_STATUS_UNCHANGED = 'unchanged'; +export const SESSION_STATE_STATUS_CHANGED = 'changed'; +export const SESSION_STATE_STATUS_ERROR = 'error'; +export const UPDATE_USER_INFO = 'UPDATE_USER_INFO'; diff --git a/src/components/security/reducers.js b/src/components/security/reducers.js index bfffbcca..f9fba8c0 100644 --- a/src/components/security/reducers.js +++ b/src/components/security/reducers.js @@ -19,7 +19,7 @@ import { SET_LOGGED_USER, UPDATE_SESSION_STATE_STATUS, UPDATE_USER_INFO -} from './actions'; +} from './constants'; import IdTokenVerifier from 'idtoken-verifier'; diff --git a/src/utils/actions.js b/src/utils/actions.js index 1d7ea724..510e2b45 100644 --- a/src/utils/actions.js +++ b/src/utils/actions.js @@ -19,7 +19,7 @@ let http = request; 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 { CLEAR_SESSION_STATE } from '../components/security/constants'; import { doLogin, initLogOut } 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.";