89 lines
3.1 KiB
JavaScript
89 lines
3.1 KiB
JavaScript
/* ============================================================
|
|
refresh.js — single-flight token refresh.
|
|
|
|
WHY SINGLE-FLIGHT IS MANDATORY, NOT AN OPTIMISATION
|
|
---------------------------------------------------
|
|
POST /users/refresh calls serialize_token(create_access_token(user),
|
|
create_refresh_token(user), user) — it mints a NEW refresh token every time
|
|
and there is no reuse detection or jti tracking on the server, so the old one
|
|
stays valid until its 7-day exp.
|
|
|
|
Two concurrent refreshes therefore BOTH succeed and return two different
|
|
valid pairs. Whichever setSession() lands second wins; requests already in
|
|
flight carry a token from the losing pair. The result is intermittent 401s
|
|
that look random and are close to undiagnosable from logs.
|
|
|
|
So: one promise per tab, and a Web Locks mutex across tabs.
|
|
============================================================ */
|
|
|
|
import { SessionExpiredError } from './errors'
|
|
import {
|
|
getSession,
|
|
getRefreshToken,
|
|
setSession,
|
|
clearSession,
|
|
reloadFromStorage,
|
|
} from './tokenStore'
|
|
|
|
const API_BASE = import.meta.env.VITE_API_BASE ?? ''
|
|
|
|
let inFlight = null
|
|
|
|
/**
|
|
* Refresh the session. Concurrent callers all await the SAME promise, so at most
|
|
* one POST /users/refresh is outstanding per tab at any moment.
|
|
*/
|
|
export function refreshSession() {
|
|
if (inFlight) return inFlight
|
|
inFlight = run().finally(() => {
|
|
inFlight = null
|
|
})
|
|
return inFlight
|
|
}
|
|
|
|
async function post(refreshToken) {
|
|
let res
|
|
try {
|
|
res = await fetch(`${API_BASE}/users/refresh`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
// The backend takes the refresh token in the JSON BODY — not a cookie,
|
|
// not an Authorization header (backend/users/app.py: TokenRefresh).
|
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
})
|
|
} catch {
|
|
// Network failure is not an expired session — don't destroy a good session
|
|
// because the wifi dropped. Surface it and let the caller retry later.
|
|
throw new Error('Unable to reach the server while renewing your session.')
|
|
}
|
|
|
|
if (!res.ok) {
|
|
// 401 "Invalid or expired refresh token", or the user was deactivated.
|
|
clearSession()
|
|
throw new SessionExpiredError()
|
|
}
|
|
// Rotates BOTH tokens; setSession preserves `permissions` from /users/me.
|
|
return setSession(await res.json())
|
|
}
|
|
|
|
async function run() {
|
|
reloadFromStorage()
|
|
const token = getRefreshToken()
|
|
if (!token) throw new SessionExpiredError()
|
|
|
|
// Cross-tab single flight. Without it, five open tabs hitting an expired token
|
|
// means five rotations of the same refresh token: four pairs are orphaned and
|
|
// four tabs end up holding a stale access token.
|
|
if (typeof navigator !== 'undefined' && navigator.locks) {
|
|
return navigator.locks.request('tf-auth-refresh', async () => {
|
|
reloadFromStorage()
|
|
const current = getRefreshToken()
|
|
// Another tab rotated while we waited for the lock — reuse its result
|
|
// instead of spending our now-superseded token.
|
|
if (current && current !== token) return getSession()
|
|
return post(current ?? token)
|
|
})
|
|
}
|
|
return post(token)
|
|
}
|