From 8e817ec48d62630b3d5a2d700f98f15d6f19d95c Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 19:01:16 +0000 Subject: [PATCH] feat(auth): generic HMAC signed-cookie module for operator auth Co-Authored-By: Claude Sonnet 4.6 --- backend/auth_cookies.py | 64 ++++++++++++++++++++++++++++++++++ tests/test_operator_cookies.py | 49 ++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 backend/auth_cookies.py create mode 100644 tests/test_operator_cookies.py diff --git a/backend/auth_cookies.py b/backend/auth_cookies.py new file mode 100644 index 0000000..f83c2a6 --- /dev/null +++ b/backend/auth_cookies.py @@ -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 diff --git a/tests/test_operator_cookies.py b/tests/test_operator_cookies.py new file mode 100644 index 0000000..95ab082 --- /dev/null +++ b/tests/test_operator_cookies.py @@ -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