File size: 4,636 Bytes
fb0ec21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
"""Supabase JWT verification — same identity plane as the validator engine.

The ABM engine is a product backend: the site attaches the user's Supabase
session JWT (Authorization: Bearer) and this module verifies it against the
project's public JWKS endpoint. Mirrors app/auth.py of modul-x (ES256/RS256,
audience "authenticated"). A static X-API-Key (settings.api_key) is accepted
for service-to-service calls; both are optional while enforcement is off.
"""
import logging
from typing import Optional

import httpx
from fastapi import Request
from fastapi.responses import JSONResponse
from jose import jwt as jose_jwt
from jose import jwk as jose_jwk
from jose.exceptions import JWTError

from .config import settings

logger = logging.getLogger("abm.auth")

_jwks_cache: Optional[dict] = None


def enforcement_on() -> bool:
    return (settings.license_enforcement or "").strip().lower() == "true"


async def _get_jwks() -> dict:
    global _jwks_cache
    if _jwks_cache is None:
        url = f"{settings.supabase_url}/auth/v1/.well-known/jwks.json"
        async with httpx.AsyncClient(timeout=10.0) as client:
            res = await client.get(url)
            res.raise_for_status()
            _jwks_cache = res.json()
    return _jwks_cache  # type: ignore[return-value]


async def verify_supabase_jwt(token: str) -> dict:
    jwks = await _get_jwks()
    last_error: Optional[Exception] = None
    for key_data in jwks.get("keys", []):
        try:
            key = jose_jwk.construct(key_data)
            claims = jose_jwt.decode(
                token,
                key,
                algorithms=["ES256", "RS256"],
                audience="authenticated",
            )
            if not claims.get("sub"):
                raise JWTError("JWT has no sub claim")
            return claims
        except JWTError as exc:
            last_error = exc
            continue
    raise JWTError(f"JWT verification failed: {last_error}")


async def has_abm_license(user_id: str) -> bool:
    """Active 'abm-engine' license in the central project (service-role read).
    Falls back to modul-x license so existing customers are not locked out
    during rollout."""
    service_key = settings.supabase_service_role_key
    if not service_key:
        return False
    headers = {"apikey": service_key, "Authorization": f"Bearer {service_key}"}
    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            res = await client.get(
                f"{settings.supabase_url}/rest/v1/licenses",
                params={"user_id": f"eq.{user_id}", "status": "eq.active", "select": "product"},
                headers=headers,
            )
        if res.status_code != 200:
            return False
        products = {row.get("product") for row in res.json()}
        return bool(products & {"abm-engine", "modul-x", "combo-full-bridge"})
    except Exception:
        logger.warning("license lookup failed", exc_info=True)
        return False


def _api_key_ok(request: Request) -> bool:
    supplied = request.headers.get("x-api-key", "")
    return bool(settings.api_key) and supplied == settings.api_key


async def authenticate(request: Request) -> dict:
    """Authenticate a paid-route request.

    Accepts, in order: X-API-Key (service calls), Supabase session JWT.
    Returns {"user_id", "email", "via"}. Raises PermissionError on failure —
    the route layer maps it to 401/402. When enforcement is off, an invalid
    JWT still authenticates anonymously so demo deployments keep working.
    """
    if _api_key_ok(request):
        return {"user_id": "service", "email": None, "via": "api-key"}

    header = request.headers.get("authorization", "")
    token = header[7:].strip() if header.lower().startswith("bearer ") else ""
    if token and settings.supabase_url:
        try:
            claims = await verify_supabase_jwt(token)
            user_id = claims.get("sub", "")
            if enforcement_on() and not await has_abm_license(user_id):
                raise PermissionError("402:No active ABM Engine license — see /pricing.")
            return {"user_id": user_id, "email": claims.get("email"), "via": "jwt"}
        except PermissionError:
            raise
        except JWTError:
            if enforcement_on():
                raise PermissionError("401:Invalid or expired token — sign in again.")
            return {"user_id": "anonymous", "email": None, "via": "anonymous"}
    if enforcement_on():
        raise PermissionError("401:Missing bearer token — sign in first.")
    return {"user_id": "anonymous", "email": None, "via": "anonymous"}