Skip to content

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

Open
kishore7snehil wants to merge 11 commits intomainfrom
feat/mcd-support
Open

feat: add Multiple Custom Domains (MCD) support and fix JWT verification#71
kishore7snehil wants to merge 11 commits intomainfrom
feat/mcd-support

Conversation

@kishore7snehil
Copy link
Contributor

@kishore7snehil kishore7snehil commented Feb 2, 2026

Multiple Custom Domains (MCD) Support with JWT Verification

🎯 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

🔄 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)

📊 Testing

Unit Tests

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

Manual Integration Testing

Prerequisites:

  • One Auth0 tenant with two custom domains configured (e.g., auth.acme.com and auth.globex.com both pointing to the same tenant)
  • A Regular Web Application in that tenant
  • Add local hostnames to /etc/hosts:
    127.0.0.1 acme.myapp.com globex.myapp.com
    

Setup:

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

  2. Register callback URLs in the application:

    • http://acme.myapp.com:3000/auth/callback
    • http://globex.myapp.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 = {
    "acme.myapp.com": "auth.acme.com",
    "globex.myapp.com": "auth.globex.com",
}

async def domain_resolver(context: DomainResolverContext) -> str:
    host = (context.request_headers or {}).get("host", "").split(":")[0]
    return DOMAIN_MAP.get(host, "auth.acme.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://acme.myapp.com:3000/auth/login Redirects to auth.acme.com/authorize
2 Login via custom domain B Visit http://globex.myapp.com:3000/auth/login Redirects to auth.globex.com/authorize
3 Callback validates issuer Complete login via domain A Callback succeeds, token issuer matches auth.acme.com
4 Session bound to origin domain Log in via domain A, then visit http://globex.myapp.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 auth.acme.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="auth.acme.com" (string) All flows work as before without any resolver

📚 Documentation

@kishore7snehil kishore7snehil requested a review from a team as a code owner February 2, 2026 18:13
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

Comments