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/2] 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/2] 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(); + }); +});