Skip to content

fix(auth): sign out and redirect on 401 from the CM API#1523

Merged
DavidCockerill merged 4 commits into
stagefrom
fix/api-401-auto-logout
Jul 22, 2026
Merged

fix(auth): sign out and redirect on 401 from the CM API#1523
DavidCockerill merged 4 commits into
stagefrom
fix/api-401-auto-logout

Conversation

@DavidCockerill

Copy link
Copy Markdown
Member

Draft — companion to the central-manager fabric-admin session work (fabric-admin-auth-hardening, adds a FABRIC_ADMIN_SESSION_MAX_AGE_HOURS cap). Keeping this in draft until that lands. Useful on its own, though — it fixes the client reaction to any lost session.

Problem

The CM apiClient (apiClient.ts) has no response interceptor, and the dashboard guard (dashboardRoute.ts) only redirects when the cached authStore user is already null. So when a cloud session is lost mid-use — normal cookie expiry, server-side revocation, or a fabric admin hitting the new max-age cap — the SPA keeps rendering off the stale cached user while every data call fails with Not allowed errors. The user isn't bounced to sign-in until they manually refresh (which re-runs getCurrentUser, gets 401, and empties the store).

Verified live on dev: after a fabric-admin session was invalidated server-side, the UI kept navigating (cached authStore) and only redirected on a hard refresh.

Fix

Add a response interceptor on apiClient: on 401, clear the cached cloud auth (authStore.setUserForEntity(OverallAppSignIn, null)). That triggers the existing AppRouted router.invalidate() effect → guards re-run → redirect to /sign-in. No imperative router access needed.

Only 401 triggers it. A dead session yields 401 from GET /User/current (its allowRead returns false when there's no user). 403 is left alone — it's a legitimate "authenticated but not permitted" response and must not log the user out.

Where to look

  • src/lib/unauthorizedResponseHandler.ts — the 401 detection, factored app-import-free so it unit-tests in node env (importing authStore pulls in localStorage at module load).
  • src/lib/installApiUnauthorizedRedirect.ts — wires it to apiClient with the authStore-clearing callback.
  • src/main.tsx — installed at startup alongside the other install* guards.

Testing

  • vitest run src/lib/unauthorizedResponseHandler.test.ts — 3 passing (401 clears + rejects; 403 doesn't; network error doesn't).
  • Not yet covered: the full interceptor→guard→redirect path in a running app. Worth a manual check once merged with the CM cap (sign in, let the session expire, confirm auto-redirect without a manual refresh).

Open question for reviewers

403s from a dead session (admin endpoints return Not allowed/403, not 401) won't trigger this. Keying on 401 only is the safe choice (avoids logging out on genuine permission denials), but it means a data call that only ever 403s won't itself force logout — the redirect comes from the next 401-returning call (e.g. getCurrentUser on focus/refetch). Flagging in case we want 403-on-/User/current handling too.

Generated with the assistance of an LLM (Claude Opus 4.8).

The CM api client had no response interceptor, and the dashboard route
guard only redirects when the cached authStore user is already null. So
when a cloud session is lost mid-use (expiry, revocation, or a fabric
admin hitting the new session max-age cap), the SPA keeps rendering off
the stale cached user while every data call fails with errors until a
manual refresh.

Add a response interceptor on apiClient: a 401 clears the cached cloud
auth (OverallAppSignIn -> null), which re-runs the route guards and
redirects to /sign-in. Only 401 (unauthenticated) triggers it; 403 is
left alone since it can be a legitimate permission denial for a still
-authenticated user.

The 401-detection core is factored into a small app-import-free module so
it unit-tests in the default node env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces an Axios response interceptor to automatically clear the cached user authentication state and redirect to the sign-in page when a 401 Unauthorized response is received. The feedback suggests making the error handler more robust by guarding against undefined or non-Axios errors, and adding corresponding unit tests to verify this behavior.

Comment thread src/lib/unauthorizedResponseHandler.ts
Comment thread src/lib/unauthorizedResponseHandler.test.ts
@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 51.84% 5545 / 10695
🔵 Statements 52.4% 5934 / 11323
🔵 Functions 44.28% 1369 / 3091
🔵 Branches 44.85% 3728 / 8312
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/auth/store/authStore.ts 67.92% 54.7% 80.48% 67.61% 85-91, 107-116, 126, 147, 166-177, 184-193, 248-251, 395-406, 448-454, 488, 492-499, 511, 524-552
src/lib/unauthorizedResponseHandler.ts 100% 100% 100% 100%
Generated in workflow #1567 for commit a1fd078 by the Vitest Coverage Report Action

@dawsontoth

Copy link
Copy Markdown
Contributor

sounds good to me, lemme know when you're out of draft :D

@DavidCockerill

Copy link
Copy Markdown
Member Author

@dawsontoth once my CM work relating to this is done i'll move it out of draft. Just in case i need to modify anything

@DavidCockerill
DavidCockerill marked this pull request as ready for review July 17, 2026 18:09
@DavidCockerill
DavidCockerill requested a review from a team as a code owner July 17, 2026 18:09
Review feedback (Gemini): an upstream interceptor could reject with a
non-AxiosError (even undefined); optional-chain the error so the handler
re-rejects with the original reason instead of its own TypeError. Keeps
the AxiosError typing rather than widening to any.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice

Comment thread src/lib/unauthorizedResponseHandler.ts Outdated
Comment thread src/lib/installApiUnauthorizedRedirect.ts

@dawsontoth dawsontoth left a comment

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.

I like Kris' suggestions, mind folding those in to this PR?

…tall

Review feedback (Kris): CM's verifyToken rejects a bad/stale reset or
verify-email token with 401, so a stale email link opened in a second
tab would have signed the user out of a live session. The interceptor
now takes an exemption predicate and skips the unauthenticated auth
flows (/Login, /ForgotPassword, /ResetPassword, /VerifyEmail,
/ResendVerificationEmail). Also guards install against re-invocation
(HMR) to match the sibling install* helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@DavidCockerill

Copy link
Copy Markdown
Member Author

@dawsontoth folded in (70fb4dc) — Kris's first point turned out to be a live bug, not just a question: CM 401s stale reset/verify-email tokens, so a stale link in another tab would have signed out an active session. The interceptor is now scoped away from the unauthenticated auth flows, and install is idempotent. Still holding draft until the related CM session work (central-manager#459) lands.

— Claude Opus 4.8 (on behalf of @DavidCockerill)

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good, maybe a race condition to address.

apiClient.interceptors.response.use(
(response) => response,
makeUnauthorizedResponseHandler(
() => authStore.setUserForEntity(OverallAppSignIn, null),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This clears only the cloud-user slot, so it is not equivalent to the existing logout path. After A's CM session expires, the in-memory entity connections/Fabric tokens, persisted basic/Fabric flags, and React Query cache remain; if B signs in without a reload, instanceLayoutRoute can trust A's still-populated entity connection before checking B's cloud permissions, and queries can reuse A's cached data. Could we centralize a local-only full-sign-out cleanup (all auth connections/tokens/flags plus user-scoped query/session data, without retrying the failed CM logout) and use it here? An A → 401 → B test should assert those stores are empty before B's routes load.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in a1fd078. You're right the partial clear wasn't equivalent to logout. Added authStore.signOutAllLocally() — a local-only teardown that reuses the existing per-entity signOutLocally() for every entity in potentiallyAuthenticated (clears the in-memory connection, the stored basic-auth creds, the Fabric Connect flag and its in-memory token), then clears the residual maps (fabricConnectAuth/fabricConnectInFlight/operationTokenRefreshInFlight) and the cloud slot — no per-instance or CM network logout. The 401 path now calls a clearAuthStateLocally() handler that runs that plus both React Query caches (Query + Mutation — logout only cleared QueryCache) and storage. A test sets up A's cloud + instance connections with a Fabric flag and asserts they're all null/false after sign-out, so B can't inherit them.

— Claude Opus 4.8 (on behalf of @DavidCockerill)

Comment thread src/lib/unauthorizedResponseHandler.ts Outdated
// `error?.`: axios itself always rejects with an AxiosError, but an upstream
// interceptor added later could reject with anything (even undefined) — don't
// let this handler replace the rejection reason with its own TypeError.
if (error?.response?.status === 401 && !isExemptUrl(error.config?.url ?? '')) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could we guard this with the auth generation that was current when the request was sent? A normal expiry sequence can otherwise clear a fresh login: requests A/B leave with the expired cookie, A's 401 redirects to sign-in, /Login/ succeeds and stores the new user, then slower B returns its old 401 and this unconditional handler clears the new session. Stamping the request config in a request interceptor (generation or user identity) and only clearing when it still matches would close the race; a deferred-response test resolving B after simulated login would cover it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in a1fd078, via identity stamping as you suggested. A request interceptor now stamps the cloud user id at send time onto the request config; the handler takes an isStillCurrent(config) guard and only clears when the stamped identity equals the current one. So the sequence you described — A expires → 401 clears + redirect → re-login as B → B's earlier in-flight request returns its stale 401 — no longer clears B: at response time the current identity is B but the stale request was stamped A, so it's ignored. Two tests cover it: identity-unchanged (normal expiry → clears) and identity-changed-after-relogin (stale 401 → does not clear).

— Claude Opus 4.8 (on behalf of @DavidCockerill)

Review feedback (Kris):

1. The 401 handler did only a partial clear (cloud-user slot), leaving A's
   in-memory entity connections / Fabric tokens / query cache alive — so a
   same-tab re-login as B could inherit them. Added authStore.signOutAllLocally()
   (local-only: clears every connection, Fabric token, and flag for all
   entities + the cloud slot, no network logout) and a clearAuthStateLocally()
   handler that also clears both React Query caches and storage. The 401 path
   now uses it. Test asserts A's connections/flags are gone after sign-out.

2. Auth-generation race: a slow 401 from a request sent under the OLD session
   could clear a freshly re-established session (A expires → 401 → re-login as
   B → B's earlier in-flight request 401s). A request interceptor now stamps the
   cloud identity at send time; the handler's new isStillCurrent guard only
   clears when the identity is unchanged at response time. Tests cover both the
   normal-expiry (clears) and stale-401-after-relogin (does not clear) cases.

Full suite 1599 passing; tsc/lint/format clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@DavidCockerill
DavidCockerill added this pull request to the merge queue Jul 22, 2026
Merged via the queue into stage with commit b5083d8 Jul 22, 2026
2 checks passed
@DavidCockerill
DavidCockerill deleted the fix/api-401-auto-logout branch July 22, 2026 13:52
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.

3 participants