File size: 3,928 Bytes
8777bc2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
"""Authentication and authorization helpers for the HTTP API.

The public web app uses a server-signed anonymous bearer identity.  It is a
capability token, not a replacement for an enterprise IdP, but it prevents
different browser visitors from sharing the old ``default`` user namespace.
An OIDC/JWT gateway can later replace ``issue_anonymous_token`` without
changing the API routes.
"""
from __future__ import annotations

import base64
import hashlib
import hmac
import secrets
import time
from dataclasses import dataclass

from fastapi import Header, HTTPException, Request, status

from app.config import get_settings


@dataclass(frozen=True)
class AuthContext:
    user_id: str
    is_admin: bool = False


def _secret() -> bytes:
    return get_settings().effective_auth_secret.encode("utf-8")


def issue_anonymous_token(user_id: str | None = None) -> tuple[str, AuthContext]:
    """Create an opaque, signed bearer token for one browser profile."""
    settings = get_settings()
    user_id = user_id or f"anon_{secrets.token_urlsafe(18)}"
    if not user_id.startswith("anon_"):
        raise ValueError("Anonymous user id required")
    expires_at = int(time.time()) + settings.auth_token_ttl_seconds
    payload = f"v1.{user_id}.{expires_at}"
    signature = hmac.new(_secret(), payload.encode("utf-8"), hashlib.sha256).digest()
    token = f"{payload}.{base64.urlsafe_b64encode(signature).decode('ascii').rstrip('=')}"
    return token, AuthContext(user_id=user_id)


def _decode_token(token: str) -> AuthContext:
    try:
        version, user_id, expires_raw, signature = token.split(".", 3)
        expires_at = int(expires_raw)
    except ValueError as exc:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid bearer token") from exc
    if version != "v1" or not user_id.startswith("anon_") or expires_at < int(time.time()):
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Expired or invalid bearer token")
    payload = f"{version}.{user_id}.{expires_at}".encode()
    expected = hmac.new(_secret(), payload, hashlib.sha256).digest()
    try:
        supplied = base64.urlsafe_b64decode(signature + "=" * (-len(signature) % 4))
    except ValueError as exc:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid bearer token") from exc
    if not hmac.compare_digest(supplied, expected):
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid bearer token")
    return AuthContext(user_id=user_id)


async def require_user(authorization: str | None = Header(default=None)) -> AuthContext:
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Bearer token required")
    return _decode_token(authorization.removeprefix("Bearer ").strip())


def decode_optional_bearer(authorization: str | None) -> AuthContext | None:
    """Decode a valid bearer when present; invalid/expired values renew as new."""
    if not authorization or not authorization.startswith("Bearer "):
        return None
    try:
        return _decode_token(authorization.removeprefix("Bearer ").strip())
    except HTTPException:
        return None


async def require_admin(
    request: Request,
    x_api_key: str | None = Header(default=None),
) -> AuthContext:
    """Guard mutable operator endpoints with a distinct server-side key."""
    configured_key = get_settings().operator_api_key
    if not configured_key:
        raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "Admin API is not configured")
    if not x_api_key or not hmac.compare_digest(x_api_key, configured_key):
        raise HTTPException(status.HTTP_403_FORBIDDEN, "Administrator credentials required")
    # Read the request so rate-limit decorators can retain a normal request
    # argument and so this dependency has the same shape as other guards.
    _ = request
    return AuthContext(user_id="admin", is_admin=True)