T13.1: idle timeout + absolute ceiling (D18)

Sessions now slide on activity (AUTH_IDLE_MINUTES, default 30) capped by
a hard ceiling from original sign-in (AUTH_SESSION_HOURS, meaning changed,
default 12 -> proposed 8). login_at carried across reissues so the ceiling
survives refreshes; pre-D18 tokens with no login_at fall back to iat.
Refresh is throttled (~IDLE_MINUTES/3) so the cookie isn't rewritten on
every request. Wired into auth_gate (server/app.py) - no DB hit, reads
only the already-validated claims.

Verified: 7 unit-level checks (fresh-token expiry, past-ceiling refusal,
throttling, mid-session extension, legacy-token fallback both live and
expired, idle cutoff itself) all pass, plus the full 27-check smoke
suite still passes end to end through the new middleware path.
This commit is contained in:
2026-09-23 11:40:59 -07:00
parent 358469531c
commit 6cde6e3f60
4 changed files with 120 additions and 7 deletions

View File

@@ -103,12 +103,23 @@ def _csrf_ok(request: Request) -> bool:
async def auth_gate(request: Request, call_next):
path = request.url.path
method = request.method
claims = None
if method != "OPTIONS" and auth._needs_auth(path):
if not auth.is_request_authenticated(request):
claims = auth.is_request_authenticated(request)
if not claims:
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
if method in _UNSAFE_METHODS and path.startswith("/api/") and not _csrf_ok(request):
return JSONResponse(status_code=403, content={"detail": "Cross-origin request rejected"})
return await call_next(request)
response = await call_next(request)
# D18: slide the session forward on activity, capped by its absolute
# ceiling. Reads only the already-validated claims - no DB hit here, and
# the separate is_active/token_version check in get_current_user still
# runs on its own for every request regardless of whether this refreshes.
if claims is not None:
refreshed = auth.maybe_refresh_token(claims)
if refreshed:
auth.set_session_cookie(response, request, refreshed)
return response
def gen_id(prefix: str) -> str: