Skip to content

Commit 67694bc

Browse files
feat: add Auth0-Client telemetry header (#103)
* feat: add Auth0-Client telemetry header to all HTTP requests Send an Auth0-Client header (base64-encoded JSON with SDK name, version, and Python runtime) on every request to Auth0 endpoints. This follows the standard Auth0 SDK telemetry convention. - Add Telemetry class in telemetry.py for building and caching headers - Add _get_http_client() helper to ServerClient, MyAccountClient, and MfaClient to inject telemetry headers into all httpx requests - Pass telemetry headers to AsyncOAuth2Client for token exchange calls - Add unit tests for telemetry header format and integration * fix: use Optional for Python 3.9 compatibility Replace `from __future__ import annotations` with `Optional[dict[str, str]]` syntax for the headers parameter in telemetry.py, mfa_client.py, and my_account_client.py. This avoids triggering lint warnings on existing Optional[X] annotations while maintaining Python 3.9 compatibility. * fix: address review feedback for telemetry PR * fix: address PR review feedback for telemetry - Build headers eagerly in Telemetry.__init__ instead of lazy caching - Narrow exception catch to PackageNotFoundError in Telemetry.default() - Reverse header merge order so telemetry headers cannot be overwritten - Fix resource leak in test by closing httpx.AsyncClient - Replace mock-based OIDC test with direct header assertion - Add test for AsyncOAuth2Client telemetry header propagation - Add tests for MFA client header propagation and merge behavior
1 parent 344ff66 commit 67694bc

5 files changed

Lines changed: 219 additions & 21 deletions

File tree

src/auth0_server_python/auth_server/mfa_client.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ def __init__(
5959
client_secret: str,
6060
secret: str,
6161
state_store=None,
62-
state_identifier: str = "_a0_session"
62+
state_identifier: str = "_a0_session",
63+
headers: Optional[dict[str, str]] = None
6364
):
6465
if callable(domain):
6566
self._domain = None
@@ -72,6 +73,12 @@ def __init__(
7273
self._secret = secret
7374
self._state_store = state_store
7475
self._state_identifier = state_identifier
76+
self._headers = headers or {}
77+
78+
def _get_http_client(self, **kwargs) -> httpx.AsyncClient:
79+
"""Return an httpx.AsyncClient with default headers injected."""
80+
headers = {**kwargs.pop("headers", {}), **self._headers}
81+
return httpx.AsyncClient(headers=headers, **kwargs)
7582

7683
async def _resolve_base_url(
7784
self,
@@ -157,7 +164,7 @@ async def list_authenticators(
157164
url = f"{base_url}/mfa/authenticators"
158165

159166
try:
160-
async with httpx.AsyncClient() as client:
167+
async with self._get_http_client() as client:
161168
response = await client.get(
162169
url,
163170
auth=BearerAuth(mfa_token)
@@ -232,7 +239,7 @@ async def enroll_authenticator(
232239
body["email"] = options["email"]
233240

234241
try:
235-
async with httpx.AsyncClient() as client:
242+
async with self._get_http_client() as client:
236243
response = await client.post(
237244
url,
238245
json=body,
@@ -311,7 +318,7 @@ async def challenge_authenticator(
311318
body["authenticator_id"] = options["authenticator_id"]
312319

313320
try:
314-
async with httpx.AsyncClient() as client:
321+
async with self._get_http_client() as client:
315322
response = await client.post(
316323
url,
317324
json=body,
@@ -395,7 +402,7 @@ async def verify(
395402
base_url = await self._resolve_base_url(store_options)
396403
token_endpoint = f"{base_url}/oauth/token"
397404

398-
async with httpx.AsyncClient() as client:
405+
async with self._get_http_client() as client:
399406
response = await client.post(
400407
token_endpoint,
401408
data=body,

src/auth0_server_python/auth_server/my_account_client.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,21 @@ class MyAccountClient:
2525
Client for interacting with the Auth0 MyAccount API.
2626
"""
2727

28-
def __init__(self, domain: str):
28+
def __init__(self, domain: str, headers: Optional[dict[str, str]] = None):
2929
"""
3030
Initialize the MyAccount API client.
3131
3232
Args:
3333
domain: Auth0 domain (e.g., '<tenant>.<locality>.auth0.com')
34+
headers: Optional default headers to include on every request
3435
"""
3536
self._domain = domain
37+
self._headers = headers or {}
38+
39+
def _get_http_client(self, **kwargs) -> httpx.AsyncClient:
40+
"""Return an httpx.AsyncClient with default headers injected."""
41+
headers = {**kwargs.pop("headers", {}), **self._headers}
42+
return httpx.AsyncClient(headers=headers, **kwargs)
3643

3744
@property
3845
def audience(self):
@@ -64,7 +71,7 @@ async def connect_account(
6471
ApiError: If the request fails due to network or other issues
6572
"""
6673
try:
67-
async with httpx.AsyncClient() as client:
74+
async with self._get_http_client() as client:
6875
response = await client.post(
6976
url=f"{self.audience}v1/connected-accounts/connect",
7077
json=request.model_dump(exclude_none=True),
@@ -114,7 +121,7 @@ async def complete_connect_account(
114121
ApiError: If the request fails due to network or other issues
115122
"""
116123
try:
117-
async with httpx.AsyncClient() as client:
124+
async with self._get_http_client() as client:
118125
response = await client.post(
119126
url=f"{self.audience}v1/connected-accounts/complete",
120127
json=request.model_dump(exclude_none=True),
@@ -176,7 +183,7 @@ async def list_connected_accounts(
176183
raise InvalidArgumentError("take", "The 'take' parameter must be a positive integer.")
177184

178185
try:
179-
async with httpx.AsyncClient() as client:
186+
async with self._get_http_client() as client:
180187
params = {}
181188
if connection:
182189
params["connection"] = connection
@@ -243,7 +250,7 @@ async def delete_connected_account(
243250
raise MissingRequiredArgumentError("connected_account_id")
244251

245252
try:
246-
async with httpx.AsyncClient() as client:
253+
async with self._get_http_client() as client:
247254
response = await client.delete(
248255
url=f"{self.audience}v1/connected-accounts/accounts/{connected_account_id}",
249256
auth=BearerAuth(access_token)
@@ -298,7 +305,7 @@ async def list_connected_account_connections(
298305
raise InvalidArgumentError("take", "The 'take' parameter must be a positive integer.")
299306

300307
try:
301-
async with httpx.AsyncClient() as client:
308+
async with self._get_http_client() as client:
302309
params = {}
303310
if from_param:
304311
params["from"] = from_param

src/auth0_server_python/auth_server/server_client.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
PollingApiError,
5858
StartLinkUserError,
5959
)
60+
from auth0_server_python.telemetry import Telemetry
6061
from auth0_server_python.utils import PKCE, URL, State
6162
from auth0_server_python.utils.helpers import (
6263
build_domain_resolver_context,
@@ -152,13 +153,20 @@ def __init__(
152153
self._transaction_identifier = transaction_identifier
153154
self._state_identifier = state_identifier
154155

156+
# Initialize telemetry
157+
self._telemetry = Telemetry.default()
158+
self._telemetry_headers = self._telemetry.headers
159+
155160
# Initialize OAuth client
156161
self._oauth = AsyncOAuth2Client(
157162
client_id=client_id,
158163
client_secret=client_secret,
164+
headers=self._telemetry_headers,
159165
)
160166

161-
self._my_account_client = MyAccountClient(domain=domain)
167+
self._my_account_client = MyAccountClient(
168+
domain=domain, headers=self._telemetry_headers
169+
)
162170

163171
# Unified cache for OIDC metadata and JWKS per domain (LRU eviction + TTL)
164172
self._discovery_cache: OrderedDict[str, dict] = OrderedDict()
@@ -172,9 +180,15 @@ def __init__(
172180
client_secret=self._client_secret,
173181
secret=self._secret,
174182
state_store=self._state_store,
175-
state_identifier=self._state_identifier
183+
state_identifier=self._state_identifier,
184+
headers=self._telemetry_headers,
176185
)
177186

187+
def _get_http_client(self, **kwargs) -> httpx.AsyncClient:
188+
"""Return an httpx.AsyncClient with telemetry headers injected."""
189+
headers = {**kwargs.pop("headers", {}), **self._telemetry_headers}
190+
return httpx.AsyncClient(headers=headers, **kwargs)
191+
178192
def _normalize_url(self, value: str) -> str:
179193
"""
180194
Normalize a URL-like value (domain or issuer) for comparison.
@@ -281,7 +295,7 @@ async def _fetch_oidc_metadata(self, domain: str) -> dict:
281295
"""Fetch OIDC metadata from domain."""
282296
normalized_domain = self._normalize_url(domain)
283297
metadata_url = f"{normalized_domain}/.well-known/openid-configuration"
284-
async with httpx.AsyncClient() as client:
298+
async with self._get_http_client() as client:
285299
response = await client.get(metadata_url)
286300
response.raise_for_status()
287301
return response.json()
@@ -352,7 +366,7 @@ async def _fetch_jwks(self, jwks_uri: str) -> dict:
352366
ApiError: If JWKS fetch fails
353367
"""
354368
try:
355-
async with httpx.AsyncClient() as client:
369+
async with self._get_http_client() as client:
356370
response = await client.get(jwks_uri)
357371
response.raise_for_status()
358372
return response.json()
@@ -516,7 +530,7 @@ async def start_interactive_login(
516530

517531
auth_params["client_id"] = self._client_id
518532
# Post the auth_params to the PAR endpoint
519-
async with httpx.AsyncClient() as client:
533+
async with self._get_http_client() as client:
520534
par_response = await client.post(
521535
par_endpoint,
522536
data=auth_params,
@@ -1077,7 +1091,7 @@ async def get_token_by_refresh_token(self, options: dict[str, Any]) -> dict[str,
10771091
token_params["scope"] = merged_scope
10781092

10791093
# Exchange the refresh token for an access token
1080-
async with httpx.AsyncClient() as client:
1094+
async with self._get_http_client() as client:
10811095
response = await client.post(
10821096
token_endpoint,
10831097
data=token_params,
@@ -1391,7 +1405,7 @@ async def initiate_backchannel_authentication(
13911405
params.update(authorization_params)
13921406

13931407
# Make the backchannel authentication request
1394-
async with httpx.AsyncClient() as client:
1408+
async with self._get_http_client() as client:
13951409
backchannel_response = await client.post(
13961410
backchannel_endpoint,
13971411
data=params,
@@ -1466,7 +1480,7 @@ async def backchannel_authentication_grant(
14661480
}
14671481

14681482
# Exchange the auth_req_id for an access token
1469-
async with httpx.AsyncClient() as client:
1483+
async with self._get_http_client() as client:
14701484
response = await client.post(
14711485
token_endpoint,
14721486
data=token_params,
@@ -1918,7 +1932,7 @@ async def get_token_for_connection(self, options: dict[str, Any]) -> dict[str, A
19181932
params["login_hint"] = options["login_hint"]
19191933

19201934
# Make the request
1921-
async with httpx.AsyncClient() as client:
1935+
async with self._get_http_client() as client:
19221936
response = await client.post(
19231937
token_endpoint,
19241938
data=params,
@@ -2272,7 +2286,7 @@ async def custom_token_exchange(
22722286
params[key] = value
22732287

22742288
# Make the token exchange request
2275-
async with httpx.AsyncClient() as client:
2289+
async with self._get_http_client() as client:
22762290
response = await client.post(
22772291
token_endpoint,
22782292
data=params,
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""
2+
Telemetry support for auth0-server-python SDK.
3+
4+
Builds and caches the Auth0-Client and User-Agent headers sent
5+
on every HTTP request to Auth0 endpoints.
6+
"""
7+
8+
import base64
9+
import importlib.metadata
10+
import json
11+
import platform
12+
from typing import Optional
13+
14+
15+
class Telemetry:
16+
"""Builds telemetry headers for Auth0 HTTP requests."""
17+
18+
_PACKAGE_NAME = "auth0-server-python"
19+
20+
def __init__(self, name: str, version: str, env: Optional[dict[str, str]] = None):
21+
self.name = name
22+
self.version = version
23+
self.env = env if env is not None else {"python": platform.python_version()}
24+
payload = {"name": self.name, "version": self.version, "env": self.env}
25+
self.headers: dict[str, str] = {
26+
"Auth0-Client": base64.b64encode(
27+
json.dumps(payload).encode("utf-8")
28+
).decode("utf-8"),
29+
"User-Agent": f"Python/{platform.python_version()}",
30+
}
31+
32+
@staticmethod
33+
def default() -> "Telemetry":
34+
"""Create a Telemetry instance with this SDK's package metadata."""
35+
try:
36+
version = importlib.metadata.version(Telemetry._PACKAGE_NAME)
37+
except importlib.metadata.PackageNotFoundError:
38+
version = "unknown"
39+
return Telemetry(name=Telemetry._PACKAGE_NAME, version=version)

0 commit comments

Comments
 (0)