Skip to content

Comments

Auth: Fix multi-tab auth failures by removing appauth dependency (closes #20873)#21830

Open
iOvergaard wants to merge 30 commits intomainfrom
v17/bugfix/20873-fix-multi-tab-auth-failures
Open

Auth: Fix multi-tab auth failures by removing appauth dependency (closes #20873)#21830
iOvergaard wants to merge 30 commits intomainfrom
v17/bugfix/20873-fix-multi-tab-auth-failures

Conversation

@iOvergaard
Copy link
Contributor

@iOvergaard iOvergaard commented Feb 19, 2026

Summary

Caution

Waiting for test helper PR to be merged before this can be reviewed

  • Replace the @openid/appauth library 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 conditions
  • Merge UmbAuthFlow into UmbAuthContext and rewrite using BroadcastChannel (cross-tab signaling), Web Locks (single concurrent refresh), and in-memory session state
  • Replace SharedWorker with setTimeout + leader-elected timeout modal via Web Lock
  • Add configureClient() method to UmbAuthContext for extension developers to easily configure @hey-api/openapi-ts clients for authenticated API calls — also binds the default response interceptors (401 retry, error handling, notifications)
  • Set auth: () => '[redacted]' and default Authorization header at module level in @umbraco-cms/backoffice/http-client so direct .get()/.post() calls work without security metadata
  • Supply keepUserLoggedIn from the backend via HTML attribute on <umb-app> (from SecuritySettings) instead of fetching it async from the Management API
  • Deprecate external/openid package and UMB_STORAGE_TOKEN_RESPONSE_NAME constant (scheduled for removal in v19)
  • Fix race condition in server-event.context.ts where SignalR hub init could throw if auth context arrived before server context

Root 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

Area Change
Token storage localStorage → in-memory session ({ accessTokenExpiresAt, expiresAt })
Cross-tab sync SharedWorker → BroadcastChannel (umb:auth channel)
Refresh coordination None → Web Lock (umb:token-refresh, ifAvailable: true) — first tab refreshes, others skip
Timeout modal SharedWorker-triggered → setTimeout with Web Lock leader election (umb:timeout-modal)
New tab init Server call → Peer session request via BroadcastChannel (falls back to /token)
PKCE (popup) localStorage → window.opener.postMessage for code_verifier exchange
PKCE (redirect) localStorage → sessionStorage (tab-scoped, survives navigation)
Sign-out Single tab → Broadcast signedOut to all tabs for redirect
keepUserLoggedIn Async API call after auth → Sync HTML attribute from SecuritySettings
Extension DX Manual wiring → authContext.configureClient(myClient) one-liner (sets auth + binds interceptors)
http-client module credentials: 'include' only → also sets auth callback + default Authorization: Bearer [redacted] header at import time (covers both SDK and direct calls)
OAuth completion history.replaceStatewindow.location.href (force full navigation for cookie hydration)
SignalR hub Could throw if auth context arrived before server context → waits for both contexts

Deprecations (v17 → removal in v19)

  • @umbraco-cms/backoffice/external/openid — replaced with type-only stubs
  • UMB_STORAGE_TOKEN_RESPONSE_NAME constant
  • authorizationSignal on UmbAuthContext (use isAuthorized instead)
  • getLatestToken() on UmbAuthContext (use configureClient() or getOpenApiConfiguration() instead)

Known: E2E acceptance tests need updating

The @umbraco/playwright-testhelpers package (ApiHelpers.getLocalStorageAuthToken) reads umb:userAuthTokenResponse from localStorage to check token validity and manage refresh timing. Since this PR removes localStorage usage, those helpers fail with:

TypeError: Cannot read properties of undefined (reading 'value')
    at ApiHelpers.getLocalStorageAuthToken

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's storageState already replays them. The refresh call itself (POST /token with grant_type: refresh_token) still works; only the "should I refresh?" timing check needs updating.

Test plan

  • Single tab login/logout
  • Multi-tab login (no blank pages — the original bug)
  • Token refresh with keepUserLoggedIn=true (only one tab calls /token via ifAvailable lock)
  • Sign-out propagation across tabs
  • Timeout modal with keepUserLoggedIn=false (leader-elected, one tab)
  • Incognito mode (zero localStorage)
  • New tab peer session via BroadcastChannel
  • TypeScript compilation clean
  • Unit tests pass (30 tests — PKCE generation, auth client, auth context)
  • OAuth redirect flow (force navigation for cookie hydration)
  • SignalR hub connects after login (no race condition)
  • External identity provider popup flow
  • Playwright E2E acceptance tests (blocked on E2E: Move test helpers and builders into the acceptance test project and publish as @umbraco/acceptance-test-helpers #21773)

🤖 Generated with Claude Code

iOvergaard and others added 13 commits February 19, 2026 13:11
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>
Copilot AI review requested due to automatic review settings February 19, 2026 13:17
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>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 rewrites UmbAuthContext around in-memory session timing + BroadcastChannel signaling.
  • Replaces the SharedWorker-based session timeout logic with a setTimeout scheduler + 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.

iOvergaard and others added 12 commits February 19, 2026 14:27
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>
iOvergaard and others added 4 commits February 20, 2026 09:29
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant