strat-gameplay-webapp/backend/app/api/routes/auth.py
Cal Corum 9b30d3dfb2 CLAUDE: Implement Discord OAuth authentication and SBA API integration
## 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>
2025-11-20 16:54:27 -06:00

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}