diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 188 |
1 files changed, 171 insertions, 17 deletions
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') { |