Skip to content

feat: Add Multiple Custom Domains (MCD) support#106

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

feat: Add Multiple Custom Domains (MCD) support#106
kishore7snehil merged 11 commits intomainfrom
feat/mcd-support

Conversation

@kishore7snehil
Copy link
Copy Markdown
Contributor

@kishore7snehil kishore7snehil commented Feb 11, 2026

This PR adds Multiple Custom Domains (MCD) support to auth0-fastapi, enabling FastAPI applications to use multiple custom domains configured on the same Auth0 tenant. This wrapper leverages the MCD implementation in auth0-server-python while handling FastAPI-specific concerns like dynamic redirect URI construction.

✨ Features

1. Dynamic Domain Configuration

  • Callable Domain Support: Accept Callable as domain parameter in Auth0Config
  • Dynamic Redirect URI: Automatically builds redirect_uri from request host when MCD enabled
  • Backward Compatible: Static string domains continue to work unchanged

Example:

from auth0_server_python.auth_types import DomainResolverContext

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

config = Auth0Config(
    domain=domain_resolver,  # Callable triggers MCD mode
    client_id="...",
    client_secret="...",
    app_base_url="https://myapp.com",
    secret="...",
)

2. Request Base URL Builder

  • Proxy Header Support: Handles x-forwarded-host and x-forwarded-proto
  • Port Normalization: Removes standard ports (80, 443) from URLs
  • MCD Ready: Different custom domains get different redirect URIs

Example:

# Request to https://login.brand-1.com/auth/login
# → redirect_uri = https://brand-1.yourapp.com/auth/callback

# Request to https://login.brand-2.com/auth/login
# → redirect_uri = https://brand-2.yourapp.com/auth/callback

3. Route Handler Updates

  • Login: Builds dynamic redirect_uri when domain is callable
  • Callback: Uses dynamic base URL for safe redirects
  • Logout: Builds dynamic returnTo URL for MCD scenarios

4. Backchannel Logout (BCLO) Fixes

  • OR Matching Logic: delete_by_logout_token now matches on sid OR sub (per OIDC Back-Channel Logout spec). Previously required both to match, which caused sessions to be missed during logout.
  • Cross-Domain Deletion Prevention: When iss is present in logout token claims, it is validated against the session's stored domain before deletion. Prevents a logout token for one custom domain from deleting sessions belonging to another domain in MCD deployments.
  • normalize_url Utility: Added RFC 3986 compliant URL normalization to util/__init__.py for reliable issuer-to-domain comparison (scheme normalization, lowercase host, default port removal).

🔄 Compatibility

Backward Compatible - No breaking changes for existing users.

Existing Usage (Unchanged)

# Static domain - works exactly as before
config = Auth0Config(domain="login.brand-1.com", ...)

New Usage (Optional)

# Dynamic MCD - new opt-in feature
config = Auth0Config(domain=domain_resolver, ...)

📊 Testing

Unit Tests

112 tests passing (poetry run pytest), including MCD-specific tests covering URL building, login/callback/logout routes, dependency injection, and session management.

Manual Integration Testing

Prerequisites:

  • One Auth0 tenant with two custom domains configured (e.g., login.brand-1.com and login.brand-2.com)
  • 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 (Settings → 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, Response
from fastapi.responses import HTMLResponse
from starlette.middleware.sessions import SessionMiddleware

from auth0_fastapi.config import Auth0Config
from auth0_fastapi.auth.auth_client import AuthClient
from auth0_fastapi.server.routes import router, register_auth_routes
from auth0_server_python.auth_types import DomainResolverContext

# 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",
}
DEFAULT_DOMAIN = "login.yourapp.com"

async def domain_resolver(context: DomainResolverContext) -> str:
    host = (context.request_headers or {}).get("x-forwarded-host") or \
           (context.request_headers or {}).get("host", "")
    host = host.split(":")[0]
    return DOMAIN_MAP.get(host, DEFAULT_DOMAIN)

app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key=os.environ["APP_SECRET"])

config = Auth0Config(
    domain=domain_resolver,
    client_id=os.environ["AUTH0_CLIENT_ID"],
    client_secret=os.environ["AUTH0_CLIENT_SECRET"],
    app_base_url="http://localhost:3000",
    secret=os.environ["APP_SECRET"],
    mount_routes=True,
    authorization_params={"scope": "openid profile email"},
)

app.state.config = config
auth_client = AuthClient(config)
app.state.auth_client = auth_client

register_auth_routes(router, config)
app.include_router(router)

@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
    user = await auth_client.client.get_user(store_options={"request": request})
    host = request.headers.get("host", "localhost")
    domain = DOMAIN_MAP.get(host.split(":")[0], DEFAULT_DOMAIN)
    if user:
        name = user.get("name", user.get("email", "Unknown"))
        return f"<p>Host: {host} | Domain: {domain}</p><p>Logged in as: {name}</p><a href='/auth/logout'>Logout</a>"
    return f"<p>Host: {host} | Domain: {domain}</p><p>Not logged in</p><a href='/auth/login'>Login</a>"

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/ Shows "Not logged in" (session bound to domain A)
5 Independent sessions per domain Log in via domain B separately Both sessions exist independently on the same tenant
6 Dynamic redirect_uri Log in via each domain redirect_uri in authorize URL matches the request host, not appBaseUrl
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 Auth0Config with domain="login.brand-1.com" (string) All flows work as before without any resolver
9 BCLO deletes session by sid only Send logout token with sid but no sub Session with matching sid is deleted
10 BCLO deletes session by sub only Send logout token with sub but no sid Session with matching sub is deleted
11 BCLO respects domain isolation Send logout token with iss for domain A Only domain A sessions deleted; domain B sessions remain

📚 Documentation

Document Description
examples/MultipleCustomDomains.md Developer guide with usage patterns, security best practices, and discovery cache guidance

@kishore7snehil kishore7snehil requested a review from a team as a code owner February 11, 2026 13:32
@kishore7snehil kishore7snehil merged commit bdb891b into main Apr 8, 2026
8 checks passed
@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.

2 participants