SLICE-2: identity — email + one-time-code sign-up, sessions, Landing/Sign-in (SD-0001 §7.2) #6
@@ -0,0 +1,49 @@
|
|||||||
|
"""platform/session — stateless signed session tokens (SD-0001 §6.2).
|
||||||
|
|
||||||
|
Sessions are signed HTTP-only cookies; there is no session table. A token is
|
||||||
|
`base64url(payload_json).base64url(hmac_sha256(payload))` keyed by the configured session
|
||||||
|
secret (INV-8). `verify` checks the MAC in constant time and returns the payload dict, or
|
||||||
|
None for any malformed/tampered/wrong-key token. No server-side state to revoke — a leaked
|
||||||
|
secret is rotated, invalidating every session at once (§6.2, accepted at MVP scale).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _b64e(raw: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _b64d(s: str) -> bytes:
|
||||||
|
pad = "=" * (-len(s) % 4)
|
||||||
|
return base64.urlsafe_b64decode(s + pad)
|
||||||
|
|
||||||
|
|
||||||
|
def _mac(payload_b64: str, secret: str) -> str:
|
||||||
|
digest = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||||
|
return _b64e(digest)
|
||||||
|
|
||||||
|
|
||||||
|
def sign(payload: dict[str, Any], secret: str) -> str:
|
||||||
|
"""Serialize and sign a session payload into a cookie-safe token."""
|
||||||
|
payload_b64 = _b64e(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8"))
|
||||||
|
return f"{payload_b64}.{_mac(payload_b64, secret)}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify(token: str, secret: str) -> dict[str, Any] | None:
|
||||||
|
"""Return the payload if the token's signature is valid, else None."""
|
||||||
|
if not token or "." not in token:
|
||||||
|
return None
|
||||||
|
payload_b64, _, sig = token.partition(".")
|
||||||
|
expected = _mac(payload_b64, secret)
|
||||||
|
if not hmac.compare_digest(sig, expected):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(_b64d(payload_b64))
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from app.platform import session
|
||||||
|
|
||||||
|
SECRET = "test-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_then_verify_round_trips():
|
||||||
|
token = session.sign({"account_id": 7, "email": "m@example.com"}, SECRET)
|
||||||
|
assert session.verify(token, SECRET) == {"account_id": 7, "email": "m@example.com"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_tampered_payload():
|
||||||
|
token = session.sign({"account_id": 7}, SECRET)
|
||||||
|
payload_b64, _, sig = token.partition(".")
|
||||||
|
# Flip the payload but keep the old signature -> must be rejected.
|
||||||
|
forged = session.sign({"account_id": 8}, SECRET).split(".")[0] + "." + sig
|
||||||
|
assert session.verify(forged, SECRET) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_wrong_secret():
|
||||||
|
token = session.sign({"account_id": 7}, SECRET)
|
||||||
|
assert session.verify(token, "other-secret") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_garbage():
|
||||||
|
assert session.verify("not-a-token", SECRET) is None
|
||||||
|
assert session.verify("", SECRET) is None
|
||||||
Reference in New Issue
Block a user