"""Auth helpers, JWT token creation/verification, and FastAPI dependencies.""" from datetime import datetime, timedelta, timezone from typing import Optional import httpx from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from jose import JWTError, jwt from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database import get_session from app.user_models import User class AuthBearer(HTTPBearer): """HTTP Bearer scheme that doesn't auto-fail on missing token.""" def __init__(self, auto_error: bool = False): super().__init__(auto_error=auto_error) bearer = AuthBearer(auto_error=False) # ── Token helpers ────────────────────────────────────────────── def create_access_token(user_id: int, is_admin: bool) -> str: """Create a JWT access token for the given user.""" expire = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES) payload = { "sub": str(user_id), "is_admin": is_admin, "exp": expire, } return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256") def create_download_token(artifact_id: int) -> str: """Create a short-lived JWT for downloading a specific artifact.""" expire = datetime.now(timezone.utc) + timedelta(minutes=settings.DOWNLOAD_TOKEN_MINUTES) payload = { "sub": "download", "artifact_id": artifact_id, "exp": expire, } return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256") def verify_download_token(token: str) -> int | None: """Verify a download token and return the artifact_id, or None on failure.""" try: payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"]) if payload.get("sub") != "download": return None artifact_id = payload.get("artifact_id") if not isinstance(artifact_id, int): return None return artifact_id except JWTError: return None def decode_token(token: str) -> dict: """Decode and verify a JWT token. Raises HTTPException 401 on failure.""" try: payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"]) user_id = payload.get("sub") if user_id is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") return payload except JWTError: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token") # ── FastAPI dependencies ────────────────────────────────────── async def get_current_user( credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer), session: AsyncSession = Depends(get_session), ) -> User: """Validate JWT and return the current User.""" if credentials is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") payload = decode_token(credentials.credentials) user_id = int(payload["sub"]) result = await session.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") return user async def get_current_admin_user( current_user: User = Depends(get_current_user), ) -> User: """Raise 403 if the current user is not an admin.""" if not current_user.is_admin: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") return current_user # ── Google OIDC verification ───────────────────────────────── _GOOGLE_CERTS_URL = "https://www.googleapis.com/oauth2/v3/certs" _GOOGLE_TOKEN_INFO_URL = "https://oauth2.googleapis.com/tokeninfo" async def verify_google_id_token(id_token: str) -> dict: """Verify a Google ID token and return (email, name, picture).""" async with httpx.AsyncClient() as client: # Use tokeninfo endpoint for simplicity (no signature parsing needed) resp = await client.get(_GOOGLE_TOKEN_INFO_URL, params={"idtoken": id_token}) if resp.status_code != 200: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Google ID token", ) info = resp.json() email = info.get("email") if not email: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No email in Google token") return { "email": email, "name": info.get("name", email.split("@")[0]), "picture": info.get("picture", ""), }