Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>`) 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="<AUTH0_DOMAIN>",
audience="<AUTH0_AUDIENCE>",
client_info={"name": "my-wrapper-sdk", "version": "1.2.3", "env": {"my-framework": "4.5.6"}},
))
```

## Feedback

### Contributing
Expand Down
23 changes: 18 additions & 5 deletions src/auth0_api_python/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
OrganizationNotAllowedError,
VerifyAccessTokenError,
)
from .telemetry import build_telemetry_headers, validate_client_info
from .token_store import (
IndexedTokenStore,
VerifiedToken,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/auth0_api_python/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
61 changes: 61 additions & 0 deletions src/auth0_api_python/telemetry.py
Original file line number Diff line number Diff line change
@@ -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}",
}
20 changes: 16 additions & 4 deletions src/auth0_api_python/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -123,19 +129,25 @@ 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


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.
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand Down
Loading
Loading