From 396e26b4bf60b91ced41cc43678bba201230d9a0 Mon Sep 17 00:00:00 2001 From: Karl Jussila Date: Thu, 18 Jun 2026 14:15:48 -0500 Subject: [PATCH] fix(auth): tie remember-me cookie lifetime to TOKEN_TTL (#4472) The persistent login cookie's max_age hardcoded 60 * 60 * 24 * 7, an independent copy of the session token lifetime that core/auth.py already defines once as TOKEN_TTL (and reports to the frontend via /api/auth/policy as session_days). If TOKEN_TTL changes, the cookie silently drifts: the browser keeps a cookie for a token whose lifetime no longer matches. Import TOKEN_TTL and use it for the cookie max_age so the session lifetime has a single source of truth. No behaviour change at the current value. Fixes #4471 --- routes/auth_routes.py | 4 +-- tests/test_auth_policy.py | 55 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/routes/auth_routes.py b/routes/auth_routes.py index f180f44e9..5c7a4e04a 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -12,7 +12,7 @@ import re from pathlib import Path from core.atomic_io import atomic_write_json, atomic_write_text -from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult +from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR from src.rate_limiter import RateLimiter from src.settings_scrub import scrub_settings @@ -161,7 +161,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: path="/", ) if body.remember: - cookie_kwargs["max_age"] = 60 * 60 * 24 * 7 # 7 days + cookie_kwargs["max_age"] = TOKEN_TTL response.set_cookie(**cookie_kwargs) return {"ok": True, "username": username} diff --git a/tests/test_auth_policy.py b/tests/test_auth_policy.py index 89b0e7dd9..8fceeccc0 100644 --- a/tests/test_auth_policy.py +++ b/tests/test_auth_policy.py @@ -215,3 +215,58 @@ def test_setup_rejects_seven_char_password(tmp_path): asyncio.run(endpoint(body=body, request=request)) assert exc.value.status_code == 400 + + +# ── Login "remember me" cookie lifetime ──────────────────────────────── + + +class _CapturingResponse: + """Stand-in for fastapi.Response that records set_cookie kwargs.""" + + def __init__(self): + self.cookie_kwargs = None + + def set_cookie(self, **kwargs): + self.cookie_kwargs = kwargs + + +def _login_endpoint(auth_manager): + sys.modules.pop("routes.auth_routes", None) + _real_core_package() + from routes.auth_routes import LoginRequest, setup_auth_routes + + router = setup_auth_routes(auth_manager) + for route in router.routes: + if getattr(route, "path", None) == "/api/auth/login": + return route.endpoint, LoginRequest + raise AssertionError("login route not found") + + +def test_remember_cookie_max_age_matches_token_ttl(tmp_path): + auth_mod = _auth_module() + mgr = _make_manager(tmp_path) + mgr.create_user("alice", "alice-password", is_admin=False) + endpoint, LoginRequest = _login_endpoint(mgr) + request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1")) + response = _CapturingResponse() + body = LoginRequest(username="alice", password="alice-password", remember=True) + + result = asyncio.run(endpoint(body=body, request=request, response=response)) + + assert result == {"ok": True, "username": "alice"} + # The persistent cookie must outlive neither more nor less than the token. + assert response.cookie_kwargs["max_age"] == auth_mod.TOKEN_TTL + + +def test_no_remember_omits_cookie_max_age(tmp_path): + mgr = _make_manager(tmp_path) + mgr.create_user("bob", "bob-password", is_admin=False) + endpoint, LoginRequest = _login_endpoint(mgr) + request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1")) + response = _CapturingResponse() + body = LoginRequest(username="bob", password="bob-password", remember=False) + + asyncio.run(endpoint(body=body, request=request, response=response)) + + # Without "remember", the cookie is a session cookie (no max_age). + assert "max_age" not in response.cookie_kwargs