PaperMate / backend /auth.py
ntphuc149's picture
refactor: remove Resend email service — login required, no email needed
f19919a
Raw
History Blame
5.54 kB
"""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 <jwt>``. 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