myYouTube/backend/app/api/auth.py
vrubelroman fde9a439df Fix review findings and remove Uncategorized from sidebar
Backend: cache Google access tokens (drop dead access_token_expires_at,
migration 0007), handle MeTube cleared/canceled events by URL, return
email from /auth/status only when authenticated, move Google base URLs
into settings, run container as non-root.

Frontend: include local feed filters in the query key, remove dead
Saved page and unused assets, drop stale CategoryNav props and classes,
send Content-Type only with a body, remove Uncategorized from sidebar.
2026-09-17 17:22:22 +00:00

101 lines
3.4 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:
# 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}