mantimon-tcg/backend/app/api/auth.py
Cal Corum 3ad79a4860 Fix OAuth absolute URLs and add account linking endpoints
- Add base_url config setting for OAuth callback URLs
- Change OAuth callbacks from relative to absolute URLs
- Add account linking OAuth flow (GET /auth/link/{provider})
- Add unlink endpoint (DELETE /users/me/link/{provider})
- Add AccountLinkingError and service methods for linking
- Add 14 new tests for linking functionality
- Update Phase 2 plan to mark complete (1072 tests passing)
2026-01-27 22:06:22 -06:00

652 lines
20 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.config import settings
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 AccountLinkingError, 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
# Must be an absolute URL for OAuth providers
oauth_callback = f"{settings.base_url}/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 = f"{settings.base_url}/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 (must be absolute for OAuth providers)
oauth_callback = f"{settings.base_url}/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 = f"{settings.base_url}/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)
# =============================================================================
# Account Linking
# =============================================================================
async def _store_link_state(state: str, provider: str, user_id: str, redirect_uri: str) -> None:
"""Store OAuth state for account linking (includes user_id)."""
async with get_redis() as redis:
key = f"oauth_link_state:{state}"
value = f"{provider}:{user_id}:{redirect_uri}"
await redis.setex(key, OAUTH_STATE_TTL, value)
async def _validate_link_state(state: str, provider: str) -> tuple[str, str] | None:
"""Validate and consume link state, returning (user_id, redirect_uri) if valid."""
async with get_redis() as redis:
key = f"oauth_link_state:{state}"
value = await redis.get(key)
if not value:
return None
# Delete state (one-time use)
await redis.delete(key)
# Parse and validate
parts = value.split(":", 2)
if len(parts) != 3:
return None
stored_provider, user_id, redirect_uri = parts
if stored_provider != provider:
return None
return user_id, redirect_uri
@router.get("/link/google")
async def google_link_redirect(
user: CurrentUser,
redirect_uri: str = Query(..., description="URI to redirect to after linking"),
) -> RedirectResponse:
"""Start Google OAuth flow for account linking.
Requires authentication. Links Google account to the current user.
Args:
redirect_uri: Where to redirect after linking completes.
Returns:
Redirect to Google OAuth authorization URL.
Raises:
HTTPException: 501 if Google OAuth is not configured.
HTTPException: 400 if Google is already the primary provider.
"""
if not google_oauth.is_configured():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Google OAuth is not configured",
)
# Check if Google is already their primary provider
if user.oauth_provider == "google":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Google is already your primary login provider",
)
# Generate state for CSRF protection
state = secrets.token_urlsafe(32)
await _store_link_state(state, "google", str(user.id), redirect_uri)
# Build OAuth callback URL for linking
oauth_callback = f"{settings.base_url}/api/auth/link/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("/link/google/callback")
async def google_link_callback(
db: DbSession,
code: str = Query(..., description="Authorization code from Google"),
state: str = Query(..., description="State parameter for CSRF validation"),
) -> RedirectResponse:
"""Handle Google OAuth callback for account linking.
Exchanges the authorization code and links the Google account to the user.
Args:
code: Authorization code from Google.
state: State parameter for CSRF validation.
Returns:
Redirect to the original redirect_uri with success/error query params.
"""
# Validate state
result = await _validate_link_state(state, "google")
if result is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired state parameter",
)
user_id_str, redirect_uri = result
try:
# Exchange code for user info
oauth_callback = f"{settings.base_url}/api/auth/link/google/callback"
oauth_info = await google_oauth.get_user_info(code, oauth_callback)
# Get the user
from uuid import UUID
user_id = UUID(user_id_str)
user = await user_service.get_by_id(db, user_id)
if user is None:
return RedirectResponse(
url=f"{redirect_uri}?error=user_not_found",
status_code=status.HTTP_302_FOUND,
)
# Link the account
await user_service.link_oauth_account(db, user, oauth_info)
return RedirectResponse(
url=f"{redirect_uri}?linked=google",
status_code=status.HTTP_302_FOUND,
)
except GoogleOAuthError as e:
return RedirectResponse(
url=f"{redirect_uri}?error=oauth_failed&message={e}",
status_code=status.HTTP_302_FOUND,
)
except AccountLinkingError as e:
return RedirectResponse(
url=f"{redirect_uri}?error=linking_failed&message={e}",
status_code=status.HTTP_302_FOUND,
)
@router.get("/link/discord")
async def discord_link_redirect(
user: CurrentUser,
redirect_uri: str = Query(..., description="URI to redirect to after linking"),
) -> RedirectResponse:
"""Start Discord OAuth flow for account linking.
Requires authentication. Links Discord account to the current user.
Args:
redirect_uri: Where to redirect after linking completes.
Returns:
Redirect to Discord OAuth authorization URL.
Raises:
HTTPException: 501 if Discord OAuth is not configured.
HTTPException: 400 if Discord is already the primary provider.
"""
if not discord_oauth.is_configured():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Discord OAuth is not configured",
)
# Check if Discord is already their primary provider
if user.oauth_provider == "discord":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Discord is already your primary login provider",
)
# Generate state for CSRF protection
state = secrets.token_urlsafe(32)
await _store_link_state(state, "discord", str(user.id), redirect_uri)
# Build OAuth callback URL for linking
oauth_callback = f"{settings.base_url}/api/auth/link/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("/link/discord/callback")
async def discord_link_callback(
db: DbSession,
code: str = Query(..., description="Authorization code from Discord"),
state: str = Query(..., description="State parameter for CSRF validation"),
) -> RedirectResponse:
"""Handle Discord OAuth callback for account linking.
Exchanges the authorization code and links the Discord account to the user.
Args:
code: Authorization code from Discord.
state: State parameter for CSRF validation.
Returns:
Redirect to the original redirect_uri with success/error query params.
"""
# Validate state
result = await _validate_link_state(state, "discord")
if result is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired state parameter",
)
user_id_str, redirect_uri = result
try:
# Exchange code for user info
oauth_callback = f"{settings.base_url}/api/auth/link/discord/callback"
oauth_info = await discord_oauth.get_user_info(code, oauth_callback)
# Get the user
from uuid import UUID
user_id = UUID(user_id_str)
user = await user_service.get_by_id(db, user_id)
if user is None:
return RedirectResponse(
url=f"{redirect_uri}?error=user_not_found",
status_code=status.HTTP_302_FOUND,
)
# Link the account
await user_service.link_oauth_account(db, user, oauth_info)
return RedirectResponse(
url=f"{redirect_uri}?linked=discord",
status_code=status.HTTP_302_FOUND,
)
except DiscordOAuthError as e:
return RedirectResponse(
url=f"{redirect_uri}?error=oauth_failed&message={e}",
status_code=status.HTTP_302_FOUND,
)
except AccountLinkingError as e:
return RedirectResponse(
url=f"{redirect_uri}?error=linking_failed&message={e}",
status_code=status.HTTP_302_FOUND,
)