aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-15 02:16:39 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-15 02:21:01 +0200
commit73ad8e4eb566fe682107fa7e50ef624591199e99 (patch)
treeff0017d014d46d8835487c080dca55c6def7fd6b /packages/meshbay-hub/src/meshbay_hub/api
parentbdefcd025604f2c3009fe5e0cc01213c2ba62a6a (diff)
downloadmeshbay-73ad8e4eb566fe682107fa7e50ef624591199e99.tar.gz
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
-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
3 files changed, 120 insertions, 6 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,