"""FastAPI Users: Google OAuth2, cookie-based admin auth.""" import ssl import uuid from collections.abc import AsyncGenerator from typing import Optional from fastapi import Depends from fastapi.responses import RedirectResponse from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin from fastapi_users.authentication import AuthenticationBackend, CookieTransport, JWTStrategy from fastapi_users.db import SQLAlchemyUserDatabase from fastapi_users_db_sqlalchemy import SQLAlchemyBaseOAuthAccountTableUUID, SQLAlchemyBaseUserTableUUID from httpx_oauth.clients.google import GoogleOAuth2 as _GoogleOAuth2 from httpx_oauth.exceptions import GetIdEmailError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase, Mapped, relationship from starlette.responses import Response from app.core.config import get_settings class Base(DeclarativeBase): pass class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base): pass # default tablename: "oauthaccount" class User(SQLAlchemyBaseUserTableUUID, Base): pass # default tablename: "user" — required by OAuthAccount FK oauth_accounts: Mapped[list[OAuthAccount]] = relationship("OAuthAccount", lazy="joined") # ── Async engine (asyncmy driver) ────────────────────────────────────────── _async_engine = None _async_session_maker = None def _build_async_engine(): settings = get_settings() url = settings.DATABASE_URL.replace("+pymysql", "+aiomysql") if "?" in url: url = url.split("?")[0] connect_args = {} if "ssl_verify_cert=true" in settings.DATABASE_URL: connect_args["ssl"] = ssl.create_default_context() return create_async_engine(url, connect_args=connect_args, pool_pre_ping=True, pool_recycle=180) def get_async_engine(): global _async_engine if _async_engine is None: _async_engine = _build_async_engine() return _async_engine def get_async_session_maker(): global _async_session_maker if _async_session_maker is None: _async_session_maker = async_sessionmaker(get_async_engine(), expire_on_commit=False) return _async_session_maker async def get_async_session() -> AsyncGenerator[AsyncSession, None]: async with get_async_session_maker()() as session: yield session async def get_user_db(session: AsyncSession = Depends(get_async_session)): yield SQLAlchemyUserDatabase(session, User, OAuthAccount) # ── User manager ─────────────────────────────────────────────────────────── class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): pass async def get_user_manager(user_db=Depends(get_user_db)): yield UserManager(user_db) # ── Auth backend (cookie + JWT, redirects to /admin after login) ── class _AdminCookieTransport(CookieTransport): async def get_login_response(self, token: str) -> Response: response = RedirectResponse("/admin", status_code=302) response.set_cookie( self.cookie_name, token, max_age=self.cookie_max_age, path=self.cookie_path, domain=self.cookie_domain, secure=self.cookie_secure, httponly=self.cookie_httponly, samesite=self.cookie_samesite, ) return response def _build_auth_backend() -> AuthenticationBackend: settings = get_settings() transport = _AdminCookieTransport( cookie_name="admin_session", cookie_max_age=3600 * 24 * 7, cookie_secure=settings.ENVIRONMENT == "production", cookie_httponly=True, cookie_samesite="lax", ) def get_strategy() -> JWTStrategy: return JWTStrategy(secret=settings.jwt_secret, lifetime_seconds=3600 * 24 * 7) return AuthenticationBackend(name="cookie", transport=transport, get_strategy=get_strategy) auth_backend = _build_auth_backend() class GoogleOAuth2(_GoogleOAuth2): """Use OpenID Connect userinfo endpoint instead of the People API.""" async def get_id_email(self, token: str) -> tuple[str, str | None]: import httpx async with self.get_httpx_client() as client: response = await client.get( "https://openidconnect.googleapis.com/v1/userinfo", headers={"Authorization": f"Bearer {token}"}, ) try: response.raise_for_status() except httpx.HTTPStatusError as e: raise GetIdEmailError(response=e.response) from e data = response.json() return data["sub"], data.get("email") google_oauth_client = GoogleOAuth2( client_id=get_settings().GOOGLE_CLIENT_ID, client_secret=get_settings().GOOGLE_CLIENT_SECRET, ) fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend]) _optional_user = fastapi_users.current_user(active=True, optional=True) # ── Admin guard ──────────────────────────────────────────────────────────── class RequireLogin(Exception): pass async def require_admin(user: Optional[User] = Depends(_optional_user)) -> User: settings = get_settings() if user is None: raise RequireLogin() # If a domain is specified, check the email suffix. if settings.ADMIN_EMAIL_DOMAIN and not user.email.endswith(f"@{settings.ADMIN_EMAIL_DOMAIN}"): raise RequireLogin() return user