fix(auth): stop the /login redirect loop left by a dead Auth0 session
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-tokenbypass is guarded by the reason param, since it's immune tologout(). - Removed the duplicate
useAuthInterceptorregistration on the Auth0 callback route. - Fixed
@qovery/shared/routes' jest transform, which had silently prevented its suite from running.