summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py25
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/hub.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py97
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/config.py15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/hub_settings.py40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/admin-page.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js94
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/hub-client.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/idle.js77
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/profile-page.js26
-rw-r--r--packages/meshbay-hub/tests/conftest.py2
-rw-r--r--packages/meshbay-hub/tests/test_browser_idle_signout.py130
-rw-r--r--packages/meshbay-hub/tests/test_session_lifetime.py183
-rw-r--r--packages/meshbay-hub/tests/test_session_renewal.py17
-rw-r--r--packages/meshbay-hub/tests/test_unauthenticated_surface.py1
25 files changed, 817 insertions, 46 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
index 397b4d9..7ca1e68 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
@@ -42,6 +42,8 @@ class SettingsPatchRequest(BaseModel):
mail: dict[str, int] | None = None
# The sign-in lockout's two numbers, each optional, as for mail.
login: dict[str, int] | None = None
+ # Session lifetime, in hours, each optional.
+ session: dict[str, int] | None = None
# ── Instance settings ────────────────────────────────────────────────────────
@@ -59,6 +61,9 @@ async def _settings_payload(db: AsyncSession) -> dict:
"login": await hub_settings.login_limits(db),
"login_defaults": dict(hub_settings.LOGIN_DEFAULTS),
"login_bounds": {k: list(v) for k, v in hub_settings.LOGIN_BOUNDS.items()},
+ "session": await hub_settings.session_limits(db),
+ "session_defaults": dict(hub_settings.SESSION_DEFAULTS),
+ "session_bounds": {k: list(v) for k, v in hub_settings.SESSION_BOUNDS.items()},
}
@@ -138,6 +143,26 @@ async def admin_patch_settings(
))
await db.commit()
+ if body.session:
+ unknown = sorted(set(body.session) - set(hub_settings.SESSION_KEYS))
+ if unknown:
+ raise HTTPException(
+ status_code=422, detail=f"Unknown session setting(s): {unknown}")
+ changed = []
+ for key, value in body.session.items():
+ clamped = hub_settings.clamp_session_value(key, value)
+ await hub_settings.set_raw(db, f"session.{key}", str(clamped))
+ changed.append(f"{key}={clamped}")
+ log.info("Session lifetime changed by %s: %s",
+ current_user.username, ", ".join(changed))
+ db.add(IPLog(
+ user_id=current_user.id,
+ event="admin_session_update",
+ ip_address="admin",
+ detail=", ".join(changed)[:255],
+ ))
+ await db.commit()
+
return await _settings_payload(db)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
index 5a3eb4b..94e9b3c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
@@ -40,6 +40,10 @@ async def hub_info(db: AsyncSession = Depends(get_db)):
# read it without opening the source.
"federation": federation.FEDERATION_ENABLED,
"captcha_site_key": _cfg.captcha.site_key if _cfg and _cfg.captcha.enabled else "",
+ # How long a browser tab stays signed in with nobody at it. The page
+ # measures it (static/idle.js); the hub only says how long.
+ "browser_idle_hours":
+ (await hub_settings.session_limits(db))["browser_idle_hours"],
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 9150909..a74f6af 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -50,8 +50,28 @@ def set_config(cfg: HubConfig) -> None:
def _ttl() -> int:
return _cfg.jwt.access_token_ttl if _cfg else 3600
-def _refresh_ttl() -> int:
- return _cfg.jwt.refresh_token_ttl if _cfg else 86400 * 30
+async def _refresh_expiry(db: AsyncSession, family_id: str | None = None) -> datetime:
+ """
+ When a refresh token stops being accepted.
+
+ Sliding: each renewal moves it `refresh_idle_hours` past now, so a session
+ in use stays open — but never past `max_hours` after the sign-in that
+ started its family. The idle window never drops below the access token's
+ own life plus an hour: shorter, and a session would lapse between two
+ renewals of a tab that is being used.
+ """
+ limits = await hub_settings.session_limits(db)
+ now = datetime.now(timezone.utc)
+ idle = max(limits["refresh_idle_hours"] * 3600, _ttl() + 3600)
+ started = now
+ if family_id is not None:
+ first = await db.scalar(
+ select(func.min(RefreshToken.created_at))
+ .where(RefreshToken.family_id == family_id))
+ if first is not None:
+ started = first if first.tzinfo else first.replace(tzinfo=timezone.utc)
+ return min(now + timedelta(seconds=idle),
+ started + timedelta(hours=limits["max_hours"]))
VERIFICATION_TTL = 86400 # 24 hours
VERIFICATION_MAX_ATTEMPTS = 10
@@ -134,6 +154,10 @@ class RefreshRequest(BaseModel):
refresh_token: str
+class LogoutRequest(BaseModel):
+ refresh_token: str
+
+
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.post("/register", status_code=201)
@@ -404,7 +428,7 @@ async def login(
raw_rt, rt_hash = generate_refresh_token()
family_id = str(uuid.uuid4())
- expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl())
+ expires_at = await _refresh_expiry(db)
db.add(RefreshToken(
user_id=user.id, token_hash=rt_hash,
family_id=family_id, expires_at=expires_at,
@@ -586,7 +610,7 @@ async def device_auth(
group_ids = [gid for (gid,) in memberships.all()]
access_token = issue_access_token(user.id, ttl=_ttl(), groups=group_ids)
raw_rt, rt_hash = generate_refresh_token()
- expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl())
+ expires_at = await _refresh_expiry(db)
db.add(RefreshToken(user_id=user.id, token_hash=rt_hash,
family_id=str(uuid.uuid4()), expires_at=expires_at))
db.add(IPLog(user_id=user.id, event="device_auth",
@@ -633,12 +657,21 @@ async def token_refresh(
if not user or user.status != "active":
raise HTTPException(status_code=401, detail="User not found or suspended")
+ # The family's first sign-in was longer ago than any session may last.
+ expires_at = await _refresh_expiry(db, rt.family_id)
+ if expires_at <= datetime.now(timezone.utc):
+ await db.execute(
+ update(RefreshToken)
+ .where(RefreshToken.family_id == rt.family_id)
+ .values(revoked=True))
+ await db.commit()
+ raise HTTPException(status_code=401, detail="Session expired")
+
# Revoke old token
rt.revoked = True
# Issue new refresh token in the same family
new_raw_rt, new_rt_hash = generate_refresh_token()
- expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl())
db.add(RefreshToken(
user_id=user.id, token_hash=new_rt_hash,
family_id=rt.family_id, expires_at=expires_at,
@@ -882,6 +915,58 @@ class ChangePasswordRequest(BaseModel):
new_auth_key: str
+@router.post("/logout")
+@limiter.limit("20/minute")
+async def logout(
+ body: LogoutRequest,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ End this session on the hub, not only in the browser.
+
+ The browser already forgets its tokens and keys on sign-out; this makes the
+ refresh token it held worthless to anyone who copied it. The token is the
+ credential, so no access token is asked for — it may well have expired. An
+ unknown or already revoked token gets the same answer.
+ """
+ rt = await db.scalar(select(RefreshToken).where(
+ RefreshToken.token_hash == hash_refresh_token(body.refresh_token)))
+ if rt is not None:
+ await db.execute(
+ update(RefreshToken)
+ .where(RefreshToken.family_id == rt.family_id)
+ .values(revoked=True))
+ db.add(IPLog(user_id=rt.user_id, event="logout", ip_address=client_ip(request)))
+ await db.commit()
+ return {"status": "signed_out"}
+
+
+@router.post("/me/sessions/revoke")
+@limiter.limit("5/minute")
+async def revoke_all_sessions(
+ request: Request,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ Sign out everywhere: no refresh token of this account renews any more.
+
+ Access tokens already issued run out on their own, within their lifetime.
+ A desktop device key is not a session and is left alone — removing the
+ device is what stops it signing back in.
+ """
+ result = await db.execute(
+ update(RefreshToken)
+ .where(RefreshToken.user_id == current_user.id,
+ RefreshToken.revoked.is_(False))
+ .values(revoked=True))
+ db.add(IPLog(user_id=current_user.id, event="sessions_revoked",
+ ip_address=client_ip(request)))
+ await db.commit()
+ return {"revoked": result.rowcount}
+
+
@router.post("/password")
@limiter.limit("5/minute")
async def change_password(
@@ -918,7 +1003,7 @@ async def change_password(
group_ids = [gid for (gid,) in memberships.all()]
access_token = issue_access_token(current_user.id, ttl=_ttl(), groups=group_ids)
raw_rt, rt_hash = generate_refresh_token()
- expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl())
+ expires_at = await _refresh_expiry(db)
db.add(RefreshToken(
user_id=current_user.id, token_hash=rt_hash,
family_id=str(uuid.uuid4()), expires_at=expires_at,
diff --git a/packages/meshbay-hub/src/meshbay_hub/config.py b/packages/meshbay-hub/src/meshbay_hub/config.py
index 827538c..876583e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/config.py
+++ b/packages/meshbay-hub/src/meshbay_hub/config.py
@@ -9,6 +9,7 @@ Priority (highest first):
Production config file example: /etc/meshbay/hub.toml
"""
+import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
@@ -18,6 +19,8 @@ try:
except ImportError:
import tomli as tomllib # type: ignore[no-redef]
+log = logging.getLogger(__name__)
+
DEFAULT_CONFIG_PATHS = [
Path("/etc/meshbay/hub.toml"),
Path.home() / ".config" / "meshbay" / "hub.toml",
@@ -47,8 +50,9 @@ class HubIdentityConfig:
@dataclass
class JWTConfig:
# 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.
+ # token is, and the SPA renews against it well before this runs out, so a
+ # film or a working day never meets this number. How long the session
+ # lasts is an admin setting (`hub_settings.SESSION_*`), not configuration.
#
# 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
@@ -58,7 +62,6 @@ class JWTConfig:
# 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
@dataclass
@@ -161,7 +164,11 @@ def load_config(path: Path | None = None) -> HubConfig:
cfg.identity.admin_usernames = list(admins)
if jwt := raw.get("jwt", {}):
cfg.jwt.access_token_ttl = jwt.get("access_token_ttl", cfg.jwt.access_token_ttl)
- cfg.jwt.refresh_token_ttl = jwt.get("refresh_token_ttl", cfg.jwt.refresh_token_ttl)
+ # One source for the session's length: a value left here would
+ # otherwise look authoritative and change nothing.
+ if "refresh_token_ttl" in jwt:
+ log.warning("%s: [jwt] refresh_token_ttl is ignored — session "
+ "lifetime is set in the admin panel", p)
if ml := raw.get("mail", {}):
for name in (
"destination_cooldown_seconds", "destination_daily_cap",
diff --git a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
index 1dcff84..c397f58 100644
--- a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
+++ b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
@@ -116,6 +116,46 @@ async def login_limits(db: AsyncSession) -> dict[str, int]:
for k in LOGIN_KEYS}
+# ── Session lifetime ─────────────────────────────────────────────────────────
+#
+# `browser_idle_hours`: a browser tab signs itself out after this long with no
+# input and nothing playing (`static/idle.js`). The hub cannot measure that — it
+# hears a renewal from any open tab, attended or not — so the page does, and
+# reads the number from `/v1/hub/info`. The desktop application is exempt: it is
+# its owner's machine and signs back in with its device key.
+#
+# `refresh_idle_hours`: a refresh token unused for this long stops renewing. The
+# hub's own backstop, for a token that left the browser it was issued to.
+#
+# `max_hours`: no session renews past this since its sign-in, used or not.
+
+SESSION_KEYS = ("browser_idle_hours", "refresh_idle_hours", "max_hours")
+
+SESSION_DEFAULTS: dict[str, int] = {
+ "browser_idle_hours": 1,
+ "refresh_idle_hours": 24,
+ "max_hours": 720,
+}
+
+SESSION_BOUNDS: dict[str, tuple[int, int]] = {
+ "browser_idle_hours": (1, 168), # a week
+ "refresh_idle_hours": (1, 720), # 30 days
+ "max_hours": (1, 8_760), # a year
+}
+
+
+def clamp_session_value(key: str, value: int) -> int:
+ low, high = SESSION_BOUNDS[key]
+ return max(low, min(high, int(value)))
+
+
+async def session_limits(db: AsyncSession) -> dict[str, int]:
+ """The three session numbers, stored value or built-in default."""
+ return {k: clamp_session_value(
+ k, await get_int(db, f"session.{k}", SESSION_DEFAULTS[k]))
+ for k in SESSION_KEYS}
+
+
async def get_raw(db: AsyncSession, key: str) -> str | None:
row = await db.get(HubSetting, key)
return row.value if row else None
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
</div>
`}
</div>
+
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('admin.session_heading')}</h3>
+ <p class="settings-hint">${t('admin.session_hint')}</p>
+
+ ${sessionDraft && SESSION_FIELDS.map(key => html`
+ <div class="settings-row" key=${key}>
+ <span class="settings-label">${t('admin.session_' + key)}</span>
+ <input type="number" class="settings-number"
+ min=${(settings.session_bounds?.[key] || [0])[0]}
+ max=${(settings.session_bounds?.[key] || [0, 0])[1]}
+ value=${sessionDraft[key]}
+ disabled=${!canEditSettings || settingsSaving}
+ onInput=${e => setSessionDraft(d => ({ ...d, [key]: e.target.value }))} />
+ </div>
+ `)}
+
+ ${canEditSettings && sessionDraft && html`
+ <div class="settings-row">
+ <button class="btn" disabled=${settingsSaving}
+ onClick=${() => saveSettings({
+ session: changedNumbers(SESSION_FIELDS, sessionDraft, settings.session),
+ })}>${t('admin.session_save')}</button>
+ <button class="btn btn-secondary" disabled=${settingsSaving}
+ onClick=${() => setSessionDraft({ ...settings.session_defaults })}
+ >${t('admin.mail_reset_defaults')}</button>
+ </div>
+ `}
+ </div>
`}
`}
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 <video>/<audio> on the page that is playing: a film
+// nobody touches for two hours is somebody watching it. The last-active time
+// lives in localStorage so every tab of this browser shares it — one idle tab
+// must not sign out the tab in use — and a browser closed without signing out
+// is caught the moment it is opened again.
+//
+// Never started in the desktop application (app.js): that is its owner's own
+// machine, and it signs back in with its device key.
+
+const LAST_ACTIVE_KEY = 'mb_last_active';
+const CHECK_MS = 60000;
+// Input fires constantly; the timestamp only has to be minutes-accurate.
+const WRITE_EVERY_MS = 30000;
+const INPUT_EVENTS = ['pointerdown', 'keydown', 'wheel', 'touchstart'];
+
+let lastWrite = 0;
+
+function readLastActive() {
+ try {
+ const v = Number(localStorage.getItem(LAST_ACTIVE_KEY));
+ return Number.isFinite(v) && v > 0 ? v : null;
+ } catch {
+ return null;
+ }
+}
+
+/** Record activity now. `force` skips the write throttle — a sign-in uses it. */
+function markActive(force = false) {
+ const now = Date.now();
+ if (!force && now - lastWrite < WRITE_EVERY_MS) return;
+ lastWrite = now;
+ try { localStorage.setItem(LAST_ACTIVE_KEY, String(now)); } catch { /* private mode */ }
+}
+
+function mediaPlaying() {
+ return [...document.querySelectorAll('video, audio')]
+ .some((m) => !m.paused && !m.ended);
+}
+
+/**
+ * Watch for `idleMs` without activity, then call `onIdle` once.
+ * Returns the function that stops watching.
+ */
+function startIdleWatch(idleMs, onIdle) {
+ let fired = false;
+ const onInput = () => markActive();
+ const check = () => {
+ if (fired) return;
+ if (mediaPlaying()) { markActive(); return; }
+ const last = readLastActive();
+ // No record at all is a browser that predates this, not an idle one.
+ if (last === null) { markActive(true); return; }
+ if (Date.now() - last > idleMs) { fired = true; onIdle(); }
+ };
+
+ INPUT_EVENTS.forEach((e) =>
+ window.addEventListener(e, onInput, { capture: true, passive: true }));
+ document.addEventListener('visibilitychange', check);
+ const timer = setInterval(check, CHECK_MS);
+ check();
+
+ return () => {
+ INPUT_EVENTS.forEach((e) =>
+ window.removeEventListener(e, onInput, { capture: true }));
+ document.removeEventListener('visibilitychange', check);
+ clearInterval(timer);
+ };
+}
+
+export { startIdleWatch, markActive, LAST_ACTIVE_KEY };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 065e58e..6e5afa6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -408,6 +408,10 @@ export default {
'settings.recovery_partial': 'Diese Gruppen waren nicht erreichbar. Öffnen Sie sie später oder führen Sie dies erneut aus:',
'settings.recovery_need_relogin': 'Bitte melden Sie sich ab und wieder an und versuchen Sie es erneut.',
'settings.danger': 'Konto löschen',
+ 'settings.sessions_heading': 'Sitzungen',
+ 'settings.sign_out_everywhere': 'Überall abmelden',
+ 'settings.sign_out_everywhere_hint': 'Beendet alle Sitzungen Ihres Kontos, auch diese: nützlich, wenn Sie auf einem fremden Computer angemeldet geblieben sind. Eine Desktop-Anwendung meldet sich mit ihrem Geräteschlüssel wieder an, bis Sie dieses Gerät entfernen.',
+ 'settings.sign_out_everywhere_confirm': 'Von allen Browsern und Anwendungen abmelden, auch von diesem?',
'settings.delete_hint': 'Entfernt Ihr Konto, Ihre Gruppenmitgliedschaften und Ihre '
+ 'Benachrichtigungen vom Hub und gibt Ihren Benutzernamen frei. Was auf den Nodes '
+ 'liegt, erreicht das nicht: hochgeladene Dateien bleiben dort, wo ihr Betreiber '
@@ -504,6 +508,12 @@ export default {
'admin.mail_reset_defaults': "Standardwerte wiederherstellen",
'admin.login_heading': "Anmeldung",
'admin.login_hint': "Nach so vielen falschen Passphrasen für einen Benutzernamen wird die Anmeldung mit Passphrase für die angegebene Dauer verweigert. Bereits offene Sitzungen und registrierte Geräte funktionieren weiter, und ein Zurücksetzen der Passphrase hebt die Sperre auf. 0 schaltet sie ab.",
+ 'admin.session_heading': 'Sitzungen',
+ 'admin.session_hint': 'Ein Browser meldet sich nach der ersten Stundenzahl ohne Eingabe und ohne laufende Wiedergabe ab; die Desktop-Anwendung nicht. Eine Sitzung, die so viele Stunden wie die zweite Zahl ungenutzt bleibt, wird nicht mehr verlängert, und keine dauert länger als die dritte.',
+ 'admin.session_browser_idle_hours': 'Browser-Abmeldung nach Inaktivität (Stunden)',
+ 'admin.session_refresh_idle_hours': 'Ungenutzte Sitzung läuft ab nach (Stunden)',
+ 'admin.session_max_hours': 'Maximale Sitzungsdauer (Stunden)',
+ 'admin.session_save': 'Sitzungseinstellungen speichern',
'admin.login_max_failures': "Falsche Passphrasen bis zur Sperre",
'admin.login_lockout_minutes': "Dauer der Sperre (Minuten)",
'admin.login_save': "Anmeldegrenzen speichern",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 83a39e7..7ffbc28 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -402,6 +402,10 @@ export default {
'settings.recovery_partial': 'These groups could not be reached. Open them later, or run this again:',
'settings.recovery_need_relogin': 'Please sign out and back in, then try again.',
'settings.danger': 'Delete account',
+ 'settings.sessions_heading': 'Sessions',
+ 'settings.sign_out_everywhere': 'Sign out everywhere',
+ 'settings.sign_out_everywhere_hint': 'Ends every session of your account, including this one: useful if you stayed signed in on a computer that is not yours. A desktop application signs back in with its device key until you remove that device.',
+ 'settings.sign_out_everywhere_confirm': 'Sign out of every browser and application, including this one?',
'settings.delete_hint': 'Removes your account, your group memberships and your '
+ 'notifications from the hub, and frees your username. It cannot reach what '
+ 'lives on nodes: files you uploaded stay where their operator keeps them, '
@@ -494,6 +498,12 @@ export default {
'admin.mail_reset_defaults': "Restore defaults",
'admin.login_heading': "Sign-in",
'admin.login_hint': "After this many wrong passphrases for one username, signing in with a passphrase is refused for the set duration. Sessions already open and registered devices keep working, and a passphrase reset ends the lockout. 0 turns it off.",
+ 'admin.session_heading': 'Sessions',
+ 'admin.session_hint': 'A browser signs itself out after the first number of hours with no input and nothing playing; the desktop application does not. A session unused for the second number stops renewing, and none lasts past the third.',
+ 'admin.session_browser_idle_hours': 'Browser sign-out after inactivity (hours)',
+ 'admin.session_refresh_idle_hours': 'Session expires when unused for (hours)',
+ 'admin.session_max_hours': 'Maximum session length (hours)',
+ 'admin.session_save': 'Save session settings',
'admin.login_max_failures': "Wrong passphrases before a lockout",
'admin.login_lockout_minutes': "Lockout duration (minutes)",
'admin.login_save': "Save sign-in limits",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 73757d0..e675e10 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -405,6 +405,10 @@ export default {
'settings.recovery_partial': 'No se pudo contactar con estos grupos. Ábralos más tarde o vuelva a ejecutar esto:',
'settings.recovery_need_relogin': 'Cierre sesión y vuelva a entrar, luego inténtelo de nuevo.',
'settings.danger': 'Eliminar la cuenta',
+ 'settings.sessions_heading': 'Sesiones',
+ 'settings.sign_out_everywhere': 'Cerrar sesión en todas partes',
+ 'settings.sign_out_everywhere_hint': 'Termina todas las sesiones de tu cuenta, incluida esta: útil si dejaste la sesión abierta en un ordenador que no es tuyo. Una aplicación de escritorio vuelve a entrar con su clave de dispositivo hasta que retires ese dispositivo.',
+ 'settings.sign_out_everywhere_confirm': '¿Cerrar sesión en todos los navegadores y aplicaciones, incluido este?',
'settings.delete_hint': 'Elimina su cuenta, sus pertenencias a grupos y sus '
+ 'notificaciones del hub, y libera su nombre de usuario. No alcanza lo que vive '
+ 'en los nodes: los archivos que subió se quedan donde los guarda su operador, y '
@@ -500,6 +504,12 @@ export default {
'admin.mail_reset_defaults': "Restaurar valores por defecto",
'admin.login_heading': "Inicio de sesión",
'admin.login_hint': "Tras este número de frases de contraseña incorrectas para un mismo nombre de usuario, se rechaza el inicio de sesión con frase de contraseña durante el tiempo indicado. Las sesiones ya abiertas y los dispositivos registrados siguen funcionando, y restablecer la frase de contraseña levanta el bloqueo. 0 lo desactiva.",
+ 'admin.session_heading': 'Sesiones',
+ 'admin.session_hint': 'Un navegador cierra la sesión tras el primer número de horas sin interacción ni reproducción en curso; la aplicación de escritorio no. Una sesión sin usar durante el segundo número deja de renovarse, y ninguna dura más del tercero.',
+ 'admin.session_browser_idle_hours': 'Cierre de sesión del navegador por inactividad (horas)',
+ 'admin.session_refresh_idle_hours': 'Una sesión sin usar caduca tras (horas)',
+ 'admin.session_max_hours': 'Duración máxima de una sesión (horas)',
+ 'admin.session_save': 'Guardar ajustes de sesión',
'admin.login_max_failures': "Frases incorrectas antes del bloqueo",
'admin.login_lockout_minutes': "Duración del bloqueo (minutos)",
'admin.login_save': "Guardar límites de inicio de sesión",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 35bce2c..cc0892d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -407,6 +407,10 @@ export default {
'settings.recovery_partial': 'Ces groupes n’ont pas pu être joints. Ouvrez-les plus tard, ou relancez l’opération :',
'settings.recovery_need_relogin': 'Veuillez vous déconnecter puis vous reconnecter, et réessayer.',
'settings.danger': 'Supprimer le compte',
+ 'settings.sessions_heading': 'Sessions',
+ 'settings.sign_out_everywhere': 'Se déconnecter partout',
+ 'settings.sign_out_everywhere_hint': 'Met fin à toutes les sessions de votre compte, y compris celle-ci : utile si vous êtes resté connecté sur un ordinateur qui n\'est pas le vôtre. Une application desktop se reconnecte avec sa clé d\'appareil tant que vous ne retirez pas cet appareil.',
+ 'settings.sign_out_everywhere_confirm': 'Se déconnecter de tous les navigateurs et applications, y compris celui-ci ?',
'settings.delete_hint': 'Supprime votre compte, vos adhésions aux groupes et vos '
+ 'notifications du hub, et libère votre nom d’utilisateur. Cela n’atteint pas '
+ 'ce qui se trouve sur les nodes : les fichiers que vous avez envoyés restent là '
@@ -503,6 +507,12 @@ export default {
'admin.mail_reset_defaults': "Rétablir les valeurs par défaut",
'admin.login_heading': "Connexion",
'admin.login_hint': "Après ce nombre de phrases secrètes erronées pour un même nom d'utilisateur, la connexion par phrase secrète est refusée pendant la durée indiquée. Les sessions déjà ouvertes et les appareils enregistrés continuent de fonctionner, et une réinitialisation de la phrase secrète lève le blocage. 0 le désactive.",
+ 'admin.session_heading': 'Sessions',
+ 'admin.session_hint': 'Un navigateur se déconnecte après le premier nombre d\'heures sans interaction ni lecture en cours ; l\'application desktop non. Une session inutilisée pendant le deuxième nombre n\'est plus renouvelée, et aucune ne dure au-delà du troisième.',
+ 'admin.session_browser_idle_hours': 'Déconnexion du navigateur après inactivité (heures)',
+ 'admin.session_refresh_idle_hours': 'Expiration d\'une session inutilisée (heures)',
+ 'admin.session_max_hours': 'Durée maximale d\'une session (heures)',
+ 'admin.session_save': 'Enregistrer les réglages de session',
'admin.login_max_failures': "Phrases secrètes erronées avant blocage",
'admin.login_lockout_minutes': "Durée du blocage (minutes)",
'admin.login_save': "Enregistrer les limites de connexion",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index edc0d97..7ccaff4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -407,6 +407,10 @@ export default {
'settings.recovery_partial': 'Non è stato possibile raggiungere questi gruppi. Li apra più tardi, o riesegua l’operazione:',
'settings.recovery_need_relogin': 'Esca e rientri, poi riprovi.',
'settings.danger': "Elimina l'account",
+ 'settings.sessions_heading': 'Sessioni',
+ 'settings.sign_out_everywhere': 'Esci ovunque',
+ 'settings.sign_out_everywhere_hint': 'Termina tutte le sessioni del tuo account, compresa questa: utile se sei rimasto connesso su un computer non tuo. Un\'applicazione desktop rientra con la sua chiave del dispositivo finché non rimuovi quel dispositivo.',
+ 'settings.sign_out_everywhere_confirm': 'Uscire da tutti i browser e le applicazioni, compreso questo?',
'settings.delete_hint': "Rimuove il suo account, le sue adesioni ai gruppi e le sue "
+ "notifiche dall'hub, e libera il suo nome utente. Non arriva a ciò che si trova "
+ 'sui node: i file che ha caricato restano dove il loro operatore li conserva, e '
@@ -503,6 +507,12 @@ export default {
'admin.mail_reset_defaults': "Ripristina i valori predefiniti",
'admin.login_heading': "Accesso",
'admin.login_hint': "Dopo questo numero di passphrase errate per uno stesso nome utente, l'accesso con passphrase viene rifiutato per la durata indicata. Le sessioni già aperte e i dispositivi registrati continuano a funzionare, e la reimpostazione della passphrase rimuove il blocco. 0 lo disattiva.",
+ 'admin.session_heading': 'Sessioni',
+ 'admin.session_hint': 'Un browser si disconnette dopo il primo numero di ore senza interazione né riproduzione in corso; l\'applicazione desktop no. Una sessione inutilizzata per il secondo numero non viene più rinnovata, e nessuna dura oltre il terzo.',
+ 'admin.session_browser_idle_hours': 'Disconnessione del browser dopo inattività (ore)',
+ 'admin.session_refresh_idle_hours': 'Una sessione inutilizzata scade dopo (ore)',
+ 'admin.session_max_hours': 'Durata massima di una sessione (ore)',
+ 'admin.session_save': 'Salva impostazioni sessione',
'admin.login_max_failures': "Passphrase errate prima del blocco",
'admin.login_lockout_minutes': "Durata del blocco (minuti)",
'admin.login_save': "Salva i limiti di accesso",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 8667d20..aaec812 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -402,6 +402,10 @@ export default {
'settings.recovery_partial': 'これらのグループに到達できませんでした。あとで開くか、もう一度実行してください:',
'settings.recovery_need_relogin': '一度サインアウトして再度サインインしてから、もう一度お試しください。',
'settings.danger': 'アカウントを削除',
+ 'settings.sessions_heading': 'セッション',
+ 'settings.sign_out_everywhere': 'すべての場所からサインアウト',
+ 'settings.sign_out_everywhere_hint': 'このセッションを含め、アカウントのすべてのセッションを終了します。自分のものではないコンピューターでサインインしたままにした場合に役立ちます。デスクトップアプリは、その端末を削除するまで端末キーで再びサインインします。',
+ 'settings.sign_out_everywhere_confirm': 'このブラウザーを含む、すべてのブラウザーとアプリからサインアウトしますか?',
'settings.delete_hint': 'hub から、お客様のアカウント、グループへの参加、通知を'
+ '削除し、ユーザー名を解放します。node 上にあるものには及びません。'
+ 'アップロードしたファイルは、その運営者が保管する場所に残り、'
@@ -496,6 +500,12 @@ export default {
'admin.mail_reset_defaults': "既定値に戻す",
'admin.login_heading': "サインイン",
'admin.login_hint': "1つのユーザー名に対してこの回数パスフレーズを誤ると、設定した時間のあいだパスフレーズによるサインインが拒否されます。すでに開いているセッションと登録済みの端末は引き続き使え、パスフレーズをリセットするとロックは解除されます。0 で無効になります。",
+ 'admin.session_heading': 'セッション',
+ 'admin.session_hint': 'ブラウザーは、操作も再生もないまま1つ目の時間が経過するとサインアウトします(デスクトップアプリは対象外)。2つ目の時間使われなかったセッションは更新されず、どのセッションも3つ目の時間を超えて続きません。',
+ 'admin.session_browser_idle_hours': '無操作時のブラウザーのサインアウト(時間)',
+ 'admin.session_refresh_idle_hours': '未使用セッションの有効期限(時間)',
+ 'admin.session_max_hours': 'セッションの最大時間(時間)',
+ 'admin.session_save': 'セッション設定を保存',
'admin.login_max_failures': "ロックまでの誤りの回数",
'admin.login_lockout_minutes': "ロック時間(分)",
'admin.login_save': "サインインの制限を保存",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index fc2eccb..3049cef 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -408,6 +408,10 @@ export default {
'settings.recovery_partial': 'Deze groepen konden niet worden bereikt. Open ze later, of voer dit opnieuw uit:',
'settings.recovery_need_relogin': 'Meld u af en weer aan en probeer het opnieuw.',
'settings.danger': 'Account verwijderen',
+ 'settings.sessions_heading': 'Sessies',
+ 'settings.sign_out_everywhere': 'Overal afmelden',
+ 'settings.sign_out_everywhere_hint': 'Beëindigt alle sessies van je account, ook deze: handig als je aangemeld bleef op een computer die niet van jou is. Een desktoptoepassing meldt zich weer aan met haar apparaatsleutel tot je dat apparaat verwijdert.',
+ 'settings.sign_out_everywhere_confirm': 'Afmelden bij alle browsers en toepassingen, ook deze?',
'settings.delete_hint': 'Verwijdert uw account, uw groepslidmaatschappen en uw '
+ 'meldingen van de hub, en geeft uw gebruikersnaam vrij. Wat op de nodes staat, '
+ 'wordt hiermee niet bereikt: bestanden die u hebt geüpload blijven waar hun '
@@ -504,6 +508,12 @@ export default {
'admin.mail_reset_defaults': "Standaardwaarden herstellen",
'admin.login_heading': "Aanmelden",
'admin.login_hint': "Na dit aantal onjuiste wachtwoordzinnen voor één gebruikersnaam wordt aanmelden met een wachtwoordzin geweigerd gedurende de ingestelde tijd. Al geopende sessies en geregistreerde apparaten blijven werken, en het opnieuw instellen van de wachtwoordzin heft de blokkade op. 0 schakelt dit uit.",
+ 'admin.session_heading': 'Sessies',
+ 'admin.session_hint': 'Een browser meldt zich af na het eerste aantal uur zonder invoer en zonder lopende weergave; de desktoptoepassing niet. Een sessie die het tweede aantal uur ongebruikt blijft, wordt niet meer verlengd, en geen enkele duurt langer dan het derde.',
+ 'admin.session_browser_idle_hours': 'Browser afmelden na inactiviteit (uren)',
+ 'admin.session_refresh_idle_hours': 'Ongebruikte sessie verloopt na (uren)',
+ 'admin.session_max_hours': 'Maximale sessieduur (uren)',
+ 'admin.session_save': 'Sessie-instellingen opslaan',
'admin.login_max_failures': "Onjuiste wachtwoordzinnen vóór blokkade",
'admin.login_lockout_minutes': "Duur van de blokkade (minuten)",
'admin.login_save': "Aanmeldlimieten opslaan",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index af65489..8922d0a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -421,6 +421,10 @@ export default {
'settings.recovery_partial': 'Nie udało się połączyć z tymi grupami. Otwórz je później lub uruchom to ponownie:',
'settings.recovery_need_relogin': 'Wyloguj się i zaloguj ponownie, a następnie spróbuj jeszcze raz.',
'settings.danger': 'Usunięcie konta',
+ 'settings.sessions_heading': 'Sesje',
+ 'settings.sign_out_everywhere': 'Wyloguj wszędzie',
+ 'settings.sign_out_everywhere_hint': 'Kończy wszystkie sesje Twojego konta, łącznie z tą: przydatne, jeśli pozostałeś zalogowany na cudzym komputerze. Aplikacja desktopowa loguje się ponownie kluczem urządzenia, dopóki nie usuniesz tego urządzenia.',
+ 'settings.sign_out_everywhere_confirm': 'Wylogować ze wszystkich przeglądarek i aplikacji, łącznie z tą?',
'settings.delete_hint': 'Usuwa z huba konto, członkostwa w grupach i powiadomienia '
+ 'oraz zwalnia nazwę użytkownika. Nie sięga tego, co znajduje się na nodes: '
+ 'wysłane pliki pozostają tam, gdzie przechowuje je ich operator, a każdy node '
@@ -516,6 +520,12 @@ export default {
'admin.mail_reset_defaults': "Przywróć domyślne",
'admin.login_heading': "Logowanie",
'admin.login_hint': "Po tylu błędnych hasłach-frazach dla jednej nazwy użytkownika logowanie hasłem-frazą jest odrzucane przez ustawiony czas. Otwarte już sesje i zarejestrowane urządzenia działają dalej, a zresetowanie hasła-frazy znosi blokadę. 0 ją wyłącza.",
+ 'admin.session_heading': 'Sesje',
+ 'admin.session_hint': 'Przeglądarka wylogowuje się po pierwszej liczbie godzin bez interakcji i bez odtwarzania; aplikacja desktopowa nie. Sesja nieużywana przez drugą liczbę godzin przestaje być odnawiana, a żadna nie trwa dłużej niż trzecia.',
+ 'admin.session_browser_idle_hours': 'Wylogowanie przeglądarki po bezczynności (godziny)',
+ 'admin.session_refresh_idle_hours': 'Nieużywana sesja wygasa po (godziny)',
+ 'admin.session_max_hours': 'Maksymalny czas sesji (godziny)',
+ 'admin.session_save': 'Zapisz ustawienia sesji',
'admin.login_max_failures': "Błędne hasła-frazy przed blokadą",
'admin.login_lockout_minutes': "Czas blokady (minuty)",
'admin.login_save': "Zapisz limity logowania",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 65e1ec1..460a902 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -407,6 +407,10 @@ export default {
'settings.recovery_partial': 'Não foi possível alcançar estes grupos. Abra-os mais tarde, ou execute isto de novo:',
'settings.recovery_need_relogin': 'Saia e entre novamente, depois tente de novo.',
'settings.danger': 'Excluir a conta',
+ 'settings.sessions_heading': 'Sessões',
+ 'settings.sign_out_everywhere': 'Sair em todos os lugares',
+ 'settings.sign_out_everywhere_hint': 'Encerra todas as sessões da sua conta, inclusive esta: útil se você ficou conectado em um computador que não é seu. Um aplicativo desktop entra de novo com a chave do dispositivo até você remover esse dispositivo.',
+ 'settings.sign_out_everywhere_confirm': 'Sair de todos os navegadores e aplicativos, inclusive este?',
'settings.delete_hint': 'Remove sua conta, suas participações em grupos e suas '
+ 'notificações do hub, e libera seu nome de usuário. Não alcança o que está nos '
+ 'nodes: os arquivos que você enviou permanecem onde o operador deles os guarda, e '
@@ -502,6 +506,12 @@ export default {
'admin.mail_reset_defaults': "Restaurar padrões",
'admin.login_heading': "Login",
'admin.login_hint': "Após este número de frases secretas incorretas para um mesmo nome de usuário, o login com frase secreta é recusado pelo tempo definido. Sessões já abertas e dispositivos registrados continuam funcionando, e redefinir a frase secreta encerra o bloqueio. 0 desativa.",
+ 'admin.session_heading': 'Sessões',
+ 'admin.session_hint': 'Um navegador encerra a sessão após o primeiro número de horas sem interação e sem reprodução em andamento; o aplicativo desktop não. Uma sessão sem uso pelo segundo número deixa de ser renovada, e nenhuma dura além do terceiro.',
+ 'admin.session_browser_idle_hours': 'Saída do navegador após inatividade (horas)',
+ 'admin.session_refresh_idle_hours': 'Sessão sem uso expira após (horas)',
+ 'admin.session_max_hours': 'Duração máxima da sessão (horas)',
+ 'admin.session_save': 'Salvar configurações de sessão',
'admin.login_max_failures': "Frases incorretas antes do bloqueio",
'admin.login_lockout_minutes': "Duração do bloqueio (minutos)",
'admin.login_save': "Salvar limites de login",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 87257b4..b8ab4ef 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -398,6 +398,10 @@ export default {
'settings.recovery_partial': '无法连接这些群组。稍后打开它们,或再次运行此操作:',
'settings.recovery_need_relogin': '请先退出登录再重新登录,然后重试。',
'settings.danger': '删除账户',
+ 'settings.sessions_heading': '会话',
+ 'settings.sign_out_everywhere': '在所有设备上退出',
+ 'settings.sign_out_everywhere_hint': '结束您账户的所有会话,包括当前会话:适用于您在他人电脑上忘记退出的情况。桌面应用会使用其设备密钥重新登录,直到您移除该设备。',
+ 'settings.sign_out_everywhere_confirm': '要退出所有浏览器和应用(包括当前这个)吗?',
'settings.delete_hint': '这会从 hub 上删除您的账户、您的群组成员身份和您的通知,'
+ '并释放您的用户名。它无法触及存放在各个 node 上的内容:您上传的文件仍留在其运营者'
+ '保管之处,每个 node 也会保留它为您固定的身份,直到其运营者取消固定。'
@@ -488,6 +492,12 @@ export default {
'admin.mail_reset_defaults': "恢复默认值",
'admin.login_heading': "登录",
'admin.login_hint': "同一用户名输错密码短语达到此次数后,将在设定时长内拒绝使用密码短语登录。已打开的会话和已注册的设备不受影响,重置密码短语即可解除锁定。设为 0 则关闭。",
+ 'admin.session_heading': '会话',
+ 'admin.session_hint': '浏览器在第一个小时数内没有操作且没有正在播放的内容时会自动退出;桌面应用不会。未使用达到第二个小时数的会话将不再续期,且任何会话都不会超过第三个小时数。',
+ 'admin.session_browser_idle_hours': '浏览器无操作后退出(小时)',
+ 'admin.session_refresh_idle_hours': '未使用的会话过期时间(小时)',
+ 'admin.session_max_hours': '会话最长时长(小时)',
+ 'admin.session_save': '保存会话设置',
'admin.login_max_failures': "锁定前允许的错误次数",
'admin.login_lockout_minutes': "锁定时长(分钟)",
'admin.login_save': "保存登录限制",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
index 7e355e7..4d452e1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
@@ -42,6 +42,23 @@ export function ProfilePage({ user, onLogout }) {
const [delPass, setDelPass] = useState('');
const [delError, setDelError] = useState('');
const [deleting, setDeleting] = useState(false);
+ const [revoking, setRevoking] = useState(false);
+ const [revokeError, setRevokeError] = useState('');
+
+ // Every session of this account stops renewing, this one included — the
+ // case it exists for is a browser left signed in somewhere else.
+ const signOutEverywhere = useCallback(async () => {
+ if (!confirm(t('settings.sign_out_everywhere_confirm'))) return;
+ setRevoking(true);
+ setRevokeError('');
+ try {
+ await hubFetch('/v1/users/me/sessions/revoke', { method: 'POST', token: user.token });
+ onLogout();
+ } catch (err) {
+ setRevokeError(err.message);
+ setRevoking(false);
+ }
+ }, [user, onLogout]);
const deleteAccount = useCallback(async (e) => {
e.preventDefault();
@@ -485,6 +502,15 @@ export function ProfilePage({ user, onLogout }) {
</div>
<div class="settings-section">
+ <h3 class="settings-heading">${t('settings.sessions_heading')}</h3>
+ <p class="settings-hint">${t('settings.sign_out_everywhere_hint')}</p>
+ ${revokeError && html`<p class="error-msg">${revokeError}</p>`}
+ <button class="btn-secondary" disabled=${revoking} onClick=${signOutEverywhere}>
+ ${revoking ? '…' : t('settings.sign_out_everywhere')}
+ </button>
+ </div>
+
+ <div class="settings-section">
<h3 class="settings-heading">${t('settings.danger')}</h3>
<p class="settings-hint">${t('settings.delete_hint')}</p>
${delError && html`<p class="error-msg">${delError}</p>`}
diff --git a/packages/meshbay-hub/tests/conftest.py b/packages/meshbay-hub/tests/conftest.py
index 69c3c5b..bf94464 100644
--- a/packages/meshbay-hub/tests/conftest.py
+++ b/packages/meshbay-hub/tests/conftest.py
@@ -33,7 +33,7 @@ def hub_config(hub_key_path, tmp_path):
db=DatabaseConfig(url="sqlite+aiosqlite:///:memory:"),
server=ServerConfig(host="127.0.0.1", port=8000),
identity=HubIdentityConfig(id="test-hub", private_key_path=hub_key_path),
- jwt=JWTConfig(access_token_ttl=3600, refresh_token_ttl=86400),
+ jwt=JWTConfig(access_token_ttl=3600),
)
return cfg
diff --git a/packages/meshbay-hub/tests/test_browser_idle_signout.py b/packages/meshbay-hub/tests/test_browser_idle_signout.py
new file mode 100644
index 0000000..50f7f04
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_browser_idle_signout.py
@@ -0,0 +1,130 @@
+"""
+A browser signs itself out after a stretch with nobody at it (design §7.7).
+
+`idle.js` is executed, not read: a fake document and localStorage stand in for
+the browser, and a clock the test moves stands in for time. What is modelled is
+the environment — the module under test is the real file. The wiring in app.js
+is checked by reading it, the only evidence there is for the shell.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+IDLE = STATIC / "idle.js"
+APP = STATIC / "app.js"
+NODE = shutil.which("node") or ("/opt/nodejs/bin/node"
+ if Path("/opt/nodejs/bin/node").exists() else None)
+
+HARNESS = r"""
+const store = new Map();
+globalThis.localStorage = {
+ getItem: (k) => (store.has(k) ? store.get(k) : null),
+ setItem: (k, v) => store.set(k, String(v)),
+};
+const media = [];
+globalThis.document = {
+ querySelectorAll: () => media, addEventListener() {}, removeEventListener() {},
+};
+globalThis.window = { addEventListener() {}, removeEventListener() {} };
+let now = 1_700_000_000_000;
+Date.now = () => now;
+const ticks = [];
+globalThis.setInterval = (fn) => { ticks.push(fn); return ticks.length; };
+globalThis.clearInterval = () => {};
+const HOUR = 3600e3;
+const { startIdleWatch, markActive, LAST_ACTIVE_KEY } = await import(process.argv[1]);
+const out = {};
+
+// Signed in just now, then left alone.
+markActive(true);
+let fired = 0;
+const stop1 = startIdleWatch(HOUR, () => fired++);
+now += HOUR - 1000; ticks.at(-1)(); out.justBefore = fired;
+now += 2000; ticks.at(-1)(); out.justAfter = fired;
+ticks.at(-1)(); out.firesOnce = fired;
+stop1();
+
+// A film nobody touches for two hours, then paused and left.
+store.set(LAST_ACTIVE_KEY, String(now));
+let filmFired = 0;
+startIdleWatch(HOUR, () => filmFired++);
+media.push({ paused: false, ended: false });
+for (let i = 0; i < 120; i++) { now += 60e3; ticks.at(-1)(); }
+out.duringFilm = filmFired;
+media[0].paused = true;
+now += HOUR + 60e3; ticks.at(-1)();
+out.afterPause = filmFired;
+media.length = 0;
+
+// A browser closed without signing out and opened again the next day.
+store.set(LAST_ACTIVE_KEY, String(now - 20 * HOUR));
+let reopened = 0;
+startIdleWatch(HOUR, () => reopened++);
+out.reopened = reopened;
+
+// A browser that has never recorded anything is not idle.
+store.delete(LAST_ACTIVE_KEY);
+let fresh = 0;
+startIdleWatch(HOUR, () => fresh++);
+out.noRecord = fresh;
+
+console.log(JSON.stringify(out));
+"""
+
+
+@pytest.fixture(scope="module")
+def outcome():
+ if NODE is None:
+ pytest.skip("node is not available")
+ proc = subprocess.run(
+ [NODE, "--input-type=module", "--eval", HARNESS, IDLE.as_uri()],
+ capture_output=True, text=True, timeout=30)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout.strip().splitlines()[-1])
+
+
+def test_nobody_at_it_for_the_delay_signs_out_once(outcome):
+ assert outcome["justBefore"] == 0
+ assert outcome["justAfter"] == 1
+ assert outcome["firesOnce"] == 1
+
+
+def test_a_film_playing_is_somebody_watching(outcome):
+ assert outcome["duringFilm"] == 0, "two hours of film signed the browser out"
+ assert outcome["afterPause"] == 1, "a paused film kept the session forever"
+
+
+def test_a_browser_closed_without_signing_out_is_caught_when_reopened(outcome):
+ assert outcome["reopened"] == 1
+
+
+def test_a_browser_with_no_record_is_not_idle(outcome):
+ assert outcome["noRecord"] == 0
+
+
+def test_the_desktop_application_is_not_watched():
+ src = APP.read_text(encoding="utf-8")
+ m = re.search(r"useEffect\(\(\) => \{\n(.*?)startIdleWatch\(", src, re.S)
+ assert m, "app.js no longer starts the idle watch in an effect"
+ assert "platform.isNative" in m.group(1)
+
+
+def test_a_sign_in_resets_the_clock_before_the_session_lands():
+ src = APP.read_text(encoding="utf-8")
+ login = src[src.index("login: async (username, password)"):src.index("logout: () =>")]
+ assert login.index("markActive(true)") < login.index("setAuth(u)"), (
+ "the idle watch would read the previous user's last-active time")
+
+
+def test_signing_out_revokes_on_the_hub_before_forgetting_the_token():
+ src = APP.read_text(encoding="utf-8")
+ logout = src[src.index("logout: () =>"):]
+ logout = logout[:logout.index("},")]
+ assert logout.index("logoutOnHub()") < logout.index("setAuth(null)"), (
+ "the refresh token is cleared before it can be sent for revocation")
diff --git a/packages/meshbay-hub/tests/test_session_lifetime.py b/packages/meshbay-hub/tests/test_session_lifetime.py
new file mode 100644
index 0000000..af73767
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_session_lifetime.py
@@ -0,0 +1,183 @@
+"""
+How long a session lasts, and how it ends (design §7.7).
+
+Three admin settings in hours — browser idle sign-out, refresh idle window,
+maximum — plus a sign-out the hub honours and "sign out everywhere". The
+browser half is `test_browser_idle_signout.py`; this is the hub's.
+"""
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from sqlalchemy import select, update
+
+from meshbay_hub.api.deps import set_admin_usernames
+from meshbay_hub.db.models import RefreshToken, User
+
+AUTH_KEY = "s" * 44
+
+
+async def _register(client, username):
+ r = await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@example.com", "auth_key": AUTH_KEY})
+ assert r.status_code == 201, r.text
+
+
+async def _login(client, username):
+ r = await client.post("/v1/users/login", json={"username": username, "auth_key": AUTH_KEY})
+ assert r.status_code == 200, r.text
+ return r.json()
+
+
+async def _admin(client):
+ await _register(client, "session_admin")
+ set_admin_usernames(["session_admin"])
+ return {"Authorization": f"Bearer {(await _login(client, 'session_admin'))['access_token']}"}
+
+
+async def _refresh(client, token):
+ return await client.post("/v1/users/token/refresh", json={"refresh_token": token})
+
+
+def _aware(dt):
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
+
+
+async def _expiry(db_session, username):
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == username))).scalar_one()
+ rows = (await db_session.execute(
+ select(RefreshToken.expires_at).where(RefreshToken.user_id == uid))).scalars().all()
+ return _aware(max(rows))
+
+
+@pytest.mark.asyncio
+async def test_the_defaults_are_in_the_panel_and_the_browser_delay_is_public(client):
+ admin = await _admin(client)
+ r = await client.get("/v1/admin/settings", headers=admin)
+ assert r.json()["session"] == {
+ "browser_idle_hours": 1, "refresh_idle_hours": 24, "max_hours": 720}
+
+ info = (await client.get("/v1/hub/info")).json()
+ assert info["browser_idle_hours"] == 1
+
+
+@pytest.mark.asyncio
+async def test_a_refresh_token_lasts_the_idle_window(client, db_session):
+ await _register(client, "idle_window")
+ await _login(client, "idle_window")
+ left = await _expiry(db_session, "idle_window") - datetime.now(timezone.utc)
+ assert timedelta(hours=23, minutes=58) < left <= timedelta(hours=24)
+
+
+@pytest.mark.asyncio
+async def test_the_idle_window_never_undercuts_the_access_token(client, db_session):
+ """One hour idle with a one-hour access token would lapse between renewals."""
+ admin = await _admin(client)
+ await client.patch("/v1/admin/settings", headers=admin,
+ json={"session": {"refresh_idle_hours": 1}})
+ await _register(client, "short_idle")
+ await _login(client, "short_idle")
+ left = await _expiry(db_session, "short_idle") - datetime.now(timezone.utc)
+ # The test hub's access token lives 3600 s; the floor is that plus an hour.
+ assert left > timedelta(hours=1, minutes=58)
+
+
+@pytest.mark.asyncio
+async def test_no_session_renews_past_its_maximum(client, db_session):
+ await _register(client, "long_session")
+ first = await _login(client, "long_session")
+
+ # Renewal while young works, and slides the window.
+ r = await _refresh(client, first["refresh_token"])
+ assert r.status_code == 200, r.text
+ current = r.json()["refresh_token"]
+
+ # The family's sign-in is now older than the 720 h maximum.
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == "long_session"))).scalar_one()
+ await db_session.execute(
+ update(RefreshToken).where(RefreshToken.user_id == uid)
+ .values(created_at=datetime.now(timezone.utc) - timedelta(hours=721)))
+ await db_session.commit()
+
+ r = await _refresh(client, current)
+ assert r.status_code == 401
+ assert r.json()["detail"] == "Session expired"
+
+
+@pytest.mark.asyncio
+async def test_signing_out_ends_the_session_on_the_hub(client):
+ await _register(client, "signs_out")
+ tokens = await _login(client, "signs_out")
+
+ r = await client.post("/v1/users/logout", json={"refresh_token": tokens["refresh_token"]})
+ assert r.status_code == 200
+ assert (await _refresh(client, tokens["refresh_token"])).status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_an_unknown_token_gets_the_same_answer(client):
+ r = await client.post("/v1/users/logout", json={"refresh_token": "not-a-token"})
+ assert r.status_code == 200
+ assert r.json() == {"status": "signed_out"}
+
+
+@pytest.mark.asyncio
+async def test_signing_out_ends_only_that_session(client):
+ await _register(client, "two_browsers")
+ here = await _login(client, "two_browsers")
+ there = await _login(client, "two_browsers")
+
+ await client.post("/v1/users/logout", json={"refresh_token": here["refresh_token"]})
+ assert (await _refresh(client, there["refresh_token"])).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_sign_out_everywhere_ends_every_session(client):
+ await _register(client, "everywhere")
+ here = await _login(client, "everywhere")
+ there = await _login(client, "everywhere")
+
+ r = await client.post("/v1/users/me/sessions/revoke",
+ headers={"Authorization": f"Bearer {here['access_token']}"})
+ assert r.status_code == 200
+ assert r.json()["revoked"] == 2
+ assert (await _refresh(client, here["refresh_token"])).status_code == 401
+ assert (await _refresh(client, there["refresh_token"])).status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_sign_out_everywhere_needs_the_account(client):
+ # No header at all is refused before the route runs, as a missing field.
+ r = await client.post("/v1/users/me/sessions/revoke")
+ assert r.status_code in (401, 403, 422)
+ r = await client.post("/v1/users/me/sessions/revoke",
+ headers={"Authorization": "Bearer not-a-real-token"})
+ assert r.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_values_are_clamped_and_unknown_keys_refused(client):
+ admin = await _admin(client)
+ r = await client.patch("/v1/admin/settings", headers=admin,
+ json={"session": {"browser_idle_hours": 0, "max_hours": 10**9}})
+ assert r.status_code == 200
+ bounds = r.json()["session_bounds"]
+ assert r.json()["session"]["browser_idle_hours"] == bounds["browser_idle_hours"][0]
+ assert r.json()["session"]["max_hours"] == bounds["max_hours"][1]
+
+ r = await client.patch("/v1/admin/settings", headers=admin,
+ json={"session": {"idle_minutes": 5}})
+ assert r.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_only_an_admin_changes_them(client):
+ await _admin(client)
+ await _register(client, "not_an_admin")
+ token = (await _login(client, "not_an_admin"))["access_token"]
+ r = await client.patch("/v1/admin/settings",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"session": {"browser_idle_hours": 168}})
+ assert r.status_code == 403
diff --git a/packages/meshbay-hub/tests/test_session_renewal.py b/packages/meshbay-hub/tests/test_session_renewal.py
index a839f1e..5d4bcfd 100644
--- a/packages/meshbay-hub/tests/test_session_renewal.py
+++ b/packages/meshbay-hub/tests/test_session_renewal.py
@@ -168,13 +168,18 @@ def test_the_access_token_outlives_a_film_on_its_own():
def test_the_session_is_much_longer_than_the_token():
- """The two must not be confused: the session is the refresh token."""
+ """The two must not be confused: the session is the refresh token.
+
+ Its lifetime is an admin setting (`hub_settings.SESSION_*`); what is held
+ here is what an instance starts with.
+ """
+ from meshbay_hub import hub_settings
from meshbay_hub.config import JWTConfig
- cfg = JWTConfig()
- assert cfg.refresh_token_ttl >= 7 * 86400
- assert cfg.refresh_token_ttl > cfg.access_token_ttl * 20, (
- "the refresh token is barely longer than the access token, so renewing "
- "buys almost nothing and signing in again comes round just as fast")
+ access = JWTConfig().access_token_ttl
+ assert hub_settings.SESSION_DEFAULTS["refresh_idle_hours"] * 3600 > access, (
+ "the refresh token lapses before the access token it is meant to renew")
+ assert hub_settings.SESSION_DEFAULTS["max_hours"] >= 7 * 24, (
+ "a session capped under a week makes signing in again a weekly chore")
# ── The margin ────────────────────────────────────────────────────────────────
diff --git a/packages/meshbay-hub/tests/test_unauthenticated_surface.py b/packages/meshbay-hub/tests/test_unauthenticated_surface.py
index 2c9541a..f2da42b 100644
--- a/packages/meshbay-hub/tests/test_unauthenticated_surface.py
+++ b/packages/meshbay-hub/tests/test_unauthenticated_surface.py
@@ -35,6 +35,7 @@ PUBLIC = {
("POST", "/v1/users/login"): "obtains a session — per-IP limit, per-name lockout",
("POST", "/v1/users/auth"): "device sign-in — Ed25519 signature over a fresh timestamp",
("POST", "/v1/users/token/refresh"): "the refresh token is the credential",
+ ("POST", "/v1/users/logout"): "the refresh token is the credential; it can only revoke",
("POST", "/v1/users/verify-email"): "the e-mailed code is the credential, attempts capped",
("POST", "/v1/users/password/reset-request"): "captcha, per-IP and per-account limits",
("POST", "/v1/users/password/reset"): "the e-mailed code is the credential, attempts capped",