"""Clerk JWT verification for FastAPI.""" from __future__ import annotations import os import logging import jwt from typing import Union from fastapi import Depends, HTTPException, Request, WebSocket from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer logger = logging.getLogger("bodhi.auth") _security = HTTPBearer(auto_error=False) # Cache the JWKS (public keys) from Clerk _jwks_client: jwt.PyJWKClient | None = None def _get_jwks_client() -> jwt.PyJWKClient: """Lazily create a JWKS client pointing to your Clerk instance.""" global _jwks_client if _jwks_client is None: clerk_frontend_api = os.getenv("CLERK_FRONTEND_API_URL", "") if not clerk_frontend_api: raise RuntimeError( "CLERK_FRONTEND_API_URL is required " "(e.g. https://your-app.clerk.accounts.dev)" ) jwks_url = f"{clerk_frontend_api}/.well-known/jwks.json" _jwks_client = jwt.PyJWKClient(jwks_url) return _jwks_client _CLERK_CONFIGURED = bool(os.getenv("CLERK_FRONTEND_API_URL", "").strip()) # The anonymous-auth bypass is OFF by default and must be explicitly enabled for # local development. This prevents a missing/forgotten CLERK_FRONTEND_API_URL in # production from silently disabling authentication for the entire API. _ALLOW_ANONYMOUS = os.getenv("BODHI_ALLOW_ANONYMOUS_AUTH", "").strip().lower() in ( "1", "true", "yes", ) def assert_auth_configured() -> None: """Fail fast at startup if auth is neither configured nor explicitly bypassed. Call this from the app's startup/lifespan. Without this guard, a deployment that forgets CLERK_FRONTEND_API_URL would fall through to anonymous access. """ if not _CLERK_CONFIGURED and not _ALLOW_ANONYMOUS: raise RuntimeError( "Authentication is not configured. Set CLERK_FRONTEND_API_URL for " "production, or set BODHI_ALLOW_ANONYMOUS_AUTH=true to explicitly " "allow unauthenticated access in local development." ) if _ALLOW_ANONYMOUS: logger.warning( "BODHI_ALLOW_ANONYMOUS_AUTH is enabled — all requests run as " "'anonymous'. This must NEVER be set in production." ) async def verify_clerk_token( request: Request = None, websocket: WebSocket = None, ) -> dict: """Verify the Bearer JWT and return its claims. Handles both HTTP Request and WebSocket connections. Returns an empty dict if no credentials are provided or Clerk is not configured. """ if not _CLERK_CONFIGURED: return {} conn = request or websocket if not conn: return {} # Extract token from Authorization header or 'token' query param token = None auth_header = conn.headers.get("Authorization") if auth_header and auth_header.startswith("Bearer "): token = auth_header.removeprefix("Bearer ") elif isinstance(conn, WebSocket): token = conn.query_params.get("token") if not token: return {} try: signing_key = _get_jwks_client().get_signing_key_from_jwt(token) payload = jwt.decode( token, signing_key.key, algorithms=["RS256"], options={"verify_aud": False}, leeway=60, ) return payload except jwt.ExpiredSignatureError: raise HTTPException(status_code=401, detail="Token expired") except jwt.InvalidTokenError as e: logger.warning("Invalid JWT: %s", e) raise HTTPException(status_code=401, detail="Invalid token") def require_auth( claims: dict = Depends(verify_clerk_token), ) -> str: """Require authentication — returns the Clerk user_id (sub claim). In development (no CLERK_FRONTEND_API_URL set), returns 'anonymous' so endpoints work without a Clerk token. In production (Clerk configured), raises 401 if no valid token. """ user_id = claims.get("sub", "") if not user_id: if not _CLERK_CONFIGURED and _ALLOW_ANONYMOUS: # Local dev only — explicitly opted in via BODHI_ALLOW_ANONYMOUS_AUTH. logger.debug("Auth bypassed (anonymous mode) — using 'anonymous'") return "anonymous" raise HTTPException(status_code=401, detail="Authentication required") return user_id def get_current_user_id( claims: dict = Depends(verify_clerk_token), ) -> str | None: """Optional auth — returns user_id if authenticated, None otherwise.""" return claims.get("sub") or None async def authenticate_websocket(websocket: WebSocket) -> str | None: """Authenticate a WebSocket handshake before accept(). Returns the authenticated user_id, or None if authentication fails. The caller is responsible for closing the socket when None is returned. Mirrors the anonymous-bypass policy of require_auth(). """ try: claims = await verify_clerk_token(websocket=websocket) except HTTPException: # Expired/invalid token — treat as unauthenticated. return None user_id = claims.get("sub", "") if user_id: return user_id if not _CLERK_CONFIGURED and _ALLOW_ANONYMOUS: return "anonymous" return None