From ae05306aff595082d073d55f99fb8346b5528290 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 043bd518..c68d08b9 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 @@ -182,7 +203,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', @@ -286,5 +307,5 @@ module.exports = { } ] }, - externals: [nodeExternals()] + externals: [nodeExternals(), shareSecurityMethods] }; From 458fb39317bd50627ab19f0f5a0063b84ed61eaf 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 83d3a4e36787d31ed2eef7a8d878b9edfc25e6b0 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 fe35ff1c..6f0ebb95 100644 --- a/src/utils/query-actions.js +++ b/src/utils/query-actions.js @@ -31,8 +31,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); @@ -68,9 +68,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 cfd9def069977a178a055f7df3e5432dfe7a6a32 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..0b2edc50 --- /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 { render, act } from "@testing-library/react"; +import http from "superagent/lib/client"; +import { setAccessTokenResolver } from "../security/methods"; +import AttendanceTracker from "../attendance-tracker"; + +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 { unmount } = render(); + await act(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 act(async () => { + window.dispatchEvent(new Event("beforeunload")); + await flush(); + }); + const beaconUrl = navigator.sendBeacon.mock.calls[0][0]; + expect(beaconUrl).toContain("/api/v1/summits/13/metrics/leave?access_token=RES-TOK"); + + jest.clearAllMocks(); + unmount(); + await act(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 459e5e80bf7dc3124f6196a67a13b84207023df4 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 1e864f95..fdd2cb64 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 24484ddd27884b79b15d2be1619f575696478f00 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 fdd2cb64..2a23dce8 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 72535d78..b63d52a9 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'; import {CODE_200} from "./constants";