summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js188
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') {