"""Authentication / RBAC layer for the FastAPI app. Uses Supabase Auth (GoTrue) — the frontend signs in with Google and sends the resulting access token as ``Authorization: Bearer ``. We verify the token by calling GoTrue's ``/auth/v1/user`` endpoint (no extra JWT dependency), then resolve the local ``profiles`` row (role / is_active) via the service-role store. All endpoints are protected through FastAPI dependencies: * ``get_current_user`` — 401 if no/invalid token, 403 if the account is disabled. * ``get_optional_user`` — returns ``None`` when no token is present (anonymous). * ``require_admin`` — 403 unless the resolved profile has role 'admin'. """ from __future__ import annotations from dataclasses import dataclass import httpx from fastapi import Depends, HTTPException, Request from backend.config import settings from backend.storage import jobs as job_store @dataclass class CurrentUser: profile_id: str auth_user_id: str email: str role: str is_active: bool monthly_review_limit: int | None = None monthly_cost_limit_usd: float | None = None @property def is_admin(self) -> bool: return self.role == "admin" def _admin_email_set() -> set[str]: return {e.strip().lower() for e in (settings.admin_emails or "").split(",") if e.strip()} def _bearer_token(request: Request) -> str | None: header = request.headers.get("Authorization") or request.headers.get("authorization") if not header: return None parts = header.split(" ", 1) if len(parts) == 2 and parts[0].lower() == "bearer" and parts[1].strip(): return parts[1].strip() return None async def _verify_token(token: str) -> dict: """Validate the Supabase access token via GoTrue and return the user object.""" if not settings.supabase_url: raise HTTPException(status_code=500, detail="Supabase is not configured.") headers = {"Authorization": f"Bearer {token}"} if settings.supabase_anon_key: headers["apikey"] = settings.supabase_anon_key url = settings.supabase_url.rstrip("/") + "/auth/v1/user" try: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get(url, headers=headers) except httpx.HTTPError: raise HTTPException(status_code=503, detail="Auth service unavailable.") if resp.status_code != 200: raise HTTPException(status_code=401, detail="Invalid or expired session.") return resp.json() async def _resolve_current_user(token: str) -> CurrentUser: user = await _verify_token(token) auth_user_id = user.get("id") email = (user.get("email") or "").strip() if not auth_user_id or not email: raise HTTPException(status_code=401, detail="Token has no user identity.") meta = user.get("user_metadata") or {} # Only trust the email for admin / welcome decisions if the provider verified # it (Google always does). Prevents claiming an unverified admin email. email_verified = bool( user.get("email_confirmed_at") or user.get("confirmed_at") or meta.get("email_verified") ) profile = await job_store.ensure_profile_for_auth_user( auth_user_id, email, display_name=meta.get("full_name") or meta.get("name"), avatar_url=meta.get("avatar_url"), ) role = profile.get("role") or "user" pmeta = profile.get("metadata") or {} # Auto-promote configured admin emails (bootstraps the first admin before any # SQL seed). Gated so it cannot override a deliberate demote (role_locked, set # whenever an admin edits a role) and cannot be triggered by an unverified email. if ( email_verified and role != "admin" and not pmeta.get("role_locked") and email.lower() in _admin_email_set() ): updated = await job_store.set_profile_role(profile["id"], "admin") role = (updated or {}).get("role", "admin") is_active = profile.get("is_active", True) if is_active is False: raise HTTPException(status_code=403, detail="Account is disabled.") return CurrentUser( profile_id=profile["id"], auth_user_id=auth_user_id, email=email, role=role, is_active=bool(is_active), monthly_review_limit=profile.get("monthly_review_limit"), monthly_cost_limit_usd=profile.get("monthly_cost_limit_usd"), ) async def get_current_user(request: Request) -> CurrentUser: token = _bearer_token(request) if not token: raise HTTPException(status_code=401, detail="Authentication required.") return await _resolve_current_user(token) async def get_optional_user(request: Request) -> CurrentUser | None: token = _bearer_token(request) if not token: return None try: return await _resolve_current_user(token) except HTTPException as exc: # The anonymous path must keep working: a stale/expired/invalid token, a # disabled account, or a transient auth-service outage should degrade to # anonymous rather than hard-failing submit. (auth_required_for_submit # still rejects None upstream, so this does not weaken a required-login.) if exc.status_code in (401, 403, 503): return None raise async def require_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser: if not user.is_admin: raise HTTPException(status_code=403, detail="Admin access required.") return user