fix(auth): sign out and redirect on 401 from the CM API#1523
fix(auth): sign out and redirect on 401 from the CM API#1523DavidCockerill wants to merge 3 commits into
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.
| return (error: AxiosError): Promise<never> => { | ||
| if (error.response?.status === 401) { | ||
| clearAuth(); | ||
| } | ||
| // Re-reject so individual callers still see (and can handle) the error. | ||
| return Promise.reject(error); | ||
| }; |
There was a problem hiding this comment.
Axios response interceptor rejection handlers can receive non-Axios errors, generic Error objects, or even undefined depending on how upstream interceptors or network adapters behave. Accessing error.response directly without guarding against a null/undefined error can lead to unhandled runtime TypeError exceptions. Typing the parameter as any and using optional chaining on error ensures robust error handling.
| return (error: AxiosError): Promise<never> => { | |
| if (error.response?.status === 401) { | |
| clearAuth(); | |
| } | |
| // Re-reject so individual callers still see (and can handle) the error. | |
| return Promise.reject(error); | |
| }; | |
| return (error: any): Promise<never> => { | |
| if (error?.response?.status === 401) { | |
| clearAuth(); | |
| } | |
| // Re-reject so individual callers still see (and can handle) the error. | |
| return Promise.reject(error); | |
| }; |
There was a problem hiding this comment.
Applied in ba6a7f8 — good defensive catch. One deviation from the suggestion: kept the AxiosError typing with error?.response?.status rather than widening the parameter to any, which would drop type safety for no extra robustness. (Today this handler is the only response interceptor on apiClient, and axios itself always rejects with an AxiosError, so the guard is future-proofing for interceptors added later.)
— Claude Opus 4.8 (on behalf of @DavidCockerill)
| it('does not clear auth on a network error with no response', async () => { | ||
| const clearAuth = vi.fn(); | ||
| const error = errorWithStatus(undefined); | ||
| await expect(makeUnauthorizedResponseHandler(clearAuth)(error)).rejects.toBe(error); | ||
| expect(clearAuth).not.toHaveBeenCalled(); | ||
| }); |
There was a problem hiding this comment.
Add a test case to verify that the handler does not crash when receiving an undefined error or a generic error without a response object.
it("does not clear auth on a network error with no response", async () => {
const clearAuth = vi.fn();
const error = errorWithStatus(undefined);
await expect(makeUnauthorizedResponseHandler(clearAuth)(error)).rejects.toBe(error);
expect(clearAuth).not.toHaveBeenCalled();
});
it("does not crash when error is undefined", async () => {
const clearAuth = vi.fn();
await expect(makeUnauthorizedResponseHandler(clearAuth)(undefined as any)).rejects.toBe(undefined);
expect(clearAuth).not.toHaveBeenCalled();
});There was a problem hiding this comment.
Partially applied in ba6a7f8. Note the first suggested test ("does not clear auth on a network error with no response") already exists in this file — see the third it block. Added the second one (undefined rejection reason passes through without throwing), which became meaningful once the guard landed.
— Claude Opus 4.8 (on behalf of @DavidCockerill)
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>
| // `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) { |
There was a problem hiding this comment.
Question, not a blocker: apiClient also carries /Login/, /ResetPassword/, /VerifyEmail/, and /ForgotPassword/ — none of which represent a lost CM session. For a signed-out visitor clearAuth() is a no-op today (already null), but if the CM API ever models an invalid/expired reset or verify-email token as 401 (rather than 400/410), a user who's signed in in one tab and opens a stale verify/reset link in another tab would get signed out of their live session as a side effect. Could you confirm those endpoints don't 401 for "bad token" cases? If they can, might be worth scoping this interceptor away from the unauthenticated auth-flow requests.
There was a problem hiding this comment.
Great catch — and it's not hypothetical: CM's verifyToken (sharedHelpers.js) throws ERROR_CODE.UNAUTHORIZED for a bad/expired token, and both ResetPassword and VerifyEmail route through it — plus /Login 401s on bad credentials. So a stale verify/reset link in a second tab would have signed out a live session today. Fixed in 70fb4dc: the handler now takes an exemption predicate and the installer scopes it away from the unauthenticated auth flows (/Login/, /ForgotPassword/, /ResetPassword/, /VerifyEmail/, /ResendVerificationEmail/), with tests for exempt/non-exempt/missing-config cases.
— Claude Opus 4.8 (on behalf of @DavidCockerill)
| * cleared, which re-runs the route guards and redirects to /sign-in. | ||
| */ | ||
| export function installApiUnauthorizedRedirect(): void { | ||
| apiClient.interceptors.response.use( |
There was a problem hiding this comment.
Nit: the other install*.ts helpers in this directory (installBrowserTranslationDomGuard, installStaleDeployReload) explicitly guard/document being safe to call more than once (HMR, accidental re-invocation). This one registers the interceptor unconditionally, so a second call would stack a duplicate (harmless today since main.tsx calls it once and clearAuth() is idempotent, but worth matching the sibling convention for consistency).
There was a problem hiding this comment.
Applied in 70fb4dc — module-level installed flag, matching the sibling install* convention; re-invocations are no-ops.
— Claude Opus 4.8 (on behalf of @DavidCockerill)
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.
| // `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.
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).