""" Create and delete Auth0 machine-to-machine applications (clients) ## Python SDK * https://github.com/auth0/auth0-python ## Auth0 management api docs * https://auth0.com/docs/api/management/v2 """ import logging import math import time from typing import Any from auth0.authentication import GetToken from auth0.exceptions import Auth0Error from auth0.management import ClientGrants as ClientGrantsAPI, Clients as MgmtClientsAPI from pydantic import BaseModel, Field from m2mconfig import config, utils from m2mconfig.schemas import ( ClientCredentials, ClientGrants, ClientGrantsValidator, ClientMetadata, RegistryEntry, ) logger = logging.getLogger(__name__) GET_CLIENT_FIELDS = ["name", "client_id", "client_metadata", "client_secret"] GET_CLIENT_EXTRA_PARAMS = {"include_totals": "true", "app_type": "non_interactive"} DESCRIPTION_TEMPLATE = "Application to generate Machine-to-machine JWTs for: '{}'." class ClientGrantsCreateError(Exception): """Indicate that an error occurred when attempting to create client-grants for the m2m app.""" pass class Auth0ClientConfiguration(BaseModel): # Required Fields name: str client_metadata: ClientMetadata description: str = Field(max_length=1024) # Optional Fields is_token_endpoint_ip_header_trusted: bool = False is_first_party: bool = True oidc_conformant: bool = True sso_disabled: bool = False cross_origin_auth: bool = False logo_uri: str = "" sso: bool = False callbacks: list[str] = [] allowed_logout_urls: list[str] = [] allowed_clients: list[str] = [] allowed_origins: list[str] = [] jwt_configuration: dict[str, Any] = { "alg": "RS256", "lifetime_in_seconds": 36000, "secret_encoded": False, } token_endpoint_auth_method: str = "client_secret_post" app_type: str = "non_interactive" grant_types: list[str] = [ "authorization_code", "implicit", "refresh_token", "client_credentials", ] web_origins: list[str] = [] custom_login_page_on: bool = False class Auth0ClientResponse(BaseModel): """Auth0 Client object for GET_CLIENT_FIELDS entries only. https://auth0.com/docs/api/management/v2/clients/get-clients#response-messages """ name: str | None = None client_id: str client_secret: str client_metadata: dict[str, Any] = {} def get_mgmt_api_token() -> str: """Fetch a bearer token for the management api client.""" domain = config.AUTH0_DOMAIN client_credentials = ClientCredentials.model_validate_json( config.AUTH0_M2M_CONFIG_CLIENT_CREDENTIALS ) get_token = GetToken( domain, client_credentials.client_id, client_secret=client_credentials.client_secret, ) token = get_token.client_credentials(client_credentials.audience) return str(token["access_token"]) def get_auth0_mgmt_client_api(mgmt_api_token: str) -> MgmtClientsAPI: """Fetch the management api connector.""" return MgmtClientsAPI(config.AUTH0_DOMAIN, mgmt_api_token) def get_client_grants_api(mgmt_api_token: str) -> ClientGrantsAPI: """Fetch client grants api connector.""" return ClientGrantsAPI(config.AUTH0_DOMAIN, mgmt_api_token) def clients_dict_by_name( clients_list: list[dict[str, Any]], ) -> dict[str, Auth0ClientResponse]: """Convert a /v2/clients/get-clients response list to a dictionary keyed by name.""" by_name_dict = {} for _client in clients_list: entry = Auth0ClientResponse.model_validate(_client) if not entry.name: logger.error("Missing name for client_id: %s", entry.client_id) continue by_name_dict[entry.name] = entry return by_name_dict class Auth0Connector: """ Wrapper class for the auth-management-api client """ def __init__( self, auth0_mgmt_client_api: MgmtClientsAPI, client_grants_api: ClientGrantsAPI, ): """Constructor.""" self.auth0_mgmt_client_api = auth0_mgmt_client_api self.client_grants_api = client_grants_api def get_clients( self, page_size: int = config.AUTH0_GET_CLIENT_DEFAULT_PAGE_SIZE ) -> dict[str, Auth0ClientResponse]: """ Retrieves a list of all the applications. https://auth0.com/docs/api/management/v2/clients/get-clients """ client_dict = {} response = self.auth0_mgmt_client_api.all( fields=GET_CLIENT_FIELDS, page=0, per_page=page_size, extra_params=GET_CLIENT_EXTRA_PARAMS, ) total = response["total"] total_pages = int(math.ceil(total / page_size)) logger.debug( "[get_clients] total: %s, total_pages: %s, page_size=%s", total, total_pages, page_size, ) logger.debug( "[get_clients] Fetched page 0 with %s entries", len(response["clients"]) ) client_dict.update(clients_dict_by_name(response["clients"])) for next_page in range(1, total_pages): # Calling GET too frequently causes a 429 error. So sleeeeeeep. time.sleep(config.AUTH_MGMT_RATE_LIMIT_SLEEP_SECONDS) response = self.auth0_mgmt_client_api.all( fields=GET_CLIENT_FIELDS, page=next_page, per_page=page_size, extra_params=GET_CLIENT_EXTRA_PARAMS, ) logger.debug( "[get_clients] Fetched page %d of %d with %s entries", next_page, total_pages, len(response["clients"]), ) client_dict.update(clients_dict_by_name(response["clients"])) return client_dict def get_client_by_id(self, client_id: str) -> Auth0ClientResponse: """ Lookup an Auth0 client by the client_id See: https://auth0.com/docs/api/management/v2#!/Clients/get_clients_by_id """ _client = self.auth0_mgmt_client_api.get( id=client_id, fields=GET_CLIENT_FIELDS, ) return Auth0ClientResponse.model_validate(_client) def get_app_by_name(self, client: RegistryEntry) -> Auth0ClientResponse | None: """Fetch the list of deployed clients and check if the name already exists.""" _deployed_clients_dict = self.get_clients() if client.name in _deployed_clients_dict: logger.info( "[create_client] Application with name '%s 'already exists. Skipping entry.", client.name, ) return Auth0ClientResponse.model_validate( _deployed_clients_dict.get(client.name) ) return None def create_client(self, client: RegistryEntry) -> Auth0ClientResponse: """Create a new M2M application. https://auth0.com/docs/api/v2#!/Clients/post_clients **NOTE** The caller must verify if the app does not exist using `get_app_by_name()`. """ logger.info( "[create_client] Creating application with name '%s'.", client.name, ) body = Auth0ClientConfiguration( name=client.name, description=DESCRIPTION_TEMPLATE.format(client.name), client_metadata=client.client_metadata, ).model_dump() _client = self.auth0_mgmt_client_api.create(body=body) return Auth0ClientResponse.model_validate(_client) def delete_client(self, client_id: str) -> Any: """Delete an application and all its related assets. https://auth0.com/docs/api/management/v2#!/Clients/delete_clients_by_id """ response = self.auth0_mgmt_client_api.delete(id=client_id) logger.debug("Delete succeeded for client_id: '%s'", client_id) return response def create_client_grant(self, client_id: str) -> ClientGrants: """Get the existing grant or create a new grant for the m2mclient - https://auth0.com/docs/api/management/v2#!/Client_Grants/get_client_grants - https://auth0.com/docs/api/management/v2#!/Client_Grants/post_client_grants """ audience = utils.get_auth0_audience(config.ENVIRONMENT) # Get all the client grants for this application all_grants = self.client_grants_api.all(client_id=client_id) client_grants = ClientGrantsValidator.validate_python(all_grants) # Filter for the grant with audience == PROD_AUDIENCE or QA_AUDIENCE matching_client_grants = list( filter(lambda cg: cg.audience == audience, client_grants) ) if matching_client_grants: logger.info( "Found the client grant for '%s', grant:'%s'", audience, matching_client_grants[0], ) return matching_client_grants[0] # We didn't find a grant for the audience, so create the missing grant logger.info("Creating the missing client grant for %s.", audience) body = { "client_id": client_id, "audience": audience, "scope": [], } try: grant = self.client_grants_api.create(body=body) except Auth0Error as ex: raise ClientGrantsCreateError( f"Failed to create client grant for client_id: '{client_id}'" ) from ex return ClientGrants.model_validate(grant) def rotate_client_secret(self, client_id: str) -> Auth0ClientResponse: """Rotate a client secret. https://auth0.com/docs/api/management/v2/clients/post-rotate-secret """ _client = self.auth0_mgmt_client_api.rotate_secret(client_id) return Auth0ClientResponse.model_validate(_client) def update_client_metadata( self, client_id: str, client: RegistryEntry ) -> Auth0ClientResponse: """Update client metadata. https://auth0.com/docs/api/management/v2/clients/patch-clients-by-id """ _client = self.auth0_mgmt_client_api.update( client_id, body={ "client_metadata": client.client_metadata.model_dump(), }, ) return Auth0ClientResponse.model_validate(_client)