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