from __future__ import annotations import secrets from urllib.parse import urlencode import httpx from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi.responses import RedirectResponse from itsdangerous import BadSignature, URLSafeTimedSerializer from sqlalchemy import func, select from sqlalchemy.orm import Session from app.auth import ( SESSION_COOKIE_NAME, create_session_token, get_or_create_user_from_identity, ) from app.config import Settings, get_settings from app.database import get_db from app.models import User from app.schemas import AuthConfigOut, DevLoginIn, TokenSessionOut, UserOut router = APIRouter(prefix="/api/v1/auth", tags=["Authentication"]) def _state_serializer(settings: Settings) -> URLSafeTimedSerializer: return URLSafeTimedSerializer(settings.session_secret, salt="auth0-oauth-state") @router.get("/config", response_model=AuthConfigOut) def get_auth_config( db: Session = Depends(get_db), settings: Settings = Depends(get_settings), ) -> AuthConfigOut: admin_count = db.scalar(select(func.count(User.id)).where(User.is_admin.is_(True))) or 0 admin_unclaimed = (admin_count == 0) and not bool(settings.admin_user.strip()) return AuthConfigOut( auth0_enabled=settings.auth0_configured, auth0_domain=settings.auth0_domain, auth0_client_id=settings.auth0_client_id, auth0_audience=settings.auth0_audience, dev_auth_enabled=settings.dev_auth_enabled, admin_unclaimed=admin_unclaimed, ) @router.get("/login") def auth0_login( return_to: str = Query(default="/"), settings: Settings = Depends(get_settings), ) -> RedirectResponse: if not settings.auth0_configured: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Auth0 federated login is not configured on this server. Set AUTH0_DOMAIN and AUTH0_CLIENT_ID.", ) domain = settings.auth0_domain.strip().rstrip("/") base = domain if domain.startswith("http") else f"https://{domain}" state = _state_serializer(settings).dumps( {"nonce": secrets.token_hex(8), "return_to": return_to} ) params = { "response_type": "code", "client_id": settings.auth0_client_id, "redirect_uri": settings.auth0_callback_url, "scope": "openid profile email", "state": state, } if settings.auth0_audience: params["audience"] = settings.auth0_audience return RedirectResponse(url=f"{base}/authorize?{urlencode(params)}") @router.get("/callback") async def auth0_callback( code: str = Query(...), state: str = Query(...), db: Session = Depends(get_db), settings: Settings = Depends(get_settings), ) -> RedirectResponse: if not settings.auth0_configured or not settings.auth0_client_secret: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Auth0 callback requires AUTH0_DOMAIN, AUTH0_CLIENT_ID, and AUTH0_CLIENT_SECRET.", ) try: state_data = _state_serializer(settings).loads(state, max_age=600) except BadSignature as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired OAuth state parameter.", ) from exc domain = settings.auth0_domain.strip().rstrip("/") base = domain if domain.startswith("http") else f"https://{domain}" async with httpx.AsyncClient(timeout=10.0) as client: token_resp = await client.post( f"{base}/oauth/token", json={ "grant_type": "authorization_code", "client_id": settings.auth0_client_id, "client_secret": settings.auth0_client_secret, "code": code, "redirect_uri": settings.auth0_callback_url, }, ) if token_resp.status_code >= 400: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Failed to exchange Auth0 authorization code.", ) tokens = token_resp.json() userinfo_resp = await client.get( f"{base}/userinfo", headers={"Authorization": f"Bearer {tokens['access_token']}"}, ) if userinfo_resp.status_code >= 400: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Failed to retrieve user profile from Auth0.", ) profile = userinfo_resp.json() user = get_or_create_user_from_identity( db, settings, auth_sub=str(profile["sub"]), email=profile.get("email"), preferred_nickname=profile.get("nickname"), first_name=profile.get("given_name", "") or "", last_name=profile.get("family_name", "") or "", ) session_jwt = create_session_token(user.id, user.auth_sub, settings) return_to = str(state_data.get("return_to") or "/") if not return_to.startswith("/"): return_to = "/" target = f"{settings.frontend_base_url.rstrip('/')}{return_to}" redirect = RedirectResponse(url=target, status_code=status.HTTP_302_FOUND) redirect.set_cookie( key=SESSION_COOKIE_NAME, value=session_jwt, httponly=True, samesite="lax", max_age=settings.session_ttl_hours * 3600, ) return redirect @router.post("/dev-login", response_model=TokenSessionOut) def dev_login( payload: DevLoginIn, response: Response, db: Session = Depends(get_db), settings: Settings = Depends(get_settings), ) -> TokenSessionOut: if not settings.dev_auth_enabled: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Local development login is disabled. Please sign in with Auth0.", ) handle = payload.nickname.strip().lower().replace(" ", "_") auth_sub = f"dev|{handle}" user = get_or_create_user_from_identity( db, settings, auth_sub=auth_sub, email=payload.email or f"{handle}@example.com", preferred_nickname=payload.nickname.strip(), ) if payload.as_moderator and not user.is_admin: # In dev mode, allow moderator login when either ADMIN_USER is unset or matches configured_admin = settings.admin_user.strip().lower() if not configured_admin or configured_admin in { user.auth_sub.lower(), user.nickname.lower(), (user.email or "").lower(), }: user.is_admin = True db.commit() db.refresh(user) if user.is_banned: reason_suffix = f" Reason: {user.ban_reason}" if user.ban_reason else "" raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Your account has been suspended by a moderator.{reason_suffix}", ) token = create_session_token(user.id, user.auth_sub, settings) response.set_cookie( key=SESSION_COOKIE_NAME, value=token, httponly=True, samesite="lax", max_age=settings.session_ttl_hours * 3600, ) return TokenSessionOut(access_token=token, user=UserOut.model_validate(user)) @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) def logout(response: Response) -> Response: response.delete_cookie(key=SESSION_COOKIE_NAME) response.status_code = status.HTTP_204_NO_CONTENT return response