feat(auth): generic HMAC signed-cookie module for operator auth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 19:01:16 +00:00
parent 37e6ca55c1
commit 8e817ec48d
2 changed files with 113 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
# 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
+49
View File
@@ -0,0 +1,49 @@
# tests/test_operator_cookies.py
import time
import base64
import json
from backend.auth_cookies import sign, read
def test_sign_then_read_round_trips():
now = int(time.time())
raw = sign({"uid": "abc", "iat": now})
data = read(raw, max_age=3600)
assert data == {"uid": "abc", "iat": now}
def test_tampered_signature_is_rejected():
raw = sign({"uid": "abc", "iat": int(time.time())})
body, _sig = raw.rsplit(".", 1)
assert read(body + ".deadbeef", max_age=3600) is None
def test_tampered_body_is_rejected():
raw = sign({"uid": "abc", "iat": int(time.time())})
body, sig = raw.rsplit(".", 1)
forged = base64.urlsafe_b64encode(json.dumps({"uid": "evil", "iat": int(time.time())}).encode()).decode()
assert read(forged + "." + sig, max_age=3600) is None
def test_expired_by_iat_is_rejected():
raw = sign({"uid": "abc", "iat": int(time.time()) - 10_000})
assert read(raw, max_age=3600) is None
def test_garbage_input_is_none_not_raise():
assert read("not-a-cookie", max_age=3600) is None
assert read("", max_age=3600) is None
assert read(None, max_age=3600) is None
def test_wrong_secret_is_rejected(monkeypatch):
import backend.auth_cookies as ac
monkeypatch.setattr(ac, "SECRET_KEY", "secret-A")
raw = ac.sign({"uid": "x", "iat": int(time.time())})
monkeypatch.setattr(ac, "SECRET_KEY", "secret-B")
assert ac.read(raw, max_age=3600) is None
def test_future_dated_iat_is_rejected():
raw = sign({"uid": "x", "iat": int(time.time()) + 10_000})
assert read(raw, max_age=3600) is None