"""Discord OAuth service for Mantimon TCG. This module handles Discord OAuth 2.0 authentication flow: 1. Generate authorization URL for user redirect 2. Exchange authorization code for tokens 3. Fetch user information from Discord Discord OAuth Endpoints: - Authorization: https://discord.com/api/oauth2/authorize - Token: https://discord.com/api/oauth2/token - User Info: https://discord.com/api/users/@me Example: from app.services.oauth.discord import discord_oauth # Step 1: Redirect user to Discord auth_url = discord_oauth.get_authorization_url( redirect_uri="https://play.mantimon.com/api/auth/discord/callback", state="random-csrf-token" ) # Step 2: Handle callback and get user info user_info = await discord_oauth.get_user_info(code, redirect_uri) """ from urllib.parse import urlencode import httpx from app.config import settings from app.schemas.user import OAuthUserInfo class DiscordOAuthError(Exception): """Exception raised for Discord OAuth errors.""" pass class DiscordOAuth: """Discord OAuth 2.0 service. Handles the OAuth flow for authenticating users with Discord accounts. """ AUTHORIZATION_URL = "https://discord.com/api/oauth2/authorize" TOKEN_URL = "https://discord.com/api/oauth2/token" USER_INFO_URL = "https://discord.com/api/users/@me" CDN_URL = "https://cdn.discordapp.com" # Scopes we request from Discord SCOPES = ["identify", "email"] def get_authorization_url(self, redirect_uri: str, state: str) -> str: """Generate the Discord OAuth authorization URL. Args: redirect_uri: Where Discord should redirect after authorization. state: Random string for CSRF protection. Returns: Full authorization URL to redirect user to. Raises: DiscordOAuthError: If Discord OAuth is not configured. Example: url = discord_oauth.get_authorization_url( redirect_uri="https://play.mantimon.com/api/auth/discord/callback", state="abc123" ) # Redirect user to url """ if not settings.discord_client_id: raise DiscordOAuthError("Discord OAuth is not configured") params = { "client_id": settings.discord_client_id, "redirect_uri": redirect_uri, "response_type": "code", "scope": " ".join(self.SCOPES), "state": state, "prompt": "consent", # Always show consent screen } return f"{self.AUTHORIZATION_URL}?{urlencode(params)}" async def exchange_code_for_tokens( self, code: str, redirect_uri: str, ) -> dict: """Exchange authorization code for access tokens. Args: code: Authorization code from Discord callback. redirect_uri: Same redirect_uri used in authorization request. Returns: Token response containing access_token, refresh_token, etc. Raises: DiscordOAuthError: If token exchange fails. """ if not settings.discord_client_id or not settings.discord_client_secret: raise DiscordOAuthError("Discord OAuth is not configured") data = { "client_id": settings.discord_client_id, "client_secret": settings.discord_client_secret.get_secret_value(), "code": code, "grant_type": "authorization_code", "redirect_uri": redirect_uri, } async with httpx.AsyncClient() as client: response = await client.post( self.TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) if response.status_code != 200: error_data = response.json() if response.content else {} error_msg = error_data.get("error_description", response.text) raise DiscordOAuthError(f"Token exchange failed: {error_msg}") return response.json() async def fetch_user_info(self, access_token: str) -> dict: """Fetch user information from Discord. Args: access_token: Valid Discord access token. Returns: User info dict with id, username, email, avatar, etc. Raises: DiscordOAuthError: If fetching user info fails. """ async with httpx.AsyncClient() as client: response = await client.get( self.USER_INFO_URL, headers={"Authorization": f"Bearer {access_token}"}, ) if response.status_code != 200: raise DiscordOAuthError(f"Failed to fetch user info: {response.text}") return response.json() def _build_avatar_url(self, user_id: str, avatar_hash: str | None) -> str | None: """Build Discord avatar URL from user ID and avatar hash. Args: user_id: Discord user ID. avatar_hash: Avatar hash from Discord API (can be None). Returns: Full CDN URL for avatar, or None if no avatar. Note: Discord avatar format: https://cdn.discordapp.com/avatars/{user_id}/{avatar_hash}.png If avatar_hash starts with 'a_', it's animated (gif). """ if not avatar_hash: return None # Animated avatars start with 'a_' extension = "gif" if avatar_hash.startswith("a_") else "png" return f"{self.CDN_URL}/avatars/{user_id}/{avatar_hash}.{extension}" async def get_user_info( self, code: str, redirect_uri: str, ) -> OAuthUserInfo: """Complete OAuth flow: exchange code and fetch user info. This is the main method to call after receiving the OAuth callback. It exchanges the authorization code for tokens, then fetches user info. Args: code: Authorization code from Discord callback. redirect_uri: Same redirect_uri used in authorization request. Returns: Normalized OAuthUserInfo ready for user creation/lookup. Raises: DiscordOAuthError: If any step of the OAuth flow fails. Example: # In your callback handler: user_info = await discord_oauth.get_user_info(code, redirect_uri) user, created = await user_service.get_or_create_from_oauth(db, user_info) """ # Exchange code for tokens tokens = await self.exchange_code_for_tokens(code, redirect_uri) access_token = tokens.get("access_token") if not access_token: raise DiscordOAuthError("No access token in response") # Fetch user info user_data = await self.fetch_user_info(access_token) # Discord requires email scope, but email can still be None if not verified email = user_data.get("email") if not email: raise DiscordOAuthError("Discord account does not have a verified email") # Build display name: prefer global_name, then username display_name = user_data.get("global_name") or user_data["username"] # Build avatar URL avatar_url = self._build_avatar_url( user_data["id"], user_data.get("avatar"), ) # Normalize to our schema return OAuthUserInfo( provider="discord", oauth_id=user_data["id"], email=email, name=display_name, avatar_url=avatar_url, ) def is_configured(self) -> bool: """Check if Discord OAuth is properly configured. Returns: True if client ID and secret are set. """ return bool(settings.discord_client_id and settings.discord_client_secret) # Global instance discord_oauth = DiscordOAuth()