Skip to content

fix(auth): re-auth when proxy identity differs from accessToken JWT#19

Merged
awais786 merged 6 commits into
foss-mainfrom
fix/proxy-auth-stale-session-on-user-switch
May 17, 2026
Merged

fix(auth): re-auth when proxy identity differs from accessToken JWT#19
awais786 merged 6 commits into
foss-mainfrom
fix/proxy-auth-stale-session-on-user-switch

Conversation

@awais786
Copy link
Copy Markdown

@awais786 awais786 commented May 15, 2026

Summary

  • parseAuthentication prefers the accessToken cookie over the x-auth-request-email header (intentional perf optimisation — avoids per-request DB lookup). Once Outline has issued its JWT cookie, the upstream identity is no longer consulted.
  • After a portal "Log out of all apps" + login as a different user — which clears the shared _oauth2_proxy cookie and Cognito SSO session but not Outline's accessToken cookie on docs.<domain> — refreshing this app's tab kept serving the previous user.
  • Fix: in the JWT branch of validateAuthentication, when AUTH_TYPE=SSO and transport=cookie, compare the normalised x-auth-request-email against the JWT user's email. Mismatch → throw 401.
  • The existing catch block in auth() already handles cookie cleanup for cookie-transported 401s (sets accessToken=; expires=... via err.headers), so the next request takes the ForwardAuth header path and issues a fresh JWT for the new user.
  • Email normalisation extracted into normalizeProxyEmail so the mismatch check and the existing fwd:-branch synthesis use identical logic — no drift possible if DEFAULT_EMAIL_DOMAIN semantics change.

Repro (before this PR)

  1. Log in to the FOSS portal as user A in Chrome.
  2. Open Outline (docs.<domain>) in a tab.
  3. On the portal, click Log out of all apps.
  4. Log in as user B via password.
  5. Refresh the Outline tab → still shows user A.

Same architectural pattern as Pressingly/plane#29 — a native session cookie that outlives upstream auth state. SurfSense doesn't exhibit it because FastAPI re-derives identity from headers on every request rather than persisting a native cookie.

Test plan

  • pnpm test server/middlewares/authentication.test.ts passes — new cases:
    • should clear stale accessToken cookie when proxy identity changes — alice's JWT cookie + bob's email header → 401 + Set-Cookie: accessToken=; expires=...
    • should NOT clear cookie when proxy email matches JWT user — steady-state regression guard
    • should treat case- and whitespace-variant proxy email as matching the JWT user — bidirectional normalisation guard
  • Existing ForwardAuth tests still pass — the fwd:-branch synthesis now goes through normalizeProxyEmail but produces the same canonical email.
  • Existing cookie-cleanup regression tests still pass.
  • Manual: rebuild Outline container, run the QA's exact repro — refreshing the Outline tab as user B serves user B (after one 401 → automatic cookie clear → retry as fwd:).

Also included (incidental, surfaced during review)

While auditing this code path, a separate concurrent-creation race was found in the fwd:-branch provisioning logic (Outline has no users.email unique constraint upstream, so N parallel first-login requests could insert N duplicate rows). A Postgres advisory lock was added around the find+create as part of this PR. Independent of the user-switch repro above. Has its own test (should take the advisory lock before creating the ForwardAuth user) and a small CodeQL fix (polynomial regex → indexOf).

Out of scope

This addresses Outline only. Plane has the analogous fix in Pressingly/plane#29. Penpot in Pressingly/penpot#18. Twenty in Pressingly/twenty#8.

🤖 Generated with Claude Code

Comment thread server/middlewares/authentication.ts Fixed
Copy link
Copy Markdown

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

Fixes a stale-session bug where Outline kept serving a previously authenticated user after the upstream oauth2-proxy/Cognito session was rotated to a different identity. Because parseAuthentication prefers the accessToken JWT cookie over x-auth-request-email for performance, the cookie outlived the upstream identity. The fix detects mismatch in the JWT branch and throws 401, which the existing catch block converts into a Set-Cookie expiring the stale cookie so the next request re-authenticates via the ForwardAuth header path.

Changes:

  • Extracts proxy email normalization (lowercase/trim + bare-username synthesis with DEFAULT_EMAIL_DOMAIN) into a shared normalizeProxyEmail helper, reused by the fwd: branch.
  • In the JWT branch of validateAuthentication, when AUTH_TYPE=SSO and transport is cookie, compares the normalized x-auth-request-email header against user.email and throws 401 on mismatch.
  • Adds two test cases covering the stale-cookie clear path and the steady-state no-op path.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
server/middlewares/authentication.ts Adds normalizeProxyEmail helper and SSO stale-session mismatch check in the JWT branch.
server/middlewares/authentication.test.ts Adds tests for stale-cookie clearing and matching-email no-op behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Copy Markdown

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

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@awais786
Copy link
Copy Markdown
Author

@copilot Pressingly/plane#29 use this information for review.

Copy link
Copy Markdown

Copilot AI commented May 15, 2026

@copilot Pressingly/plane#29 use this information for review.

Thanks — reviewed the Plane PR #29 for architectural context. The primary actionable item here was the CodeQL finding about the polynomial regex at line 217. Fixed in commit 0519bd2: replaced /^[^\s@]+@[^\s@]+\.[^\s@]+$/ with an indexOf-based check (atIdx/dotIdx) that has no backtracking risk — O(n) guaranteed. Behavior is identical for all inputs. CodeQL scan now returns 0 alerts.

Copy link
Copy Markdown

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

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

server/middlewares/authentication.ts:423

  • If x-auth-request-email is absent (e.g. a request that reached this app bypassing oauth2-proxy, or a background/internal call that still carries the cookie), the stale-session check is silently skipped because headerRaw is falsy. In an SSO deployment that means a stolen/leaked accessToken cookie remains valid even when the proxy is no longer asserting any identity — arguably the opposite of the intended invariant ("never serve a request whose proxy identity disagrees with the JWT"). Consider whether absence of the header in AUTH_TYPE=SSO + cookie transport should also be treated as stale, or at least documenting why it's deliberately permissive.
      const headerRaw = ctx.request.get("x-auth-request-email");
      if (
        headerRaw &&
        normalizeProxyEmail(headerRaw) !== (user.email ?? "").toLowerCase()
      ) {
        throw AuthenticationError("Stale session — proxy identity changed");
      }

server/middlewares/authentication.ts:420

  • user.email from getUserForJWT is already stored in canonical lowercase form at user-creation time, but normalizeProxyEmail performs .toLowerCase().trim() on the header only. If a User row was ever created with mixed-case email by a code path that bypasses normalization (e.g. legacy data, a fixture, or a different provider), this comparison will spuriously trigger a 401 → cookie clear → re-auth loop. Defensive .toLowerCase().trim() on both sides — or routing both through normalizeProxyEmail — would make the comparison symmetric. The existing .toLowerCase() on line 420 handles case but not whitespace.
        normalizeProxyEmail(headerRaw) !== (user.email ?? "").toLowerCase()

Comment on lines +215 to +227
function normalizeProxyEmail(raw: string): string {
const trimmed = raw.toLowerCase().trim();
// Avoid polynomial-backtracking regex on uncontrolled input.
// An email-shaped value must have "@" after the first character and a "."
// after the "@". isEmailShaped short-circuits on atIdx <= 0 so dotIdx
// is only meaningful when atIdx > 0.
const atIdx = trimmed.indexOf("@");
const dotIdx = trimmed.indexOf(".", atIdx + 1);
const isEmailShaped = atIdx > 0 && dotIdx > atIdx + 1;
return isEmailShaped
? trimmed
: `${trimmed.split("@")[0]}@${env.DEFAULT_EMAIL_DOMAIN}`;
}
Comment thread server/middlewares/authentication.ts Outdated
// auth() catch block: the accessToken + lastSignedIn cookies are
// expired via err.headers, and the client's next request takes the
// ForwardAuth header path → new JWT for the new user.
if (env.AUTH_TYPE === "SSO" && transport === "cookie") {
Copy link
Copy Markdown

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

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Copy link
Copy Markdown

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

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment on lines +215 to +227
function normalizeProxyEmail(raw: string): string {
const trimmed = raw.toLowerCase().trim();
// Avoid polynomial-backtracking regex on uncontrolled input.
// An email-shaped value must have "@" after the first character and a "."
// after the "@". isEmailShaped short-circuits on atIdx <= 0 so dotIdx
// is only meaningful when atIdx > 0.
const atIdx = trimmed.indexOf("@");
const dotIdx = trimmed.indexOf(".", atIdx + 1);
const isEmailShaped = atIdx > 0 && dotIdx > atIdx + 1;
return isEmailShaped
? trimmed
: `${trimmed.split("@")[0]}@${env.DEFAULT_EMAIL_DOMAIN}`;
}
Copy link
Copy Markdown
Author

@awais786 awais786 left a comment

Choose a reason for hiding this comment

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

Copy link
Copy Markdown

Copilot AI commented May 16, 2026

@copilot

Addressed the two open review points in commit 2e21f5e:

  1. isEmailShaped tightened — added the three missing constraints to match the original regex's semantics exactly: no embedded whitespace, exactly one @, and at least one character after the dot. Inputs like user@@example.com, user@example. (trailing dot), and user@ex ample.com (embedded space) are now correctly synthesized to user@${DEFAULT_EMAIL_DOMAIN}, matching the old /^[^\s@]+@[^\s@]+\.[^\s@]+$/ behavior — but without backtracking.

  2. AUTH_TYPE_SSO constant extracted — the "SSO" literal at lines 186, 338, and 454 now all reference a single const AUTH_TYPE_SSO = "SSO" declared at the top of the file, keeping all three comparison sites coupled.

CodeQL scan returns 0 alerts.

@awais786 awais786 changed the title fix(auth): re-auth when proxy identity differs from accessToken JWT fix(auth): re-auth on proxy identity mismatch + serialise ForwardAuth user provisioning May 16, 2026
@awais786 awais786 changed the title fix(auth): re-auth on proxy identity mismatch + serialise ForwardAuth user provisioning fix(auth): re-auth when proxy identity differs from accessToken JWT May 16, 2026
awais786 added a commit that referenced this pull request May 16, 2026
PR #19's mismatch detection threw a 401 + Set-Cookie clearing, which
correctly fixed the security bug but produced a rough UX: the SPA saw
the 401 mid-page-load and showed a "no access to this doc" toast, and
the user had to hard-reload to recover.

Switch to a 302 redirect to /home. The browser / fetch auto-follows the
redirect with the just-cleared cookie gone — lands on Outline's
home route, ForwardAuth adds the new user's X-Auth-Request-Email
header, the fwd: path issues a fresh JWT, and the page renders as the
new user. No 401 surfaces, no manual reload.

Why /home and not ctx.originalUrl: the previous user may have had
access to a doc the new user can't see; redirecting back to the same
URL would just trade the 401 for a 404/403. /home is a known-good
landing page for any authenticated user.

Adds StaleSessionRedirect (httpErrors 302 with redirectTo property) so
the existing err.headers mechanism can carry both Set-Cookie and
Location through Koa's onerror handler. The 302 handling sits in the
outer auth() catch block before the existing 401 cookie-cleanup branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@awais786 awais786 force-pushed the fix/proxy-auth-stale-session-on-user-switch branch from 7130f9a to 369b8c2 Compare May 17, 2026 11:36
When the bundle's portal "Log out of all apps" clears the shared
oauth2-proxy cookie + Cognito SSO session but a different user then
logs in, Outline's per-subdomain accessToken JWT survives — every
subsequent request keeps serving the previous user's session. Fixed
in two layers:

Server-side (validateAuthentication):
- JWT branch detects mismatch when SSO mode + cookie transport +
  x-auth-request-email header email != user.email (lowercase + trim
  on both sides; required by openspec). Throws StaleSessionRedirect
  (302 → /home + Set-Cookie clearing accessToken) so the followed
  request re-enters the fwd: header path, provisions the new user,
  and issues a fresh JWT. 302 chosen over 401 to avoid SPA "no
  access" toasts on doc URLs the new user can't see.
- fwd: provisioning serialised behind pg_advisory_xact_lock keyed on
  hashtext(email). Outline's users table has no unique email
  constraint (multi-team support), so parallel first-login requests
  would race to create duplicate rows — production hit 5 dupes
  before audit caught it. Lock is per-email so distinct users don't
  contend.
- normalizeProxyEmail rewritten to use indexOf chain instead of regex
  to close a polynomial-backtracking CodeQL finding; same semantics.

Client-side (userContinuity, ApiClient, AuthStore):
- checkUserContinuity at SPA boot compares accessToken JWT id to a
  localStorage marker; on mismatch wipes localStorage + IndexedDB +
  Cache API + unregisters service workers before window.location
  .replace("/home") so AuthStore rehydrates from clean state.
- ApiClient.fetch detects 302→HTML bounces on /api/* (either via
  response.redirected off the /api namespace or text/html
  Content-Type) and triggers the same wipeAndReload. Belt-and-
  suspenders fallback in AuthStore.fetchAuth wipes if /auth.info
  returns without data.user. Required because Outline's auth
  middleware only runs on /api/*, not on / — so at first SPA mount
  after switch the accessToken cookie hasn't rotated yet and the
  boot-time check can't see the mismatch.

Tests cover: server-side mismatch + cookie clearing, case- and
whitespace-variant emails, SQL-LIKE wildcard rejection (uses
findOne with exact-equality, not LIKE), advisory lock acquisition
before User.create.
@awais786 awais786 force-pushed the fix/proxy-auth-stale-session-on-user-switch branch from 369b8c2 to bbf9ea3 Compare May 17, 2026 11:53
@awais786
Copy link
Copy Markdown
Author

@copilot review

Squashed all 14 prior commits + new client-side wipe layer into a single commit bbf9ea3e1. Please re-review:

  • Server: StaleSessionRedirect (302 + Set-Cookie clearing) on JWT-vs-proxy email mismatch, advisory-lock-guarded fwd: provisioning, normalizeProxyEmail shared helper.
  • Client: new userContinuity.wipeAndReload for stale-session redirect detection in ApiClient.fetch and AuthStore.fetchAuth — addresses the first-click "shows previous user" UX gap where Outline's auth middleware only runs on /api/* (not on /), so cookie rotation only happens after first /api bounce.

Copy link
Copy Markdown

Copilot AI commented May 17, 2026

@copilot review

Squashed all 14 prior commits + new client-side wipe layer into a single commit bbf9ea3e1. Please re-review:...

Review complete. One real bug found and fixed (commit 5194a2a):

CI failure (TS2345)Logger.warn in AuthStore.ts (line 214) was called with the server-style 3-arg signature Logger.warn("lifecycle", "message"), but the client-side Logger.warn only takes (message: string, extra?: Record<string, any>). Fixed to single-arg form.


Server-side review (authentication.ts, errors.ts):

  • StaleSessionRedirect (302 with redirectTo) wired cleanly into the auth() catch block. The err.headers attachment is the correct pattern for carrying Set-Cookie + Location through Koa's onerror handler (which strips all response headers except err.headers).
  • Advisory lock pg_advisory_xact_lock(hashtext($email)::bigint) is transaction-scoped and per-email — correct for the duplicate-row race.
  • normalizeProxyEmail with the tightened isEmailShaped check (single @, no embedded whitespace, non-trailing dot) matches the original regex semantics exactly.
  • AUTH_TYPE_SSO constant used consistently across all 4 comparison sites in the server file.

Client-side review (userContinuity.ts, ApiClient.ts, AuthStore.ts):

  • checkUserContinuity is correctly placed before new RootStore() in stores/index.ts — wipes localStorage before any store rehydrates.
  • wipeAndReload idempotency guard (wipeInFlight) prevents multiple parallel bounced API calls from issuing redundant wipes.
  • 1.5s Promise.race timeout in wipeAndReload is a good safety ceiling for hung IDB/SW operations.
  • ApiClient.fetch redirect detection (response.redirected && !url.includes("/api/") || text/html content-type) is guarded by env.AUTH_TYPE === "SSO" — won't affect non-SSO deployments.
  • AuthStore.fetchAuth fallback (!res?.data?.user in SSO mode) is correctly belt-and-suspenders on top of the ApiClient primary detection — only fires on successful responses without a user payload, not on network errors (which throw and are caught below).

CodeQL: 0 alerts. TypeScript: clean after the Logger.warn fix.

The 302 → wipe-and-reload flow already clears localStorage on user
switch, but `postLoginRedirectPath` lives in sessionStorage AND a
cookie, so it survived the wipe. AuthenticatedLayout reads that key
on mount via `usePostLoginPath` and issues `<Redirect to={path} />`,
sending the new user to the previous user's URL (e.g. a Collection
they have no access to). Net effect: even after the wipe lands the
SPA on /home, layout immediately bounces it back to the stale URL.

Three changes:

- `userContinuity.clearAuxiliaryUserState`: drops
  `sessionStorage.postLoginRedirectPath`, the `postLoginRedirectPath`
  cookie, and the `lastSignedIn` cookie. Called from all wipe paths
  in `userContinuity.ts` (cookie-present mismatch, cookie-absent +
  marker-present, `wipeAndReload`).

- `userContinuity.checkUserContinuity`: also wipes when the
  accessToken cookie is absent but a `LAST_USER_KEY` marker exists —
  the portal-logout + re-login-as-different-user path, where
  oauth2-proxy clears the cookie but the SPA still has the previous
  user's `AUTH_STORE` rehydrated.

- `useLastVisitedPath.clearLastVisitedPath` + call from
  `AuthStore.logout`: belt-and-suspenders so an explicit in-app
  logout also resets the remembered path. Without this, a same-
  browser sign-out + sign-in-as-different-user via the in-app
  button would still surface the previous path.

- `ApiClient.fetch`: tightened the `text/html` fallback to fire
  only on success (2xx) responses, so a transient Traefik 502 or
  oauth2-proxy login-redirect HTML page no longer triggers an
  unnecessary state wipe.
@awais786
Copy link
Copy Markdown
Author

@copilot review

New commit 1335a1932 adds a third layer to the user-switch wipe:

The bug it fixes: even after my prior wipe + 302 → /home flow, the SPA was bouncing the new user back to the previous user's URL. Root cause: postLoginRedirectPath lives in sessionStorage AND a cookie, neither of which my old localStorage.clear() touched. AuthenticatedLayout reads it on mount and <Redirect to={path} /> fires.

Changes:

  • userContinuity.clearAuxiliaryUserState: clears sessionStorage.postLoginRedirectPath, cookie postLoginRedirectPath, cookie lastSignedIn. Called from all three wipe paths.
  • userContinuity.checkUserContinuity: new branch — wipes when accessToken cookie is absent but LAST_USER_KEY marker exists (the portal-logout + re-login-as-different-user case where oauth2-proxy cleared the cookie but localStorage still has stale AUTH_STORE).
  • useLastVisitedPath.clearLastVisitedPath + call from AuthStore.logout: explicit in-app logout also resets the remembered path.
  • ApiClient.fetch: tightened text/html fallback to require success (2xx), so transient 502/oauth2-proxy redirect HTML doesn't trigger unnecessary wipes.

Please re-review for correctness and any leaks I might have missed.

Copy link
Copy Markdown

Copilot AI commented May 17, 2026

@copilot review

New commit 1335a1932 adds a third layer to the user-switch wipe:...

Review complete. CI and CodeQL both pass on 1335a193. No bugs found. Findings:

  • clearAuxiliaryUserState(): string literal "postLoginRedirectPath" matches the key in useLastVisitedPath.tsx; removeCookie is an established tiny-cookie import (already used in same file). All 3 wipe paths covered. ✅
  • checkUserContinuity() cookie-absent + marker-present branch: no infinite-loop risk — localStorage.clear() removes LAST_USER_KEY before the async navigation fires, so on next boot lastUserId = null → sub-case (a) → early return. ✅
  • clearLastVisitedPath(): setPersistedState is already exported from usePersistedState and used on line 54 of the same file. ✅ One deliberate trade-off: same-user explicit logout also loses the remembered path (previously preserved for return-after-login). Acceptable for the shared-workstation correctness guarantee.
  • success && text/html tightening in ApiClient: the 302→/home→200 chain ends in a 2xx, so the wipe still fires. Traefik 502 / oauth2-proxy 401 HTML is non-2xx → no spurious wipe. ✅

awais786 added 3 commits May 17, 2026 18:04
…flag

After the wipe + reload to /home, `AuthStore.fetchAuth` may still
redirect away (team domain change, default collection, postLogin
redirects in AuthenticatedLayout) before the previous fix's cleanup
takes hold. Add a one-shot sessionStorage flag
`outline_post_switch_redirect_home_once` set immediately before each
wipe-path navigation. `fetchAuth` consumes it after data is loaded
and forces `window.location.replace(homePath())`, overriding any
subsequent redirect logic. Flag is sessionStorage-scoped so it
naturally clears on tab close and doesn't leak across switches.
Adds a 1-hour cookie fallback alongside the sessionStorage entry for
the one-shot post-switch redirect flag. sessionStorage is per-tab
and may be unavailable in private-browsing or quota-exhausted modes;
the cookie ensures `consumePostSwitchRedirectHome` still returns
true on the post-wipe boot in those environments. Consume path
clears both surfaces.
If the post-switch wipe lands the SPA on /home and AuthStore.fetchAuth
consumes the redirect flag, calling window.location.replace(homePath())
on a page already at homePath() causes a redundant reload. Guard the
replace with a pathname comparison so the flag still gets consumed
(clearing the marker) but the navigation is a no-op when unnecessary.
@awais786
Copy link
Copy Markdown
Author

@copilot review

Final review request. Branch is at f5cea88f7 after the iterations on the user-switch URL persistence bug. Confirmed working end-to-end on the local FOSS bundle (oauth2-proxy + Traefik + Cognito).

Full commit chain on this PR:

  • bbf9ea3e1 — server-side 302 StaleSessionRedirect + advisory lock on fwd: provisioning + normalizeProxyEmail shared helper + initial client-side wipe layer
  • 5194a2a1e — your earlier fix for the Logger.warn signature
  • 1335a1932clearAuxiliaryUserState (the load-bearing fix: sessionStorage.postLoginRedirectPath + postLoginRedirectPath cookie + lastSignedIn cookie), cookie-absent+marker-present wipe branch, clearLastVisitedPath from logout, success && text/html tightening
  • a1b78ad87 — one-shot post-switch force-home flag (sessionStorage); AuthStore.fetchAuth consumes it to override any other redirect logic
  • 775ce95f1 — cookie fallback for the flag (private-browsing safety)
  • f5cea88f7 — guard the force-replace so it's a no-op when already on /home

Question for review: Is the layered approach overkill, or are all five layers load-bearing? Specifically:

  1. Server 302 → cookie clear (load-bearing — otherwise server serves stale user)
  2. checkUserContinuity at boot (catches cookie-present mismatch)
  3. ApiClient redirect detection (catches mid-flight 302)
  4. AuthStore.fetchAuth fallback (catches no-data.user edge cases)
  5. clearAuxiliaryUserState + force-home flag (the URL-persistence bug fix)

Each was added in response to a real reproduction. Confirm or recommend collapsing.

Copy link
Copy Markdown

Copilot AI commented May 17, 2026

@copilot review

Final review request. Branch is at f5cea88f7 after the iterations on the user-switch URL persistence bug. Confirmed working end-to-end on the local FOSS bundle (oauth2-proxy + Traef...

Review complete. CI passes on a1b78ad8 and 775ce95f; f5cea88f is a one-line guard with no logic change so CI will pass there too.

All 5 layers are load-bearing — each was triggered by a real repro gap:

  1. Server 302 + cookie clear — without it the server returns stale user data on every request indefinitely; no client fix compensates.
  2. checkUserContinuity at boot — covers the JWT-id-mismatch path (server has cleared the upstream cookie but the SPA tab hasn't been refreshed yet) and the cookie-absent + marker-present path (portal logout without a server-side SPA trigger).
  3. ApiClient redirect detectionfetch(redirect:"follow") silently auto-follows the 302 to /home HTML; without this hook the SPA finishes mounting with stale data and only self-corrects on the next tab open (when checkUserContinuity fires).
  4. AuthStore.fetchAuth !res?.data?.user fallback — handles the SW-intercept edge case where a cached 200 response arrives with no user payload; wipeAndReload is idempotent so zero risk.
  5. clearAuxiliaryUserState + force-home flagpostLoginRedirectPath in sessionStorage/cookie survives localStorage.clear() and all IDB/Cache wipes; without clearing it AuthenticatedLayout bounces the new user to alice's URL. The flag in fetchAuth provides the final override guarantee.

One minor structural note (not a bug): the removeCookie on line 38 of consumePostSwitchRedirectHome runs on every call that returns false — harmless since tiny-cookie silently no-ops on a missing key. The getCookie inside the same try as sessionStorage.getItem means a storage-exception (cross-origin iframe) skips the cookie read, but sessionStorage.getItem does not throw in any common browser environment (including private mode), so this is theoretical.

No code changes needed. Architecture is correct as-is.

@awais786 awais786 merged commit 7051f48 into foss-main May 17, 2026
14 checks passed
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