Skip to content

feat: add Multiple Custom Domains (MCD) support and fix JWT verification#71

Merged
kishore7snehil merged 21 commits intomainfrom
feat/mcd-support
Apr 8, 2026
Merged

feat: add Multiple Custom Domains (MCD) support and fix JWT verification#71
kishore7snehil merged 21 commits intomainfrom
feat/mcd-support

Conversation

@kishore7snehil
Copy link
Copy Markdown
Contributor

@kishore7snehil kishore7snehil commented Feb 2, 2026

🎯 Overview

This PR adds Multiple Custom Domains (MCD) support to auth0-server-python, enabling applications to serve multiple custom domains on the same Auth0 tenant from a single application. Additionally, this PR includes critical security fixes for JWT verification and token refresh in MCD scenarios.

✨ Features

1. Multiple Custom Domains Support

  • Dynamic Domain Resolution: Accept Callable as domain parameter for runtime resolution
  • Type-Safe Context: New DomainResolverContext with request_url and request_headers
  • Backward Compatible: Static string domains continue to work unchanged
  • Framework-Agnostic: Works with FastAPI, Flask, Django, or any Python framework

Example:

async def domain_resolver(context: DomainResolverContext) -> str:
    host = context.request_headers.get('host', '').split(':')[0]
    return DOMAIN_MAP.get(host, DEFAULT_DOMAIN)

client = ServerClient(domain=domain_resolver, ...)  # MCD enabled

2. Unified Discovery Cache (OIDC Metadata & JWKS)

  • Unified Per-Domain Cache: Single OrderedDict stores both OIDC metadata and JWKS per domain
  • 10-Minute TTL: Cache entries expire after 10 minutes with automatic cleanup
  • LRU Eviction: Least recently used domains evicted first when max 100 domains reached
  • Expired Sweep: All expired entries purged on every cache miss
  • Linked Invalidation: Metadata expiry automatically invalidates corresponding JWKS
  • Lazy JWKS Population: JWKS fetched on first token verification, not on metadata fetch
  • Performance: Reduces API calls by ~99% for frequently used domains

3. JWT Signature Verification with Issuer Validation

  • JWKS-Based Verification: Proper signature validation using PyJWT
  • Normalized Issuer Matching: Custom _normalize_issuer() handles trailing slashes, case sensitivity, scheme differences
  • Key Extraction: Correctly extracts signing key from JWKS using kid
  • Security: Prevents token substitution and cross-tenant replay attacks

4. Domain-Specific Session Management

  • Automatic Isolation: Sessions bound to their origin domain
  • Cross-Domain Protection: Sessions from domain A rejected on domain B
  • Token Refresh Fix: Uses session's stored domain (not current request domain)
  • Migration Support: Sessions coexist during domain migrations

5. Backchannel Logout (BCLO) in MCD Mode

  • Domain Resolver as Trust Anchor: BCLO now validates the token's iss against the resolved domain (from Host header) before signature verification
  • Prevents Circular Trust: Previously, the token's iss determined which JWKS to use - an attacker-controlled token could choose its own verifier
  • iss in LogoutTokenClaims: iss is now a first-class field on LogoutTokenClaims (previously added dynamically to the claims dict)
  • Store Guidance: delete_by_logout_token abstract docstring documents OR matching logic and iss validation for store implementors

6. ID Token Verification in Custom Token Exchange

  • Signature Verification: login_with_custom_token_exchange now verifies ID token signature via JWKS (previously used verify_signature=False)
  • Issuer Validation: Validates token iss against OIDC metadata issuer
  • Aligned with Callback Flow: Uses the same _verify_and_decode_jwt path as the standard login callback

🔄 Compatibility

Backward Compatible - No breaking changes for existing users.

Existing Usage (Unchanged)

# Static domain - works exactly as before
client = ServerClient(domain="tenant.auth0.com", ...)

New Usage (Optional)

# Dynamic MCD - new opt-in feature
async def domain_resolver(context: DomainResolverContext) -> str:
    return "tenant.auth0.com"

client = ServerClient(domain=domain_resolver, ...)

What Changed Internally

  • JWT verification now correctly extracts signing key from JWKS (bug fix)
  • Token refresh uses session domain instead of request domain (bug fix)
  • Domain parameter now accepts callables for dynamic resolution (new feature)
  • OIDC metadata and JWKS caches unified into single discovery cache with LRU eviction (improvement)
  • Issuer validation uses normalized comparison for robustness (improvement)
  • BCLO in MCD mode now validates token iss against the resolved domain before signature verification (security fix)
  • login_with_custom_token_exchange now verifies ID token signature and issuer (security fix)
  • LogoutTokenClaims model now includes iss as an explicit field (improvement)
  • delete_by_logout_token abstract docstring updated with OR matching and iss validation guidance (improvement)

📊 Testing

Unit Tests

135 tests passing (poetry run pytest), including MCD-specific tests covering domain resolution, session isolation, cache behavior, issuer normalization, migration scenarios, and BCLO issuer validation

Manual Integration Testing

Prerequisites:

  • One Auth0 tenant with two custom domains configured (e.g., login.brand-1.com and login.brand-2.com both pointing to the same tenant)
  • A Regular Web Application in that tenant
  • Add local hostnames to /etc/hosts:
    127.0.0.1 brand-1.yourapp.com brand-2.yourapp.com
    

Setup:

  1. Configure two custom domains in your Auth0 tenant (Manage Dashboard → Branding → Custom Domains)

  2. Register callback URLs in the application:

    • http://brand-1.yourapp.com:3000/auth/callback
    • http://brand-2.yourapp.com:3000/auth/callback
  3. Create a FastAPI app with MCD:

import os
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse, JSONResponse
from auth0_server_python.auth_server.server_client import ServerClient
from auth0_server_python.auth_types import (
    DomainResolverContext, StartInteractiveLoginOptions, LogoutOptions
)

# Two custom domains on the same Auth0 tenant
DOMAIN_MAP = {
    "brand-1.yourapp.com": "login.brand-1.com",
    "brand-2.yourapp.com": "login.brand-2.com",
}

async def domain_resolver(context: DomainResolverContext) -> str:
    host = (context.request_headers or {}).get("host", "").split(":")[0]
    return DOMAIN_MAP.get(host, "login.brand-1.com")

auth0 = ServerClient(
    domain=domain_resolver,
    client_id=os.environ["AUTH0_CLIENT_ID"],
    client_secret=os.environ["AUTH0_CLIENT_SECRET"],
    secret=os.environ["SESSION_SECRET"],
)

app = FastAPI()

@app.get("/auth/login")
async def login(request: Request):
    url = await auth0.start_interactive_login(
        StartInteractiveLoginOptions(redirect_uri=f"http://{request.headers['host']}/auth/callback"),
        store_options={"request": request}
    )
    return RedirectResponse(url=url)

@app.get("/auth/callback")
async def callback(request: Request):
    result = await auth0.complete_interactive_login(
        str(request.url),
        store_options={"request": request}
    )
    return JSONResponse({"user": result["user"]})

@app.get("/profile")
async def profile(request: Request):
    user = await auth0.get_user(store_options={"request": request})
    if not user:
        return JSONResponse({"error": "not authenticated"}, status_code=401)
    return JSONResponse({"user": user})

@app.get("/auth/logout")
async def logout(request: Request):
    url = await auth0.logout(
        LogoutOptions(return_to=f"http://{request.headers['host']}"),
        store_options={"request": request}
    )
    return RedirectResponse(url=url)

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=3000)
  1. Start the app: python server.py

Test cases:

# Test Steps Expected
1 Login via custom domain A Visit http://brand-1.yourapp.com:3000/auth/login Redirects to login.brand-1.com/authorize
2 Login via custom domain B Visit http://brand-2.yourapp.com:3000/auth/login Redirects to login.brand-2.com/authorize
3 Callback validates issuer Complete login via domain A Callback succeeds, token issuer matches login.brand-1.com
4 Session bound to origin domain Log in via domain A, then visit http://brand-2.yourapp.com:3000/profile Returns 401 (session bound to domain A, not domain B)
5 Independent sessions per domain Log in via domain B separately Both sessions exist independently on the same tenant
6 Token refresh uses session domain Wait for access token expiry, request access token via domain A Refreshes using login.brand-1.com/oauth/token
7 Logout is domain-specific Log out via domain A Only domain A session cleared; domain B session remains
8 Static domain backward compat Create ServerClient with domain="login.brand-1.com" (string) All flows work as before without any resolver
9 BCLO rejects mismatched issuer Send logout token with iss not matching resolved domain Token rejected before signature verification
10 BCLO accepts matching issuer Send logout token with iss matching resolved domain Session deleted correctly
11 Custom token exchange verifies ID token Call login_with_custom_token_exchange with valid token ID token signature and issuer verified

📚 Documentation

@kishore7snehil kishore7snehil requested a review from a team as a code owner February 2, 2026 18:13
@kishore7snehil kishore7snehil merged commit 026b0df into main Apr 8, 2026
8 checks passed
@kishore7snehil kishore7snehil deleted the feat/mcd-support branch April 8, 2026 15:19
@kishore7snehil kishore7snehil mentioned this pull request Apr 8, 2026
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.

3 participants