import logging import secrets from fastapi import APIRouter, BackgroundTasks, Depends, Request from fastapi.responses import RedirectResponse from sqlalchemy.orm import Session from app.config import settings from app.core.auth_dependency import require_session from app.db import SessionLocal, get_db from app.services import google_oauth, sync logger = logging.getLogger(__name__) router = APIRouter() @router.get("/auth/status") def auth_status(request: Request, db: Session = Depends(get_db)) -> dict: # This endpoint is public (the Connect page needs `authenticated` before # logging in), so the connected account's email must only be revealed to a # request holding a valid session. authenticated = bool(request.session.get("authenticated")) connected = google_oauth.is_connected(db) email = google_oauth.get_connected_email(db) if authenticated and connected else None return { "authenticated": authenticated, "connected": connected, "email": email, } @router.get("/auth/google/start") def google_start(request: Request): auth_url, state = google_oauth.build_authorization_url() request.session["oauth_state"] = state return RedirectResponse(auth_url) def _run_initial_sync() -> None: db = SessionLocal() try: sync.sync_subscriptions(db) sync.sync_videos(db) except Exception: logger.exception("Initial sync after OAuth failed") finally: db.close() @router.get("/auth/google/callback") def google_callback( request: Request, background_tasks: BackgroundTasks, code: str | None = None, state: str | None = None, error: str | None = None, db: Session = Depends(get_db), ): expected_state = request.session.pop("oauth_state", None) if error: logger.warning("Google OAuth returned error: %s", error) return RedirectResponse(f"{settings.app_base_url}/?auth_error=google_error") if not code or not state or not expected_state or not secrets.compare_digest(state, expected_state): logger.warning("Google OAuth callback with invalid/missing state") return RedirectResponse(f"{settings.app_base_url}/?auth_error=invalid_state") try: credentials = google_oauth.exchange_code(code, state) except Exception: logger.exception("Failed to exchange Google OAuth code") return RedirectResponse(f"{settings.app_base_url}/?auth_error=exchange_failed") try: userinfo = google_oauth.fetch_userinfo(credentials.token) except Exception: logger.exception("Failed to fetch Google userinfo") return RedirectResponse(f"{settings.app_base_url}/?auth_error=userinfo_failed") email = (userinfo.get("email") or "").lower() allowed_email = settings.allowed_google_email.lower() if not allowed_email or email != allowed_email: logger.warning("Rejected Google OAuth login for disallowed account") google_oauth.revoke_token(credentials.refresh_token or credentials.token) return RedirectResponse(f"{settings.app_base_url}/?auth_error=account_not_allowed") google_oauth.store_credentials(db, email, credentials) request.session["authenticated"] = True background_tasks.add_task(_run_initial_sync) return RedirectResponse(f"{settings.app_base_url}/") @router.post("/auth/logout") def logout(request: Request, _: None = Depends(require_session)): request.session.clear() return {"ok": True}