-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add Multiple Custom Domains (MCD) support and fix JWT verification #71
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kishore7snehil
wants to merge
17
commits into
main
Choose a base branch
from
feat/mcd-support
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 12 commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
ac24ef7
feat: add Multiple Custom Domains (MCD) support and fix JWT verification
kishore7snehil 1164848
Bump poetry version from latest to 2.2.1 in test workflow
kishore7snehil 2646682
Fix linting errors
kishore7snehil 307000b
test: improve cache verification in OIDC metadata and JWKS tests
kishore7snehil 3f68c3c
refactor: rename cache size variable and reorganize test comments
kishore7snehil 2e0ae17
chore: add cryptography package to Snyk license ignore list
kishore7snehil 3b131d8
Merge remote-tracking branch 'origin/main' into feat/mcd-support
kishore7snehil daa6d35
Implement normalized issuer validation and add related tests
kishore7snehil 74329ab
refactor: remove unused jwt import from test_server_client.py
kishore7snehil 073c38b
refactor: remove requirement comments for OIDC metadata and JWKS caching
kishore7snehil 75a8531
feat: improve caching mechanism and add more examples to the example doc
kishore7snehil 0eb64b4
feat: enhance Multiple Custom Domains (MCD) support with dynamic domaβ¦
kishore7snehil f8d8470
fix: update type hint for audience parameter in _verify_and_decode_jwβ¦
kishore7snehil 283377f
feat: add DOMAIN_MISMATCH error code for session domain validation inβ¦
kishore7snehil e40942d
feat: implement domain validation for backchannel logout and enhance β¦
kishore7snehil 1c5f2d8
chore: fix for review comments (set-1)
kishore7snehil a9debdd
fix: scoped logout, backchannel logout
kishore7snehil File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,5 +23,4 @@ setup.py | |
| test.py | ||
| test-script.py | ||
| .coverage | ||
| coverage.xml | ||
|
|
||
| coverage.xml | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,336 @@ | ||
| # Multiple Custom Domains (MCD) | ||
|
|
||
| MCD lets you resolve the Auth0 domain per request while keeping a single `ServerClient` instance. This is useful when your application uses multiple custom domains configured on the same Auth0 tenant. | ||
|
|
||
| **Example:** | ||
| - `https://acme.yourapp.com` β Custom domain: `auth.acme.com` | ||
| - `https://globex.yourapp.com` β Custom domain: `auth.globex.com` | ||
|
|
||
| MCD is enabled by providing a **domain resolver function** instead of a static domain string. | ||
|
|
||
| ## Configuration Methods | ||
|
|
||
| ### Method 1: Static Domain (Single Domain) | ||
|
|
||
| For applications with a single Auth0 domain: | ||
|
|
||
| ```python | ||
| from auth0_server_python import ServerClient | ||
|
|
||
| client = ServerClient( | ||
| domain="your-tenant.auth0.com", # Static string | ||
| client_id="your_client_id", | ||
| client_secret="your_client_secret", | ||
| secret="your_encryption_secret" | ||
| ) | ||
| ``` | ||
|
|
||
| ### Method 2: Dynamic Domain Resolver (MCD) | ||
|
|
||
| For MCD support, provide a domain resolver function that receives a `DomainResolverContext`: | ||
|
|
||
| ```python | ||
| from auth0_server_python import ServerClient | ||
| from auth0_server_python.auth_types import DomainResolverContext | ||
|
|
||
| # Map your app hostnames to Auth0 domains | ||
| DOMAIN_MAP = { | ||
| "acme.yourapp.com": "acme.auth0.com", | ||
| "globex.yourapp.com": "globex.auth0.com", | ||
| } | ||
| DEFAULT_DOMAIN = "default.auth0.com" | ||
|
|
||
| async def domain_resolver(context: DomainResolverContext) -> str: | ||
| """ | ||
| Resolve Auth0 domain based on request hostname. | ||
|
|
||
| Args: | ||
| context: Contains request_url and request_headers | ||
|
|
||
| Returns: | ||
| Auth0 domain string (e.g., "acme.auth0.com") | ||
| """ | ||
| # Extract hostname from request headers | ||
| if not context.request_headers: | ||
| return DEFAULT_DOMAIN | ||
|
|
||
| host = context.request_headers.get('host', DEFAULT_DOMAIN) | ||
| host_without_port = host.split(':')[0] | ||
|
|
||
| # Look up Auth0 domain | ||
| return DOMAIN_MAP.get(host_without_port, DEFAULT_DOMAIN) | ||
|
|
||
| client = ServerClient( | ||
| domain=domain_resolver, # Callable function | ||
| client_id="your_client_id", | ||
| client_secret="your_client_secret", | ||
| secret="your_encryption_secret" | ||
| ) | ||
| ``` | ||
|
|
||
| ## DomainResolverContext | ||
|
|
||
| The `DomainResolverContext` object provides request information to your resolver: | ||
|
|
||
| | Property | Type | Description | | ||
| |----------|------|-------------| | ||
| | `request_url` | `Optional[str]` | Full request URL (e.g., "https://acme.yourapp.com/auth/login") | | ||
| | `request_headers` | `Optional[dict[str, str]]` | Request headers dictionary | | ||
|
|
||
| **Common headers:** | ||
| - `host`: Request hostname (e.g., "acme.yourapp.com") | ||
| - `x-forwarded-host`: Original host when behind proxy/load balancer | ||
|
|
||
| **Example usage:** | ||
|
|
||
| ```python | ||
| async def domain_resolver(context: DomainResolverContext) -> str: | ||
| # Check if we have request headers | ||
| if not context.request_headers: | ||
| return DEFAULT_DOMAIN | ||
|
|
||
| # Use x-forwarded-host if behind proxy, otherwise use host | ||
| host = (context.request_headers.get('x-forwarded-host') or | ||
| context.request_headers.get('host', '')) | ||
|
|
||
| # Remove port number if present | ||
| hostname = host.split(':')[0].lower() | ||
|
|
||
| # Look up in mapping | ||
| return DOMAIN_MAP.get(hostname, DEFAULT_DOMAIN) | ||
| ``` | ||
|
|
||
| > **Note:** In resolver mode, the SDK builds the `redirect_uri` dynamically from the resolved domain. You do not need to set it per request. If you override `redirect_uri` in `authorization_params`, the SDK uses your value as-is. | ||
kishore7snehil marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| ## Resolver Patterns | ||
|
|
||
| ### Database Lookup (SQLAlchemy) | ||
|
|
||
| Resolve domains from a database using async SQLAlchemy: | ||
|
|
||
| ```python | ||
| from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession | ||
| from sqlalchemy import text | ||
|
|
||
| engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/mydb") | ||
|
|
||
| async def domain_resolver(context: DomainResolverContext) -> str: | ||
| host = context.request_headers.get("host", "").split(":")[0] | ||
| tenant = host.split(".")[0] | ||
|
|
||
| async with AsyncSession(engine) as session: | ||
| result = await session.execute( | ||
| text("SELECT auth0_domain FROM tenants WHERE slug = :slug"), | ||
| {"slug": tenant} | ||
| ) | ||
| row = result.fetchone() | ||
| if row: | ||
| return row[0] | ||
|
|
||
| return DEFAULT_DOMAIN | ||
| ``` | ||
|
|
||
| ### Database Lookup with In-Memory Cache | ||
|
|
||
| Avoid hitting the database on every request by caching the tenant map: | ||
|
|
||
| ```python | ||
| import time | ||
|
|
||
| _tenant_cache = {} | ||
| _cache_ttl = 300 # 5 minutes | ||
|
|
||
| async def domain_resolver(context: DomainResolverContext) -> str: | ||
| host = context.request_headers.get("host", "").split(":")[0] | ||
| tenant = host.split(".")[0] | ||
|
|
||
| now = time.time() | ||
| cached = _tenant_cache.get(tenant) | ||
| if cached and cached["expires_at"] > now: | ||
| return cached["domain"] | ||
|
|
||
| # Cache miss - fetch from database | ||
| async with AsyncSession(engine) as session: | ||
| result = await session.execute( | ||
| text("SELECT auth0_domain FROM tenants WHERE slug = :slug"), | ||
| {"slug": tenant} | ||
| ) | ||
| row = result.fetchone() | ||
| domain = row[0] if row else DEFAULT_DOMAIN | ||
|
|
||
| _tenant_cache[tenant] = {"domain": domain, "expires_at": now + _cache_ttl} | ||
| return domain | ||
| ``` | ||
|
|
||
| ### Redis Lookup | ||
|
|
||
| Use Redis for shared tenant configuration across multiple app instances: | ||
|
|
||
| ```python | ||
| import redis.asyncio as redis | ||
|
|
||
| redis_client = redis.Redis(host="localhost", port=6379, decode_responses=True) | ||
|
|
||
| async def domain_resolver(context: DomainResolverContext) -> str: | ||
| host = context.request_headers.get("host", "").split(":")[0] | ||
| tenant = host.split(".")[0] | ||
|
|
||
| # Key format: "tenant:acme" -> "acme.auth0.com" | ||
| domain = await redis_client.get(f"tenant:{tenant}") | ||
| if domain: | ||
| return domain | ||
|
|
||
| return DEFAULT_DOMAIN | ||
| ``` | ||
|
|
||
| ### Redis with Hash Map | ||
|
|
||
| Store all tenant mappings in a single Redis hash: | ||
|
|
||
| ```python | ||
| async def domain_resolver(context: DomainResolverContext) -> str: | ||
| host = context.request_headers.get("host", "").split(":")[0] | ||
| tenant = host.split(".")[0] | ||
|
|
||
| # All tenants in one hash: HGET tenant_domains acme -> "acme.auth0.com" | ||
| domain = await redis_client.hget("tenant_domains", tenant) | ||
| if domain: | ||
| return domain | ||
|
|
||
| return DEFAULT_DOMAIN | ||
| ``` | ||
|
|
||
| ### Path-Based Resolution | ||
|
|
||
| Resolve tenant from URL path instead of hostname: | ||
|
|
||
| ```python | ||
| from urllib.parse import urlparse | ||
|
|
||
| async def domain_resolver(context: DomainResolverContext) -> str: | ||
| if context.request_url: | ||
| path = urlparse(context.request_url).path | ||
| # URL pattern: /tenant/acme/auth/login | ||
| parts = path.strip("/").split("/") | ||
| if len(parts) >= 2 and parts[0] == "tenant": | ||
| tenant = parts[1] | ||
| return DOMAIN_MAP.get(tenant, DEFAULT_DOMAIN) | ||
|
|
||
| return DEFAULT_DOMAIN | ||
| ``` | ||
|
|
||
| ### Custom Header Resolution | ||
|
|
||
| Use a custom header set by your API gateway or load balancer: | ||
|
|
||
| ```python | ||
| async def domain_resolver(context: DomainResolverContext) -> str: | ||
| headers = context.request_headers or {} | ||
|
|
||
| # API gateway sets X-Tenant-Id header | ||
| tenant_id = headers.get("x-tenant-id") | ||
| if tenant_id: | ||
| return DOMAIN_MAP.get(tenant_id, DEFAULT_DOMAIN) | ||
|
|
||
| # Fallback to host header | ||
| host = headers.get("host", "").split(":")[0] | ||
| return DOMAIN_MAP.get(host, DEFAULT_DOMAIN) | ||
| ``` | ||
|
|
||
| ## Error Handling | ||
|
|
||
| ### DomainResolverError | ||
|
|
||
| The domain resolver should return a valid Auth0 domain string. Invalid returns will raise `DomainResolverError`: | ||
|
|
||
| ```python | ||
| from auth0_server_python.error import DomainResolverError | ||
|
|
||
| async def domain_resolver(context: DomainResolverContext) -> str: | ||
| try: | ||
| domain = lookup_domain_from_db(context) | ||
|
|
||
| if not domain: | ||
| # Return default instead of None | ||
| return DEFAULT_DOMAIN | ||
|
|
||
| return domain # Must be a non-empty string | ||
|
|
||
| except Exception as e: | ||
| # Log error and return default | ||
| logger.error(f"Domain resolution failed: {e}") | ||
| return DEFAULT_DOMAIN | ||
| ``` | ||
|
|
||
| **Invalid return values that raise `DomainResolverError`:** | ||
| - `None` | ||
| - Empty string `""` | ||
| - Non-string types (int, list, dict, etc.) | ||
|
|
||
| **Exceptions raised by your resolver:** | ||
| - Automatically wrapped in `DomainResolverError` | ||
| - Original exception accessible via `.original_error` | ||
|
|
||
| ## Session Behavior in Resolver Mode | ||
|
|
||
| In resolver mode, sessions are bound to the domain that created them. On each request, the SDK compares the session's stored domain against the current resolved domain: | ||
|
|
||
| - `get_user()` and `get_session()` return `None` on domain mismatch. | ||
| - `get_access_token()` raises `AccessTokenError` on domain mismatch. | ||
| - Token refresh uses the session's stored domain, not the current request domain. | ||
|
|
||
| > **Warning:** If you switch from a static domain string to a resolver function, existing sessions that do not include a stored domain continue to work β the SDK treats the absent domain field as valid. New sessions will store the resolved domain automatically. Once old sessions expire, all sessions will be domain-aware. | ||
|
|
||
| ## Discovery Cache | ||
|
|
||
| The SDK caches OIDC metadata and JWKS per domain in memory (LRU eviction, 600-second TTL, up to 100 domains). This avoids repeated network calls when serving multiple domains. The cache is shared across all requests to the same `ServerClient` instance. | ||
|
|
||
| ## Security Best Practices | ||
|
|
||
| ### Use an Allowlist in Your Resolver | ||
|
|
||
| The SDK passes request headers to your domain resolver via `DomainResolverContext`. These headers come directly from the HTTP request and can be spoofed by an attacker (e.g., `Host: evil.com` or `X-Forwarded-Host: evil.com`). | ||
|
|
||
| The SDK uses the resolved domain to fetch OIDC metadata and JWKS. If an attacker can influence the resolved domain, they could point the SDK at an OIDC provider they control. | ||
|
|
||
| **Always use a mapping or allowlist β never construct domains from raw header values:** | ||
|
|
||
| ```python | ||
| # Safe: allowlist lookup β unknown hosts fall back to default | ||
| DOMAIN_MAP = { | ||
| "acme.myapp.com": "auth.acme.com", | ||
| "globex.myapp.com": "auth.globex.com", | ||
| } | ||
|
|
||
| async def domain_resolver(context: DomainResolverContext) -> str: | ||
| host = context.request_headers.get("host", "").split(":")[0] | ||
| return DOMAIN_MAP.get(host, DEFAULT_DOMAIN) | ||
| ``` | ||
|
|
||
| ```python | ||
| # Risky: constructs domain from raw input β attacker can influence resolved domain | ||
| async def domain_resolver(context: DomainResolverContext) -> str: | ||
| host = context.request_headers.get("host", "").split(":")[0] | ||
| tenant = host.split(".")[0] | ||
| return f"{tenant}.auth0.com" # attacker sends Host: evil.myapp.com β evil.auth0.com | ||
| ``` | ||
|
|
||
| ### Trust Forwarded Headers Only Behind a Proxy | ||
|
|
||
| If your application is directly exposed to the internet (not behind a reverse proxy), do not trust `x-forwarded-host` or `x-forwarded-proto` β any client can set these headers. | ||
|
|
||
| Only use forwarded headers when your application runs behind a trusted reverse proxy (nginx, AWS ALB, Cloudflare, etc.) that sets these headers and strips any client-provided values. | ||
|
|
||
| ```python | ||
| # Only trust x-forwarded-host if behind a trusted proxy | ||
| async def domain_resolver(context: DomainResolverContext) -> str: | ||
| headers = context.request_headers or {} | ||
|
|
||
| if BEHIND_TRUSTED_PROXY: | ||
| host = headers.get("x-forwarded-host") or headers.get("host", "") | ||
| else: | ||
| host = headers.get("host", "") | ||
|
|
||
| host = host.split(":")[0] | ||
| return DOMAIN_MAP.get(host, DEFAULT_DOMAIN) | ||
| ``` | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.