import hmac import hashlib import json import base64 from datetime import datetime, timedelta, timezone from typing import Any from src.config import Settings def _base64url_encode(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") def _base64url_decode(data: str) -> bytes: padding = 4 - len(data) % 4 if padding != 4: data += "=" * padding return base64.urlsafe_b64decode(data) def create_session_cookie(*, settings: Settings, user_id: str) -> str: """Create a signed session cookie value.""" payload = { "user_id": user_id, "exp": int((datetime.now(timezone.utc) + timedelta(hours=settings.session_ttl_hours)).timestamp()), } header = _base64url_encode(json.dumps({"alg": "HS256", "typ": "session"}).encode()) payload_encoded = _base64url_encode(json.dumps(payload).encode()) message = f"{header}.{payload_encoded}" signature = hmac.new( settings.session_secret.encode(), message.encode(), hashlib.sha256, ).digest() signature_encoded = _base64url_encode(signature) return f"{message}.{signature_encoded}" def decode_session_cookie(*, settings: Settings, cookie_value: str) -> dict[str, Any]: """Decode and verify a session cookie. Returns payload or raises ValueError.""" parts = cookie_value.split(".") if len(parts) != 3: raise ValueError("invalid session format") header, payload_encoded, signature_encoded = parts message = f"{header}.{payload_encoded}" # Verify signature expected_signature = hmac.new( settings.session_secret.encode(), message.encode(), hashlib.sha256, ).digest() expected_signature_encoded = _base64url_encode(expected_signature) if not hmac.compare_digest(signature_encoded, expected_signature_encoded): raise ValueError("invalid session signature") # Decode payload payload_bytes = _base64url_decode(payload_encoded) payload = json.loads(payload_bytes) # Check expiry if payload.get("exp", 0) < int(datetime.now(timezone.utc).timestamp()): raise ValueError("session expired") return payload