""" Supabase JWT verification — FastAPI dependency. When SUPABASE_URL + SUPABASE_ANON_KEY are configured, verifies the Bearer token from Authorization header using Supabase's JWKS endpoint. When not configured (local dev / SQLite mode), returns an anonymous user so all routes work without auth. """ from __future__ import annotations import httpx from typing import Optional from fastapi import Header, HTTPException from jose import jwt, JWTError from .config import get_settings, is_supabase_configured # In-memory JWKS cache (refreshed on startup) _jwks_cache: dict | None = None ANON_USER = {'id': 'anonymous', 'name': 'Investigator', 'email': ''} async def _fetch_jwks() -> dict: global _jwks_cache if _jwks_cache is not None: return _jwks_cache s = get_settings() url = f"{s['supabase_url']}/auth/v1/.well-known/jwks.json" try: async with httpx.AsyncClient(timeout=5.0) as client: resp = await client.get(url) resp.raise_for_status() _jwks_cache = resp.json() return _jwks_cache except Exception as exc: print(f"[auth] JWKS fetch failed: {exc}") return {} def _decode_token(token: str, jwks: dict) -> dict: """Decode a Supabase JWT and return user claims.""" keys = jwks.get('keys', []) if keys: # jose accepts the full JWKS dict as the key argument return jwt.decode( token, jwks, algorithms=['RS256', 'ES256'], options={'verify_aud': False}, # Supabase audience varies by project ) jwt_secret = get_settings().get('jwt_secret', '') if jwt_secret: return jwt.decode( token, jwt_secret, algorithms=['HS256'], options={'verify_aud': False}, ) raise JWTError("No JWKS keys or SUPABASE_JWT_SECRET available") async def get_current_user( authorization: Optional[str] = Header(default=None), ) -> dict: """ FastAPI dependency — returns current user dict: { id, name, email } If Supabase is not configured or no token provided, returns ANON_USER. Routes can use this for audit attribution without requiring auth. """ if not is_supabase_configured(): return ANON_USER if not authorization or not authorization.startswith('Bearer '): return ANON_USER token = authorization[7:].strip() if token.startswith("guest:"): parts = token.split(":") guest_id = parts[1] if len(parts) > 1 else "guest_unknown" name_slice = guest_id[6:12] if len(guest_id) > 12 else guest_id return { 'id': guest_id, 'name': f"Guest ({name_slice})", 'email': '', } try: jwks = await _fetch_jwks() payload = _decode_token(token, jwks) meta = payload.get('user_metadata', {}) or {} return { 'id': payload.get('sub', 'unknown'), 'name': meta.get('full_name') or meta.get('name') or payload.get('email', 'Investigator'), 'email': payload.get('email', ''), } except JWTError as exc: # Expired / tampered token — reject with 401 when auth is configured raise HTTPException(status_code=401, detail=f"Invalid token: {exc}") async def get_current_user_optional( authorization: Optional[str] = Header(default=None), ) -> dict: """ Soft auth — never raises 401. Used on endpoints that are readable without auth but write attribution when authenticated. """ try: return await get_current_user(authorization) except HTTPException: return ANON_USER