From 6562665c80f96f30fef95af83a0abcf71f41795f Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 17 Aug 2026 17:38:00 +0200 Subject: fix(hub): a session that renews itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../meshbay-hub/tests/harness/session_harness.mjs | 165 +++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 packages/meshbay-hub/tests/harness/session_harness.mjs (limited to 'packages/meshbay-hub/tests/harness') diff --git a/packages/meshbay-hub/tests/harness/session_harness.mjs b/packages/meshbay-hub/tests/harness/session_harness.mjs new file mode 100644 index 0000000..f0d0cb2 --- /dev/null +++ b/packages/meshbay-hub/tests/harness/session_harness.mjs @@ -0,0 +1,165 @@ +// Run the shipped session code against a hub that behaves like the real one. +// +// The defect this guards was not subtle once seen: the hub rotates refresh +// tokens — it revokes the one presented and returns a replacement, and a +// revoked token presented again revokes the entire family — and the client kept +// only the access token out of that response. So the refresh token was spent on +// first use and the second attempt locked the account out of renewal +// altogether, leaving signing out and back in as the only way to carry on. +// +// The fake hub below enforces exactly that rule, which is the point: a stub +// that happily accepts the same refresh token twice would have passed against +// the broken client. +import { readFileSync } from 'fs'; + +const app = readFileSync(process.argv[2], 'utf8'); +const cfg = JSON.parse(process.argv[3] || '{}'); +const { scenario = 'expired-access' } = cfg; + +// ── The pieces of app.js under test, lifted as text ────────────────────────── +const between = (from, to) => { + const i = app.indexOf(from); + if (i < 0) throw new Error(`not found: ${from}`); + const j = app.indexOf(to, i); + if (j < 0) throw new Error(`not found: ${to}`); + return app.slice(i, j); +}; +const sessionBlock = between('let _auth = loadAuth();', '\n// ── Theme'); +const hubFetchFn = between('async function hubFetch(', '\n// ── Router'); + +// ── The world ──────────────────────────────────────────────────────────────── +const store = new Map(); +const localStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, v), + removeItem: (k) => store.delete(k), +}; +const AUTH_KEY = 'mb_auth'; +const HUB = ''; +const TOKEN_RENEW_MARGIN_S = Number(app.match(/const TOKEN_RENEW_MARGIN_S = (\d+)/)[1]); + +const jwt = (secondsLeft) => { + const payload = Buffer.from(JSON.stringify({ + exp: Math.floor(Date.now() / 1000) + secondsLeft, + })).toString('base64url'); + return `head.${payload}.sig`; +}; + +// The hub. One live refresh token at a time; presenting a revoked one kills the +// family, as the real endpoint does. +let live = 'RT-1'; +let issued = 1; +let familyRevoked = false; +const seen = []; +let refreshCalls = 0; + +const fakeFetch = async (url, opts = {}) => { + if (url.endsWith('/v1/users/token/refresh')) { + refreshCalls++; + const presented = JSON.parse(opts.body).refresh_token; + seen.push(presented); + if (familyRevoked || presented !== live) { + familyRevoked = true; + return { ok: false, status: 401, + json: async () => ({ detail: 'Token reuse detected — family revoked' }) }; + } + live = `RT-${++issued}`; + return { + ok: true, status: 200, + json: async () => ({ access_token: jwt(3600), refresh_token: live, + token_type: 'bearer', expires_in: 3600 }), + }; + } + // Any other endpoint: 401 unless the bearer is a token that has life left. + const auth = (opts.headers || {})['Authorization'] || ''; + const token = auth.replace('Bearer ', ''); + let alive = false; + try { + alive = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()).exp + > Math.floor(Date.now() / 1000); + } catch { alive = false; } + calls.push({ url, token, alive }); + if (!alive) { + return { ok: false, status: 401, json: async () => ({ detail: 'Token expired or invalid' }) }; + } + return { ok: true, status: 200, json: async () => ({ ok: true }) }; +}; +const calls = []; + +let _bundleKey = null; +const _clearKeyDB = () => {}; +function loadAuth() { + try { return JSON.parse(localStorage.getItem(AUTH_KEY)); } catch { return null; } +} +function saveAuth(auth) { + if (auth) localStorage.setItem(AUTH_KEY, JSON.stringify(auth)); + else { localStorage.removeItem(AUTH_KEY); _bundleKey = null; _clearKeyDB(); } +} + +// The session starts with an access token that expired an hour ago and a +// refresh token that is still perfectly good — a tab reopened the next morning. +const startingLife = scenario === 'fresh-access' ? 3600 + : scenario === 'nearly-expired' ? 60 + : -60; +localStorage.setItem(AUTH_KEY, JSON.stringify({ + username: 'someone', userId: 'u1', role: 'user', + token: jwt(startingLife), refreshToken: 'RT-1', +})); + +const ctx = { + localStorage, AUTH_KEY, HUB, TOKEN_RENEW_MARGIN_S, + loadAuth, saveAuth, fetch: fakeFetch, atob: (s) => Buffer.from(s, 'base64').toString('binary'), + console, +}; +const fns = new Function(...Object.keys(ctx), + sessionBlock + '\n' + hubFetchFn + + '\n return {hubFetch, refreshAccessToken, ensureFreshToken, tokenLifeLeft,' + + ' getAuth: () => _auth};')(...Object.values(ctx)); + +// ── The run ────────────────────────────────────────────────────────────────── +const out = { scenario }; +const stored = () => JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); + +if (scenario === 'concurrent') { + // Two requests hit a dead token at the same instant. If each renews on its + // own, the second presents a token the first has already spent, and the hub + // tears down the family. + const before = stored().token; + // Either may reject once the family is gone; the run still has to report. + await Promise.allSettled([ + fns.hubFetch('/v1/groups/mine', { token: before }), + fns.hubFetch('/v1/users/me', { token: before }), + ]); +} else if (scenario === 'repeat') { + // Renew several times over. Each response carries a new refresh token, and + // keeping the old one would be caught on the very next attempt. + for (let i = 0; i < 4; i++) { + await fns.refreshAccessToken(); + // Age the access token again so the next one really has to renew. A + // rejected renewal signs the session out, and there is then nothing left + // to age — which is the outcome under test, not a reason to stop. + const a = stored(); + if (!a) break; + localStorage.setItem(AUTH_KEY, JSON.stringify({ ...a, token: jwt(-60) })); + const live_ = fns.getAuth(); + if (live_) live_.token = jwt(-60); + } +} else if (scenario === 'refresh-rejected') { + localStorage.setItem(AUTH_KEY, JSON.stringify({ ...stored(), refreshToken: 'RT-STALE' })); + fns.getAuth().refreshToken = 'RT-STALE'; + try { await fns.hubFetch('/v1/groups/mine', { token: stored().token }); } + catch (e) { out.threw = String(e.message); } +} else { + await fns.ensureFreshToken(); + await fns.hubFetch('/v1/groups/mine', { token: stored() ? stored().token : null }); +} + +out.refreshCalls = refreshCalls; +out.refreshTokensPresented = seen; +out.familyRevoked = familyRevoked; +out.signedOut = stored() === null; +out.storedRefreshToken = stored() ? stored().refreshToken : null; +out.apiCalls = calls.map(c => ({ url: c.url, accepted: c.alive })); +out.everRejected = calls.some(c => !c.alive); +out.finalCallAccepted = calls.length ? calls[calls.length - 1].alive : null; +console.log(JSON.stringify(out)); -- cgit v1.2.3