myYouTube/backend/app/api/auth.py
vrubelroman 0ed20bb838 Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck
- Google OAuth (single allowed account), encrypted refresh token storage
- Subscriptions sync with pagination, uploads playlist batch fetch
- Categories CRUD, many-to-many channel assignment, category filtering
- Video sync (playlistItems + videos.list batching), cached feed with cursor
  pagination, background scheduler (APScheduler)
- Video detail page with YouTube embed player
- SPA fallback routing, optimistic UI updates, client-side query caching

40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes,
cursor pagination, and category filtering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:44:30 +00:00

96 lines
3.2 KiB
Python

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:
connected = google_oauth.is_connected(db)
return {
"authenticated": bool(request.session.get("authenticated")),
"connected": connected,
"email": google_oauth.get_connected_email(db) if connected else None,
}
@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}