mantimon-tcg/backend/app/services/oauth/google.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

208 lines
6.6 KiB
Python

"""Google OAuth service for Mantimon TCG.
This module handles Google OAuth 2.0 authentication flow:
1. Generate authorization URL for user redirect
2. Exchange authorization code for tokens
3. Fetch user information from Google
Google OAuth Endpoints:
- Authorization: https://accounts.google.com/o/oauth2/v2/auth
- Token: https://oauth2.googleapis.com/token
- User Info: https://www.googleapis.com/oauth2/v2/userinfo
Example:
from app.services.oauth.google import google_oauth
# Step 1: Redirect user to Google
auth_url = google_oauth.get_authorization_url(
redirect_uri="https://play.mantimon.com/api/auth/google/callback",
state="random-csrf-token"
)
# Step 2: Handle callback and get user info
user_info = await google_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 GoogleOAuthError(Exception):
"""Exception raised for Google OAuth errors."""
pass
class GoogleOAuth:
"""Google OAuth 2.0 service.
Handles the OAuth flow for authenticating users with Google accounts.
"""
AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"
TOKEN_URL = "https://oauth2.googleapis.com/token"
USER_INFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
# Scopes we request from Google
SCOPES = ["openid", "email", "profile"]
def get_authorization_url(self, redirect_uri: str, state: str) -> str:
"""Generate the Google OAuth authorization URL.
Args:
redirect_uri: Where Google should redirect after authorization.
state: Random string for CSRF protection.
Returns:
Full authorization URL to redirect user to.
Raises:
GoogleOAuthError: If Google OAuth is not configured.
Example:
url = google_oauth.get_authorization_url(
redirect_uri="https://play.mantimon.com/api/auth/google/callback",
state="abc123"
)
# Redirect user to url
"""
if not settings.google_client_id:
raise GoogleOAuthError("Google OAuth is not configured")
params = {
"client_id": settings.google_client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": " ".join(self.SCOPES),
"state": state,
"access_type": "offline", # Get refresh token
"prompt": "select_account", # Always show account picker
}
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 Google callback.
redirect_uri: Same redirect_uri used in authorization request.
Returns:
Token response containing access_token, id_token, etc.
Raises:
GoogleOAuthError: If token exchange fails.
"""
if not settings.google_client_id or not settings.google_client_secret:
raise GoogleOAuthError("Google OAuth is not configured")
data = {
"client_id": settings.google_client_id,
"client_secret": settings.google_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 GoogleOAuthError(f"Token exchange failed: {error_msg}")
return response.json()
async def fetch_user_info(self, access_token: str) -> dict:
"""Fetch user information from Google.
Args:
access_token: Valid Google access token.
Returns:
User info dict with id, email, name, picture, etc.
Raises:
GoogleOAuthError: 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 GoogleOAuthError(f"Failed to fetch user info: {response.text}")
return response.json()
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 Google callback.
redirect_uri: Same redirect_uri used in authorization request.
Returns:
Normalized OAuthUserInfo ready for user creation/lookup.
Raises:
GoogleOAuthError: If any step of the OAuth flow fails.
Example:
# In your callback handler:
user_info = await google_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 GoogleOAuthError("No access token in response")
# Fetch user info
user_data = await self.fetch_user_info(access_token)
# Normalize to our schema
return OAuthUserInfo(
provider="google",
oauth_id=user_data["id"],
email=user_data["email"],
name=user_data.get("name", user_data["email"].split("@")[0]),
avatar_url=user_data.get("picture"),
)
def is_configured(self) -> bool:
"""Check if Google OAuth is properly configured.
Returns:
True if client ID and secret are set.
"""
return bool(settings.google_client_id and settings.google_client_secret)
# Global instance
google_oauth = GoogleOAuth()