import logging from typing import Any import httpx from fastapi import APIRouter, HTTPException, Header from pydantic import BaseModel from app.config import get_settings from app.utils.auth import create_token, verify_token from jose import JWTError logger = logging.getLogger(f"{__name__}.auth") router = APIRouter() settings = get_settings() # ============================================================================ # Request/Response Models # ============================================================================ class DiscordCallbackRequest(BaseModel): """Request model for Discord OAuth callback""" code: str state: str class DiscordUser(BaseModel): """Discord user information""" id: str username: str discriminator: str avatar: str | None = None email: str | None = None class AuthResponse(BaseModel): """Response model for successful authentication""" access_token: str refresh_token: str expires_in: int token_type: str = "bearer" user: DiscordUser class RefreshRequest(BaseModel): """Request model for token refresh""" refresh_token: str class RefreshResponse(BaseModel): """Response model for token refresh""" access_token: str expires_in: int token_type: str = "bearer" class UserInfoResponse(BaseModel): """Response model for /me endpoint""" user: DiscordUser teams: list[dict[str, Any]] = [] # ============================================================================ # Discord OAuth Helpers # ============================================================================ async def exchange_code_for_token(code: str) -> dict[str, Any]: """ Exchange Discord OAuth code for access token Args: code: OAuth authorization code from Discord Returns: Discord OAuth token response Raises: HTTPException: If exchange fails """ data = { "client_id": settings.discord_client_id, "client_secret": settings.discord_client_secret, "grant_type": "authorization_code", "code": code, "redirect_uri": settings.discord_redirect_uri, } async with httpx.AsyncClient() as client: try: response = await client.post( "https://discord.com/api/oauth2/token", data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) response.raise_for_status() return response.json() except httpx.HTTPStatusError as e: logger.error(f"Discord token exchange failed: {e}") logger.error(f"Response: {e.response.text}") raise HTTPException( status_code=400, detail="Failed to exchange code for token" ) async def get_discord_user(access_token: str) -> DiscordUser: """ Get Discord user information using access token Args: access_token: Discord OAuth access token Returns: Discord user information Raises: HTTPException: If request fails """ async with httpx.AsyncClient() as client: try: response = await client.get( "https://discord.com/api/users/@me", headers={"Authorization": f"Bearer {access_token}"}, ) response.raise_for_status() user_data = response.json() return DiscordUser(**user_data) except httpx.HTTPStatusError as e: logger.error(f"Failed to get Discord user: {e}") raise HTTPException(status_code=400, detail="Failed to get user information") # ============================================================================ # Auth Endpoints # ============================================================================ @router.post("/discord/callback", response_model=AuthResponse) async def discord_callback(request: DiscordCallbackRequest): """ Handle Discord OAuth callback Exchange authorization code for Discord token, get user info, and create our JWT tokens. Args: request: OAuth callback data (code and state) Returns: JWT tokens and user information """ try: # Exchange code for Discord access token logger.info("Exchanging Discord code for token") discord_token_data = await exchange_code_for_token(request.code) # Get Discord user information logger.info("Fetching Discord user information") discord_user = await get_discord_user(discord_token_data["access_token"]) # Create JWT tokens for our application user_payload = { "user_id": discord_user.id, "username": discord_user.username, "discord_id": discord_user.id, } access_token = create_token(user_payload) # Create refresh token with longer expiration refresh_token_payload = {**user_payload, "type": "refresh"} refresh_token = create_token(refresh_token_payload) logger.info(f"User {discord_user.username} authenticated successfully") return AuthResponse( access_token=access_token, refresh_token=refresh_token, expires_in=604800, # 7 days in seconds user=discord_user, ) except HTTPException: raise except Exception as e: logger.error(f"Discord OAuth callback error: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Authentication failed") @router.post("/refresh", response_model=RefreshResponse) async def refresh_access_token(request: RefreshRequest): """ Refresh JWT access token using refresh token Args: request: Refresh token Returns: New access token """ try: # Verify refresh token payload = verify_token(request.refresh_token) # Check if it's a refresh token if payload.get("type") != "refresh": raise HTTPException(status_code=400, detail="Invalid refresh token") # Create new access token user_payload = { "user_id": payload["user_id"], "username": payload["username"], "discord_id": payload["discord_id"], } access_token = create_token(user_payload) logger.info(f"Token refreshed for user {payload['username']}") return RefreshResponse( access_token=access_token, expires_in=604800, # 7 days in seconds ) except JWTError: logger.warning("Invalid refresh token provided") raise HTTPException(status_code=401, detail="Invalid or expired refresh token") except Exception as e: logger.error(f"Token refresh error: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Failed to refresh token") @router.get("/me", response_model=UserInfoResponse) async def get_current_user_info(authorization: str = Header(None)): """ Get current authenticated user information Args: authorization: Bearer token in Authorization header Returns: User information and teams """ if not authorization or not authorization.startswith("Bearer "): raise HTTPException( status_code=401, detail="Missing or invalid authorization header" ) token = authorization.split(" ")[1] try: # Verify token payload = verify_token(token) # Create user info user = DiscordUser( id=payload["discord_id"], username=payload["username"], discriminator="0", # Discord removed discriminators ) # TODO: Load user's teams from database teams = [] logger.info(f"User info retrieved for {user.username}") return UserInfoResponse(user=user, teams=teams) except JWTError: logger.warning("Invalid token in /me request") raise HTTPException(status_code=401, detail="Invalid or expired token") except Exception as e: logger.error(f"Get user info error: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Failed to get user information") @router.get("/verify") async def verify_auth(authorization: str = Header(None)): """ Verify authentication status Args: authorization: Bearer token in Authorization header Returns: Authentication status """ if not authorization or not authorization.startswith("Bearer "): return {"authenticated": False} token = authorization.split(" ")[1] try: payload = verify_token(token) return { "authenticated": True, "user_id": payload["user_id"], "username": payload["username"], } except JWTError: return {"authenticated": False}