diff --git a/README.md b/README.md index 4e48f65..c949635 100644 --- a/README.md +++ b/README.md @@ -462,6 +462,18 @@ claims = await api_client.verify_access_token(access_token) See the **[Organization Policy Guide](docs/OrganizationPolicy.md)** for policy modes, the allowlist, and error handling. +### 10. Client Telemetry + +The SDK sends an `Auth0-Client` header and a `User-Agent` header (`Python/`) on the requests it makes to Auth0 with its default HTTP client. Requests served by `custom_fetch` do not carry them. If you build a wrapper SDK on top of this one, pass `client_info` so the header names your SDK. An invalid `client_info` raises `ConfigurationError` when the client is created. + +```python +api_client = ApiClient(ApiClientOptions( + domain="", + audience="", + client_info={"name": "my-wrapper-sdk", "version": "1.2.3", "env": {"my-framework": "4.5.6"}}, +)) +``` + ## Feedback ### Contributing diff --git a/src/auth0_api_python/api_client.py b/src/auth0_api_python/api_client.py index f200996..f1711b2 100644 --- a/src/auth0_api_python/api_client.py +++ b/src/auth0_api_python/api_client.py @@ -31,6 +31,7 @@ OrganizationNotAllowedError, VerifyAccessTokenError, ) +from .telemetry import build_telemetry_headers, validate_client_info from .token_store import ( IndexedTokenStore, VerifiedToken, @@ -114,6 +115,13 @@ def __init__(self, options: ApiClientOptions): ) self.options = options + if options.client_info is not None: + try: + validate_client_info(options.client_info) + except ValueError as e: + raise ConfigurationError(str(e)) from e + self._telemetry_headers = build_telemetry_headers(options.client_info) + # Validate cache configuration if not isinstance(options.cache_ttl_seconds, (int, float)) or options.cache_ttl_seconds < 0: raise ConfigurationError("cache_ttl_seconds must be a non-negative number") @@ -855,7 +863,8 @@ async def get_access_token_for_connection( response = await client.post( token_endpoint, data=params, - auth=(client_id, client_secret) + auth=(client_id, client_secret), + headers=self._telemetry_headers ) if response.status_code != 200: @@ -1033,7 +1042,8 @@ async def example(): response = await client.post( token_endpoint, data=params, - auth=(client_id, client_secret) + auth=(client_id, client_secret), + headers=self._telemetry_headers ) if response.status_code != 200: @@ -1287,7 +1297,8 @@ async def get_client_credentials_token( response = await client.post( token_endpoint, data=params, - auth=(client_id, client_secret) + auth=(client_id, client_secret), + headers=self._telemetry_headers ) if response.status_code != 200: @@ -1431,7 +1442,8 @@ async def _discover(self, issuer: Optional[str] = None) -> dict[str, Any]: metadata, max_age = await fetch_oidc_metadata( domain=domain, - custom_fetch=self.options.custom_fetch + custom_fetch=self.options.custom_fetch, + headers=self._telemetry_headers ) effective_ttl = self._cache_ttl @@ -1462,7 +1474,8 @@ async def _fetch_jwks(self, jwks_uri: str) -> dict[str, Any]: jwks_data, max_age = await fetch_jwks( jwks_uri=jwks_uri, - custom_fetch=self.options.custom_fetch + custom_fetch=self.options.custom_fetch, + headers=self._telemetry_headers ) effective_ttl = self._cache_ttl diff --git a/src/auth0_api_python/config.py b/src/auth0_api_python/config.py index 7275c21..e450e70 100644 --- a/src/auth0_api_python/config.py +++ b/src/auth0_api_python/config.py @@ -20,7 +20,7 @@ class ApiClientOptions: Can be a static list of domain strings or a callable that returns allowed domains dynamically. Optional if domain is provided. audience: The expected 'aud' claim in the token. - custom_fetch: Optional callable that can replace the default HTTP fetch logic. + custom_fetch: Optional callable that replaces the default HTTP fetch for discovery and JWKS requests. It is called with the URL only and receives no telemetry headers. cache_adapter: Custom cache implementation. If not provided, uses default InMemoryCache. token_store: Custom token storage for token caching. If not provided, caching is disabled and every exchange makes a fresh network call. cache_ttl_seconds: Time-to-live for cache entries in seconds (default: 600 = 10 minutes). @@ -47,6 +47,7 @@ class ApiClientOptions: match. "non_strict" reuses any cached token whose granted scopes cover the request, and requires a token_store subclassing IndexedTokenStore (raises ConfigurationError at construction without one). + client_info: Name, version and optional env of a wrapper SDK built on this one, as a dict with non-empty string "name" and "version" keys and an optional "env" dict of string keys and values. Sent in the Auth0-Client header. An invalid value raises ConfigurationError at construction, and the "python" and "auth0-api-python" entries in env are set by this SDK. """ def __init__( self, @@ -68,6 +69,7 @@ def __init__( organization_policy: Literal["required", "allow"] = "allow", organization_id: Optional[Union[str, list[str]]] = None, scope_matching: str = "strict", + client_info: Optional[dict] = None, ): self.domain = domain self.domains = domains @@ -87,3 +89,4 @@ def __init__( self.organization_policy = organization_policy self.organization_id = organization_id self.scope_matching = scope_matching + self.client_info = client_info diff --git a/src/auth0_api_python/telemetry.py b/src/auth0_api_python/telemetry.py new file mode 100644 index 0000000..b862612 --- /dev/null +++ b/src/auth0_api_python/telemetry.py @@ -0,0 +1,61 @@ +""" +Client telemetry headers sent on requests this SDK makes to Auth0 with its default HTTP client. +""" + +import base64 +import importlib.metadata +import json +import platform +from typing import Optional + +_PACKAGE_NAME = "auth0-api-python" + + +def _package_version() -> str: + try: + return importlib.metadata.version(_PACKAGE_NAME) + except importlib.metadata.PackageNotFoundError: + return "unknown" + + +def validate_client_info(client_info: dict) -> None: + """ + Check the shape of a wrapper's client_info. + + Raises: + ValueError: If client_info is not a dict with non-empty string name and version, and an optional env dict of strings. + """ + if not isinstance(client_info, dict): + raise ValueError("client_info must be a dict") + for key in ("name", "version"): + value = client_info.get(key) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"client_info['{key}'] must be a non-empty string") + env = client_info.get("env", {}) + if not isinstance(env, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in env.items()): + raise ValueError("client_info['env'] must be a dict of string keys and string values") + + +def build_telemetry_headers(client_info: Optional[dict] = None) -> dict[str, str]: + """ + Build the Auth0-Client and User-Agent headers. + + Args: + client_info: Name, version and optional env of a wrapper SDK built on this one. + + Returns: + Headers to attach to each HTTP request. + """ + core_version = _package_version() + python_version = platform.python_version() + + if client_info is None: + payload = {"name": _PACKAGE_NAME, "version": core_version, "env": {"python": python_version}} + else: + env = {**client_info.get("env", {}), "python": python_version, _PACKAGE_NAME: core_version} + payload = {"name": client_info["name"], "version": client_info["version"], "env": env} + + return { + "Auth0-Client": base64.b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8"), + "User-Agent": f"Python/{python_version}", + } diff --git a/src/auth0_api_python/utils.py b/src/auth0_api_python/utils.py index cb18750..a3c63e0 100644 --- a/src/auth0_api_python/utils.py +++ b/src/auth0_api_python/utils.py @@ -104,11 +104,17 @@ def is_auth_server_outage(error: Exception) -> bool: async def fetch_oidc_metadata( domain: str, - custom_fetch: Optional[Callable[..., Any]] = None + custom_fetch: Optional[Callable[..., Any]] = None, + headers: Optional[dict[str, str]] = None ) -> tuple[dict[str, Any], Optional[int]]: """ Asynchronously fetch the OIDC config from https://{domain}/.well-known/openid-configuration. + Args: + domain: The Auth0 domain. + custom_fetch: Optional callable that replaces the default HTTP fetch. + headers: Headers for the default HTTP client. Not applied when custom_fetch is set. + Returns: Tuple of (metadata_dict, max_age_or_none). max_age is parsed from the Cache-Control response header if present. @@ -123,7 +129,7 @@ async def fetch_oidc_metadata( return response, None else: async with httpx.AsyncClient() as client: - resp = await client.get(url) + resp = await client.get(url, headers=headers) resp.raise_for_status() max_age = parse_cache_control_max_age(resp.headers) return resp.json(), max_age @@ -131,11 +137,17 @@ async def fetch_oidc_metadata( async def fetch_jwks( jwks_uri: str, - custom_fetch: Optional[Callable[..., Any]] = None + custom_fetch: Optional[Callable[..., Any]] = None, + headers: Optional[dict[str, str]] = None ) -> tuple[dict[str, Any], Optional[int]]: """ Asynchronously fetch the JSON Web Key Set from jwks_uri. + Args: + jwks_uri: The JWKS endpoint URL. + custom_fetch: Optional callable that replaces the default HTTP fetch. + headers: Headers for the default HTTP client. Not applied when custom_fetch is set. + Returns: Tuple of (jwks_dict, max_age_or_none). max_age is parsed from the Cache-Control response header if present. @@ -149,7 +161,7 @@ async def fetch_jwks( return response, None else: async with httpx.AsyncClient() as client: - resp = await client.get(jwks_uri) + resp = await client.get(jwks_uri, headers=headers) resp.raise_for_status() max_age = parse_cache_control_max_age(resp.headers) return resp.json(), max_age diff --git a/tests/conftest.py b/tests/conftest.py index 57943d9..d872400 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -218,6 +218,17 @@ def assert_http_basic_auth(httpx_mock: HTTPXMock, username: str, password: str): assert decoded == expected, f"Expected '{expected}', got '{decoded}'" +def auth0_client_payload(request) -> dict: + """Decode the Auth0-Client header of a recorded request.""" + return json.loads(base64.b64decode(request.headers["Auth0-Client"])) + + +def assert_telemetry_headers(request, name: str = "auth0-api-python"): + """Assert a recorded request carries both telemetry headers.""" + assert auth0_client_payload(request)["name"] == name + assert request.headers["User-Agent"].startswith("Python/") + + def assert_form_post( httpx_mock: HTTPXMock, *, diff --git a/tests/test_api_client.py b/tests/test_api_client.py index d5d7a36..35bc973 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -17,6 +17,7 @@ assert_api_error, assert_form_post, assert_no_requests, + assert_telemetry_headers, last_form, token_success, unsigned_jwt, @@ -638,6 +639,24 @@ async def test_verify_access_token_jwks_fetch_failure_status(httpx_mock: HTTPXMo assert err.value.get_error_code() == ("auth_server_unavailable" if unavailable else "invalid_token") +@pytest.mark.asyncio +async def test_discovery_and_jwks_requests_send_telemetry_header(httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="GET", + url=DISCOVERY_URL, + json={"issuer": "https://auth0.local/", "jwks_uri": JWKS_URL} + ) + httpx_mock.add_response(method="GET", url=JWKS_URL, json={"keys": [PUBLIC_RSA_JWK]}) + access_token = await generate_token(domain="auth0.local", user_id="user_123", audience="my-audience") + api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) + + await api_client.verify_access_token(access_token) + + discovery, jwks = httpx_mock.get_requests() + assert_telemetry_headers(discovery) + assert_telemetry_headers(jwks) + + @pytest.mark.asyncio async def test_verify_access_token_custom_fetch_failure_is_unavailable(): """A custom_fetch that raises is reported as an unavailable auth server.""" @@ -669,6 +688,36 @@ async def broken_fetch(url, **kwargs): assert err.value.get_status_code() == 401 +@pytest.mark.asyncio +@pytest.mark.parametrize("wrap", [True, False], ids=["response-object", "plain-dict"]) +async def test_custom_fetch_gets_url_only_and_token_requests_keep_telemetry(wrap, httpx_mock: HTTPXMock): + """custom_fetch serves discovery and JWKS with the URL only, while token requests still send telemetry.""" + calls = [] + metadata = {"issuer": "https://auth0.local/", "jwks_uri": JWKS_URL, "token_endpoint": TOKEN_ENDPOINT} + + async def fetch(url): # no **kwargs, so a headers argument would raise TypeError + calls.append(url) + body = metadata if url == DISCOVERY_URL else {"keys": [PUBLIC_RSA_JWK]} + return httpx.Response(200, json=body) if wrap else body + + api_client = ApiClient(ApiClientOptions( + domain="auth0.local", + audience="my-audience", + client_id="cid", + client_secret="csecret", + custom_fetch=fetch, + )) + access_token = await generate_token(domain="auth0.local", user_id="user_123", audience="my-audience") + + claims = await api_client.verify_access_token(access_token) + assert claims["sub"] == "user_123" + assert calls == [DISCOVERY_URL, JWKS_URL] + + httpx_mock.add_response(method="POST", url=TOKEN_ENDPOINT, json=token_success()) + await api_client.get_client_credentials_token(audience="https://billing-api.example.com") + assert_telemetry_headers(httpx_mock.get_requests()[-1]) + + @pytest.mark.asyncio async def test_verify_access_token_bad_signature_stays_invalid_token(httpx_mock: HTTPXMock): """A reachable tenant with a token it did not sign still fails as a 401 invalid token.""" @@ -2698,6 +2747,18 @@ async def test_get_access_token_for_connection_with_login_hint(httpx_mock: HTTPX form_data = last_form(httpx_mock) assert form_data["login_hint"] == ["user@example.com"] +@pytest.mark.asyncio +async def test_get_access_token_for_connection_sends_telemetry_header(mock_discovery, api_client_confidential_no_store, httpx_mock): + httpx_mock.add_response(method="POST", url=TOKEN_ENDPOINT, json=token_success()) + + await api_client_confidential_no_store.get_access_token_for_connection({ + "connection": "test-conn", + "access_token": "user-token" + }) + + assert_telemetry_headers(httpx_mock.get_requests()[-1]) + + @pytest.mark.asyncio async def test_get_access_token_for_connection_missing_connection(): options = ApiClientOptions( @@ -3032,6 +3093,19 @@ async def test_get_token_by_exchange_profile_success(httpx_mock: HTTPXMock): assert form_data["audience"] == ["https://api.example.com"] +@pytest.mark.asyncio +async def test_get_token_by_exchange_profile_sends_telemetry_header(mock_discovery, api_client_confidential, httpx_mock): + httpx_mock.add_response(method="POST", url=TOKEN_ENDPOINT, json=token_success()) + + await api_client_confidential.get_token_by_exchange_profile( + subject_token="custom-token-123", + subject_token_type="urn:example:custom-token", + audience="https://api.example.com", + ) + + assert_telemetry_headers(httpx_mock.get_requests()[-1]) + + @freeze_time("2025-01-01T00:00:00Z") @pytest.mark.asyncio async def test_sets_expires_at(mock_discovery, api_client_confidential, httpx_mock): @@ -3832,6 +3906,18 @@ async def test_get_token_on_behalf_of_success(mock_discovery, api_client_confide ) +@pytest.mark.asyncio +async def test_get_token_on_behalf_of_sends_telemetry_header(mock_discovery, api_client_confidential_no_store, httpx_mock): + httpx_mock.add_response(method="POST", url=TOKEN_ENDPOINT, json=token_success()) + + await api_client_confidential_no_store.get_token_on_behalf_of( + access_token="incoming-access-token", + audience="https://api.backend.com", + ) + + assert_telemetry_headers(httpx_mock.get_requests()[-1]) + + @pytest.mark.asyncio async def test_get_token_on_behalf_of_preserves_inferred_expires_at( api_client_confidential_no_store, @@ -4002,6 +4088,15 @@ async def test_get_client_credentials_token_success(mock_discovery, api_client_c ) +@pytest.mark.asyncio +async def test_get_client_credentials_token_sends_telemetry_header(mock_discovery, api_client_confidential, httpx_mock): + httpx_mock.add_response(method="POST", url=TOKEN_ENDPOINT, json=token_success()) + + await api_client_confidential.get_client_credentials_token(audience="https://billing-api.example.com") + + assert_telemetry_headers(httpx_mock.get_requests()[-1]) + + @pytest.mark.asyncio async def test_get_client_credentials_token_missing_credentials(): """Test that M2M raises GetClientCredentialsTokenError when credentials are not configured.""" diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 0000000..5ce5975 --- /dev/null +++ b/tests/test_telemetry.py @@ -0,0 +1,137 @@ +""" +Tests for the Auth0-Client telemetry header built in auth0_api_python.telemetry +""" + +import base64 +import importlib.metadata +import json +import platform +import re + +import pytest +from conftest import TOKEN_ENDPOINT, auth0_client_payload, token_success +from pytest_httpx import HTTPXMock + +from auth0_api_python import ApiClient, ApiClientOptions +from auth0_api_python.errors import ConfigurationError +from auth0_api_python.telemetry import build_telemetry_headers, validate_client_info + +WRAPPER = {"name": "auth0-ai-fastmcp", "version": "1.2.3", "env": {"auth0-ai-mcp": "4.5.6"}} + + +@pytest.fixture +def core_version(monkeypatch): + monkeypatch.setattr(importlib.metadata, "version", lambda name: "9.9.9") + return "9.9.9" + + +def _decode(headers: dict) -> dict: + return json.loads(base64.b64decode(headers["Auth0-Client"])) + + +# ===== build_telemetry_headers ===== + + +def test_default_headers_identify_this_sdk(core_version): + headers = build_telemetry_headers() + + assert _decode(headers) == { + "name": "auth0-api-python", + "version": core_version, + "env": {"python": platform.python_version()}, + } + assert headers["User-Agent"] == f"Python/{platform.python_version()}" + + +def test_version_is_unknown_when_package_is_not_installed(monkeypatch): + def missing(name): + raise importlib.metadata.PackageNotFoundError(name) + + monkeypatch.setattr(importlib.metadata, "version", missing) + + assert _decode(build_telemetry_headers())["version"] == "unknown" + + +def test_client_info_is_reported_as_the_client_and_this_sdk_is_listed_in_env(core_version): + payload = _decode(build_telemetry_headers(WRAPPER)) + + assert payload["name"] == "auth0-ai-fastmcp" + assert payload["version"] == "1.2.3" + assert payload["env"] == { + "auth0-ai-mcp": "4.5.6", + "python": platform.python_version(), + "auth0-api-python": core_version, + } + + +def test_client_info_env_cannot_replace_this_sdk_or_python_entries(core_version): + payload = _decode(build_telemetry_headers({**WRAPPER, "env": {"auth0-api-python": "0.0.0", "python": "0.0"}})) + + assert payload["env"]["auth0-api-python"] == core_version + assert payload["env"]["python"] == platform.python_version() + + +def test_client_info_without_env_is_accepted(core_version): + payload = _decode(build_telemetry_headers({"name": "wrapper", "version": "1.0.0"})) + + assert payload["env"]["auth0-api-python"] == core_version + + +# ===== validate_client_info ===== + + +NOT_A_DICT = "client_info must be a dict" +BAD_NAME = "client_info['name'] must be a non-empty string" +BAD_VERSION = "client_info['version'] must be a non-empty string" +BAD_ENV = "client_info['env'] must be a dict of string keys and string values" + +INVALID_CLIENT_INFO = [ + ("wrapper", NOT_A_DICT), + ([], NOT_A_DICT), + ({}, BAD_NAME), + ({"version": "1.0.0"}, BAD_NAME), + ({"name": "", "version": "1.0.0"}, BAD_NAME), + ({"name": 1, "version": "1.0.0"}, BAD_NAME), + ({"name": "wrapper"}, BAD_VERSION), + ({"name": "wrapper", "version": " "}, BAD_VERSION), + ({"name": "wrapper", "version": "1.0.0", "env": "python"}, BAD_ENV), + ({"name": "wrapper", "version": "1.0.0", "env": None}, BAD_ENV), + ({"name": "wrapper", "version": "1.0.0", "env": {"python": 3}}, BAD_ENV), + ({"name": "wrapper", "version": "1.0.0", "env": {1: "x"}}, BAD_ENV), +] + + +@pytest.mark.parametrize(("client_info", "message"), INVALID_CLIENT_INFO) +def test_validate_client_info_rejects_malformed_input(client_info, message): + with pytest.raises(ValueError, match=re.escape(message)): + validate_client_info(client_info) + + +@pytest.mark.parametrize(("client_info", "message"), INVALID_CLIENT_INFO) +def test_api_client_rejects_malformed_client_info(client_info, message): + with pytest.raises(ConfigurationError, match=re.escape(message)): + ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience", client_info=client_info)) + + +# ===== ApiClientOptions.client_info ===== + + +@pytest.mark.asyncio +async def test_client_info_reaches_the_token_endpoint(core_version, mock_discovery, httpx_mock: HTTPXMock): + httpx_mock.add_response(method="POST", url=TOKEN_ENDPOINT, json=token_success()) + api_client = ApiClient(ApiClientOptions( + domain="auth0.local", + audience="my-audience", + client_id="cid", + client_secret="csecret", + client_info=WRAPPER, + )) + + await api_client.get_client_credentials_token(audience="https://billing-api.example.com") + + request = httpx_mock.get_requests()[-1] + payload = auth0_client_payload(request) + assert request.headers["User-Agent"] == f"Python/{platform.python_version()}" + assert payload["name"] == "auth0-ai-fastmcp" + assert payload["version"] == "1.2.3" + assert payload["env"]["auth0-api-python"] == core_version