Skip to content

fix(frontend): refresh SEP-10 JWT before expiry to prevent stale-token 401s#588

Open
opepraise wants to merge 1 commit into
astera-hq:mainfrom
opepraise:fix/jwt-refresh-stale-sessions
Open

fix(frontend): refresh SEP-10 JWT before expiry to prevent stale-token 401s#588
opepraise wants to merge 1 commit into
astera-hq:mainfrom
opepraise:fix/jwt-refresh-stale-sessions

Conversation

@opepraise

Copy link
Copy Markdown

Summary

Fixes #576

Long-lived sessions (admin monitoring tabs open for >24h) silently failed after the 24h JWT expired because there was no refresh mechanism. API calls returned 401 but the UI showed no error and no re-auth prompt, making users believe transactions submitted successfully when they were actually rejected.

  • lib/auth.ts — Add getTokenExpiry, isTokenExpired, isTokenExpiringSoon helpers that decode the JWT exp claim client-side (no signature verification needed — the server verifies on every call). Add authenticatedFetch wrapper that proactively refreshes 5 min before expiry and retries once on a 401 via the SEP-10 challenge/response flow; returns the 401 as-is if re-auth fails so callers can redirect to the connect-wallet flow.
  • lib/hooks.ts — Add useAuthRefresh hook that schedules a silent background refresh timed to the stored token's expiry and reschedules itself after each successful refresh.
  • __tests__/lib/auth.test.ts — 13 Jest tests covering token expiry helpers and the full 401-retry path (valid token → server 401 → re-auth → retry → 200).

Test plan

  • getTokenExpiry — parses exp from well-formed JWT, returns null for malformed/missing exp
  • isTokenExpired — true for past exp, false for future exp, true for malformed
  • isTokenExpiringSoon — true within 5-min margin, false beyond margin
  • authenticatedFetch — attaches Bearer header, retries on 401 with fresh token, returns 401 when re-auth fails
  • Manual: log in, wait for token to approach expiry (or shorten expiry in dev), confirm silent refresh occurs
  • Manual: disconnect wallet mid-session, confirm 401 surfaces correctly in UI

…n 401s

Closes astera-hq#576

Long-lived sessions (e.g. admin monitoring tabs open for >24 h) silently
failed after the JWT expired because there was no refresh mechanism.

- Add `getTokenExpiry`, `isTokenExpired`, `isTokenExpiringSoon` helpers to
  decode the JWT exp claim client-side without signature verification.
- Add `authenticatedFetch` wrapper that proactively refreshes the token
  (5 min before expiry) and retries once on a 401 via the SEP-10
  challenge/response flow. Returns the 401 as-is if re-auth fails so
  callers can redirect to the connect-wallet flow.
- Add `useAuthRefresh` hook that schedules a silent background refresh
  timed to the stored token's expiry; reschedules itself after success.
- Add 13 Jest tests covering the helpers and the 401-retry path.
@sanmipaul

Copy link
Copy Markdown
Contributor

@opepraise Thanks for this, the overall approach is right and the code is clean. A few things before I can merge:

Blocker

authenticatedFetch reads refreshed.token directly for the retry but I need to confirm ensureAuthWithFreighter also calls setToken internally. If it doesn't, the refreshed token is never persisted to localStorage — the current request succeeds but every subsequent call still reads the expired token. Can you confirm this is handled, or add setToken(refreshed.token) explicitly in the two places authenticatedFetch uses a refreshed token?

Please address before merge

  • isTokenExpired uses exp < nowSec() — a token expiring at exactly the current second is treated as valid but the server will reject it. Change to <=.
  • Add a test for the proactive refresh path in authenticatedFetch (the isTokenExpiringSoon branch, not just the 401 path).
  • Add a test for authenticatedFetch when no token is in storage.

Nice to have (can be a follow-up)

  • useAuthRefresh has no tests — worth adding in a follow-up.
  • The window shim in the test file (global.window = global) is fragile; move it to setupFilesAfterFramework in Jest config.
  • const addr: string = address is redundant after the if (!address) return guard.
  • Concurrent 401s will both trigger ensureAuthWithFreighter independently — not a blocker but worth a follow-up issue.

The structure, the REFRESH_MARGIN_MS export, and the cleanup logic in useAuthRefresh are all done well. Get the blocker and the three required items sorted and this is good to go.

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.

fix: SEP-10 JWT never refreshed in long-lived sessions — stale tokens cause silent transaction failures

2 participants