Skip to content

fix(auth): stop the /login redirect loop left by a dead Auth0 session - #2906

Open
Astach wants to merge 1 commit into
stagingfrom
fix/login-redirect-loop-on-stale-session
Open

fix(auth): stop the /login redirect loop left by a dead Auth0 session#2906
Astach wants to merge 1 commit into
stagingfrom
fix/login-redirect-loop-on-stale-session

Conversation

@Astach

@Astach Astach commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

What:
When Auth0 cannot mint a token, the request interceptor now clears the local session before redirecting, and it tags the login URL with reason=session-expired. The login route skips its "already authenticated, go back into the app" redirect when that reason is present, and shows the user why they were signed out. Session teardown is single-flight across every axios instance. The duplicate useAuthInterceptor registration on the Auth0 callback route is removed. getSafeRedirect and the new login-URL contract move into @qovery/shared/routes so both sides share one definition.

Why:
Auth0Provider runs with cacheLocation="localstorage", and the SDK reads the cached user back with no expiry check — checkSession() swallows the refresh failure. So isAuthenticated stays true for a session that can no longer produce a token, and nothing ever clears it. That closed a loop: at / a query fires, the interceptor fails to get a token and does window.location.assign('/login?redirect=%2F') (a full page reload), the reload restores the same dead session, /login sees isAuthenticated and redirects to /, and round it goes. The tab is unrecoverable without manually wiping localStorage. Introduced by #2727, which added the redirect but no teardown.

Notes:
Teardown is awaited before navigating: a reload that outruns the cache wipe restores the dead session and the loop survives. That ordering is covered by a test.
A 401 response does NOT clear the session, only tags the URL. Because getAccessTokenSilently refreshes proactively, an expired token never reaches the API, so a 401 is as likely to be an endpoint using 401 where it means 403 — clearing there would sign out a healthy user. The reason param is what breaks the loop on that path.
Single-flight matters in practice, not just in theory: two axios instances plus React Query's default three retries turn one dead session into a dozen concurrent teardown attempts.
The reason param, not the teardown, is also the only guard that works for the qovery-e2e-auth-token bypass, which forces isAuthenticated true and is immune to logout().
libs/shared/routes had a jest transform that could not parse the shared TypeScript setup file, so its suite could never run; aligned it with shared-utils.
Out of scope, worth a follow-up: router.invalidate() on auth change, moving /'s component-level into a beforeLoad guard, and memoising the Auth0 context value. None are required for this loop.


Summary by cubic

Fixes the /login redirect loop that traps users with a dead Auth0 session — one where isAuthenticated stays true but tokens can no longer be minted. The auth interceptor now clears the local Auth0 session before redirecting to /login and tags the URL with reason=session-expired; the login page skips its "already authenticated" redirect when that reason is present and shows an expiry notice. Both sides now share the login-URL contract via @qovery/shared/routes.

Behavior changes

  • A 401 no longer clears the session, only redirects with the expiry reason, so endpoints misusing 401 can't log out healthy users.
  • Session teardown is single-flight across all axios instances and awaited before navigating, so concurrent failures trigger exactly one logout.
  • The qovery-e2e-auth-token bypass is guarded by the reason param, since it's immune to logout().
  • Removed the duplicate useAuthInterceptor registration on the Auth0 callback route.
  • Fixed @qovery/shared/routes' jest transform, which had silently prevented its suite from running.

Written for commit 1a67501. Summary will update on new commits.

Review in cubic

What:
When Auth0 cannot mint a token, the request interceptor now clears the local
session before redirecting, and it tags the login URL with
`reason=session-expired`. The login route skips its "already authenticated, go
back into the app" redirect when that reason is present, and shows the user why
they were signed out. Session teardown is single-flight across every axios
instance. The duplicate `useAuthInterceptor` registration on the Auth0 callback
route is removed. `getSafeRedirect` and the new login-URL contract move into
`@qovery/shared/routes` so both sides share one definition.

Why:
`Auth0Provider` runs with `cacheLocation="localstorage"`, and the SDK reads the
cached user back with no expiry check — `checkSession()` swallows the refresh
failure. So `isAuthenticated` stays true for a session that can no longer
produce a token, and nothing ever clears it. That closed a loop: at `/` a query
fires, the interceptor fails to get a token and does
`window.location.assign('/login?redirect=%2F')` (a full page reload), the reload
restores the same dead session, `/login` sees `isAuthenticated` and redirects to
`/`, and round it goes. The tab is unrecoverable without manually wiping
localStorage. Introduced by #2727, which added the redirect but no teardown.

Notes:
Teardown is awaited before navigating: a reload that outruns the cache wipe
restores the dead session and the loop survives. That ordering is covered by a
test.
A 401 response does NOT clear the session, only tags the URL. Because
`getAccessTokenSilently` refreshes proactively, an expired token never reaches
the API, so a 401 is as likely to be an endpoint using 401 where it means 403 —
clearing there would sign out a healthy user. The reason param is what breaks
the loop on that path.
Single-flight matters in practice, not just in theory: two axios instances plus
React Query's default three retries turn one dead session into a dozen
concurrent teardown attempts.
The reason param, not the teardown, is also the only guard that works for the
`qovery-e2e-auth-token` bypass, which forces `isAuthenticated` true and is
immune to `logout()`.
`libs/shared/routes` had a jest transform that could not parse the shared
TypeScript setup file, so its suite could never run; aligned it with
`shared-utils`.
Out of scope, worth a follow-up: `router.invalidate()` on auth change, moving
`/`'s component-level <Navigate> into a beforeLoad guard, and memoising the
Auth0 context value. None are required for this loop.
@nx-cloud

nx-cloud Bot commented Aug 26, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 1a67501

Command Status Duration Result
nx run console:build --parallel=3 --configurati... ✅ Succeeded 57s View ↗
nx affected --target=test --parallel=3 --config... ✅ Succeeded 3m 31s View ↗
nx affected --target=lint --parallel=3 ✅ Succeeded 2m 31s View ↗
nx-cloud record -- yarn nx format:check ✅ Succeeded 6s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-08-26 14:13:57 UTC

@cubic-dev-ai cubic-dev-ai 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.

2 issues found across 7 files

Confidence score: 3/5

  • In libs/shared/utils/src/lib/http/interceptors/auth-interceptor/auth-interceptor.tsx, the shared 401 promise lacks a teardown, so a silent-token failure joins it and never calls clearAuth0Session — the app navigates to login with a stale session. Add teardown logic to the shared promise so token failures always trigger session cleanup.
  • In libs/shared/routes/src/lib/sub-router/login.router.ts, the redirect condition matches a raw /login prefix, so valid routes like /login/foo incorrectly route to /. Use a segment-aware check (as isLoginPath does) after stripping the leading slash to avoid misfiring.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="libs/shared/routes/src/lib/sub-router/login.router.ts">

<violation number="1" location="libs/shared/routes/src/lib/sub-router/login.router.ts:16">
P2: When a redirect targets a valid path beginning with `/login` but not the login route, this condition sends it to `/` because it tests a raw prefix. Use the same segment-aware check as `isLoginPath` after stripping the query and hash.</violation>
</file>

<file name="libs/shared/utils/src/lib/http/interceptors/auth-interceptor/auth-interceptor.tsx">

<violation number="1" location="libs/shared/utils/src/lib/http/interceptors/auth-interceptor/auth-interceptor.tsx:53">
P1: When a 401 reaches this handler before a silent-token failure, the shared promise is created without a teardown, so the token failure joins it and never calls `clearAuth0Session`. The app navigates to login while retaining the dead Auth0 cache, allowing the redirect loop to recur. Track navigation single-flight separately from session teardown, or upgrade an in-flight failure when a token-mint failure requires cleanup.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

// token request can reject while the session is still being created.
if (isLoginPath(window.location.pathname)) return Promise.resolve()

pendingAuthFailure ??= Promise.resolve()

@cubic-dev-ai cubic-dev-ai Bot Aug 26, 2026

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.

P1: When a 401 reaches this handler before a silent-token failure, the shared promise is created without a teardown, so the token failure joins it and never calls clearAuth0Session. The app navigates to login while retaining the dead Auth0 cache, allowing the redirect loop to recur. Track navigation single-flight separately from session teardown, or upgrade an in-flight failure when a token-mint failure requires cleanup.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At libs/shared/utils/src/lib/http/interceptors/auth-interceptor/auth-interceptor.tsx, line 53:

<comment>When a 401 reaches this handler before a silent-token failure, the shared promise is created without a teardown, so the token failure joins it and never calls `clearAuth0Session`. The app navigates to login while retaining the dead Auth0 cache, allowing the redirect loop to recur. Track navigation single-flight separately from session teardown, or upgrade an in-flight failure when a token-mint failure requires cleanup.</comment>

<file context>
@@ -11,27 +12,70 @@ export interface SerializedError {
+  // token request can reject while the session is still being created.
+  if (isLoginPath(window.location.pathname)) return Promise.resolve()
+
+  pendingAuthFailure ??= Promise.resolve()
+    .then(() => clearSession?.())
+    .catch(() => undefined)
</file context>
Fix with cubic

}

export function getSafeRedirect(redirectPath?: string) {
if (!redirectPath || redirectPath.startsWith(LOGIN_URL)) {

@cubic-dev-ai cubic-dev-ai Bot Aug 26, 2026

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.

P2: When a redirect targets a valid path beginning with /login but not the login route, this condition sends it to / because it tests a raw prefix. Use the same segment-aware check as isLoginPath after stripping the query and hash.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At libs/shared/routes/src/lib/sub-router/login.router.ts, line 16:

<comment>When a redirect targets a valid path beginning with `/login` but not the login route, this condition sends it to `/` because it tests a raw prefix. Use the same segment-aware check as `isLoginPath` after stripping the query and hash.</comment>

<file context>
@@ -1,3 +1,31 @@
+}
+
+export function getSafeRedirect(redirectPath?: string) {
+  if (!redirectPath || redirectPath.startsWith(LOGIN_URL)) {
+    return '/'
+  }
</file context>
Suggested change
if (!redirectPath || redirectPath.startsWith(LOGIN_URL)) {
if (!redirectPath || isLoginPath(redirectPath.split(/[?#]/)[0])) {
Fix with cubic

@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.48148% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 48.18%. Comparing base (912122b) to head (1a67501).
⚠️ Report is 2 commits behind head on staging.

Files with missing lines Patch % Lines
...interceptors/auth-interceptor/auth-interceptor.tsx 76.19% 4 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           staging    #2906      +/-   ##
===========================================
+ Coverage    42.11%   48.18%   +6.06%     
===========================================
  Files          248     1235     +987     
  Lines         7285    26650   +19365     
  Branches      2258     7912    +5654     
===========================================
+ Hits          3068    12840    +9772     
- Misses        3659    11643    +7984     
- Partials       558     2167    +1609     
Flag Coverage Δ
unittests 48.18% <81.48%> (+6.06%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

1 participant