ai-tools-suite/backend/routers/auth.py
2025-12-27 15:33:06 +00:00

214 lines
6.7 KiB
Python

"""Authentication Router - Google OAuth"""
import os
import secrets
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, HTTPException, Request, Response, Depends
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from authlib.integrations.starlette_client import OAuth
from jose import jwt, JWTError
import httpx
router = APIRouter()
# Configuration from environment
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "")
JWT_SECRET = os.getenv("JWT_SECRET", os.getenv("SECRET_KEY", "change-me-in-production"))
JWT_ALGORITHM = "HS256"
JWT_EXPIRY_HOURS = 24 * 7 # 1 week
# Frontend URL for redirects after auth
FRONTEND_URL = os.getenv("FRONTEND_URL", "https://cockpit.valuecurve.co")
# Backend URL for OAuth callback (defaults to FRONTEND_URL for production where they share domain)
BACKEND_URL = os.getenv("BACKEND_URL", FRONTEND_URL)
# Allowed emails (invite-only) - comma-separated in env var
ALLOWED_EMAILS_STR = os.getenv("ALLOWED_EMAILS", "")
ALLOWED_EMAILS = set(email.strip().lower() for email in ALLOWED_EMAILS_STR.split(",") if email.strip())
# OAuth setup
oauth = OAuth()
oauth.register(
name='google',
client_id=GOOGLE_CLIENT_ID,
client_secret=GOOGLE_CLIENT_SECRET,
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
client_kwargs={'scope': 'openid email profile'},
)
class UserInfo(BaseModel):
email: str
name: str
picture: Optional[str] = None
class TokenData(BaseModel):
email: str
name: str
picture: Optional[str] = None
exp: datetime
def create_token(user: UserInfo) -> str:
"""Create JWT token for user"""
expire = datetime.utcnow() + timedelta(hours=JWT_EXPIRY_HOURS)
payload = {
"email": user.email,
"name": user.name,
"picture": user.picture,
"exp": expire
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def verify_token(token: str) -> Optional[TokenData]:
"""Verify JWT token and return user data"""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return TokenData(**payload)
except JWTError:
return None
def get_token_from_cookie(request: Request) -> Optional[str]:
"""Extract token from cookie"""
return request.cookies.get("auth_token")
async def get_current_user(request: Request) -> TokenData:
"""Dependency to get current authenticated user"""
token = get_token_from_cookie(request)
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
user = verify_token(token)
if not user:
raise HTTPException(status_code=401, detail="Invalid or expired token")
return user
def is_email_allowed(email: str) -> bool:
"""Check if email is in allowed list (or if list is empty, allow all)"""
if not ALLOWED_EMAILS:
# If no allowed list configured, allow anyone with valid OAuth
return True
return email.lower() in ALLOWED_EMAILS
@router.get("/login/google")
async def login_google(request: Request):
"""Initiate Google OAuth login"""
if not GOOGLE_CLIENT_ID or not GOOGLE_CLIENT_SECRET:
raise HTTPException(status_code=500, detail="Google OAuth not configured")
# Callback goes to backend URL (same as frontend in production, different locally)
redirect_uri = f"{BACKEND_URL}/auth/callback/google"
return await oauth.google.authorize_redirect(request, redirect_uri)
@router.get("/callback/google")
async def callback_google(request: Request):
"""Handle Google OAuth callback"""
try:
token = await oauth.google.authorize_access_token(request)
user_info = token.get('userinfo')
if not user_info:
# Fetch user info from Google
async with httpx.AsyncClient() as client:
resp = await client.get(
'https://www.googleapis.com/oauth2/v3/userinfo',
headers={'Authorization': f'Bearer {token["access_token"]}'}
)
user_info = resp.json()
email = user_info.get('email', '').lower()
name = user_info.get('name', email.split('@')[0])
picture = user_info.get('picture')
# Check if email is allowed
if not is_email_allowed(email):
# Redirect to login with error
return RedirectResponse(
url=f"{FRONTEND_URL}/login?error=not_authorized",
status_code=302
)
# Create JWT token
user = UserInfo(email=email, name=name, picture=picture)
jwt_token = create_token(user)
# Set cookie and redirect to app
# Use secure=False for localhost (HTTP), secure=True for production (HTTPS)
is_secure = FRONTEND_URL.startswith("https://")
response = RedirectResponse(url=FRONTEND_URL, status_code=302)
response.set_cookie(
key="auth_token",
value=jwt_token,
httponly=True,
secure=is_secure,
samesite="lax",
max_age=JWT_EXPIRY_HOURS * 3600
)
return response
except Exception as e:
import traceback
print(f"OAuth error: {e}")
traceback.print_exc()
return RedirectResponse(
url=f"{FRONTEND_URL}/login?error=oauth_failed",
status_code=302
)
@router.get("/me")
async def get_me(user: TokenData = Depends(get_current_user)):
"""Get current user info"""
return {
"email": user.email,
"name": user.name,
"picture": user.picture
}
@router.post("/logout")
async def logout():
"""Logout user by clearing cookie"""
response = Response(content='{"message": "Logged out"}', media_type="application/json")
response.delete_cookie(key="auth_token")
return response
@router.get("/status")
async def auth_status(request: Request):
"""Check authentication status (doesn't require auth)"""
token = get_token_from_cookie(request)
if not token:
return {"authenticated": False}
user = verify_token(token)
if not user:
return {"authenticated": False}
return {
"authenticated": True,
"user": {
"email": user.email,
"name": user.name,
"picture": user.picture
}
}
# Admin endpoint to manage allowed emails (protected)
@router.get("/allowed-emails")
async def get_allowed_emails(user: TokenData = Depends(get_current_user)):
"""Get list of allowed emails (admin only)"""
# For now, just return the list - could add admin check later
return {"allowed_emails": list(ALLOWED_EMAILS), "allow_all": len(ALLOWED_EMAILS) == 0}