## Authentication Implementation ### Backend - Implemented complete Discord OAuth flow in auth.py: * POST /api/auth/discord/callback - Exchange code for tokens * POST /api/auth/refresh - Refresh JWT tokens * GET /api/auth/me - Get authenticated user info * GET /api/auth/verify - Verify auth status - JWT token creation with 7-day expiration - Refresh token support for session persistence - Bearer token authentication for Discord API calls ### Frontend - Created auth/login.vue - Discord OAuth initiation page - Created auth/callback.vue - OAuth callback handler with states - Integrated with existing auth store (already implemented) - LocalStorage persistence for tokens and user data - Full error handling and loading states ### Configuration - Updated backend .env with Discord OAuth credentials - Updated frontend .env with Discord Client ID - Fixed redirect URI to port 3001 ## SBA API Integration ### Backend - Extended SbaApiClient with get_teams(season, active_only=True) - Added bearer token auth support (_get_headers method) - Created /api/teams route with TeamResponse model - Registered teams router in main.py - Filters out IL (Injured List) teams automatically - Returns team data: id, abbrev, names, color, gmid, division ### Integration - Connected to production SBA API: https://api.sba.manticorum.com - Bearer token authentication working - Successfully fetches ~16 active Season 3 teams ## Documentation - Created SESSION_NOTES.md - Current session accomplishments - Created NEXT_SESSION.md - Game creation implementation guide - Updated implementation/NEXT_SESSION.md ## Testing - ✅ Discord OAuth flow tested end-to-end - ✅ User authentication and session persistence verified - ✅ Teams API returns real data from production - ✅ All services running and communicating ## What Works Now - User can sign in with Discord - Sessions persist across reloads - Backend fetches real teams from SBA API - Ready for game creation implementation ## Next Steps See .claude/NEXT_SESSION.md for detailed game creation implementation plan. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
310 lines
8.7 KiB
Python
310 lines
8.7 KiB
Python
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}
|