"""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)