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
243 lines
7.8 KiB
Python
243 lines
7.8 KiB
Python
"""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()
|