Auth: Fix multi-tab auth failures by removing appauth dependency (closes #20873)#21830
Open
iOvergaard wants to merge 30 commits intomainfrom
Open
Auth: Fix multi-tab auth failures by removing appauth dependency (closes #20873)#21830iOvergaard wants to merge 30 commits intomainfrom
iOvergaard wants to merge 30 commits intomainfrom
Conversation
Introduces UmbAuthClient — a focused OAuth PKCE client that replaces the forked @openid/appauth library. Uses Web Crypto API for code_challenge generation and fetch() with credentials:'include' for cookie-based auth. Zero localStorage usage — PKCE state held in memory. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Merges UmbAuthFlow into UmbAuthContext (single consumer, no export).
Replaces localStorage token storage with in-memory session state.
- BroadcastChannel('umb:auth') for cross-tab auth event coordination
- Web Locks API prevents concurrent refresh token race conditions
- postMessage for popup PKCE code_verifier exchange
- sessionStorage for redirect-flow PKCE state (tab-scoped)
- Adds configureClient() for extension developer DX
- Deprecates authorizationSignal (scheduled for removal in Umbraco 19)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Session timeout controller simplified to take only UmbAuthContext (no separate authFlow parameter). Observes session$ for timing updates. SharedWorker now accepts expiresAt timestamp instead of full TokenResponse. Removes TokenResponse import and TOKEN_EXPIRY_MULTIPLIER. Sends current session state to new tab connections. Cleans up stale ports via try/catch on postMessage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
app.element.ts: Remove authorizationSignal wait pattern — completeAuthorizationRequest() now handles everything. Remove umbHttpClient.setConfig() call (moved to auth context constructor). api-interceptor.controller.ts: Replace deprecated authorizationSignal observer with isAuthorized transition for retrying 401 requests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Delete all 17 appauth implementation files. Replace index.ts with deprecated type-only stubs for backwards compatibility — external consumers can still reference types through v18. Mark UMB_STORAGE_TOKEN_RESPONSE_NAME as deprecated (scheduled for removal in Umbraco 19). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Rewrite tests to cover the new auth context API surface including configureClient(), getOpenApiConfiguration(), URL generation, lifecycle management, and bypass auth mode. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace manual getOpenApiConfiguration() pattern with the new configureClient() method on UmbAuthContext — single line to configure any @hey-api/openapi-ts client for authenticated Management API calls. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two bugs fixed: 1. makeRefreshTokenRequest() checked expiresAt > now which always returned true when the worker fired proactively (before session expiry). Changed to compare session reference before/after acquiring the Web Lock — only skips if another tab actually refreshed. 2. getLatestToken() checked the full session expiresAt (with 4x multiplier) instead of the access token expiry. Split UmbAuthSession into accessTokenExpiresAt and expiresAt so each check uses the correct threshold. Also made the worker's buffer and check interval adaptive for short sessions (< 2 minutes) — buffer is reduced to 25% of session lifetime and check interval scales proportionally. Fixes the long-standing issue where very low timeouts caused the buffer to exceed the session. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a user signs out in one tab, broadcast a 'signedOut' message so other tabs redirect to the logout page. Previously, other tabs only cleared their in-memory session but continued showing stale data. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…reshes setInitialState() was calling refreshToken() directly, bypassing the Web Lock. Concurrent API calls (via getLatestToken) also triggered refresh through the lock. This caused duplicate /token calls — one outside the lock, one inside — leading to rolling refresh token invalidation races. Now setInitialState() goes through makeRefreshTokenRequest() so all refresh calls are serialized by the same Web Lock. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When the session$ observable emits a new session (e.g. from a BroadcastChannel update after another tab refreshed), close any open timeout modal. Previously the modal stayed open with its own countdown, eventually triggering a spurious logout even though the session was already extended. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Four improvements from a fresh design review: 1. Remove SharedWorker — replaced with a simple setTimeout in the timeout controller. A 15-60s timer is negligible on the main thread, and the focused tab's timer is never throttled by browsers. 2. Leader-elected timeout modal — uses Web Lock (ifAvailable) so only one tab shows the timeout modal. Non-leader tabs set a fallback timeout. When the leader tab resolves the modal, BroadcastChannel propagates the result and session$ observer closes stale modals. 3. Peer session request — new tabs ask existing tabs for their session via BroadcastChannel before attempting a server refresh. Avoids the 400 error on fresh sessions and eliminates unnecessary /token calls for new tabs in an existing session. 4. Single expiry concept — no more refreshToken vs logout distinction from the worker. The controller checks remaining time and decides based on keepUserLoggedIn and whether time has fully expired. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The #onSessionExpiring guard used isSessionValid() which returns true during the warning buffer (before full expiry), preventing the modal from ever appearing. Replace with expiresAt comparison that only skips if the session was actually refreshed since the check was scheduled. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move `auth: () => '[redacted]'` into the http-client module-level config so it's available from first import. Previously, extensions importing umbHttpClient before UmbAuthContext initialized would send cookies but not the Authorization header needed by HideBackOfficeTokensHandler, causing 401s. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR refactors the backoffice authentication flow to avoid multi-tab token refresh races by removing the previous AppAuth/localStorage-based approach and replacing it with an in-memory session timing model plus cross-tab coordination (BroadcastChannel + Web Locks). It also updates extension developer ergonomics by adding a one-liner API client configurator.
Changes:
- Introduces a minimal PKCE/token client (
UmbAuthClient) and rewritesUmbAuthContextaround in-memory session timing + BroadcastChannel signaling. - Replaces the SharedWorker-based session timeout logic with a
setTimeoutscheduler + Web Lock leader election for the timeout modal. - Deprecates legacy OpenID exports and updates extension template usage to
authContext.configureClient(client).
Reviewed changes
Copilot reviewed 31 out of 31 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| templates/UmbracoExtension/Client/src/entrypoints/entrypoint.ts | Updates extension template to use configureClient() for authenticated generated API calls. |
| src/Umbraco.Web.UI.Client/src/packages/core/resources/api-interceptor.controller.ts | Switches 401 retry trigger from deprecated authorizationSignal to isAuthorized. |
| src/Umbraco.Web.UI.Client/src/packages/core/auth/workers/token-check.worker.ts | Removes SharedWorker token check implementation. |
| src/Umbraco.Web.UI.Client/src/packages/core/auth/umb-auth-client.ts | Adds minimal PKCE + token/refresh/revoke client (cookie-auth timing only). |
| src/Umbraco.Web.UI.Client/src/packages/core/auth/umb-auth-client.test.ts | Adds unit tests for PKCE generation and state clearing. |
| src/Umbraco.Web.UI.Client/src/packages/core/auth/controllers/auth-session-timeout.controller.ts | Replaces worker-driven token checks with scheduled expiry checks + leader-elected timeout modal. |
| src/Umbraco.Web.UI.Client/src/packages/core/auth/constants.ts | Marks legacy storage key constant as deprecated. |
| src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts | Merges auth flow into context; adds BroadcastChannel sync, Web Lock refresh coordination, and configureClient(). |
| src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.test.ts | Updates tests for new public surface (session$, configureClient, deprecated authorizationSignal). |
| src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts | Removes previous AppAuth/localStorage-based flow implementation. |
| src/Umbraco.Web.UI.Client/src/external/openid/src/xhr.ts | Removes legacy OpenID helper implementation (now deprecated). |
| src/Umbraco.Web.UI.Client/src/external/openid/src/types.ts | Removes legacy OpenID types (now replaced by deprecated stubs). |
| src/Umbraco.Web.UI.Client/src/external/openid/src/token_response.ts | Removes legacy OpenID token response implementation. |
| src/Umbraco.Web.UI.Client/src/external/openid/src/token_request_handler.ts | Removes legacy OpenID token request handler implementation. |
| src/Umbraco.Web.UI.Client/src/external/openid/src/token_request.ts | Removes legacy OpenID token request implementation. |
| src/Umbraco.Web.UI.Client/src/external/openid/src/storage.ts | Removes legacy OpenID localStorage backend implementation. |
| src/Umbraco.Web.UI.Client/src/external/openid/src/revoke_token_request.ts | Removes legacy OpenID revoke request implementation. |
| src/Umbraco.Web.UI.Client/src/external/openid/src/redirect_based_handler.ts | Removes legacy redirect handler implementation. |
| src/Umbraco.Web.UI.Client/src/external/openid/src/query_string_utils.ts | Removes legacy querystring utility implementation. |
| src/Umbraco.Web.UI.Client/src/external/openid/src/logger.ts | Removes legacy OpenID logger implementation. |
| src/Umbraco.Web.UI.Client/src/external/openid/src/flags.ts | Removes legacy OpenID flags implementation. |
| src/Umbraco.Web.UI.Client/src/external/openid/src/errors.ts | Removes legacy OpenID error implementation. |
| src/Umbraco.Web.UI.Client/src/external/openid/src/crypto_utils.ts | Removes legacy crypto utils implementation. |
| src/Umbraco.Web.UI.Client/src/external/openid/src/base64-js/index.ts | Removes legacy base64 helper implementation. |
| src/Umbraco.Web.UI.Client/src/external/openid/src/authorization_service_configuration.ts | Removes legacy authorization service configuration implementation. |
| src/Umbraco.Web.UI.Client/src/external/openid/src/authorization_response.ts | Removes legacy authorization response implementation. |
| src/Umbraco.Web.UI.Client/src/external/openid/src/authorization_request_handler.ts | Removes legacy authorization request handler implementation. |
| src/Umbraco.Web.UI.Client/src/external/openid/src/authorization_request.ts | Removes legacy authorization request implementation. |
| src/Umbraco.Web.UI.Client/src/external/openid/src/index.ts | Replaces legacy exports with deprecated compatibility stubs. |
| src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts | Updates oauth completion + initial auth setup to align with new context behavior. |
src/Umbraco.Web.UI.Client/src/packages/core/auth/controllers/auth-session-timeout.controller.ts
Outdated
Show resolved
Hide resolved
src/Umbraco.Web.UI.Client/src/packages/core/auth/controllers/auth-session-timeout.controller.ts
Show resolved
Hide resolved
src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts
Outdated
Show resolved
Hide resolved
configureClient() now creates an UmbApiInterceptorController and binds the default response interceptors (401 retry, error handling, notifications) alongside auth config. app.element.ts uses this for umbHttpClient, giving extensions the same middleware pipeline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- clearTokenStorage: also set isAuthorized=false on originating tab - signOut: inline state clearing to avoid double-broadcasting sessionCleared + signedOut; fix dead URL base arg; use window.location.origin consistently - makeRefreshTokenRequest: compare accessTokenExpiresAt values instead of object identity for robustness - completeAuthorizationRequest: only remove sessionStorage PKCE entry when state matches (preserve valid entry on mismatch) - umb-auth-client: warn when expires_in is missing or zero - configureClient: guard against duplicate calls with WeakSet Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add eslint-disable blocks around OAuth wire-format URLSearchParams keys (client_id, redirect_uri, grant_type, etc.) — these must use snake_case per RFC 6749/7636 and cannot be renamed - Fix optional chaining gap in #openTimeoutModal: store modal ref before awaiting so modal?.onSubmit() is safe when modalManager is undefined - Fix popup Promise never settling: poll for authWindowProxy.closed and resolve (cleanup) when the user closes or cancels the login popup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ookie auth With cookie-based auth, getLatestToken() always returns '[redacted]'. The proactive token refresh it performed is no longer needed since: - The session timeout controller refreshes proactively via setTimeout - The API interceptor retries 401s automatically Internal callers (linkLogin, unlinkLogin, server-event, tryXhrRequest) now use '[redacted]' directly. getOpenApiConfiguration() is kept as the recommended API for manual fetch calls. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Mark getLatestToken() as deprecated (always returns '[redacted]' with cookie auth). Points to configureClient() and getOpenApiConfiguration(). - Inline '[redacted]' in internal callers (linkLogin, unlinkLogin, server-event, tryXhrRequest) instead of going through getLatestToken(). - Update getOpenApiConfiguration().token to return '[redacted]' directly. - Clarify external/openid deprecation header: data classes remain functional, handler classes reject because the operations are no longer possible with cookie-based auth. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Instead of fetching keepUserLoggedIn asynchronously from the Management API after authorization, the server now renders it as a boolean attribute on <umb-app> from SecuritySettings. This eliminates the timing gap where the access token could expire before the async preference was fetched, causing 401s on API calls. Chain: Index.cshtml → <umb-app keep-user-logged-in> → UmbAuthContext → UmbAuthSessionTimeoutController. When true, the timeout controller schedules based on accessTokenExpiresAt (proactive refresh) instead of expiresAt (full session expiry). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix BroadcastChannel message storm: completeAuthorizationRequest was calling #updateSession (which broadcasts sessionUpdate) AND separately broadcasting 'authorized'. Other tabs receiving 'authorized' called #updateSession again, cascading N² messages. Split into #setSessionLocally (no broadcast) and #updateSession (broadcasts). - Increase PKCE state from 10 to 32 characters for stronger CSRF nonce (was ~59 bits, now ~190 bits of entropy). - Fix tryXhrRequest spread order: ...options was last, allowing callers to accidentally override baseUrl/token. Now baseUrl/token come last. - Remove unused endSessionEndpoint from UmbAuthClientEndpoints interface (signOut URL is constructed directly in auth.context.ts). - Export UmbAuthSession interface for extension developers observing session$. - Add clarifying comments for: anonymous UmbApiInterceptorController in configureClient, refresh_token server contract, Web Lock deduplication edge case. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…s fallback - Fix redirect loop after code exchange by using force=true navigation so setInitialState() runs with fresh httpOnly cookies - Clean up pending popup flows before starting new ones (prevents pkceHandler/closedPoll leaks) - Add navigator.locks fallback for environments without Web Locks - Clear session on timeOut() to prevent stale in-memory state - Make AuthorizationError constructor params optional (compat fix) - Remove dead #previousAuthUrl field - Add clarifying comments on configureClient and peer session timeout Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…lR hub Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tabs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The hey-api `auth` callback is only invoked when requests include `security` metadata (which generated SDK functions do automatically). Direct `.get()`/`.post()` calls lack this metadata, so the Authorization header was silently omitted. Adding it as a default header ensures all requests through umbHttpClient trigger the server-side HideBackOfficeTokensHandler cookie swap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replaces ifAvailable lock with an exclusive lock that queues tabs. After acquiring the lock, isSessionValid() checks whether another tab already refreshed — preventing sequential /token calls when timers fire slightly offset. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This was
linked to
issues
Feb 20, 2026
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Summary
Caution
Waiting for test helper PR to be merged before this can be reviewed
@openid/appauthlibrary with a minimal PKCE client (umb-auth-client.ts) that uses zero localStorage — all token values are[redacted]with cookie auth, so the library's localStorage-based token management was the source of cross-tab race conditionsUmbAuthFlowintoUmbAuthContextand rewrite using BroadcastChannel (cross-tab signaling), Web Locks (single concurrent refresh), and in-memory session statesetTimeout+ leader-elected timeout modal via Web LockconfigureClient()method toUmbAuthContextfor extension developers to easily configure@hey-api/openapi-tsclients for authenticated API calls — also binds the default response interceptors (401 retry, error handling, notifications)auth: () => '[redacted]'and defaultAuthorizationheader at module level in@umbraco-cms/backoffice/http-clientso direct.get()/.post()calls work withoutsecuritymetadatakeepUserLoggedInfrom the backend via HTML attribute on<umb-app>(fromSecuritySettings) instead of fetching it async from the Management APIexternal/openidpackage andUMB_STORAGE_TOKEN_RESPONSE_NAMEconstant (scheduled for removal in v19)server-event.context.tswhere SignalR hub init could throw if auth context arrived before server contextRoot Cause
Opening multiple backoffice tabs caused blank pages because concurrent tabs would race to refresh the rolling refresh token. OpenIddict invalidates tokens on first use, so losing tabs would call
clearTokenStorage()and nuke the shared localStorage — destroying the valid token the winning tab just wrote.What Changed
{ accessTokenExpiresAt, expiresAt })umb:authchannel)umb:token-refresh,ifAvailable: true) — first tab refreshes, others skipsetTimeoutwith Web Lock leader election (umb:timeout-modal)/token)window.opener.postMessagefor code_verifier exchangesessionStorage(tab-scoped, survives navigation)signedOutto all tabs for redirectSecuritySettingsauthContext.configureClient(myClient)one-liner (sets auth + binds interceptors)credentials: 'include'only → also setsauthcallback + defaultAuthorization: Bearer [redacted]header at import time (covers both SDK and direct calls)history.replaceState→window.location.href(force full navigation for cookie hydration)Deprecations (v17 → removal in v19)
@umbraco-cms/backoffice/external/openid— replaced with type-only stubsUMB_STORAGE_TOKEN_RESPONSE_NAMEconstantauthorizationSignalonUmbAuthContext(useisAuthorizedinstead)getLatestToken()onUmbAuthContext(useconfigureClient()orgetOpenApiConfiguration()instead)Known: E2E acceptance tests need updating
The
@umbraco/playwright-testhelperspackage (ApiHelpers.getLocalStorageAuthToken) readsumb:userAuthTokenResponsefrom localStorage to check token validity and manage refresh timing. Since this PR removes localStorage usage, those helpers fail with:This will be fixed after #21773 merges (which moves the test helpers into this repo). The fix is straightforward:
isLoginStateValid()should stop reading from localStorage — the httpOnly cookies handle auth, and Playwright'sstorageStatealready replays them. The refresh call itself (POST /tokenwithgrant_type: refresh_token) still works; only the "should I refresh?" timing check needs updating.Test plan
keepUserLoggedIn=true(only one tab calls/tokenviaifAvailablelock)keepUserLoggedIn=false(leader-elected, one tab)🤖 Generated with Claude Code