From 73ad8e4eb566fe682107fa7e50ef624591199e99 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 15 Sep 2026 02:16:39 +0200 Subject: feat(hub): session lifetime is an admin setting, and a browser signs out when idle Browser idle sign-out (media playback counts as activity; not the desktop app), refresh idle window and maximum session length, in hours. Sign-out now revokes on the hub, and the profile has "sign out everywhere". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm --- .../src/meshbay_hub/static/admin-page.js | 34 ++++++++ packages/meshbay-hub/src/meshbay_hub/static/app.js | 94 +++++++++++++++------- .../src/meshbay_hub/static/hub-client.js | 18 ++++- .../meshbay-hub/src/meshbay_hub/static/idle.js | 77 ++++++++++++++++++ .../src/meshbay_hub/static/locales/de.js | 10 +++ .../src/meshbay_hub/static/locales/en.js | 10 +++ .../src/meshbay_hub/static/locales/es.js | 10 +++ .../src/meshbay_hub/static/locales/fr.js | 10 +++ .../src/meshbay_hub/static/locales/it.js | 10 +++ .../src/meshbay_hub/static/locales/ja.js | 10 +++ .../src/meshbay_hub/static/locales/nl.js | 10 +++ .../src/meshbay_hub/static/locales/pl.js | 10 +++ .../src/meshbay_hub/static/locales/pt-BR.js | 10 +++ .../src/meshbay_hub/static/locales/zh-CN.js | 10 +++ .../src/meshbay_hub/static/profile-page.js | 26 ++++++ 15 files changed, 320 insertions(+), 29 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/idle.js (limited to 'packages/meshbay-hub/src/meshbay_hub/static') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js index c40d240..d5cc7a8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js @@ -17,6 +17,7 @@ export function AdminPage({ token, role }) { // and a rejected one never looks applied. const [mailDraft, setMailDraft] = useState(null); const [loginDraft, setLoginDraft] = useState(null); + const [sessionDraft, setSessionDraft] = useState(null); const [users, setUsers] = useState([]); const [usersTotal, setUsersTotal] = useState(0); const [userSearch, setUserSearch] = useState(''); @@ -45,6 +46,7 @@ export function AdminPage({ token, role }) { setSettings(data); setMailDraft({ ...data.mail }); setLoginDraft({ ...data.login }); + setSessionDraft({ ...data.session }); } catch (e) { setError(e.message); } try { setMailStatus(await hubFetch('/v1/admin/mail', { token })); @@ -64,6 +66,7 @@ export function AdminPage({ token, role }) { // answer rather than left showing a number that was not stored. setMailDraft({ ...data.mail }); setLoginDraft({ ...data.login }); + setSessionDraft({ ...data.session }); if (patch.mail) { try { setMailStatus(await hubFetch('/v1/admin/mail', { token })); @@ -198,6 +201,8 @@ const MAIL_FIELDS = [ const LOGIN_FIELDS = ['max_failures', 'lockout_minutes']; +const SESSION_FIELDS = ['browser_idle_hours', 'refresh_idle_hours', 'max_hours']; + // Only what changed, and only what is a number: an empty field is someone // mid-edit, not a request to set zero. const changedNumbers = (fields, draft, stored) => Object.fromEntries(fields @@ -296,6 +301,35 @@ const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist `} + +
+

${t('admin.session_heading')}

+

${t('admin.session_hint')}

+ + ${sessionDraft && SESSION_FIELDS.map(key => html` +
+ ${t('admin.session_' + key)} + setSessionDraft(d => ({ ...d, [key]: e.target.value }))} /> +
+ `)} + + ${canEditSettings && sessionDraft && html` +
+ + +
+ `} +
`} `} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 367774f..a249ccf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -13,8 +13,9 @@ import { HUB, navigate, session, getCachedGroupIndex, _storeBundleKey, _loadBundleKey, _clearKeyDB, loadAuth, saveAuth, setAuth, setAuthChangeListener, ensureFreshToken, hubFetch, - refreshAccessToken, + refreshAccessToken, logoutOnHub, } from './hub-client.js'; +import { startIdleWatch, markActive } from './idle.js'; import { GroupPage } from './group-page.js'; import { SearchPage, ConnectionPool } from './search-page.js'; import { MusicPlayerBar } from './music-player.js'; @@ -741,12 +742,49 @@ function App() { const resolved = resolveTheme(theme); + // The name of the last session. A failed renewal clears the stored session, + // name included, and the device sign-in below needs it to try again. + const lastUsernameRef = useRef(user ? user.username : null); + if (user) lastUsernameRef.current = user.username; + // Set by a deliberate sign-out, which the device key must not undo. + const signedOutRef = useRef(false); + + // Sign in with this device's key. Desktop only: resolves false in a browser, + // or with no key, or with a key the hub no longer knows. + const signInWithDevice = useCallback(async (username) => { + if (!username || !platform.device.available) return false; + try { + const signed = await platform.device.sign(username); + if (!signed) return false; + const data = await hubFetch('/v1/users/auth', { + method: 'POST', + body: { username, timestamp: signed.timestamp, + signature: signed.signature }, + }); + const me = await hubFetch('/v1/users/me', { token: data.access_token }); + const u = { username, userId: me.user_id, token: data.access_token, + refreshToken: data.refresh_token, role: me.role }; + signedOutRef.current = false; + setAuth(u); + setUser(u); + return true; + } catch { + return false; + } + }, []); + // 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. - setAuthChangeListener((auth) => setUser(auth)); + setAuthChangeListener((auth) => { + setUser(auth); + // A renewal the hub refused — the session outlived its idle window, say, + // on a laptop that slept through it. The desktop application signs back + // in with its device key instead of showing the form; a browser has none. + if (!auth && !signedOutRef.current) signInWithDevice(lastUsernameRef.current); + }); // 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 @@ -891,32 +929,14 @@ function App() { if (deviceTried || user) { setDeviceTried(true); return; } let cancelled = false; (async () => { - try { - // `loadAuth` keeps the username even when the tokens in it are stale, - // and `app://meshbay` is a stable origin, so localStorage survives a - // relaunch. A fresh install has nothing here and asks for a passphrase, - // which is right: the first sign-in is what registers the device. - const saved = loadAuth(); - const username = saved && saved.username; - if (!username) return; - const signed = await platform.device.sign(username); - if (!signed) return; - const data = await hubFetch('/v1/users/auth', { - method: 'POST', - body: { username, timestamp: signed.timestamp, - signature: signed.signature }, - }); - const me = await hubFetch('/v1/users/me', { token: data.access_token }); - if (cancelled) return; - const u = { username, userId: me.user_id, token: data.access_token, - refreshToken: data.refresh_token, role: me.role }; - setAuth(u); - setUser(u); - } catch { - // Falls through to the sign-in form, which is the honest outcome. - } finally { - if (!cancelled) setDeviceTried(true); - } + // `loadAuth` keeps the username even when the tokens in it are stale, + // and `app://meshbay` is a stable origin, so localStorage survives a + // relaunch. A fresh install has nothing here and asks for a passphrase, + // which is right: the first sign-in is what registers the device. A + // refusal falls through to the sign-in form, the honest outcome. + const saved = loadAuth(); + await signInWithDevice(saved && saved.username); + if (!cancelled) setDeviceTried(true); })(); return () => { cancelled = true; }; }, []); @@ -977,6 +997,10 @@ function App() { // for the passphrase again. The key is generated and held by the main // process; what travels here is only its public half. await registerThisDevice(token); + // Before the session lands, so the idle watch starting with it does not + // read the last-active time of whoever used this browser before. + markActive(true); + signedOutRef.current = false; // 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. @@ -984,9 +1008,13 @@ function App() { setUser(u); }, logout: () => { + signedOutRef.current = true; // Navigating away leaves transfers running; signing out does not. They // are moving data on tokens that are about to stop being ours. transfers.reset(); + // Revoked on the hub too, so a copy of the refresh token is worth + // nothing. Read before the next line clears it. + logoutOnHub(); setAuth(null); setUser(null); setGroups([]); @@ -994,6 +1022,16 @@ function App() { }, }; + // A browser signs itself out after a stretch with nobody at it (idle.js). Not + // the desktop application: its owner's machine, which the device key would + // sign straight back in anyway. + const idleHours = hubInfo && hubInfo.browser_idle_hours; + const signedInId = user ? user.userId : null; + useEffect(() => { + if (!signedInId || platform.isNative || !idleHours) return undefined; + return startIdleWatch(idleHours * 3600 * 1000, () => authCtx.logout()); + }, [signedInId, idleHours]); + // 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. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js index e72961c..7ba6f92 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js @@ -245,6 +245,22 @@ async function refreshAccessToken() { return _refreshing; } +/** + * Revoke this session's refresh token on the hub. + * + * Fire and forget, and read synchronously: the caller clears the session on the + * next line, and signing out must not wait on the network or fail with it. + */ +function logoutOnHub() { + const refreshToken = _auth && _auth.refreshToken; + if (!refreshToken) return; + platform.apiFetch(HUB + '/v1/users/logout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }), + }).catch(() => {}); +} + /** Renew before it bites, rather than after. */ async function ensureFreshToken() { if (!_auth || !_auth.token) return null; @@ -290,5 +306,5 @@ export { cacheGroupIndex, getCachedGroupIndex, getAllCachedIndexes, clearAllCachedIndexes, _storeBundleKey, _loadBundleKey, _storeRecoveryKey, _loadRecoveryKey, _clearKeyDB, loadAuth, saveAuth, setAuth, setAuthChangeListener, - tokenLifeLeft, refreshAccessToken, ensureFreshToken, hubFetch, + tokenLifeLeft, refreshAccessToken, ensureFreshToken, logoutOnHub, hubFetch, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/idle.js b/packages/meshbay-hub/src/meshbay_hub/static/idle.js new file mode 100644 index 0000000..e24f758 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/idle.js @@ -0,0 +1,77 @@ +// Signing a browser out after a stretch with nobody at it. +// +// The hub cannot measure this. It hears a token renewal every few hours from +// any open tab, attended or not, and nothing at all while a film plays over +// WebRTC. So the page decides, and the hub only says how long +// (`browser_idle_hours` in /v1/hub/info). +// +// Activity is input, or any