You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(apollo-vertex)!: key the shell session by client and scope - #1369
There are two problems, and they share a cause: the shell keeps its session under one fixed key, uipath_token.
1. A scope change never reaches a signed-in user.ShellAuthProvider keeps a session going with refresh tokens. A refresh-token grant returns the scopes of the original login, so a scope added later never reaches an existing session. The token lacks that API's audience, and the API answers 401. We hit this in a customer UAT: LLMOps returned IDX10214: Audience validation failed on every call to the feedback service, until each user signed out and in.
2. Apps on the same host overwrite each other's session. Coded apps in one org are all served from <org>.uipath.host/<app>, so they share one localStorage, and every one of them reads and writes uipath_token. When the apps use different OAuth clients (for example one external application per solution), whichever app signed in last wins. The other app then refreshes a token issued to a different client, which fails.
Why key by client and scope
The key is uipath_token:<clientId>:<scopes, de-duplicated and sorted>. Each part solves one problem:
Client separates apps that use different OAuth clients. Apps that share a client also share their scopes on UiPath Apps, because the host fills uipath:scope from the client's grants when it deploys the app. So they get the same key and share one session, which is correct since the token suits both.
Scope makes a scope change force a new login. The new key has no session, so the user signs in and gets the new scopes, where a refresh never could. No comparison against the stored session is needed.
Situation
Result
Two apps, different clients
Separate sessions, no collision
Two apps, same client (so same scopes on UiPath Apps)
One shared session
The client's scopes change, and the app is redeployed so uipath:scope picks them up
New key, so the user signs in again and gets the new scopes
Keying by the app's path (e.g. /loansetupapp) would also isolate apps, but it has three costs:
The registry would need the path passed in, as a new prop or a UiPath Apps meta tag.
It still needs a scope comparison to catch scope changes.
Apps sharing a client would sign in separately.
Client plus scope needs no new input and covers both problems.
Change
lib/auth.ts:
The token storage key is derived from clientId and the normalized scope, and refresh writes back to the same key.
getTokenQueryKey(clientId, scope) gives the React Query cache entry a matching key. A caller using another client or scope gets its own entry, instead of sharing one with the shell and flipping it between signed in and signed out. A scope change also takes effect at once rather than at the next 60-second refetch. Sign-out resets only the client's entries, so other apps sharing a QueryClient keep their cached tokens.
getStoredIdToken(clientId, scope) reads the id token from this app's session, for code that needs it outside the shell. The AI Chat template's logout uses it to build id_token_hint.
scope is required wherever the session is read, and must be the same scope given to login. ShellAuthProvider already requires it, so apps that only use the provider need no change. An optional scope would need a fallback key, and a caller that omitted it could handle the login callback and store the token where the shell never looks. With scope required, a missed call site fails to compile.
The login callback:
Clears the single-use ?code and the PKCE state on every exit, including an error returned by Identity (?error=…&state=…), a state mismatch, or a missing verifier. Otherwise each token refetch would treat the page as a fresh callback and fail again. It returns to the page the login started from.
Checks that the login used the current scope (the scope login recorded in sessionStorage) before redeeming the code. If not, for example a login started from a bundle deployed before this one, it skips the exchange and asks the user to sign in again.
Doesn't reset the token query when it fails. The callback runs inside that query's fetch, so a reset started a second run beside it. With a session close to expiry, both runs refreshed with the same refresh token. After a failure the query just goes on to read the stored session.
logout ends every session of the client: the current key, keys from earlier scopes, and the unscoped pre-upgrade entry, all of which hold refresh tokens. The unscoped entry is shared by every app on the host, so it is removed only when its id token's audience is this client.
lib/auth.test.ts covers:
Session lookup: scope order and spacing, a scope change, two clients on one host, ignoring the unscoped key, refresh, and a failed refresh.
The query key, and that a failed callback refreshes a stored session only once.
The login callback: success, a login under another scope, a login with no recorded scope, an error from Identity, a state mismatch, and a failed exchange.
getStoredIdToken, and logout across sessions, cached tokens and clients, including an unscoped entry owned by another client.
The vitest include covers lib/**/*.test.ts.
packages/apollo-vertex/AGENTS.md drops its note that the package has no tests.
Breaking change
This is a major bump of @uipath/apollo-vertex (0.3.0 to 1.0.0). The commit fix(apollo-vertex)!: key the shell session by client and scope carries a BREAKING CHANGE: footer, because it changes the required-ness and order of public parameters:
Export
Before
After
ensureValidToken
(queryClient, clientId, baseUrl, redirectPath?)
(clientId, scope, baseUrl, redirectPath?), mirroring login; no queryClient
useAccessToken
{ clientId, baseUrl, redirectPath? }
adds required scope
logout
(queryClient)
(queryClient, clientId)
TOKEN_QUERY_KEY
the token's cache key
only the prefix; read the cache with getTokenQueryKey(clientId, scope)
getTokenQueryKey
new
(clientId, scope)
getStoredIdToken
new
(clientId, scope)
Migration: pass the same scope you give login, and drop the queryClient argument of ensureValidToken. A getQueryData(TOKEN_QUERY_KEY) or fetchQuery({ queryKey: TOKEN_QUERY_KEY }) moves to getTokenQueryKey(clientId, scope), and a direct read of localStorage[STORAGE_KEYS.TOKEN] moves to getStoredIdToken. Apps that only use ShellAuthProvider and useAuth() are unaffected.
Rollout
One sign-in after the upgrade. An app that picks this up looks under the new key, so every user signs in once. That's deliberate: it also clears any session that's already stale. With the Identity SSO session still active, it's usually one click.
Old entries stay until sign-out. Neither the unscoped uipath_token entry nor a previous scope's key is removed on upgrade or on a scope change, because a sibling app on the same host may still own it. Signing out removes all of them that belong to that client.
FINS (fins-vertical-solution) picks this up at its next registry sync. Its getSdkToken and the services that read the token from the query cache need the new key.
Per-package coverage and bundle size on this PR. New-line coverage = of the source lines this PR adds or changes, the % hit by tests.
Package
Coverage
New-line coverage
Packed (gzip)
Unpacked
vs main
@uipath/apollo-core
—
—
—
—
—
@uipath/apollo-react
—
—
—
—
—
@uipath/apollo-ui-icons
—
—
—
—
—
@uipath/apollo-vertex
84.0%
90.5% (57/63)
325.5 KB
1.84 MB
+834 B
@uipath/apollo-wind
—
—
—
—
—
@uipath/ap-chat
—
—
—
—
—
"Coverage" is each package's own coverage.include scope (e.g. apollo-core instruments only scripts/). "Packed"/"Unpacked" come from npm pack --dry-run and only cover built packages — "—" means not measured this run (package not affected / not built). "vs main" is the packed (gzipped) delta against the last successful main build (the package-sizes artifact from the Release workflow); "—" there means no main baseline was available this run. The baseline is main's latest build, not this PR's exact merge-base, so it includes any drift since the branch diverged. Packages with no vitest config are omitted.
RobinMennens-UiPath
changed the title
fix(apollo-vertex): sign in again when the shell auth scope changes
fix(apollo-vertex): key the shell session by client and scope
Oct 14, 2026
This overstates the isolation: apps with the same client and normalized scopes intentionally share one key. Clarify the differing-scope case so consumers do not assume sessions are isolated per app path.
RobinMennens-UiPath
changed the title
fix(apollo-vertex): key the shell session by client and scope
fix(apollo-vertex)!: key the shell session by client and scope
Oct 14, 2026
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
The core isolation behavior for different OAuth clients lacks regression coverage.
0 open findings
Previously missed (1)
In code that hasn't changed since last review
Add test coverage for client ID partitioning
packages/apollo-vertex/lib/auth.test.ts:101
The suite verifies scope partitioning but never client partitioning, which is the second core regression this change is intended to prevent. If clientId were accidentally removed from the storage key, every test here would still pass while apps using different OAuth clients would overwrite each other again. Add a test using the same scope with two client IDs and assert that each retrieves its own token.
Coded apps in one org share a host, so they share one localStorage, and
the shell kept every app's session under the same uipath_token key. Apps on
different OAuth clients overwrote each other's session. And a refresh-token
grant keeps the scopes of the original login, so a scope added to the
client later never reached a signed-in user: APIs that need it answered
401 (IDX10214 from LLMOps) until the user signed out and in.
The session now lives under uipath_token:<clientId>:<sorted scopes>, and
its React Query cache entry under a matching key. Apps on different clients
keep separate sessions, and a scope change lands on a new key, so the user
signs in again and gets the new scopes.
The login callback clears the single-use code and PKCE state on every exit,
including an error returned by Identity, so a failed callback no longer
retries on each token refetch. A login that
began under another scope is not redeemed, and the user is asked to sign
in again. Signing out ends every session of the client, and the unscoped
pre-upgrade entry only when its id token's audience is this client, and
resets only that client's cached tokens, so other apps on a shared
QueryClient keep theirs. A failed callback no longer resets the token query
from inside its own fetch, which started a second run that could refresh
the same refresh token twice.
BREAKING CHANGE: `scope` is required wherever the shell session is read.
`ensureValidToken` takes `(clientId, scope, baseUrl, redirectPath?)`,
mirroring `login`, and no longer takes a `queryClient`.
`useAccessToken` requires `scope`, and `logout` requires `clientId`. Read
the token cache with `getTokenQueryKey(clientId, scope)` instead of
`TOKEN_QUERY_KEY`, which is now only the prefix of those keys, and the
id token with `getStoredIdToken(clientId, scope)` instead of reading
`STORAGE_KEYS.TOKEN`. Pass the same scope you give `login`. Sessions under
the old key are not read, so users sign in once after upgrading.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2x5QWqQBmo2vNFJF3Xtbq
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
There are two problems, and they share a cause: the shell keeps its session under one fixed key,
uipath_token.1. A scope change never reaches a signed-in user.
ShellAuthProviderkeeps a session going with refresh tokens. A refresh-token grant returns the scopes of the original login, so a scope added later never reaches an existing session. The token lacks that API's audience, and the API answers 401. We hit this in a customer UAT: LLMOps returnedIDX10214: Audience validation failedon every call to the feedback service, until each user signed out and in.2. Apps on the same host overwrite each other's session. Coded apps in one org are all served from
<org>.uipath.host/<app>, so they share onelocalStorage, and every one of them reads and writesuipath_token. When the apps use different OAuth clients (for example one external application per solution), whichever app signed in last wins. The other app then refreshes a token issued to a different client, which fails.Why key by client and scope
The key is
uipath_token:<clientId>:<scopes, de-duplicated and sorted>. Each part solves one problem:uipath:scopefrom the client's grants when it deploys the app. So they get the same key and share one session, which is correct since the token suits both.uipath:scopepicks them upKeying by the app's path (e.g.
/loansetupapp) would also isolate apps, but it has three costs:Client plus scope needs no new input and covers both problems.
Change
lib/auth.ts:clientIdand the normalizedscope, and refresh writes back to the same key.getTokenQueryKey(clientId, scope)gives the React Query cache entry a matching key. A caller using another client or scope gets its own entry, instead of sharing one with the shell and flipping it between signed in and signed out. A scope change also takes effect at once rather than at the next 60-second refetch. Sign-out resets only the client's entries, so other apps sharing aQueryClientkeep their cached tokens.getStoredIdToken(clientId, scope)reads the id token from this app's session, for code that needs it outside the shell. The AI Chat template's logout uses it to buildid_token_hint.scopeis required wherever the session is read, and must be the samescopegiven tologin.ShellAuthProvideralready requires it, so apps that only use the provider need no change. An optionalscopewould need a fallback key, and a caller that omitted it could handle the login callback and store the token where the shell never looks. Withscoperequired, a missed call site fails to compile.The login callback:
?codeand the PKCE state on every exit, including an error returned by Identity (?error=…&state=…), a state mismatch, or a missing verifier. Otherwise each token refetch would treat the page as a fresh callback and fail again. It returns to the page the login started from.loginrecorded in sessionStorage) before redeeming the code. If not, for example a login started from a bundle deployed before this one, it skips the exchange and asks the user to sign in again.logoutends every session of the client: the current key, keys from earlier scopes, and the unscoped pre-upgrade entry, all of which hold refresh tokens. The unscoped entry is shared by every app on the host, so it is removed only when its id token's audience is this client.lib/auth.test.tscovers:getStoredIdToken, and logout across sessions, cached tokens and clients, including an unscoped entry owned by another client.The vitest
includecoverslib/**/*.test.ts.packages/apollo-vertex/AGENTS.mddrops its note that the package has no tests.Breaking change
This is a major bump of
@uipath/apollo-vertex(0.3.0 to 1.0.0). The commitfix(apollo-vertex)!: key the shell session by client and scopecarries aBREAKING CHANGE:footer, because it changes the required-ness and order of public parameters:ensureValidToken(queryClient, clientId, baseUrl, redirectPath?)(clientId, scope, baseUrl, redirectPath?), mirroringlogin; noqueryClientuseAccessToken{ clientId, baseUrl, redirectPath? }scopelogout(queryClient)(queryClient, clientId)TOKEN_QUERY_KEYgetTokenQueryKey(clientId, scope)getTokenQueryKey(clientId, scope)getStoredIdToken(clientId, scope)Migration: pass the same
scopeyou givelogin, and drop thequeryClientargument ofensureValidToken. AgetQueryData(TOKEN_QUERY_KEY)orfetchQuery({ queryKey: TOKEN_QUERY_KEY })moves togetTokenQueryKey(clientId, scope), and a direct read oflocalStorage[STORAGE_KEYS.TOKEN]moves togetStoredIdToken. Apps that only useShellAuthProvideranduseAuth()are unaffected.Rollout
uipath_tokenentry nor a previous scope's key is removed on upgrade or on a scope change, because a sibling app on the same host may still own it. Signing out removes all of them that belong to that client.FINS (fins-vertical-solution) picks this up at its next registry sync. Its
getSdkTokenand the services that read the token from the query cache need the new key.Checks
pnpm test: 22 passed (2 files)pnpm typecheck,pnpm lint(type-aware),pnpm format:check: cleanpnpm lint:deps: 0 errors (the 11 warnings are already on main)pnpm -w knip: clean🤖 Generated with Claude Code
https://claude.ai/code/session_01F2x5QWqQBmo2vNFJF3Xtbq