Skip to content

Commit 10c6a76

Browse files
feat: Add client_info support for custom telemetry in Authentication and Management clients (#802)
### Changes - Add optional `client_info` dict param to `RestClientOptions` to override the default telemetry payload - Use provided `client_info` as the `Auth0-Client` header value in `RestClient` when telemetry is enabled - Thread `client_info` param through `AuthenticationBase` constructor to `RestClientOptions` - Add `client_info` param to `ManagementClient` - encodes and merges into headers dict - Add `client_info` param to `AsyncManagementClient` with identical behavior - `telemetry=False` still takes precedence and suppresses all telemetry headers - `User-Agent` header remains `Python/{version}` regardless of `client_info` - No changes to any Fern-generated files (`client.py`, `client_wrapper.py`, `http_client.py`) ### Usage ```python # Authentication from auth0.authentication import GetToken client = GetToken( "tenant.auth0.com", client_id="YOUR_CLIENT_ID", client_info={"name": "auth0-ai-langchain", "version": "1.0.0", "env": {"python": "3.11.0"}}, ) # Management from auth0.management import ManagementClient client = ManagementClient( domain="tenant.auth0.com", token="YOUR_TOKEN", client_info={"name": "auth0-ai-langchain", "version": "1.0.0", "env": {"python": "3.11.0"}}, ) ```
1 parent e506d73 commit 10c6a76

5 files changed

Lines changed: 152 additions & 11 deletions

File tree

src/auth0/authentication/base.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@
22

33
from typing import Any
44

5+
from .client_authentication import add_client_authentication
56
from .rest import RestClient, RestClientOptions
67
from .types import RequestData, TimeoutType
78

8-
from .client_authentication import add_client_authentication
9-
109
UNKNOWN_ERROR = "a0.sdk.internal.unknown"
1110

1211

@@ -22,6 +21,9 @@ class AuthenticationBase:
2221
telemetry (bool, optional): Enable or disable telemetry (defaults to True)
2322
timeout (float or tuple, optional): Change the requests connect and read timeout. Pass a tuple to specify both values separately or a float to set both to it. (defaults to 5.0 for both)
2423
protocol (str, optional): Useful for testing. (defaults to 'https')
24+
client_info (dict, optional): Custom telemetry data for the Auth0-Client header.
25+
When provided, overrides the default SDK telemetry. Useful for wrapper
26+
SDKs that need to identify themselves. Ignored when telemetry is False.
2527
"""
2628

2729
def __init__(
@@ -34,6 +36,7 @@ def __init__(
3436
telemetry: bool = True,
3537
timeout: TimeoutType = 5.0,
3638
protocol: str = "https",
39+
client_info: dict[str, Any] | None = None,
3740
) -> None:
3841
self.domain = domain
3942
self.client_id = client_id
@@ -43,7 +46,9 @@ def __init__(
4346
self.protocol = protocol
4447
self.client = RestClient(
4548
None,
46-
options=RestClientOptions(telemetry=telemetry, timeout=timeout, retries=0),
49+
options=RestClientOptions(
50+
telemetry=telemetry, timeout=timeout, retries=0, client_info=client_info
51+
),
4752
)
4853

4954
def _add_client_authentication(self, payload: dict[str, Any]) -> dict[str, Any]:

src/auth0/authentication/rest.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from urllib.parse import urlencode
1111

1212
import requests
13-
1413
from .exceptions import Auth0Error, RateLimitError
1514
from .types import RequestData, TimeoutType
1615

@@ -38,17 +37,26 @@ class RestClientOptions:
3837
times using an exponential backoff strategy, before
3938
raising a RateLimitError exception. 10 retries max.
4039
(defaults to 3)
40+
client_info (dict, optional): Custom telemetry data to send
41+
in the Auth0-Client header instead of the default SDK
42+
info. Useful for wrapper SDKs that need to identify
43+
themselves. When provided, this dict is JSON-encoded
44+
and base64-encoded as the header value. Ignored when
45+
telemetry is False.
46+
(defaults to None)
4147
"""
4248

4349
def __init__(
4450
self,
4551
telemetry: bool = True,
4652
timeout: TimeoutType = 5.0,
4753
retries: int = 3,
54+
client_info: dict[str, Any] | None = None,
4855
) -> None:
4956
self.telemetry = telemetry
5057
self.timeout = timeout
5158
self.retries = retries
59+
self.client_info = client_info
5260

5361

5462
class RestClient:
@@ -94,17 +102,20 @@ def __init__(
94102

95103
if options.telemetry:
96104
py_version = platform.python_version()
97-
version = sys.modules["auth0"].__version__
98105

99-
auth0_client = dumps(
100-
{
106+
if options.client_info is not None:
107+
auth0_client_dict = options.client_info
108+
else:
109+
version = sys.modules["auth0"].__version__
110+
auth0_client_dict = {
101111
"name": "auth0-python",
102112
"version": version,
103113
"env": {
104114
"python": py_version,
105115
},
106116
}
107-
).encode("utf-8")
117+
118+
auth0_client = dumps(auth0_client_dict).encode("utf-8")
108119

109120
self.base_headers.update(
110121
{

src/auth0/management/management_client.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Callable, Dict, Optional, Union
3+
import base64
4+
from json import dumps
5+
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union
46

57
import httpx
68
from .client import AsyncAuth0, Auth0
@@ -86,6 +88,10 @@ class ManagementClient:
8688
The API audience. Defaults to https://{domain}/api/v2/
8789
headers : Optional[Dict[str, str]]
8890
Additional headers to send with requests.
91+
client_info : Optional[Dict[str, Any]]
92+
Custom telemetry data for the Auth0-Client header. When provided,
93+
overrides the default SDK telemetry. Useful for wrapper SDKs that
94+
need to identify themselves (e.g., ``{"name": "my-sdk", "version": "1.0.0"}``).
8995
timeout : Optional[float]
9096
Request timeout in seconds. Defaults to 60.
9197
httpx_client : Optional[httpx.Client]
@@ -106,6 +112,7 @@ def __init__(
106112
client_secret: Optional[str] = None,
107113
audience: Optional[str] = None,
108114
headers: Optional[Dict[str, str]] = None,
115+
client_info: Optional[Dict[str, Any]] = None,
109116
timeout: Optional[float] = None,
110117
httpx_client: Optional[httpx.Client] = None,
111118
):
@@ -128,6 +135,13 @@ def __init__(
128135
else:
129136
resolved_token = token # type: ignore[assignment]
130137

138+
# Encode client_info into Auth0-Client header to override default telemetry
139+
if client_info is not None:
140+
encoded = base64.b64encode(
141+
dumps(client_info).encode("utf-8")
142+
).decode()
143+
headers = {**(headers or {}), "Auth0-Client": encoded}
144+
131145
# Create underlying client
132146
self._api = Auth0(
133147
base_url=f"https://{domain}/api/v2",
@@ -333,6 +347,10 @@ class AsyncManagementClient:
333347
The API audience. Defaults to https://{domain}/api/v2/
334348
headers : Optional[Dict[str, str]]
335349
Additional headers to send with requests.
350+
client_info : Optional[Dict[str, Any]]
351+
Custom telemetry data for the Auth0-Client header. When provided,
352+
overrides the default SDK telemetry. Useful for wrapper SDKs that
353+
need to identify themselves (e.g., ``{"name": "my-sdk", "version": "1.0.0"}``).
336354
timeout : Optional[float]
337355
Request timeout in seconds. Defaults to 60.
338356
httpx_client : Optional[httpx.AsyncClient]
@@ -353,6 +371,7 @@ def __init__(
353371
client_secret: Optional[str] = None,
354372
audience: Optional[str] = None,
355373
headers: Optional[Dict[str, str]] = None,
374+
client_info: Optional[Dict[str, Any]] = None,
356375
timeout: Optional[float] = None,
357376
httpx_client: Optional[httpx.AsyncClient] = None,
358377
):
@@ -378,6 +397,13 @@ def __init__(
378397
else:
379398
resolved_token = token # type: ignore[assignment]
380399

400+
# Encode client_info into Auth0-Client header to override default telemetry
401+
if client_info is not None:
402+
encoded = base64.b64encode(
403+
dumps(client_info).encode("utf-8")
404+
).decode()
405+
headers = {**(headers or {}), "Auth0-Client": encoded}
406+
381407
# Create underlying client
382408
self._api = AsyncAuth0(
383409
base_url=f"https://{domain}/api/v2",

tests/authentication/test_base.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
import unittest
55
from unittest import mock
66

7-
import requests
8-
97
from auth0.authentication.base import AuthenticationBase
108
from auth0.authentication.exceptions import Auth0Error, RateLimitError
119

@@ -42,6 +40,39 @@ def test_telemetry_disabled(self):
4240

4341
self.assertEqual(ab.client.base_headers, {"Content-Type": "application/json"})
4442

43+
def test_telemetry_with_custom_client_info(self):
44+
custom_info = {
45+
"name": "auth0-ai-langchain",
46+
"version": "1.0.0",
47+
"env": {"python": "3.11.0"},
48+
}
49+
ab = AuthenticationBase("auth0.com", "cid", client_info=custom_info)
50+
base_headers = ab.client.base_headers
51+
52+
auth0_client_bytes = base64.b64decode(base_headers["Auth0-Client"])
53+
auth0_client = json.loads(auth0_client_bytes.decode("utf-8"))
54+
55+
self.assertEqual(auth0_client, custom_info)
56+
57+
def test_telemetry_disabled_ignores_client_info(self):
58+
custom_info = {"name": "my-sdk", "version": "2.0.0"}
59+
ab = AuthenticationBase(
60+
"auth0.com", "cid", telemetry=False, client_info=custom_info
61+
)
62+
63+
self.assertNotIn("Auth0-Client", ab.client.base_headers)
64+
self.assertNotIn("User-Agent", ab.client.base_headers)
65+
66+
def test_custom_client_info_preserves_user_agent(self):
67+
custom_info = {"name": "my-sdk", "version": "1.0.0"}
68+
ab = AuthenticationBase("auth0.com", "cid", client_info=custom_info)
69+
base_headers = ab.client.base_headers
70+
71+
python_version = "{}.{}.{}".format(
72+
sys.version_info.major, sys.version_info.minor, sys.version_info.micro
73+
)
74+
self.assertEqual(base_headers["User-Agent"], f"Python/{python_version}")
75+
4576
@mock.patch("requests.request")
4677
def test_post(self, mock_request):
4778
ab = AuthenticationBase("auth0.com", "cid", telemetry=False, timeout=(10, 2))

tests/management/test_management_client.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import base64
2+
import json
13
import time
24
from unittest.mock import MagicMock, patch
35

@@ -78,6 +80,53 @@ def test_init_with_custom_headers(self):
7880
)
7981
assert client._api is not None
8082

83+
def test_init_with_custom_client_info(self):
84+
"""Should encode client_info as Auth0-Client header."""
85+
custom_info = {
86+
"name": "auth0-ai-langchain",
87+
"version": "1.0.0",
88+
"env": {"python": "3.11.0"},
89+
}
90+
client = ManagementClient(
91+
domain="test.auth0.com",
92+
token="my-token",
93+
client_info=custom_info,
94+
)
95+
# Verify the header was set on the underlying client wrapper
96+
custom_headers = client._api._client_wrapper.get_custom_headers()
97+
assert custom_headers is not None
98+
encoded_header = custom_headers.get("Auth0-Client")
99+
assert encoded_header is not None
100+
decoded = json.loads(base64.b64decode(encoded_header).decode("utf-8"))
101+
assert decoded == custom_info
102+
103+
def test_init_with_client_info_and_custom_headers(self):
104+
"""Should merge client_info with custom headers."""
105+
custom_info = {"name": "my-sdk", "version": "2.0.0"}
106+
client = ManagementClient(
107+
domain="test.auth0.com",
108+
token="my-token",
109+
headers={"X-Custom": "value"},
110+
client_info=custom_info,
111+
)
112+
custom_headers = client._api._client_wrapper.get_custom_headers()
113+
assert custom_headers is not None
114+
assert custom_headers.get("X-Custom") == "value"
115+
assert "Auth0-Client" in custom_headers
116+
117+
def test_init_without_client_info_uses_default_telemetry(self):
118+
"""Should use default auth0-python telemetry when client_info is not provided."""
119+
client = ManagementClient(
120+
domain="test.auth0.com",
121+
token="my-token",
122+
)
123+
# get_headers() includes the default Auth0-Client telemetry
124+
headers = client._api._client_wrapper.get_headers()
125+
encoded = headers.get("Auth0-Client")
126+
assert encoded is not None
127+
decoded = json.loads(base64.b64decode(encoded).decode("utf-8"))
128+
assert decoded["name"] == "auth0-python"
129+
81130

82131
class TestManagementClientProperties:
83132
"""Tests for ManagementClient sub-client properties."""
@@ -173,6 +222,25 @@ def test_init_requires_auth(self):
173222
with pytest.raises(ValueError):
174223
AsyncManagementClient(domain="test.auth0.com")
175224

225+
def test_init_with_custom_client_info(self):
226+
"""Should encode client_info as Auth0-Client header."""
227+
custom_info = {
228+
"name": "auth0-ai-langchain",
229+
"version": "1.0.0",
230+
"env": {"python": "3.11.0"},
231+
}
232+
client = AsyncManagementClient(
233+
domain="test.auth0.com",
234+
token="my-token",
235+
client_info=custom_info,
236+
)
237+
custom_headers = client._api._client_wrapper.get_custom_headers()
238+
assert custom_headers is not None
239+
encoded_header = custom_headers.get("Auth0-Client")
240+
assert encoded_header is not None
241+
decoded = json.loads(base64.b64decode(encoded_header).decode("utf-8"))
242+
assert decoded == custom_info
243+
176244

177245
class TestTokenProvider:
178246
"""Tests for TokenProvider."""

0 commit comments

Comments
 (0)