mantimon-tcg/backend/app/api/auth.py
Cal Corum 996c43fbd9 Implement Phase 2: Authentication system
Complete OAuth-based authentication with JWT session management:

Core Services:
- JWT service for access/refresh token creation and verification
- Token store with Redis-backed refresh token revocation
- User service for CRUD operations and OAuth-based creation
- Google and Discord OAuth services with full flow support

API Endpoints:
- GET /api/auth/{google,discord} - Start OAuth flows
- GET /api/auth/{google,discord}/callback - Handle OAuth callbacks
- POST /api/auth/refresh - Exchange refresh token for new access token
- POST /api/auth/logout - Revoke single refresh token
- POST /api/auth/logout-all - Revoke all user sessions
- GET/PATCH /api/users/me - User profile management
- GET /api/users/me/linked-accounts - List OAuth providers
- GET /api/users/me/sessions - Count active sessions

Infrastructure:
- Pydantic schemas for auth/user request/response models
- FastAPI dependencies (get_current_user, get_current_premium_user)
- OAuthLinkedAccount model for multi-provider support
- Alembic migration for oauth_linked_accounts table

Dependencies added: email-validator, fakeredis (dev), respx (dev)

84 new tests, 1058 total passing
2026-01-27 21:49:59 -06:00

392 lines
12 KiB
Python

"""Authentication API router for Mantimon TCG.
This module provides endpoints for OAuth authentication:
- OAuth login redirects (Google, Discord)
- OAuth callbacks (token exchange)
- Token refresh
- Logout
OAuth Flow:
1. Client calls GET /auth/{provider} to get redirect URL
2. Client redirects user to OAuth provider
3. Provider redirects back to GET /auth/{provider}/callback
4. Server exchanges code for tokens, creates/fetches user
5. Server returns JWT access + refresh tokens
Example:
# Start OAuth flow
GET /api/auth/google?redirect_uri=https://play.mantimon.com/login/callback
# After OAuth callback, refresh tokens
POST /api/auth/refresh
{"refresh_token": "..."}
# Logout
POST /api/auth/logout
{"refresh_token": "..."}
"""
import secrets
from fastapi import APIRouter, HTTPException, Query, status
from fastapi.responses import RedirectResponse
from app.api.deps import CurrentUser, DbSession
from app.db.redis import get_redis
from app.schemas.auth import RefreshTokenRequest, TokenResponse
from app.services.jwt_service import (
create_access_token,
create_refresh_token,
get_refresh_token_expiration,
get_token_expiration_seconds,
verify_refresh_token,
)
from app.services.oauth.discord import DiscordOAuthError, discord_oauth
from app.services.oauth.google import GoogleOAuthError, google_oauth
from app.services.token_store import token_store
from app.services.user_service import user_service
router = APIRouter(prefix="/auth", tags=["auth"])
# OAuth state TTL (5 minutes)
OAUTH_STATE_TTL = 300
async def _store_oauth_state(state: str, provider: str, redirect_uri: str) -> None:
"""Store OAuth state in Redis for CSRF validation."""
async with get_redis() as redis:
key = f"oauth_state:{state}"
value = f"{provider}:{redirect_uri}"
await redis.setex(key, OAUTH_STATE_TTL, value)
async def _validate_oauth_state(state: str, provider: str) -> str | None:
"""Validate and consume OAuth state, returning redirect_uri if valid."""
async with get_redis() as redis:
key = f"oauth_state:{state}"
value = await redis.get(key)
if not value:
return None
# Delete state (one-time use)
await redis.delete(key)
# Parse and validate
stored_provider, redirect_uri = value.split(":", 1)
if stored_provider != provider:
return None
return redirect_uri
async def _create_tokens_for_user(user_id) -> TokenResponse:
"""Create access and refresh tokens for a user."""
from uuid import UUID
if isinstance(user_id, str):
user_id = UUID(user_id)
access_token = create_access_token(user_id)
refresh_token, jti = create_refresh_token(user_id)
# Store refresh token in Redis for revocation tracking
expires_at = get_refresh_token_expiration()
await token_store.store_refresh_token(user_id, jti, expires_at)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=get_token_expiration_seconds(),
)
# =============================================================================
# Google OAuth
# =============================================================================
@router.get("/google")
async def google_auth_redirect(
redirect_uri: str = Query(..., description="URI to redirect to after OAuth completes"),
) -> RedirectResponse:
"""Start Google OAuth flow.
Redirects the user to Google's OAuth consent screen.
Args:
redirect_uri: Where to redirect after successful authentication.
This is YOUR app's callback, not the OAuth callback.
Returns:
Redirect to Google OAuth authorization URL.
Raises:
HTTPException: 501 if Google OAuth is not configured.
"""
if not google_oauth.is_configured():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Google OAuth is not configured",
)
# Generate state for CSRF protection
state = secrets.token_urlsafe(32)
await _store_oauth_state(state, "google", redirect_uri)
# Build OAuth callback URL (our server endpoint)
# The redirect_uri param here is where Google sends the code
oauth_callback = "/api/auth/google/callback"
# Get authorization URL
auth_url = google_oauth.get_authorization_url(
redirect_uri=oauth_callback,
state=state,
)
return RedirectResponse(url=auth_url, status_code=status.HTTP_302_FOUND)
@router.get("/google/callback")
async def google_auth_callback(
db: DbSession,
code: str = Query(..., description="Authorization code from Google"),
state: str = Query(..., description="State parameter for CSRF validation"),
) -> TokenResponse:
"""Handle Google OAuth callback.
Exchanges the authorization code for tokens and creates/fetches the user.
Args:
code: Authorization code from Google.
state: State parameter for CSRF validation.
Returns:
JWT access and refresh tokens.
Raises:
HTTPException: 400 if state is invalid or OAuth fails.
"""
# Validate state
redirect_uri = await _validate_oauth_state(state, "google")
if redirect_uri is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired state parameter",
)
try:
# Exchange code for user info
oauth_callback = "/api/auth/google/callback"
user_info = await google_oauth.get_user_info(code, oauth_callback)
# Get or create user
user, created = await user_service.get_or_create_from_oauth(db, user_info)
# Update last login
await user_service.update_last_login(db, user)
# Create tokens
return await _create_tokens_for_user(user.id)
except GoogleOAuthError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Google OAuth failed: {e}",
) from None
# =============================================================================
# Discord OAuth
# =============================================================================
@router.get("/discord")
async def discord_auth_redirect(
redirect_uri: str = Query(..., description="URI to redirect to after OAuth completes"),
) -> RedirectResponse:
"""Start Discord OAuth flow.
Redirects the user to Discord's OAuth consent screen.
Args:
redirect_uri: Where to redirect after successful authentication.
Returns:
Redirect to Discord OAuth authorization URL.
Raises:
HTTPException: 501 if Discord OAuth is not configured.
"""
if not discord_oauth.is_configured():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Discord OAuth is not configured",
)
# Generate state for CSRF protection
state = secrets.token_urlsafe(32)
await _store_oauth_state(state, "discord", redirect_uri)
# Build OAuth callback URL
oauth_callback = "/api/auth/discord/callback"
# Get authorization URL
auth_url = discord_oauth.get_authorization_url(
redirect_uri=oauth_callback,
state=state,
)
return RedirectResponse(url=auth_url, status_code=status.HTTP_302_FOUND)
@router.get("/discord/callback")
async def discord_auth_callback(
db: DbSession,
code: str = Query(..., description="Authorization code from Discord"),
state: str = Query(..., description="State parameter for CSRF validation"),
) -> TokenResponse:
"""Handle Discord OAuth callback.
Exchanges the authorization code for tokens and creates/fetches the user.
Args:
code: Authorization code from Discord.
state: State parameter for CSRF validation.
Returns:
JWT access and refresh tokens.
Raises:
HTTPException: 400 if state is invalid or OAuth fails.
"""
# Validate state
redirect_uri = await _validate_oauth_state(state, "discord")
if redirect_uri is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired state parameter",
)
try:
# Exchange code for user info
oauth_callback = "/api/auth/discord/callback"
user_info = await discord_oauth.get_user_info(code, oauth_callback)
# Get or create user
user, created = await user_service.get_or_create_from_oauth(db, user_info)
# Update last login
await user_service.update_last_login(db, user)
# Create tokens
return await _create_tokens_for_user(user.id)
except DiscordOAuthError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Discord OAuth failed: {e}",
) from None
# =============================================================================
# Token Management
# =============================================================================
@router.post("/refresh", response_model=TokenResponse)
async def refresh_tokens(
db: DbSession,
request: RefreshTokenRequest,
) -> TokenResponse:
"""Refresh access token using refresh token.
Validates the refresh token and issues a new access token.
The refresh token itself is NOT rotated (same token can be used
until it expires or is revoked).
Args:
request: Contains the refresh token.
Returns:
New JWT access token (same refresh token).
Raises:
HTTPException: 401 if refresh token is invalid or revoked.
"""
# Verify refresh token
result = verify_refresh_token(request.refresh_token)
if result is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
user_id, jti = result
# Check if token is revoked
is_valid = await token_store.is_token_valid(user_id, jti)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token has been revoked",
)
# Verify user still exists
user = await user_service.get_by_id(db, user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
# Create new access token (keep same refresh token)
access_token = create_access_token(user_id)
return TokenResponse(
access_token=access_token,
refresh_token=request.refresh_token, # Return same refresh token
expires_in=get_token_expiration_seconds(),
)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
request: RefreshTokenRequest,
) -> None:
"""Logout by revoking the refresh token.
The access token will continue to work until it expires,
but the refresh token cannot be used to get new tokens.
Args:
request: Contains the refresh token to revoke.
"""
# Verify refresh token to get user_id and jti
result = verify_refresh_token(request.refresh_token)
if result is None:
# Token is invalid, but that's fine - user is effectively logged out
return
user_id, jti = result
# Revoke the token
await token_store.revoke_token(user_id, jti)
@router.post("/logout-all", status_code=status.HTTP_204_NO_CONTENT)
async def logout_all(
user: CurrentUser,
) -> None:
"""Logout from all devices by revoking all refresh tokens.
Requires authentication (uses current access token).
All refresh tokens for the user will be revoked.
Args:
user: Current authenticated user.
"""
from uuid import UUID
user_id = UUID(user.id) if isinstance(user.id, str) else user.id
await token_store.revoke_all_user_tokens(user_id)