fix(auth): sign out and redirect on 401 from the CM API#1523
Conversation
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>
There was a problem hiding this comment.
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.
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||
|
sounds good to me, lemme know when you're out of draft :D |
|
@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 |
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>
dawsontoth
left a comment
There was a problem hiding this comment.
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>
|
@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
left a comment
There was a problem hiding this comment.
Looks good, maybe a race condition to address.
| apiClient.interceptors.response.use( | ||
| (response) => response, | ||
| makeUnauthorizedResponseHandler( | ||
| () => authStore.setUserForEntity(OverallAppSignIn, null), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)
| // `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 ?? '')) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
Problem
The CM
apiClient(apiClient.ts) has no response interceptor, and the dashboard guard (dashboardRoute.ts) only redirects when the cachedauthStoreuser is alreadynull. 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 withNot allowederrors. The user isn't bounced to sign-in until they manually refresh (which re-runsgetCurrentUser, 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 existingAppRoutedrouter.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(itsallowReadreturns 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 (importingauthStorepulls inlocalStorageat module load).src/lib/installApiUnauthorizedRedirect.ts— wires it toapiClientwith the authStore-clearing callback.src/main.tsx— installed at startup alongside the otherinstall*guards.Testing
vitest run src/lib/unauthorizedResponseHandler.test.ts— 3 passing (401 clears + rejects; 403 doesn't; network error doesn't).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.getCurrentUseron focus/refetch). Flagging in case we want 403-on-/User/currenthandling too.Generated with the assistance of an LLM (Claude Opus 4.8).