"""Auth0 Management API client.""" import logging import time from typing import Any from urllib.parse import quote import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry LOGGER = logging.getLogger(__name__) class Auth0Client: """Thin wrapper around the Auth0 Management API.""" _TOKEN_URL_TEMPLATE = 'https://{domain}/oauth/token' _SEARCH_URL_TEMPLATE = 'https://{domain}/api/v2/users-by-email' def __init__(self, domain: str, client_id: str, client_secret: str) -> None: """Initialize the Auth0Client. :param domain: Auth0 tenant domain (e.g. mytenant.auth0.com) :param client_id: Machine-to-machine app client ID :param client_secret: Machine-to-machine app client secret """ self.domain = domain self.client_id = client_id self.client_secret = client_secret self.session = requests.Session() retries = Retry( total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504] ) self.session.mount('https://', HTTPAdapter(max_retries=retries)) self._access_token: str | None = None self._token_expiry: float | None = None def __repr__(self) -> str: """Return a safe string representation without credentials.""" return f'Auth0Client(domain={self.domain!r}, client_id=***)' def _get_access_token(self) -> str: """Obtain an access token using client credentials grant. :return: A valid Auth0 Management API access token. """ if self._access_token and time.time() < (self._token_expiry or 0) - 60: return self._access_token payload = { 'client_id': self.client_id, 'client_secret': self.client_secret, 'audience': f'https://{self.domain}/api/v2/', 'grant_type': 'client_credentials', } resp = self.session.post( self._TOKEN_URL_TEMPLATE.format(domain=self.domain), json=payload, timeout=10, ) resp.raise_for_status() data = resp.json() self._access_token = data['access_token'] expires_in = data.get('expires_in', 3600) if not isinstance(expires_in, (int, float)) or expires_in <= 0: expires_in = 3600 self._token_expiry = time.time() + expires_in return self._access_token def _log_auth0_error(self, exc: requests.exceptions.HTTPError) -> None: """Extract and log Auth0 error details from an HTTP error response.""" if exc.response is None: return try: error_detail = exc.response.json() LOGGER.error( 'Auth0 API error (status %s): %s', exc.response.status_code, error_detail, ) except Exception: LOGGER.error( 'Auth0 API error (status %s): %s', exc.response.status_code, exc.response.text[:500], ) def search_user_by_email(self, email: str) -> list[dict[str, Any]]: """Search for a user by email address. :param email: The email address to search for. :return: List of matching user dicts from Auth0. """ access_token = self._get_access_token() headers = {'Authorization': f'Bearer {access_token}'} params = {'email': email} resp = self.session.get( self._SEARCH_URL_TEMPLATE.format(domain=self.domain), headers=headers, params=params, timeout=10, ) try: resp.raise_for_status() except requests.exceptions.HTTPError as exc: self._log_auth0_error(exc) raise return resp.json() def delete_user_by_id(self, user_id: str) -> None: """Delete a user by their Auth0 user ID. :param user_id: The Auth0 user ID (e.g. ``auth0|abc123``). """ access_token = self._get_access_token() headers = {'Authorization': f'Bearer {access_token}'} url = f'https://{self.domain}/api/v2/users/{quote(user_id, safe="")}' resp = self.session.delete(url, headers=headers, timeout=10) if resp.status_code == 204: return None try: resp.raise_for_status() except requests.exceptions.HTTPError as exc: self._log_auth0_error(exc) raise raise ValueError(f'Unexpected status code: {resp.status_code}') def block_user_by_id(self, user_id: str) -> None: """Block (suspend) a user by their Auth0 user ID. Sets the ``blocked`` flag to ``True`` via ``PATCH /api/v2/users/{id}``, which disables login while preserving the account for later reinstatement. Auth0 returns 200 on success. :param user_id: The Auth0 user ID (e.g. ``auth0|abc123``). """ access_token = self._get_access_token() headers = {'Authorization': f'Bearer {access_token}'} url = f'https://{self.domain}/api/v2/users/{quote(user_id, safe="")}' resp = self.session.patch( url, headers=headers, json={'blocked': True}, timeout=10 ) if resp.status_code == 200: return None try: resp.raise_for_status() except requests.exceptions.HTTPError as exc: self._log_auth0_error(exc) raise raise ValueError(f'Unexpected status code: {resp.status_code}')