fix refresh auth token#1240
Conversation
✅ Deploy Preview for vortex-sandbox ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for vortexfi ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
There was a problem hiding this comment.
Pull request overview
This PR improves frontend authentication reliability by making access-token refresh deterministic and resilient, preventing users from being forced to log out due to expired access tokens or transient refresh failures. It centralizes refresh behavior (scheduler + 401 refresh/retry), routes refresh via the backend, and disables Supabase client background refresh to avoid competing refresh flows.
Changes:
- Implemented single-flight refresh and one retry on
401in the API client. - Moved refresh transport to
POST /v1/auth/refreshand changed refresh error handling to only clear session on confirmed-invalid (401). - Added an app-root token refresh scheduler driven by JWT
exp, and made the Supabase client passive (no auto-refresh/persist).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/security-spec/01-auth/supabase-otp.md | Documents the new refresh flow and the “teardown only on confirmed-invalid refresh” invariant. |
| apps/frontend/src/services/auth.ts | Adds expiry-aware auth helpers and refresh via backend /v1/auth/refresh. |
| apps/frontend/src/services/api/api-client.ts | Adds single-flight refresh-on-401 with one retry using the refreshed token. |
| apps/frontend/src/machines/ramp.actors.ts | Adjusts token refresh actor behavior to preserve session on transient failures. |
| apps/frontend/src/hooks/useAuthTokens.ts | Removes the old interval-based refresh setup hook. |
| apps/frontend/src/contexts/rampState.tsx | Adds a single app-root refresh scheduler based on decoded JWT expiry. |
| apps/frontend/src/config/supabase.ts | Disables Supabase client auto-refresh/persist to keep it passive. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
atob can throw on unpadded base64url payloads; convert to base64 and re-pad so access-token expiry decoding is reliable. Addresses PR #1240 review feedback.
If getAccessTokenExpiryMs() returns null the scheduler previously exited without rescheduling, permanently disabling the app-root refresh loop. Retry after a short delay so a later decodable token re-establishes it. Addresses PR #1240 review feedback.
refreshAccessToken() already clears the stored session on a confirmed 401, so clearing again in checkAndRefreshTokenActor duplicated the teardown. Keep session-teardown ownership in one place. Addresses PR #1240 review feedback.
/v1/auth/refresh previously caught every error and returned 401, so a Supabase outage or 5xx looked identical to a revoked refresh token and forced users to log out — defeating the frontend's fail-open design. Classify the Supabase error: a definite 4xx auth error means the refresh token is invalid (401); retryable-fetch/5xx/transport errors and any unexpected error return 503 so the frontend keeps the session and retries. Update the security spec's invariant, flow, and audit item. Addresses the transient-failure gap in PR #1240.
- isAuthenticated() decodes expiry from the token already in scope instead of reading localStorage a second time via getAccessTokenExpiryMs(). - Guard scheduleNext() on the cancelled flag so a refresh callback that is already running at unmount can't schedule a new timer after cleanup. Addresses PR #1240 review feedback (review 4624527032).
Problem it fixes: Users frequently had to log out and back in before they could make requests. Root cause: the Supabase access token expires (~1h) and the frontend's token-refresh was unreliable - it either
didn't fire in time or, when a refresh failed, it wiped the whole session and forced re-login.
Background
"forever" (Supabase caps it at 1 week; access JWTs aren't individually revocable).
Key decisions
POST /v1/auth/refresh), not the browser calling Supabase directly. Avoids the browser racing Supabase's refresh-token rotation and trims client attack surface. (Nobackend change — the endpoint already existed.)
401from/auth/refresh(revoked/invalid refresh token) endsthe session.
What was done
401, the API client refreshes once (single-flight, shared across concurrent requests) and retries. Expired-token requests now self-heal.services/api/api-client.ts401; transient failures are retried with the session intact.services/auth.ts,machines/ramp.actors.tsexpsetIntervalthat wascontexts/rampState.tsx,hooks/useAuthTokens.ts/v1/auth/refreshinstead ofsupabase.auth.refreshSessionwith the anon key.services/auth.tsautoRefreshToken,persistSession,detectSessionInUrl→false.config/supabase.tsisAuthenticated()now checks expiry; addedgetAccessTokenExpiryMs()+ JWT decode.services/auth.tsdocs/security-spec/01-auth/supabase-otp.mdHow it fixes both failure modes:
exp; any request that still hits an expired token recovers via the 401 retry.401logs out (cleanly routing to the login screen).