diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-17 17:38:00 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-17 17:38:00 +0200 |
| commit | 6562665c80f96f30fef95af83a0abcf71f41795f (patch) | |
| tree | d210f4c969b9b3881aba6d7d3573aa96b7831197 /packages/meshbay-hub/src/meshbay_hub | |
| parent | af30a10b83366416c25eaacec0b4df77526d0924 (diff) | |
| download | meshbay-6562665c80f96f30fef95af83a0abcf71f41795f.tar.gz | |
fix(hub): a session that renews itself
Reported: after an hour of watching a film, every action answers "token expired
or invalid", with signing out and back in as the only way on. Reopening the tab
the next day did the same.
The access token lasts an hour and the refresh token thirty days, and nothing
used the second one. `hubFetch` reported a 401 like any other error, and
watching a film is precisely an hour in which the hub hears nothing at all,
because the video travels over WebRTC. So the token aged out with no request to
notice, and a tab reopened the next morning presented a stale token with a
perfectly good refresh token sitting beside it in localStorage.
Underneath was the reason it could not be recovered from. The hub *rotates*:
the refresh endpoint revokes the token presented, returns a replacement, and
treats a revoked one presented again as theft, revoking the whole family. The
client kept only the access token out of that response. So the refresh token was
spent on first use and the second attempt did not merely fail — it destroyed the
family. Which is exactly the reported symptom.
Renewal now happens on a margin, on returning to the tab, on mount, and on a 401
with the request replayed. Concurrent renewals share one request: two 401s
racing would otherwise present the same refresh token twice, and the hub cannot
tell that from theft, so the remedy would have been worse than the fault. A
refusal signs out cleanly rather than leaving a session that fails every call
while looking signed in.
The lifetime goes to four hours, which is not what makes long sessions work —
renewal is — but is what someone has to notice by if renewal itself breaks. An
hour was less than a feature film. Twelve was considered and declined: it widens
the window in which a leaked token cannot be turned off, and it lets the renewal
path go a whole day between uses, which is how it came to be broken here without
anyone noticing. Production sets this in its own hub.toml, so both moved.
The tests run the shipped code against a hub that enforces rotation, because a
stub that accepted the same refresh token twice would have passed against the
broken client. Checked that dropping the rotated token reproduces the revoked
family, so the guard is guarding something.
Also widens the orphan-setter rule to ignore `setX` functions declared in the
module: `setAuth` is not a hook setter, and a rule that cries wolf is one
somebody eventually silences. Verified it still catches a real orphan.
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/config.py | 13 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 188 |
2 files changed, 183 insertions, 18 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/config.py b/packages/meshbay-hub/src/meshbay_hub/config.py index 31912d1..b508e45 100644 --- a/packages/meshbay-hub/src/meshbay_hub/config.py +++ b/packages/meshbay-hub/src/meshbay_hub/config.py @@ -46,7 +46,18 @@ class HubIdentityConfig: @dataclass class JWTConfig: - access_token_ttl: int = 3600 # 1 hour + # How long an access token stays good. It is not the session — the refresh + # token below is, and the SPA renews against it well before this runs out, + # so a film or a working day never meets this number. + # + # What it does bound is a token that leaks: revoking a member or suspending + # an account both take effect at once (the hub reloads the account on every + # request, and pushes signed revocations to nodes), but there is no way to + # kill one issued token short of that. Four hours keeps the window short + # while renewing several times a day — which also means the renewal path is + # exercised constantly rather than twice, and cannot rot unnoticed the way + # it did when nothing used it at all. + access_token_ttl: int = 14400 # 4 hours refresh_token_ttl: int = 86400 * 30 # 30 days diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 198bc78..cb7a462 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -11,6 +11,14 @@ import * as downloads from './downloads.js'; const HUB = ''; const AUTH_KEY = 'mb_auth'; +// Renew an access token with this much life left rather than waiting for it to +// fail. Generous against a one-hour token: a film is watched without the hub +// hearing a word, and coming back to a tab that has been asleep for an hour +// should not cost a round trip before the first click works. +const TOKEN_RENEW_MARGIN_S = 600; +// How often to look. Cheap — it reads a timestamp out of the token and almost +// always does nothing. +const TOKEN_CHECK_MS = 60000; const THEME_KEY = 'mb_theme'; const IDB_NAME = 'meshbay'; const IDB_VERSION = 1; @@ -171,6 +179,94 @@ function saveAuth(auth) { } } +// ── Session ────────────────────────────────────────────────────────────────── +// +// The access token lasts an hour and the refresh token thirty days. Nothing was +// using the second: `hubFetch` reported a 401 as an error like any other, so an +// hour of watching a film — during which the hub hears nothing, because the +// video comes over WebRTC — ended with "token expired or invalid" and no way +// out but signing out and back in. Reopening the tab the next day did the same, +// with a perfectly good refresh token sitting in localStorage beside the stale +// access one. +// +// This lives outside the component because `hubFetch` is a plain function and +// has to be able to renew a token mid-request without every caller passing the +// machinery down to it. + +let _auth = loadAuth(); +let _onAuthChange = null; // set by App, so the UI follows a background renewal +let _refreshing = null; // in flight, shared: see refreshAccessToken + +function setAuth(auth) { + _auth = auth; + saveAuth(auth); + if (_onAuthChange) _onAuthChange(auth); +} + +/** Seconds until this JWT expires, or null if it says nothing useful. */ +function tokenLifeLeft(token) { + try { + const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); + if (!payload.exp) return null; + return payload.exp - Math.floor(Date.now() / 1000); + } catch { + return null; // not a JWT we can read; treat as unknown, never as expired + } +} + +/** + * Trade the refresh token for a new pair. + * + * The hub rotates: it revokes the token presented and returns a new one, and a + * revoked token presented again revokes the whole family. So the new one must + * be stored — the previous code kept only the access token and dropped its + * replacement, which burned the refresh token on first use and locked the + * account out of renewal on the second. That is why signing out and in was the + * only way back. + * + * Concurrent callers share one request. Two 401s racing would otherwise send + * the same refresh token twice, and the second would look exactly like theft. + */ +async function refreshAccessToken() { + if (!_auth || !_auth.refreshToken) return null; + if (_refreshing) return _refreshing; + _refreshing = (async () => { + try { + const r = await fetch(HUB + '/v1/users/token/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: _auth.refreshToken }), + }); + if (!r.ok) { + // Expired, revoked, or the family was torn down. Nothing to salvage: + // sign out cleanly rather than leave a session that fails every call. + setAuth(null); + return null; + } + const data = await r.json(); + setAuth({ + ..._auth, + token: data.access_token, + refreshToken: data.refresh_token || _auth.refreshToken, + }); + return data.access_token; + } catch { + return null; // offline: keep the session, the next call can try again + } finally { + _refreshing = null; + } + })(); + return _refreshing; +} + +/** Renew before it bites, rather than after. */ +async function ensureFreshToken() { + if (!_auth || !_auth.token) return null; + const left = tokenLifeLeft(_auth.token); + if (left !== null && left > TOKEN_RENEW_MARGIN_S) return _auth.token; + return refreshAccessToken(); +} + // ── Theme ──────────────────────────────────────────────────────────────────── function getInitialTheme() { @@ -188,13 +284,26 @@ function resolveTheme(pref) { // ── Hub API ────────────────────────────────────────────────────────────────── -async function hubFetch(path, { method = 'GET', body, token } = {}) { +async function hubFetch(path, { method = 'GET', body, token, _retried } = {}) { const headers = {}; if (body) headers['Content-Type'] = 'application/json'; - if (token) headers['Authorization'] = `Bearer ${token}`; + // Prefer the token the session currently holds. Callers read theirs from + // React state, which is a render behind a renewal that happened in the + // background — and sending the stale one would 401 for no reason. + const bearer = token && _auth && _auth.token ? _auth.token : token; + if (bearer) headers['Authorization'] = `Bearer ${bearer}`; const opts = { method, headers }; if (body) opts.body = JSON.stringify(body); const r = await fetch(HUB + path, opts); + if (r.status === 401 && bearer && !_retried) { + // The one case worth a second attempt: the access token aged out while + // nothing was talking to the hub. Renew once and replay. If the renewal + // fails it signs out, and the replay below is skipped. + const fresh = await refreshAccessToken(); + if (fresh) { + return hubFetch(path, { method, body, token: fresh, _retried: true }); + } + } if (!r.ok) { const err = await r.json().catch(() => ({ detail: r.statusText })); const detail = Array.isArray(err.detail) @@ -1198,11 +1307,18 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, setStatus('connecting'); const nodeId = nodesData.nodes[0].node_id; - const transport = new window.MeshBayTransport('', token); + // Renewed here rather than taken from the prop. This effect no longer + // re-runs when the token rotates (see the dependency list below), so + // the captured one can be older than the session's — and it is used to + // sign the offer to the hub, where an expired one is a 401 and no + // connection at all. Renewals are shared, so if one is already in + // flight this waits for it instead of starting a second. + const live = (await ensureFreshToken()) || token; + const transport = new window.MeshBayTransport('', live); transportRef.current = transport; const ack = await transport.connect( - nodeId, token, groupId, null, sessionKeys, _bundleKey, username, + nodeId, live, groupId, null, sessionKeys, _bundleKey, username, userId, _pendingJoinCode); _pendingJoinCode = null; if (cancelled) return; @@ -1291,7 +1407,18 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, // `group` object, which the hub poll re-creates, and re-running this effect // means tearing down the WebRTC connection. groupId is here, so a real group // change still re-captures it. - }, [groupId, token, retryKey]); + // + // Neither is the token itself, only whether there is one. It used to be a + // dependency and that was harmless while a token never changed during a + // session — it only expired. Now that the session renews itself, the string + // rotates, and this effect tore the WebRTC connection down and rebuilt it + // every time. Worst on arrival: a stored token past its life is renewed the + // instant the page mounts, which is exactly when the group page is + // negotiating ICE, so the connection was abandoned mid-handshake and the + // node sat in `connecting` for ever. The live token is read inside + // `connect()` instead. Signing out unmounts this page; signing in mounts + // it; nothing in between should disturb a working connection. + }, [groupId, Boolean(token), retryKey]); const downloadFile = useCallback(async (entry) => { const transport = transportRef.current; @@ -4214,6 +4341,33 @@ function App() { const resolved = resolveTheme(theme); + // Keep the session alive without anyone having to think about it. + useEffect(() => { + // A renewal can happen inside hubFetch, well away from any render. This is + // how the component learns about it — including a failed one, which sets + // null and lands on the login page instead of failing every later call. + _onAuthChange = (auth) => setUser(auth); + + // On mount above all: a tab reopened tomorrow holds an hour-old access + // token and a refresh token good for a month, and used to greet its owner + // with "invalid token" rather than spending the second on renewing it. + ensureFreshToken(); + + const timer = setInterval(ensureFreshToken, TOKEN_CHECK_MS); + // A backgrounded tab has its timers throttled hard, so the check above may + // not have run for the whole time it was away. Coming back is exactly when + // the token is most likely to be stale. + const onVisible = () => { + if (document.visibilityState === 'visible') ensureFreshToken(); + }; + document.addEventListener('visibilitychange', onVisible); + return () => { + _onAuthChange = null; + clearInterval(timer); + document.removeEventListener('visibilitychange', onVisible); + }; + }, []); + useEffect(() => { document.documentElement.className = `theme-${resolved}`; localStorage.setItem(THEME_KEY, theme); @@ -4320,15 +4474,18 @@ function App() { } const me = await hubFetch('/v1/users/me', { token }); const u = { username, userId: me.user_id, token, refreshToken, role: me.role }; + // setAuth, not saveAuth: it is the one writer that also updates the copy + // hubFetch renews from. Storing the session without it left the renewal + // path with no refresh token to present. + setAuth(u); setUser(u); - saveAuth(u); }, logout: () => { // Navigating away leaves transfers running; signing out does not. They // are moving data on tokens that are about to stop being ours. transfers.reset(); + setAuth(null); setUser(null); - saveAuth(null); setGroups([]); navigate('/login'); }, @@ -4337,16 +4494,13 @@ function App() { // Group membership is baked into the access token at login and the hub does not // push updates, so someone invited after they signed in carries a token that // says they are in nothing. Refreshing re-reads membership from the database. - const refreshAuth = useCallback(async () => { - if (!user || !user.refreshToken) return null; - const data = await hubFetch('/v1/users/token/refresh', { - method: 'POST', body: { refresh_token: user.refreshToken }, - }); - const u = { ...user, token: data.access_token }; - setUser(u); - saveAuth(u); - return data.access_token; - }, [user]); + // Goes through refreshAccessToken like everything else. It used to call the + // endpoint here and keep only the access token, dropping the rotated refresh + // token that came back with it — so the refresh token was spent on first use, + // and presenting the spent one again revoked the whole family. Which is how + // a session that should last a month ended at "invalid token" with signing + // out as the only way back. + const refreshAuth = useCallback(() => refreshAccessToken(), []); let page; if (route === '/login' || route === '/register') { |