strat-gameplay-webapp/backend/app/api/routes/auth.py
Cal Corum c5869f5ba2 CLAUDE: Add team ownership to auth flow (CRIT-002)
Backend:
- Add get_teams_by_owner() to SBA API client
- Update /api/auth/me to return user's teams from SBA API
- Add sba_current_season config setting (default: 13)

Frontend:
- Replace hardcoded myTeamId with computed from auth store teams
- Fix isMyTurn logic to check actual team ownership
- Update REFACTORING_PLAN.json

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 20:28:08 -06:00

670 lines
20 KiB
Python

import logging
from typing import Any
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, HTTPException, Header, Request, Response
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from app.config import get_settings
from app.utils.auth import create_token, verify_token
from app.services.sba_api_client import sba_api_client
from app.utils.cookies import (
ACCESS_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
set_auth_cookies,
clear_auth_cookies,
)
from app.services.oauth_state import create_oauth_state, validate_and_consume_state
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
# ============================================================================
def is_discord_id_allowed(discord_id: str) -> bool:
"""
Check if a Discord user ID is allowed to access the system
Args:
discord_id: Discord user ID to check
Returns:
True if allowed, False otherwise
"""
allowed_ids = settings.allowed_discord_ids.strip()
# If empty or "*", allow all (for development)
if not allowed_ids or allowed_ids == "*":
logger.warning("Discord whitelist disabled - allowing all users")
return True
# Parse comma-separated list
whitelist = [id.strip() for id in allowed_ids.split(",") if id.strip()]
is_allowed = discord_id in whitelist
if not is_allowed:
logger.warning(f"Discord ID {discord_id} not in whitelist - access denied")
else:
logger.info(f"Discord ID {discord_id} verified in whitelist")
return is_allowed
async def exchange_code_for_token(
code: str, redirect_uri: str | None = None
) -> dict[str, Any]:
"""
Exchange Discord OAuth code for access token
Args:
code: OAuth authorization code from Discord
redirect_uri: OAuth redirect URI (defaults to legacy URI for backwards compat)
Returns:
Discord OAuth token response
Raises:
HTTPException: If exchange fails
"""
# Use provided redirect_uri or fall back to legacy for backwards compatibility
uri = redirect_uri or settings.discord_redirect_uri
data = {
"client_id": settings.discord_client_id,
"client_secret": settings.discord_client_secret,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": uri,
}
# Debug logging
logger.info(f"Token exchange using redirect_uri: {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 - Server-Side OAuth Flow
# ============================================================================
@router.get("/discord/login")
async def initiate_discord_login(return_url: str = "/") -> RedirectResponse:
"""
Initiate Discord OAuth flow (server-side).
Creates state token in Redis and redirects to Discord authorization.
Args:
return_url: Frontend URL to redirect after successful auth
Returns:
Redirect to Discord OAuth authorization page
"""
# Create and store state in Redis
state = await create_oauth_state(return_url)
# Build Discord OAuth URL with BACKEND redirect URI
params = {
"client_id": settings.discord_client_id,
"redirect_uri": settings.discord_server_redirect_uri,
"response_type": "code",
"scope": "identify email",
"state": state,
}
auth_url = f"https://discord.com/api/oauth2/authorize?{urlencode(params)}"
logger.info(f"Initiating Discord OAuth, state={state[:10]}...")
return RedirectResponse(url=auth_url, status_code=302)
@router.get("/discord/callback/server")
async def discord_callback_server(code: str, state: str) -> RedirectResponse:
"""
Handle Discord OAuth callback (server-side flow).
This endpoint:
1. Validates state token (CSRF protection via Redis)
2. Exchanges code for Discord access token
3. Gets Discord user info
4. Checks whitelist authorization
5. Creates JWT tokens
6. Sets HttpOnly cookies
7. Redirects to frontend success page
No JavaScript required on callback page.
Args:
code: OAuth authorization code from Discord
state: State token for CSRF protection
Returns:
Redirect to frontend with cookies set
"""
# Validate state (CSRF protection)
return_url = await validate_and_consume_state(state)
if not return_url:
logger.warning("OAuth callback with invalid/expired state")
frontend_url = settings.frontend_url
return RedirectResponse(
url=f"{frontend_url}/auth/login?error=invalid_state", status_code=302
)
try:
# Exchange code for Discord token
logger.info("Exchanging Discord code for token")
discord_token_data = await exchange_code_for_token(
code, redirect_uri=settings.discord_server_redirect_uri
)
# Get Discord user information
logger.info("Fetching Discord user information")
discord_user = await get_discord_user(discord_token_data["access_token"])
# Check whitelist
if not is_discord_id_allowed(discord_user.id):
logger.warning(f"Unauthorized Discord ID: {discord_user.id}")
frontend_url = settings.frontend_url
return RedirectResponse(
url=f"{frontend_url}/auth/login?error=unauthorized", status_code=302
)
# Create JWT tokens
user_payload = {
"user_id": discord_user.id,
"username": discord_user.username,
"discord_id": discord_user.id,
}
access_token = create_token(user_payload)
refresh_token_payload = {**user_payload, "type": "refresh"}
refresh_token = create_token(refresh_token_payload)
logger.info(
f"User {discord_user.username} authenticated via server flow, redirecting to {return_url}"
)
# Create redirect response with cookies
frontend_url = settings.frontend_url
response = RedirectResponse(url=f"{frontend_url}{return_url}", status_code=302)
set_auth_cookies(response, access_token, refresh_token)
return response
except HTTPException as e:
logger.error(f"OAuth callback error: {e.detail}")
frontend_url = settings.frontend_url
return RedirectResponse(
url=f"{frontend_url}/auth/login?error=auth_failed", status_code=302
)
except Exception as e:
logger.error(f"OAuth callback unexpected error: {e}", exc_info=True)
frontend_url = settings.frontend_url
return RedirectResponse(
url=f"{frontend_url}/auth/login?error=server_error", status_code=302
)
# ============================================================================
# Legacy Endpoints (Backwards Compatibility)
# ============================================================================
@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"])
# Check if user is allowed
if not is_discord_id_allowed(discord_user.id):
raise HTTPException(
status_code=403,
detail="Access denied. Your Discord account is not authorized to access this system."
)
# 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_obj: Request,
response: Response,
body: RefreshRequest | None = None,
):
"""
Refresh JWT access token.
Supports cookie-based refresh (preferred) and body-based (legacy).
Sets new access_token cookie on success.
Args:
request_obj: FastAPI request object
response: FastAPI response object
body: Refresh token in body (optional, for backwards compatibility)
Returns:
New access token
"""
# Try cookie first
refresh_token_value = request_obj.cookies.get(REFRESH_TOKEN_COOKIE)
# Fall back to body
if not refresh_token_value and body and body.refresh_token:
refresh_token_value = body.refresh_token
if not refresh_token_value:
raise HTTPException(status_code=401, detail="Missing refresh token")
try:
# Verify refresh token
payload = verify_token(refresh_token_value)
# 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)
# Set new access token cookie
from app.utils.cookies import ACCESS_TOKEN_MAX_AGE, is_production
response.set_cookie(
key=ACCESS_TOKEN_COOKIE,
value=access_token,
max_age=ACCESS_TOKEN_MAX_AGE,
httponly=True,
secure=is_production(),
samesite="lax",
path="/api",
)
logger.info(f"Token refreshed for user {payload['username']}")
return RefreshResponse(
access_token=access_token,
expires_in=ACCESS_TOKEN_MAX_AGE, # 1 hour 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(
request: Request, authorization: str = Header(None)
):
"""
Get current authenticated user information.
Supports both:
- Cookie-based auth (HttpOnly cookies) - preferred
- Header-based auth (Authorization: Bearer token) - fallback
Args:
request: FastAPI request object
authorization: Bearer token in Authorization header (optional)
Returns:
User information and teams
"""
# Try cookie first
token = request.cookies.get(ACCESS_TOKEN_COOKIE)
# Fall back to Authorization header
if not token and authorization and authorization.startswith("Bearer "):
token = authorization.split(" ")[1]
if not token:
raise HTTPException(status_code=401, detail="Missing authentication")
try:
# Verify token
payload = verify_token(token)
# Create user info
user = DiscordUser(
id=payload["discord_id"],
username=payload["username"],
discriminator="0", # Discord removed discriminators
)
# Load user's teams from SBA API
discord_id = payload["discord_id"]
sba_teams = await sba_api_client.get_teams_by_owner(
discord_id, season=settings.sba_current_season
)
teams = [
{
"id": team["id"],
"name": team.get("lname", team.get("sname", "Unknown")),
"owner_id": discord_id,
"league_id": "sba",
}
for team in sba_teams
]
logger.info(f"User info retrieved for {user.username}, {len(teams)} teams")
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("/ws-token")
async def get_websocket_token(request: Request):
"""
Get a short-lived token for WebSocket authentication.
Safari/iOS sometimes fails to send cookies with WebSocket connections.
This endpoint provides a token that can be passed as a query parameter.
The token is valid for 5 minutes and can only be used for WebSocket auth.
Returns:
{"token": "..."} - Short-lived JWT token
"""
# Get token from cookie
token = request.cookies.get(ACCESS_TOKEN_COOKIE)
# Debug: log all cookies received
logger.info(f"ws-token request cookies: {list(request.cookies.keys())}")
if token:
logger.info(f"Token length: {len(token)}, starts with: {token[:20] if len(token) > 20 else token}...")
if not token:
raise HTTPException(status_code=401, detail="Missing authentication")
try:
# Verify the existing token
payload = verify_token(token)
# Create a short-lived token (5 minutes) for WebSocket auth
ws_token = create_token(
{
"user_id": payload["user_id"],
"discord_id": payload["discord_id"],
"username": payload["username"],
"ws_only": True, # Mark as WebSocket-only token
},
expires_minutes=5,
)
logger.debug(f"Generated WS token for user {payload['username']}")
return {"token": ws_token}
except JWTError:
logger.warning("Invalid token in /ws-token request")
raise HTTPException(status_code=401, detail="Invalid or expired token")
except Exception as e:
logger.error(f"WS token generation error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to generate token")
@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}
@router.get("/logout")
@router.post("/logout")
async def logout(response: Response) -> dict:
"""
Clear auth cookies (logout).
Args:
response: FastAPI response object
Returns:
Success message
"""
clear_auth_cookies(response)
logger.info("User logged out, cookies cleared")
return {"message": "Logged out successfully"}
# ============================================================================
# Testing Endpoints (Development Only)
# ============================================================================
class TestTokenRequest(BaseModel):
"""Request model for test token creation"""
user_id: str
username: str
discord_id: str
@router.post("/token", response_model=AuthResponse)
async def create_test_token(request: TestTokenRequest):
"""
Create test JWT token without OAuth (for development/testing)
**WARNING**: This endpoint should be disabled in production!
It bypasses Discord OAuth but still respects the whitelist.
Args:
request: Test user data
Returns:
JWT tokens and mock user information
"""
# Still check whitelist for test tokens
if not is_discord_id_allowed(request.discord_id):
raise HTTPException(
status_code=403,
detail="Access denied. This Discord ID is not authorized."
)
# Create JWT tokens
user_payload = {
"user_id": request.user_id,
"username": request.username,
"discord_id": request.discord_id,
}
access_token = create_token(user_payload)
refresh_token = create_token({**user_payload, "type": "refresh"})
logger.info(f"Test token created for {request.username} (discord_id: {request.discord_id})")
# Create mock Discord user
mock_user = DiscordUser(
id=request.discord_id,
username=request.username,
discriminator="0001",
avatar=None,
email=None,
)
return AuthResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=604800, # 7 days
user=mock_user,
)