"""Session cookie signing helpers. Decisions: - Uses itsdangerous.TimestampSigner so we can enforce max-age server-side even if the cookie's own Max-Age is tampered with (defence in depth). - The cookie value is "" signed; no JWT, no JSON. Sessions don't need to carry data here — the user is single-tenant admin. """ from __future__ import annotations from itsdangerous import BadSignature, SignatureExpired, TimestampSigner from fastapi import Response from app.config import settings def _signer() -> TimestampSigner: return TimestampSigner(settings.SESSION_SECRET, salt="ud-session-v1") def sign_username(username: str) -> str: return _signer().sign(username.encode("utf-8")).decode("utf-8") def verify_signed(value: str, max_age: int | None = None) -> str | None: """Return username if signature valid and not expired; else None.""" try: max_age = max_age if max_age is not None else settings.SESSION_MAX_AGE unsigned = _signer().unsign(value, max_age=max_age) return unsigned.decode("utf-8") except SignatureExpired: return None except BadSignature: return None COOKIE_NAME = "ud_session" def set_session_cookie(response: Response, username: str) -> None: signed = sign_username(username) response.set_cookie( key=COOKIE_NAME, value=signed, max_age=settings.SESSION_MAX_AGE, path=settings.cookie_path, httponly=True, secure=True, samesite="lax", ) def clear_session_cookie(response: Response) -> None: # Match the login cookie's attributes exactly so browsers reliably # overwrite/expire the original. Anything that differs (path, # SameSite, Secure, HttpOnly) can cause the browser to treat this # as a separate cookie and leave the original in place. response.set_cookie( key=COOKIE_NAME, value="", max_age=0, path=settings.cookie_path, httponly=True, secure=True, samesite="lax", )