Skip to content

Commit b6f787c

Browse files
feat:Add Auth0-Custom-Domain header support for Multiple Custom Domains (MCD)
1 parent e506d73 commit b6f787c

4 files changed

Lines changed: 245 additions & 3 deletions

File tree

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,44 @@ client = Auth0(
315315
)
316316
```
317317

318+
### Custom Domains
319+
320+
If your Auth0 tenant uses multiple custom domains, you can specify which custom domain to use via the `Auth0-Custom-Domain` header. The SDK enforces a whitelist, the header is only sent on supported endpoints.
321+
322+
**Global (all whitelisted requests):**
323+
324+
```python
325+
from auth0.management import ManagementClient
326+
327+
client = ManagementClient(
328+
domain="your-tenant.auth0.com",
329+
token="YOUR_TOKEN",
330+
custom_domain="login.mycompany.com",
331+
)
332+
```
333+
334+
**Per-request override:**
335+
336+
```python
337+
from auth0.management import ManagementClient, CustomDomainHeader
338+
339+
client = ManagementClient(
340+
domain="your-tenant.auth0.com",
341+
token="YOUR_TOKEN",
342+
custom_domain="login.mycompany.com",
343+
)
344+
345+
# Override the global custom domain for this specific request
346+
client.users.create(
347+
connection="Username-Password-Authentication",
348+
email="user@example.com",
349+
password="SecurePass123!",
350+
request_options=CustomDomainHeader("other.mycompany.com"),
351+
)
352+
```
353+
354+
If both a global `custom_domain` and a per-request `CustomDomainHeader` are provided, the per-request value takes precedence.
355+
318356
## Feedback
319357

320358
### Contributing

src/auth0/management/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1215,7 +1215,7 @@
12151215
from .client import AsyncAuth0, Auth0
12161216
from .environment import Auth0Environment
12171217
from .event_streams import EventStreamsCreateRequest
1218-
from .management_client import AsyncManagementClient, ManagementClient
1218+
from .management_client import AsyncManagementClient, CustomDomainHeader, ManagementClient
12191219
from .version import __version__
12201220
_dynamic_imports: typing.Dict[str, str] = {
12211221
"Action": ".types",
@@ -1458,6 +1458,7 @@
14581458
"CreatedAuthenticationMethodTypeEnum": ".types",
14591459
"CreatedUserAuthenticationMethodTypeEnum": ".types",
14601460
"CredentialId": ".types",
1461+
"CustomDomainHeader": ".management_client",
14611462
"CustomDomain": ".types",
14621463
"CustomDomainCustomClientIpHeader": ".types",
14631464
"CustomDomainCustomClientIpHeaderEnum": ".types",
@@ -2690,6 +2691,7 @@ def __dir__():
26902691
"CreatedUserAuthenticationMethodTypeEnum",
26912692
"CredentialId",
26922693
"CustomDomain",
2694+
"CustomDomainHeader",
26932695
"CustomDomainCustomClientIpHeader",
26942696
"CustomDomainCustomClientIpHeaderEnum",
26952697
"CustomDomainProvisioningTypeEnum",

src/auth0/management/management_client.py

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

3-
from typing import TYPE_CHECKING, Callable, Dict, Optional, Union
3+
import re
4+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
45

56
import httpx
67
from .client import AsyncAuth0, Auth0
78
from .token_provider import TokenProvider
89

10+
CUSTOM_DOMAIN_HEADER = "Auth0-Custom-Domain"
11+
12+
WHITELISTED_PATH_PATTERNS: List[re.Pattern[str]] = [
13+
re.compile(r"^/api/v2/jobs/verification-email$"),
14+
re.compile(r"^/api/v2/tickets/email-verification$"),
15+
re.compile(r"^/api/v2/tickets/password-change$"),
16+
re.compile(r"^/api/v2/organizations/[^/]+/invitations$"),
17+
re.compile(r"^/api/v2/users$"),
18+
re.compile(r"^/api/v2/users/[^/]+$"),
19+
re.compile(r"^/api/v2/guardian/enrollments/ticket$"),
20+
re.compile(r"^/api/v2/self-service-profiles/[^/]+/sso-ticket$"),
21+
]
22+
23+
24+
def _is_path_whitelisted(path: str) -> bool:
25+
"""Check if the given path is whitelisted for the custom domain header."""
26+
return any(p.match(path) for p in WHITELISTED_PATH_PATTERNS)
27+
28+
29+
def _enforce_custom_domain_whitelist(request: httpx.Request) -> None:
30+
"""httpx event hook that strips Auth0-Custom-Domain on non-whitelisted paths."""
31+
if CUSTOM_DOMAIN_HEADER in request.headers and not _is_path_whitelisted(
32+
request.url.path
33+
):
34+
del request.headers[CUSTOM_DOMAIN_HEADER]
35+
36+
37+
def CustomDomainHeader(domain: str) -> Dict[str, Any]:
38+
"""Create request options that set the Auth0-Custom-Domain header for a single request.
39+
40+
When both a global custom_domain (set at client init) and a per-request
41+
custom_domain_header are provided, the per-request value takes precedence.
42+
The header is only sent on whitelisted endpoints.
43+
44+
Usage::
45+
46+
from auth0.management import ManagementClient, CustomDomainHeader
47+
48+
client = ManagementClient(domain="tenant.auth0.com", token="TOKEN")
49+
client.users.create(
50+
connection="Username-Password-Authentication",
51+
email="user@example.com",
52+
password="...",
53+
request_options=CustomDomainHeader("login.mycompany.com"),
54+
)
55+
"""
56+
return {"additional_headers": {CUSTOM_DOMAIN_HEADER: domain}}
57+
958
if TYPE_CHECKING:
1059
from .actions.client import ActionsClient, AsyncActionsClient
1160
from .anomaly.client import AnomalyClient, AsyncAnomalyClient
@@ -86,6 +135,10 @@ class ManagementClient:
86135
The API audience. Defaults to https://{domain}/api/v2/
87136
headers : Optional[Dict[str, str]]
88137
Additional headers to send with requests.
138+
custom_domain : Optional[str]
139+
A custom domain to send via the Auth0-Custom-Domain header.
140+
The header is only sent on whitelisted endpoints. Use
141+
``CustomDomainHeader()`` for per-request overrides.
89142
timeout : Optional[float]
90143
Request timeout in seconds. Defaults to 60.
91144
httpx_client : Optional[httpx.Client]
@@ -106,6 +159,7 @@ def __init__(
106159
client_secret: Optional[str] = None,
107160
audience: Optional[str] = None,
108161
headers: Optional[Dict[str, str]] = None,
162+
custom_domain: Optional[str] = None,
109163
timeout: Optional[float] = None,
110164
httpx_client: Optional[httpx.Client] = None,
111165
):
@@ -128,6 +182,15 @@ def __init__(
128182
else:
129183
resolved_token = token # type: ignore[assignment]
130184

185+
# Set up custom domain header with whitelist enforcement
186+
if custom_domain is not None:
187+
headers = {**(headers or {}), CUSTOM_DOMAIN_HEADER: custom_domain}
188+
if httpx_client is None:
189+
httpx_client = httpx.Client(timeout=timeout or 60, follow_redirects=True)
190+
httpx_client.event_hooks.setdefault("request", []).append(
191+
_enforce_custom_domain_whitelist
192+
)
193+
131194
# Create underlying client
132195
self._api = Auth0(
133196
base_url=f"https://{domain}/api/v2",
@@ -333,6 +396,10 @@ class AsyncManagementClient:
333396
The API audience. Defaults to https://{domain}/api/v2/
334397
headers : Optional[Dict[str, str]]
335398
Additional headers to send with requests.
399+
custom_domain : Optional[str]
400+
A custom domain to send via the Auth0-Custom-Domain header.
401+
The header is only sent on whitelisted endpoints. Use
402+
``CustomDomainHeader()`` for per-request overrides.
336403
timeout : Optional[float]
337404
Request timeout in seconds. Defaults to 60.
338405
httpx_client : Optional[httpx.AsyncClient]
@@ -353,6 +420,7 @@ def __init__(
353420
client_secret: Optional[str] = None,
354421
audience: Optional[str] = None,
355422
headers: Optional[Dict[str, str]] = None,
423+
custom_domain: Optional[str] = None,
356424
timeout: Optional[float] = None,
357425
httpx_client: Optional[httpx.AsyncClient] = None,
358426
):
@@ -378,6 +446,15 @@ def __init__(
378446
else:
379447
resolved_token = token # type: ignore[assignment]
380448

449+
# Set up custom domain header with whitelist enforcement
450+
if custom_domain is not None:
451+
headers = {**(headers or {}), CUSTOM_DOMAIN_HEADER: custom_domain}
452+
if httpx_client is None:
453+
httpx_client = httpx.AsyncClient(timeout=timeout or 60, follow_redirects=True)
454+
httpx_client.event_hooks.setdefault("request", []).append(
455+
_enforce_custom_domain_whitelist
456+
)
457+
381458
# Create underlying client
382459
self._api = AsyncAuth0(
383460
base_url=f"https://{domain}/api/v2",

tests/management/test_management_client.py

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
11
import time
22
from unittest.mock import MagicMock, patch
33

4+
import httpx
45
import pytest
56

6-
from auth0.management import AsyncManagementClient, AsyncTokenProvider, ManagementClient, TokenProvider
7+
from auth0.management import (
8+
AsyncManagementClient,
9+
AsyncTokenProvider,
10+
CustomDomainHeader,
11+
ManagementClient,
12+
TokenProvider,
13+
)
14+
from auth0.management.management_client import (
15+
CUSTOM_DOMAIN_HEADER as _CUSTOM_DOMAIN_HEADER,
16+
)
17+
from auth0.management.management_client import (
18+
_enforce_custom_domain_whitelist,
19+
_is_path_whitelisted,
20+
)
721

822

923
class TestManagementClientInit:
@@ -337,6 +351,115 @@ def test_init_with_custom_audience(self):
337351
assert provider._audience == "https://custom.api.com/"
338352

339353

354+
class TestCustomDomainHeader:
355+
"""Tests for Auth0-Custom-Domain header support."""
356+
357+
def test_init_with_custom_domain(self):
358+
"""Should set Auth0-Custom-Domain header when custom_domain is provided."""
359+
client = ManagementClient(
360+
domain="test.auth0.com",
361+
token="my-token",
362+
custom_domain="login.mycompany.com",
363+
)
364+
custom_headers = client._api._client_wrapper.get_custom_headers()
365+
assert custom_headers is not None
366+
assert custom_headers[_CUSTOM_DOMAIN_HEADER] == "login.mycompany.com"
367+
368+
def test_init_custom_domain_with_existing_headers(self):
369+
"""Should merge custom_domain with other custom headers."""
370+
client = ManagementClient(
371+
domain="test.auth0.com",
372+
token="my-token",
373+
headers={"X-Custom": "value"},
374+
custom_domain="login.mycompany.com",
375+
)
376+
custom_headers = client._api._client_wrapper.get_custom_headers()
377+
assert custom_headers is not None
378+
assert custom_headers["X-Custom"] == "value"
379+
assert custom_headers[_CUSTOM_DOMAIN_HEADER] == "login.mycompany.com"
380+
381+
def test_init_without_custom_domain(self):
382+
"""Should not set Auth0-Custom-Domain header when custom_domain is not provided."""
383+
client = ManagementClient(
384+
domain="test.auth0.com",
385+
token="my-token",
386+
)
387+
custom_headers = client._api._client_wrapper.get_custom_headers()
388+
assert custom_headers is None or _CUSTOM_DOMAIN_HEADER not in custom_headers
389+
390+
def test_custom_domain_header_helper(self):
391+
"""Should return correct request options dict."""
392+
result = CustomDomainHeader("login.mycompany.com")
393+
assert result == {
394+
"additional_headers": {
395+
_CUSTOM_DOMAIN_HEADER: "login.mycompany.com",
396+
}
397+
}
398+
399+
def test_async_init_with_custom_domain(self):
400+
"""Should set Auth0-Custom-Domain header on async client."""
401+
client = AsyncManagementClient(
402+
domain="test.auth0.com",
403+
token="my-token",
404+
custom_domain="login.mycompany.com",
405+
)
406+
custom_headers = client._api._client_wrapper.get_custom_headers()
407+
assert custom_headers is not None
408+
assert custom_headers[_CUSTOM_DOMAIN_HEADER] == "login.mycompany.com"
409+
410+
def test_whitelist_strips_header_on_non_whitelisted_path(self):
411+
"""Should strip Auth0-Custom-Domain header on non-whitelisted paths."""
412+
request = httpx.Request(
413+
"GET",
414+
"https://test.auth0.com/api/v2/clients",
415+
headers={_CUSTOM_DOMAIN_HEADER: "login.mycompany.com"},
416+
)
417+
_enforce_custom_domain_whitelist(request)
418+
assert _CUSTOM_DOMAIN_HEADER not in request.headers
419+
420+
def test_whitelist_keeps_header_on_whitelisted_path(self):
421+
"""Should keep Auth0-Custom-Domain header on whitelisted paths."""
422+
request = httpx.Request(
423+
"POST",
424+
"https://test.auth0.com/api/v2/users",
425+
headers={_CUSTOM_DOMAIN_HEADER: "login.mycompany.com"},
426+
)
427+
_enforce_custom_domain_whitelist(request)
428+
assert request.headers[_CUSTOM_DOMAIN_HEADER] == "login.mycompany.com"
429+
430+
@pytest.mark.parametrize(
431+
"path",
432+
[
433+
"/api/v2/jobs/verification-email",
434+
"/api/v2/tickets/email-verification",
435+
"/api/v2/tickets/password-change",
436+
"/api/v2/organizations/org_abc123/invitations",
437+
"/api/v2/users",
438+
"/api/v2/users/auth0|abc123",
439+
"/api/v2/guardian/enrollments/ticket",
440+
"/api/v2/self-service-profiles/ssp_abc123/sso-ticket",
441+
],
442+
)
443+
def test_whitelisted_paths_match(self, path):
444+
"""Should match all 8 whitelisted path patterns."""
445+
assert _is_path_whitelisted(path) is True
446+
447+
@pytest.mark.parametrize(
448+
"path",
449+
[
450+
"/api/v2/clients",
451+
"/api/v2/connections",
452+
"/api/v2/roles",
453+
"/api/v2/users/auth0|abc123/roles",
454+
"/api/v2/jobs/users-imports",
455+
"/api/v2/tenants/settings",
456+
],
457+
)
458+
def test_non_whitelisted_paths_do_not_match(self, path):
459+
"""Should not match non-whitelisted paths."""
460+
assert _is_path_whitelisted(path) is False
461+
462+
340463
class TestImports:
341464
"""Tests for module imports."""
342465

@@ -345,6 +468,7 @@ def test_import_from_management(self):
345468
from auth0.management import (
346469
AsyncManagementClient,
347470
AsyncTokenProvider,
471+
CustomDomainHeader,
348472
ManagementClient,
349473
TokenProvider,
350474
)
@@ -353,3 +477,4 @@ def test_import_from_management(self):
353477
assert AsyncManagementClient is not None
354478
assert TokenProvider is not None
355479
assert AsyncTokenProvider is not None
480+
assert CustomDomainHeader is not None

0 commit comments

Comments
 (0)