from datetime import datetime, timezone from authlib.integrations.starlette_client import OAuth, OAuthError from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse from sqlalchemy.orm import Session from app.config import settings from app.db import get_db from app.models import OAuthToken, User from app.security import encrypt # Single YouTube scope that allows reading subscriptions/playlists AND writing # (unsubscribe, playlist export). openid/email/profile give us the account identity. SCOPES = "openid email profile https://www.googleapis.com/auth/youtube" router = APIRouter(prefix="/auth", tags=["auth"]) oauth = OAuth() oauth.register( name="google", client_id=settings.google_client_id, client_secret=settings.google_client_secret, server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", client_kwargs={"scope": SCOPES}, ) @router.get("/login") async def login(request: Request): # access_type=offline + prompt=consent must be on the authorization URL so Google # returns a refresh_token (required for background sync). return await oauth.google.authorize_redirect( request, settings.oauth_redirect_url, access_type="offline", prompt="consent", include_granted_scopes="true", ) @router.get("/callback") async def callback(request: Request, db: Session = Depends(get_db)): try: token = await oauth.google.authorize_access_token(request) except OAuthError as exc: raise HTTPException(status_code=400, detail=f"OAuth error: {exc.error}") userinfo = token.get("userinfo") if not userinfo or not userinfo.get("sub"): raise HTTPException(status_code=400, detail="No user info returned by Google") email = (userinfo.get("email") or "").lower() if not email or email not in settings.allowed_email_set: raise HTTPException( status_code=403, detail="This Google account is not on the invite list." ) user = db.query(User).filter(User.google_sub == userinfo["sub"]).one_or_none() if user is None: user = User(google_sub=userinfo["sub"], email=email) db.add(user) user.email = email user.display_name = userinfo.get("name") user.avatar_url = userinfo.get("picture") user.role = "admin" if email in settings.admin_email_set else "user" db.flush() tok = user.token or OAuthToken(user=user) # Google only returns a refresh_token on (re)consent; keep the previous one otherwise. if token.get("refresh_token"): tok.refresh_token_enc = encrypt(token["refresh_token"]) tok.access_token = token.get("access_token") expires_at = token.get("expires_at") tok.expiry = ( datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None ) tok.scopes = token.get("scope") or SCOPES db.add(tok) db.commit() request.session["user_id"] = user.id return RedirectResponse(url="/") @router.post("/logout") async def logout(request: Request): request.session.clear() return JSONResponse({"ok": True}) def current_user(request: Request, db: Session = Depends(get_db)) -> User: user_id = request.session.get("user_id") if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") user = db.get(User, user_id) if user is None: request.session.clear() raise HTTPException(status_code=401, detail="Not authenticated") return user @router.get("/me") async def me(user: User = Depends(current_user)) -> dict: return { "id": user.id, "email": user.email, "display_name": user.display_name, "avatar_url": user.avatar_url, "role": user.role, }