# backend/auth_cookies.py """Generic HMAC-signed cookie payloads, shared by operator auth (and, optionally later, the portal). A signed value is f"{b64url(json)}.{hmac_sha256(b64)}"; read() verifies the signature in constant time and enforces a server-side iat expiry. The signing secret is the same SECRET_KEY the portal already reads, so a single env var protects both cookies. Never store or log raw secrets.""" import os import hmac import json import time import base64 import hashlib import logging logger = logging.getLogger(__name__) # Same env var the portal cookie uses — one secret protects both. The insecure # default only exists so dev/test boots without config; set a real SECRET_KEY in prod. SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-change-me") # Set COOKIE_SECURE=true once served over HTTPS; leave false on plain HTTP or the # browser won't send the cookie. COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() in ("1", "true", "yes") def _sign(body: str) -> str: return hmac.new(SECRET_KEY.encode(), body.encode(), hashlib.sha256).hexdigest() def sign(payload: dict) -> str: """Serialize + sign a payload dict into a cookie-safe string.""" body = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode() return f"{body}.{_sign(body)}" def read(raw, max_age: int): """Verify a signed value and return its payload dict, or None if missing, tampered, or older than max_age seconds (by its own `iat`).""" if not raw or not isinstance(raw, str): return None try: body, sig = raw.rsplit(".", 1) except (ValueError, AttributeError): return None if not hmac.compare_digest(sig, _sign(body)): return None try: data = json.loads(base64.urlsafe_b64decode(body.encode())) except Exception: return None if not isinstance(data, dict): return None iat = data.get("iat") if not isinstance(iat, (int, float)): return None now = time.time() # Reject implausibly future-dated tokens: the same server signs and verifies, # so there's no real clock skew — a far-future iat (e.g. to dodge max_age or # outlive a sessions_valid_from bump) is bogus. 60s of slack is generous. if iat - now > 60: return None if (now - iat) > max_age: return None return data