Skip to content

feat: add Multiple Custom Domain Support#71

Open
kishore7snehil wants to merge 8 commits intomainfrom
feat/multiple-custom-domain
Open

feat: add Multiple Custom Domain Support#71
kishore7snehil wants to merge 8 commits intomainfrom
feat/multiple-custom-domain

Conversation

@kishore7snehil
Copy link
Contributor

@kishore7snehil kishore7snehil commented Feb 23, 2026

📋 Changes

This PR implements Multiple Custom Domain (MCD) support for auth0-api-python, enabling APIs to accept tokens from multiple Auth0 custom domains with static lists, dynamic resolvers, and hybrid mode for zero-downtime domain migrations.

✨ Features

  • Multiple Custom Domain Verification: Accept tokens from multiple Auth0 domains via a domains parameter (static list or callable resolver) on ApiClientOptions
  • Double Issuer Validation: Pre-signature and post-signature issuer checks to prevent issuer confusion attacks
  • Hybrid Mode: Use domain and domains together for migration scenarios — domain drives client-initiated flows (token exchange, connection tokens), domains drives token verification
  • Dynamic Resolver: Runtime domain resolution via DomainsResolver callable with request context (DomainsResolverContext)
  • Per-Issuer Caching: OIDC discovery metadata and JWKS cached per issuer domain with configurable TTL, max entries, and LRU eviction
  • Pluggable Cache Backends: New CacheAdapter ABC allows custom backends (Redis, Memcached, etc.) with a default InMemoryCache implementation

🔧 API Changes

  • Extended ApiClientOptions with MCD parameters: domains, cache_ttl_seconds, cache_max_entries, cache_adapter
  • Added request_url and request_headers parameters to verify_access_token() and verify_request() for resolver context
  • New types: DomainsResolverContext (TypedDict), DomainsResolver (type alias)
  • New error classes: ConfigurationError (invalid SDK config, status 500), DomainsResolverError (resolver failure, status 500)
  • New cache classes: CacheAdapter (ABC), InMemoryCache (default LRU cache with TTL)

📖 Documentation

  • Updated README.md with MCD feature callout and new section 7 (Multi-Custom Domain Support)
  • Created docs/MultipleCustomDomain.md — configuration modes, resolver patterns, error handling, migration guide
  • Created docs/Caching.md — default behavior, custom adapters (Redis example), tuning recommendations

🧪 Testing

  • This change adds test coverage
  • This change has been tested on the latest version of the platform/language
Manual Integration Testing

Requires an Auth0 tenant with multiple custom domains configured and a machine-to-machine application with client credentials grant enabled.

import asyncio
import httpx
from auth0_api_python import ApiClient, ApiClientOptions

DOMAIN = "<AUTH0_DOMAIN>"               # e.g. "dev-tenant.us.auth0.com"
CUSTOM_DOMAIN_1 = "<CUSTOM_DOMAIN_1>"   # e.g. "auth.example.com"
CUSTOM_DOMAIN_2 = "<CUSTOM_DOMAIN_2>"   # e.g. "auth.acme.org"
ALL_DOMAINS = [DOMAIN, CUSTOM_DOMAIN_1, CUSTOM_DOMAIN_2]

CLIENT_ID = "<CLIENT_ID>"
CLIENT_SECRET = "<CLIENT_SECRET>"
AUDIENCE = "<API_AUDIENCE>"


# Helper: get an access token from a specific domain via client_credentials grant.
# POST to https://{domain}/oauth/token with grant_type, client_id, client_secret, audience.
# Returns the access_token string from the response.

async def get_token(domain: str) -> str:
    ...


async def test_bearer_mcd():
    api_client = ApiClient(ApiClientOptions(
        domains=ALL_DOMAINS,
        audience=AUDIENCE,
    ))

    for domain in ALL_DOMAINS:
        token = await get_token(domain)
        claims = await api_client.verify_request(
            headers={"Authorization": f"Bearer {token}"}
        )
        print(f"[PASS] {domain} -> iss={claims['iss']}, sub={claims['sub']}")

asyncio.run(test_bearer_mcd())

Expected: All three domains succeed. Each token's iss matches its issuing domain.

Contributor Checklist

@kishore7snehil kishore7snehil requested a review from a team as a code owner February 23, 2026 13:03

# Invoke resolver (supports both sync and async resolvers)
try:
result = self._allowed_domains(context)

Check failure

Code scanning / CodeQL

Non-callable called Error

Call to a
non-callable
of
builtin-class list
.
Call to a
non-callable
of
builtin-class list
.

Copilot Autofix

AI about 11 hours ago

General fix: Avoid reusing the same attribute for both a list of domains and a resolver function. Maintain separate attributes for the static list and for the callable resolver so that the callable one is never a list and vice versa.

Concrete approach for this file:

  1. In __init__, introduce two instance attributes:
    • self._allowed_domains – always a list of normalized domain strings (or None if not set).
    • self._domains_resolver – always either a callable (sync or async) or None.
  2. When options.domains is a list, normalize and store it in self._allowed_domains, leaving self._domains_resolver = None.
  3. When options.domains is callable, store it in self._domains_resolver and set self._allowed_domains = None.
  4. In the later logic (around lines 145–181), change:
    • The elif callable(self._allowed_domains): branch to instead test and call self._domains_resolver.
    • The invocation self._allowed_domains(context) to self._domains_resolver(context).
  5. Ensure that any earlier uses of self._allowed_domains (as shown) are compatible—where it’s treated as a list, it will only ever be a list; where it’s treated as callable, use the new self._domains_resolver.

This retains existing functionality (still supports both static domain lists and dynamic resolvers) while making the types consistent and eliminating the potential non‑callable call that CodeQL flags.


Suggested changeset 1
src/auth0_api_python/api_client.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/auth0_api_python/api_client.py b/src/auth0_api_python/api_client.py
--- a/src/auth0_api_python/api_client.py
+++ b/src/auth0_api_python/api_client.py
@@ -58,6 +58,10 @@
         if not options.audience:
             raise MissingRequiredArgumentError("audience")
 
+        # Initialize domain configuration
+        self._allowed_domains: Optional[list[str]] = None
+        self._domains_resolver = None
+
         # Validate domains parameter if provided
         if options.domains is not None:
             if isinstance(options.domains, list):
@@ -72,7 +76,7 @@
                 self._allowed_domains = [normalize_domain(d) for d in options.domains]
             elif callable(options.domains):
                 # Dynamic resolver - store the function
-                self._allowed_domains = options.domains
+                self._domains_resolver = options.domains
             else:
                 raise ConfigurationError(
                     "domains must be either a list of domain strings or a callable resolver function"
@@ -142,7 +146,7 @@
         if isinstance(self._allowed_domains, list):
             allowed_domains = self._allowed_domains
         # Dynamic resolver mode
-        elif callable(self._allowed_domains):
+        elif self._domains_resolver is not None and callable(self._domains_resolver):
             # Build resolver context
             context = {
                 'request_url': request_url,
@@ -152,7 +156,7 @@
 
             # Invoke resolver (supports both sync and async resolvers)
             try:
-                result = self._allowed_domains(context)
+                result = self._domains_resolver(context)
                 if asyncio.iscoroutine(result) or asyncio.isfuture(result):
                     result = await result
             except Exception as e:
EOF
@@ -58,6 +58,10 @@
if not options.audience:
raise MissingRequiredArgumentError("audience")

# Initialize domain configuration
self._allowed_domains: Optional[list[str]] = None
self._domains_resolver = None

# Validate domains parameter if provided
if options.domains is not None:
if isinstance(options.domains, list):
@@ -72,7 +76,7 @@
self._allowed_domains = [normalize_domain(d) for d in options.domains]
elif callable(options.domains):
# Dynamic resolver - store the function
self._allowed_domains = options.domains
self._domains_resolver = options.domains
else:
raise ConfigurationError(
"domains must be either a list of domain strings or a callable resolver function"
@@ -142,7 +146,7 @@
if isinstance(self._allowed_domains, list):
allowed_domains = self._allowed_domains
# Dynamic resolver mode
elif callable(self._allowed_domains):
elif self._domains_resolver is not None and callable(self._domains_resolver):
# Build resolver context
context = {
'request_url': request_url,
@@ -152,7 +156,7 @@

# Invoke resolver (supports both sync and async resolvers)
try:
result = self._allowed_domains(context)
result = self._domains_resolver(context)
if asyncio.iscoroutine(result) or asyncio.isfuture(result):
result = await result
except Exception as e:
Copilot is powered by AI and may make mistakes. Always verify output.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant