siftlode/backend/app/auth.py

113 lines
3.8 KiB
Python
Raw Normal View History

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 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",
)
@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,
}