HR-ATS-Portal/frontend/src/lib/refresh.js

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)
}