import logging 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 # YouTube's full scope (read + write: unsubscribe, playlist export) and its read-only # counterpart. Default login asks for read-only; write is an explicit opt-in via # /auth/upgrade (incremental consent), so friends who won't grant write can still browse. WRITE_SCOPE = "https://www.googleapis.com/auth/youtube" READ_SCOPES = f"openid email profile {WRITE_SCOPE}.readonly" WRITE_SCOPES = f"openid email profile {WRITE_SCOPE}" log = logging.getLogger("subfeed.auth") 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": READ_SCOPES}, ) def has_write_scope(user: User) -> bool: """Whether the user's stored grant includes YouTube write (unsubscribe / export).""" tok = user.token if tok is None or not tok.scopes: return False return WRITE_SCOPE in tok.scopes.split() @router.get("/login") async def login(request: Request): # access_type=offline ensures a refresh_token on first authorization. We avoid # prompt=consent so returning users get a quick sign-in; the stored refresh token # is kept when Google doesn't re-issue one (see the callback). return await oauth.google.authorize_redirect( request, settings.oauth_redirect_url, access_type="offline", prompt="select_account", include_granted_scopes="true", scope=READ_SCOPES, ) @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: log.warning("Login denied (not on invite list): %s", email or "") 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 READ_SCOPES db.add(tok) db.commit() request.session["user_id"] = user.id log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role) 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("/upgrade") async def upgrade(request: Request, user: User = Depends(current_user)): """Incremental consent: re-authorize with the full YouTube (write) scope. The shared callback stores the new scope set, so `can_write` flips on once Google grants it.""" return await oauth.google.authorize_redirect( request, settings.oauth_redirect_url, access_type="offline", prompt="consent", include_granted_scopes="true", scope=WRITE_SCOPES, ) @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, }