Spaces:
Running
Running
| """Clerk session-token verification for FastAPI (networkless JWT verification). | |
| Clerk issues short-lived RS256 JWTs. RS256 is *asymmetric*: Clerk signs with a | |
| private key it never shares, and publishes the matching PUBLIC key at a JWKS | |
| (JSON Web Key Set) endpoint. We fetch those public keys once, cache them, and | |
| verify each incoming token's signature locally β no network round-trip per | |
| request. If the signature + claims check out, the token's `sub` claim is a | |
| Clerk user id we can trust (it could not have been forged without Clerk's | |
| private key). | |
| Dev escape hatch: when CLERK_ISSUER is unset we skip verification entirely and | |
| return a fixed dev user id, so local development without Clerk env vars is never | |
| locked out. Configured => enforce; unconfigured => warn-and-allow. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import jwt | |
| from fastapi import Header, HTTPException, Request, status | |
| from jwt import PyJWKClient, PyJWKClientError | |
| from tracerag import config | |
| logger = logging.getLogger("tracerag.auth") | |
| # PyJWKClient fetches the JWKS once and caches the signing keys in-process, | |
| # only re-fetching when it sees a token signed by an unknown `kid` (key id) β | |
| # which is exactly how it transparently handles Clerk rotating its keys. | |
| # Built lazily as a singleton so we don't hit the network at import time. | |
| _jwk_client: PyJWKClient | None = None | |
| _warned_dev_bypass = False | |
| def _get_jwk_client() -> PyJWKClient: | |
| global _jwk_client | |
| if _jwk_client is None: | |
| _jwk_client = PyJWKClient(config.CLERK_JWKS_URL, cache_keys=True) | |
| return _jwk_client | |
| def _extract_bearer(authorization: str | None) -> str: | |
| """Pull the raw JWT out of an `Authorization: Bearer <token>` header.""" | |
| if not authorization: | |
| raise HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail="Missing Authorization header.", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| # partition splits on the FIRST space: "Bearer" | " " | "<token>" | |
| scheme, _, token = authorization.partition(" ") | |
| if scheme.lower() != "bearer" or not token: | |
| raise HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail="Authorization header must be 'Bearer <token>'.", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| return token | |
| def _verify_token(token: str) -> dict: | |
| """Verify the signature and the standard claims; return the decoded payload. | |
| Two distinct failure classes, surfaced differently: | |
| - we couldn't resolve a signing key (JWKS unreachable / unknown kid) | |
| - the token itself is bad (expired, wrong issuer, tampered signature) | |
| """ | |
| # --- Step 1: find the PUBLIC key that matches this token's `kid` header. --- | |
| # PyJWKClient reads the unverified header (just the kid, not the payload) and | |
| # hands back the corresponding public key from Clerk's JWKS. | |
| try: | |
| signing_key = _get_jwk_client().get_signing_key_from_jwt(token) | |
| except PyJWKClientError as exc: | |
| # Couldn't fetch/match a key β a server/config problem, not a forged | |
| # token. Log loudly; still answer 401 so we never leak internals. | |
| logger.warning("JWKS key resolution failed: %s", exc) | |
| raise HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail="Could not resolve token signing key.", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| except jwt.DecodeError as exc: | |
| # Header itself was malformed (not a real JWT). | |
| raise HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail=f"Malformed token: {exc}", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| # --- Step 2: verify signature AND the registered claims in one call. --- | |
| # jwt.decode() does ALL of the following atomically and raises if any fails: | |
| # * RS256 signature matches `signing_key` -> token is authentic | |
| # * `exp` (expiry) is in the future -> token still valid | |
| # * `nbf`/`iat` not in the future (with leeway) -> token already active | |
| # * `iss` equals our Clerk instance -> minted by OUR Clerk | |
| # * the `require` claims are all present -> well-formed session | |
| try: | |
| claims = jwt.decode( | |
| token, | |
| signing_key.key, | |
| algorithms=["RS256"], # PIN the algorithm β never accept | |
| # 'none'/HS256, which would let an | |
| # attacker self-sign a token. | |
| issuer=config.CLERK_ISSUER, # `iss` must equal our Clerk instance. | |
| leeway=5, # tolerate β€5s of clock skew on exp/nbf. | |
| options={ | |
| "require": ["exp", "iat", "sub"], # must be present or reject. | |
| "verify_aud": False, # Clerk session tokens carry no `aud`; | |
| # we authorize via `azp` below instead. | |
| }, | |
| ) | |
| except jwt.ExpiredSignatureError: | |
| raise HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail="Token expired.", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| except jwt.InvalidTokenError as exc: | |
| # Catch-all for bad signature, wrong issuer, missing required claim, etc. | |
| raise HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail=f"Invalid token: {exc}", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| # --- Step 3: optional `azp` (authorized party) check. --- | |
| # `azp` is the origin the token was minted for. Verifying it against an | |
| # allow-list stops a token issued for some other site from being replayed | |
| # against our API. Skipped entirely if CLERK_AUTHORIZED_PARTIES is unset. | |
| if config.CLERK_AUTHORIZED_PARTIES: | |
| azp = claims.get("azp") | |
| if azp and azp not in config.CLERK_AUTHORIZED_PARTIES: | |
| raise HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail="Token authorized-party (azp) is not allowed.", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| return claims | |
| def get_current_user( | |
| request: Request, | |
| authorization: str | None = Header(default=None), | |
| ) -> str: | |
| """FastAPI dependency β the verified Clerk user id (`sub`). | |
| Add `user_id: str = Depends(get_current_user)` to any route to (a) require a | |
| valid token and (b) receive the trustworthy user id. Routes should rely on | |
| THIS value, never on a client-supplied user_id β that's what closes IDOR. | |
| Side effect: stashes the resolved id on `request.state.user_id`. This is the | |
| bridge that lets slowapi's key_func rate-limit per USER β the key_func only | |
| receives the Request, not this dependency's return value, but FastAPI | |
| resolves dependencies BEFORE the rate-limit decorator runs, so by then | |
| request.state.user_id is populated. See ratelimit.py. | |
| """ | |
| user_id = _resolve_user_id(authorization) | |
| request.state.user_id = user_id | |
| return user_id | |
| def _resolve_user_id(authorization: str | None) -> str: | |
| """Verify the token (or dev-bypass) and return the user id.""" | |
| # Dev-bypass: no Clerk configured β don't verify, return a fixed dev user so | |
| # local testing isn't blocked. Warn once so it can't silently ship to prod. | |
| if not config.CLERK_ENABLED: | |
| global _warned_dev_bypass | |
| if not _warned_dev_bypass: | |
| logger.warning( | |
| "CLERK_ISSUER unset β auth is in DEV-BYPASS mode; every request " | |
| "runs as '%s'. Do NOT deploy without setting CLERK_ISSUER.", | |
| config.DEV_USER_ID, | |
| ) | |
| _warned_dev_bypass = True | |
| return config.DEV_USER_ID | |
| token = _extract_bearer(authorization) | |
| claims = _verify_token(token) | |
| return claims["sub"] # the Clerk user id β safe to trust post-verification. | |