Skip to content

feat(security): let a consumer supply the access token via setAccessTokenResolver - #329

Open
gcutrini wants to merge 6 commits into
mainfrom
feat/access-token-resolver-5x
Open

gcutrini wants to merge 6 commits into
mainfrom
feat/access-token-resolver-5x

Conversation

@gcutrini

@gcutrini gcutrini commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

ref: https://app.clickup.com/t/86bbnquuq

5.x line companion of #324 (same change on the v4.x line).

uicore's getAccessToken assumes the access token lives on the client. It reads and refreshes the token from local storage and serializes refreshes with navigator.locks. That model does not fit a server-rendered host such as the Next.js event site, where the token is held in an encrypted httpOnly cookie and is never exposed to JavaScript. There is no client-side token for uicore to read, lock, or renew.

Keeping the token out of JavaScript is also a deliberate security property. A token that never reaches the browser's JS context cannot be stolen by XSS or replayed from client code.

Several uicore modules call getAccessToken, so the source must be injectable inside uicore rather than overridden by the consumer. Today a cookie-based host has to work around this by aliasing security/methods at build time and substituting its own implementation, which is invasive (build-system specific, replaces the whole module) and easy to drift out of sync.

What

Add setAccessTokenResolver to security/methods: a consumer registers where the access token comes from, without replacing anything.

  • It does not bypass getAccessToken. 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.
  • It is opt-in. Existing consumers such as summit-admin are unaffected; there is no behavior change unless they call setAccessTokenResolver.

Renewal is the consumer's concern

When a resolver is registered, uicore delegates token retrieval to it and does not apply its own storage, locking, or renewal to the returned value. Freshness is left entirely to the consumer. A cookie-based host, for example, renews server-side (the server refreshes the session cookie via the refresh_token grant, and proxied API calls attach the real bearer from the cookie) and hands uicore only a "session present" placeholder, so there is nothing on the client to lock or renew.

Implementation note

The resolver lives on globalThis under a registered symbol:
globalThis[Symbol.for('openstack-uicore-foundation.accessTokenResolver')].
setAccessTokenResolver writes that slot; getAccessToken reads it.

Module state was not an option here. Every copy of security/methods has its
own module scope, and modern package managers routinely create several
copies of the package: pnpm materializes one per peer combination, widgets
can carry nested installs, and symlinked dev checkouts resolve to their own
tree. A resolver kept in module state is only visible to the copy that
registered it. The Symbol.for registry is shared by every copy on the page,
so the singleton holds with no build-system help. React uses the same
pattern for its element symbols.

The webpack build is untouched: no bundle requires the package by its own
name, so yarn link installs keep working and hosts do not need a single
hoisted copy of the package.

…okenResolver

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.
Delegates to a registered resolver, a later resolver replaces the previous one, and a non-function argument clears it.
@coderabbitai

coderabbitai Bot commented Aug 27, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 111204e4-f6e6-4a0d-87cb-ff73bb96105c
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gcutrini
gcutrini requested a review from smarcet August 27, 2026 17:02
_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.
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.
@gcutrini

Copy link
Copy Markdown
Contributor Author

Note on the externals rule added here.

security/methods.js imported the SET_LOGGED_USER string from ./actions, and actions.js imports ./methods back. That import cycle already existed on v4.x and was harmless: the methods bundle just carried its own inlined copy of actions and methods.

This PR externalizes security/methods so every bundle shares one instance (needed for the resolver state). With that rule, the inlined actions copy inside the methods bundle resolved to lib/security/methods.js itself, so the file required itself. It still loaded (nothing reads it at load time), but the inlined copies would see an empty module, and a consumer bundling uicore from source with esbuild can fail to resolve it. So it had to be fixed here, not later.

Fix: the action-type strings moved to security/constants.js; actions.js re-exports them, so existing imports keep working. reducers.js and utils/actions.js also read from constants now, so they stop inlining the actions module. No API change.

Comment thread src/components/security/methods.js Resolved
Comment thread webpack.common.js Resolved

@smarcet smarcet left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new shareSecurityMethods webpack external currently returns an untyped external spec, which can generate incorrect runtime linkage (e.g., global-var externals instead of require) and break the intended singleton behavior.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds an opt-in mechanism for consumers to supply access tokens to uicore via a registered resolver, enabling SSR/cookie-based hosts to keep tokens out of the browser JS context while keeping existing client-side token behavior as the default.

Changes:

  • Introduces setAccessTokenResolver and makes getAccessToken delegate to it when registered.
  • Refactors security action-type constants into security/constants and updates imports accordingly.
  • Adds focused Jest coverage to ensure resolver-provided tokens flow through key call sites (query-actions, getUserInfo, AttendanceTracker) and that resolver failures prevent downstream requests.
File summaries
File Description
webpack.common.js Externalizes security/methods to ensure a singleton resolver state across multiple UMD entry bundles.
src/utils/query-actions.js Prevents unhandled rejections when token retrieval fails; improves 404 callback safety.
src/utils/actions.js Switches session-state constant import to security/constants.
src/utils/tests/query-actions.test.js Verifies query-actions uses resolver token and stops on resolver failure.
src/components/security/reducers.js Updates action-type imports to come from security/constants.
src/components/security/methods.js Adds module-level access-token resolver and delegation in getAccessToken.
src/components/security/constants.js Adds action-type constants previously declared in security/actions.
src/components/security/actions.js Imports action-type constants from security/constants and re-exports them for compatibility.
src/components/security/tests/methods.test.js Adds tests for resolver delegation/replacement/reset behavior.
src/components/security/tests/get-user-info.test.js Verifies /members/me uses resolver-provided token via mocked request pipeline.
src/components/tests/attendance-tracker.test.js Verifies AttendanceTracker metrics calls use resolver-provided token.
Review details
  • Files reviewed: 11/11 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread webpack.common.js Resolved
Comment thread src/components/security/actions.js Outdated
export const SESSION_STATE_STATUS_CHANGED = 'changed';
export const SESSION_STATE_STATUS_ERROR = 'error';
export const UPDATE_USER_INFO = 'UPDATE_USER_INFO';
import {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whats the point of this change ? lets not refactor something if its out of scope. Also action name declarations tipically go in the actions file across all our products

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation changed since this comment: the resolver no longer relies on the webpack externals rule, it lives on globalThis under Symbol.for (see the updated description), so state sharing no longer depends on how the bundles resolve their modules.

The constants move was tied to that old rule. methods and actions import each other (methods needs SET_LOGGED_USER from actions, actions imports methods), a circular import that has always existed upstream and is harmless while every bundle inlines its own copies. Externalizing methods turned the cycle into the methods bundle requiring its own file, and moving the action-type strings out was how I broke it.

With the new implementation the cycle is back to its long-standing harmless state, so the refactor is dropped: SET_LOGGED_USER stays in actions.js and webpack.common.js is untouched, PKG_NAME included. Untangling the circular import for real is worth its own PR.

let http = request;
import URI from "urijs";
import IdTokenVerifier from "idtoken-verifier";
import {SET_LOGGED_USER} from "./actions";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need to do this according to the actions export

Comment thread webpack.common.js Outdated
const path = require('path');
const nodeExternals = require('webpack-node-externals');

const { name: PKG_NAME } = require('./package.json');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

by convention all capitals is for constants, use camelCase instead

…for global slot

The resolver moves from module state to
globalThis[Symbol.for('openstack-uicore-foundation.accessTokenResolver')],
so every copy of security/methods reads the same slot: bundles that inline
it, nested installs of the package, and symlinked dev installs. This drops
the webpack externals rule that made the module a cross-bundle singleton;
no bundle requires the package by its own name anymore, so yarn link
installs keep working and hosts no longer need a single hoisted copy.
@gcutrini
gcutrini force-pushed the feat/access-token-resolver-5x branch from bd79348 to eb08c62 Compare September 3, 2026 22:07
Comment thread src/components/security/methods.js Resolved
Comment thread src/components/security/methods.js Resolved

@smarcet smarcet left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gcutrini please review

…lver contract

Read and write the resolver slot through a globalThis -> window -> {}
fallback (the showConfirmDialog bridge shape) so consumers on runtimes
without globalThis keep the built-in token flow instead of throwing.
Document the resolver contract on setAccessTokenResolver: the resolved
value is unvalidated downstream, a rejection is treated as an auth
failure by every caller, and a server-side registration is process-wide.
@gcutrini
gcutrini requested a review from smarcet September 9, 2026 02:33
@gcutrini

gcutrini commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

@smarcet the Sep 7 changes are addressed in bb454dc. Could you re-review when you get a chance?

@gcutrini
gcutrini changed the base branch from main to master October 6, 2026 16:04
@gcutrini
gcutrini added this pull request to stack #354 October 6, 2026 16:04
@gcutrini
gcutrini removed this pull request from stack #354 October 6, 2026 16:08
@gcutrini
gcutrini changed the base branch from master to main October 6, 2026 16:08
@gcutrini
gcutrini added this pull request to stack #355 October 6, 2026 16:22
@smarcet
smarcet requested review from santipalenque and a lite review from Copilot October 27, 2026 20:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The documentation inaccurately generalizes resolver rejection handling, while existing callers propagate, callback, log, or log out on errors differently, which could mislead consumers about transient authentication failures.

1 open issue
1 resolved since last review

Review effort: Lite

Comment on lines +366 to +368
* - a rejection is treated as an auth failure by every caller (the Dropzone
* input logs the user out via initLogOut); handle transient errors inside
* the resolver.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gcutrini Copilot is right here. The sentence "a rejection is treated as an auth failure by every caller" overstates what the callers do; I wrote it in my earlier suggestion, so the fix is on my wording.

On this branch, a resolver rejection reaches each caller and each one handles it differently:

  • src/components/inputs/dropzone/index.js:170-180 (accept hook) calls onError, then initLogOut() unless the message starts with AUTH_ERROR_REFRESH_TOKEN_NETWORK_ERROR. This is the only path that logs the user out.
  • src/components/inputs/dropzone/index.js:113 (async status polling) calls stopPolling + onError, with no logout.
  • src/components/security/actions.js:58 (getUserInfo): the rejection escapes to whoever dispatched the thunk, before errorHandler is set up.
  • src/utils/query-actions.js:69 (_fetch) passes the error to the query* callback, and query-actions.js:45 (_fetchPromise) rejects to its caller.
  • src/components/attendance-tracker.js:51/65/80 only call console.log.

A host author reading the JSDoc should see what actually happens. Suggested replacement for that bullet:

 * - a rejection propagates to each caller and is handled per caller: the
 *   Dropzone upload `accept` hook logs the user out via initLogOut,
 *   getUserInfo rejects to its dispatcher, the query* helpers pass the error
 *   to their callback, and AttendanceTracker only logs it. Handle transient
 *   errors inside the resolver.

v4.x has the same block from #324, so the same wording should go there too.

@smarcet smarcet left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants