← Files VeraARCHIVED FILE

modules/variance-analysis/vendor/modules/auth/session.py

3.76 KB · Oct 2, 2026 · 00:29 UTC

↓ Download file

"""Session helpers for signed authentication cookies."""

from __future__ import annotations

import base64
import hashlib
import hmac
import json
import time
from dataclasses import dataclass
from typing import Any, Mapping

from modules.auth.config import AuthConfig
from modules.auth.google_identity import GoogleUserInfo

__all__ = [
    "AuthenticatedUser",
    "InvalidSessionError",
    "SessionExpiredError",
    "create_session_cookie",
    "decode_session_cookie",
]


@dataclass(frozen=True)
class AuthenticatedUser:
    email: str
    full_name: str | None = None
    given_name: str | None = None
    family_name: str | None = None
    picture: str | None = None


class InvalidSessionError(ValueError):
    """Raised when a session cookie cannot be decoded or verified."""


class SessionExpiredError(InvalidSessionError):
    """Raised when the session cookie has expired."""


def _b64_encode(value: bytes) -> str:
    return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")


def _b64_decode(value: str) -> bytes:
    pad = "=" * (-len(value) % 4)
    return base64.urlsafe_b64decode(value + pad)


def _clean_optional_str(value: Any) -> str | None:
    if not isinstance(value, str):
        return None
    result = value.strip()
    return result or None


def _payload_from_user(user: GoogleUserInfo, ttl_seconds: int) -> dict[str, Any]:
    now = int(time.time())
    return {
        "sub": user.email,
        "email": user.email,
        "name": user.full_name,
        "given_name": user.given_name,
        "family_name": user.family_name,
        "picture": user.picture,
        "iat": now,
        "exp": now + ttl_seconds,
    }


def _serialize(payload: Mapping[str, Any]) -> bytes:
    return json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")


def _sign(message: bytes, secret: str) -> bytes:
    return hmac.new(secret.encode("utf-8"), message, hashlib.sha256).digest()


def create_session_cookie(user: GoogleUserInfo, config: AuthConfig) -> tuple[str, int]:
    """Return the cookie value and expiry timestamp."""

    payload = _payload_from_user(user, config.session_ttl_seconds)
    body = _serialize(payload)
    signature = _sign(body, config.session_secret)
    token = f"{_b64_encode(body)}.{_b64_encode(signature)}"
    return token, payload["exp"]


def _split_cookie(value: str) -> tuple[bytes, bytes]:
    try:
        body_b64, sig_b64 = value.split(".", 1)
    except ValueError as exc:
        raise InvalidSessionError("Malformed session cookie.") from exc
    return _b64_decode(body_b64), _b64_decode(sig_b64)


def decode_session_cookie(cookie_value: str, config: AuthConfig) -> AuthenticatedUser:
    """Validate *cookie_value* and return the stored user."""

    if not cookie_value:
        raise InvalidSessionError("Session cookie missing.")

    body, signature = _split_cookie(cookie_value)
    expected_sig = _sign(body, config.session_secret)
    if not hmac.compare_digest(signature, expected_sig):
        raise InvalidSessionError("Session signature mismatch.")

    try:
        payload = json.loads(body)
    except json.JSONDecodeError as exc:
        raise InvalidSessionError("Session payload corrupted.") from exc

    exp = int(payload.get("exp", 0))
    if exp < int(time.time()):
        raise SessionExpiredError("Session expired.")

    email = payload.get("email") or payload.get("sub")
    if not isinstance(email, str) or not email.strip():
        raise InvalidSessionError("Session payload missing email.")

    return AuthenticatedUser(
        email=email.lower(),
        full_name=_clean_optional_str(payload.get("name")),
        given_name=_clean_optional_str(payload.get("given_name")),
        family_name=_clean_optional_str(payload.get("family_name")),
        picture=_clean_optional_str(payload.get("picture")),
    )

SHA-256: 554cb2902d1ad2ee3ce232dd5c06422cd23232d0995489e18c25be61778ae42b