| """HMAC-signed session cookies — no database, no session store. |
| |
| The cookie payload is a base64 JSON blob {uid, username, exp} followed by an |
| HMAC-SHA256 signature over that blob using SESSION_SECRET. Tampering with the |
| payload invalidates the signature; expiry is checked server-side. |
| |
| This is a stateless session: the cookie IS the session. Revocation means |
| rotating SESSION_SECRET (which invalidates every outstanding session). |
| """ |
| from __future__ import annotations |
|
|
| import base64 |
| import hashlib |
| import hmac |
| import json |
| import os |
| import time |
|
|
| COOKIE_NAME = "dr_session" |
| COOKIE_MAX_AGE = 7 * 24 * 60 * 60 |
|
|
| |
| |
| _SECRET = os.environ.get("SESSION_SECRET", "").encode("utf-8") |
|
|
|
|
| def _sign(payload_b64: str) -> str: |
| """Return a url-safe base64 HMAC signature over the payload string.""" |
| mac = hmac.new(_SECRET, payload_b64.encode("utf-8"), hashlib.sha256).digest() |
| return base64.urlsafe_b64encode(mac).decode("ascii") |
|
|
|
|
| def create_session(uid: str, username: str, reveal_key: str = "") -> str: |
| """Create a signed session cookie value for a Google user. |
| |
| reveal_key: an optional one-time plaintext API key to show the user at |
| signup. Carried inside the HttpOnly signed cookie so it can't be forged |
| or stolen by XSS. Cleared by /auth/clear-reveal after first display. |
| """ |
| if not _SECRET: |
| raise RuntimeError("SESSION_SECRET is not set") |
| payload = { |
| "uid": str(uid), |
| "username": username, |
| "exp": int(time.time()) + COOKIE_MAX_AGE, |
| } |
| if reveal_key: |
| payload["reveal_key"] = reveal_key |
| payload_b64 = base64.urlsafe_b64encode( |
| json.dumps(payload, separators=(",", ":")).encode("utf-8") |
| ).decode("ascii") |
| sig = _sign(payload_b64) |
| return f"{payload_b64}.{sig}" |
|
|
|
|
| def verify_session(cookie_value: str) -> dict | None: |
| """Return the session payload if valid, else None. |
| |
| Checks: signature matches (constant-time), not expired, well-formed. |
| Returns None on ANY failure so callers can treat it as 'not logged in'. |
| """ |
| if not _SECRET or not cookie_value: |
| return None |
| parts = cookie_value.split(".", 1) |
| if len(parts) != 2: |
| return None |
| payload_b64, sig = parts |
| expected = _sign(payload_b64) |
| if not hmac.compare_digest(sig, expected): |
| return None |
| try: |
| raw = base64.urlsafe_b64decode(payload_b64.encode("ascii")) |
| payload = json.loads(raw) |
| except Exception: |
| return None |
| if not isinstance(payload, dict): |
| return None |
| if int(payload.get("exp", 0)) < int(time.time()): |
| return None |
| return payload |
|
|
|
|
| def cookie_kwargs() -> dict: |
| """Return the kwargs for setting the session cookie (security flags).""" |
| return { |
| "key": COOKIE_NAME, |
| "httponly": True, |
| "secure": os.environ.get("SESSION_COOKIE_SECURE", "1") != "0", |
| "samesite": "lax", |
| "max_age": COOKIE_MAX_AGE, |
| "path": "/", |
| } |
|
|