|
140 | 140 | - [Step-up Authentication](#step-up-authentication) |
141 | 141 | - [Handling `MfaRequiredError`](#handling-mfarequirederror) |
142 | 142 | - [MFA Tenant Configuration](#mfa-tenant-configuration) |
| 143 | +- [Multiple Custom Domains (MCD)](#multiple-custom-domains-mcd) |
| 144 | + - [Overview](#overview-1) |
| 145 | + - [Static Mode (Default)](#static-mode-default) |
| 146 | + - [Resolver Mode](#resolver-mode) |
| 147 | + - [Basic Setup](#basic-setup) |
| 148 | + - [DomainResolver Signature](#domainresolver-signature) |
| 149 | + - [Use Cases](#use-cases) |
| 150 | + - [B2C Multi-Brand](#b2c-multi-brand) |
| 151 | + - [B2B SaaS with Database Lookup](#b2b-saas-with-database-lookup) |
| 152 | + - [URL-Based Routing](#url-based-routing) |
| 153 | + - [Discovery Cache Configuration](#discovery-cache-configuration) |
| 154 | + - [MCD with Dynamic appBaseUrl](#mcd-with-dynamic-appbaseurl) |
| 155 | + - [Session Domain Isolation](#session-domain-isolation) |
| 156 | + - [Error Handling](#error-handling-mcd) |
| 157 | + - [Security Considerations](#security-considerations-mcd) |
| 158 | + - [Backward Compatibility](#backward-compatibility) |
| 159 | + - [Debugging MCD Issues](#debugging-mcd-issues) |
143 | 160 |
|
144 | 161 | ## Passing authorization parameters |
145 | 162 |
|
@@ -3628,3 +3645,370 @@ The SDK provides typed error classes for all MFA operations: |
3628 | 3645 | | `MfaTokenNotFoundError` | `mfa_token_not_found` | No MFA context for token | Token not in session | |
3629 | 3646 | | `MfaTokenExpiredError` | `mfa_token_expired` | Token TTL exceeded | Context expired | |
3630 | 3647 | | `MfaTokenInvalidError` | `mfa_token_invalid` | Token tampered or wrong secret | Decryption failed | |
| 3648 | +
|
| 3649 | +## Multiple Custom Domains (MCD) |
| 3650 | +
|
| 3651 | +### Overview |
| 3652 | +
|
| 3653 | +Multiple Custom Domains (MCD) enables a single `@auth0/nextjs-auth0` instance to authenticate users against different Auth0 custom domains on the same tenant. This is useful for: |
| 3654 | +
|
| 3655 | +- **B2C Multi-Brand**: Multiple branded auth domains (`auth.brand1.com`, `auth.brand2.com`) on a single Auth0 tenant |
| 3656 | +- **B2B SaaS**: Dynamic per-customer domains (`auth.customer-a.com`, `auth.customer-b.com`) resolved at runtime |
| 3657 | +- **Domain Migration**: Both old and new domains valid simultaneously during transition |
| 3658 | +
|
| 3659 | +The SDK operates in two modes: |
| 3660 | +
|
| 3661 | +| Mode | Configuration | Behavior | |
| 3662 | +|------|--------------|----------| |
| 3663 | +| **Static** (default) | `domain: "example.auth0.com"` | Single domain, zero overhead, existing behavior preserved | |
| 3664 | +| **Resolver** | `domain: (ctx) => string` | Per-request domain resolution via `DomainResolver` function | |
| 3665 | +
|
| 3666 | +### Static Mode (Default) |
| 3667 | +
|
| 3668 | +Static mode requires no changes. Existing applications continue to work as-is: |
| 3669 | +
|
| 3670 | +```ts |
| 3671 | +import { Auth0Client } from "@auth0/nextjs-auth0/server"; |
| 3672 | +
|
| 3673 | +// Static mode — single domain, same as pre-MCD behavior |
| 3674 | +export const auth0 = new Auth0Client({ |
| 3675 | + domain: "example.us.auth0.com" |
| 3676 | +}); |
| 3677 | +``` |
| 3678 | +
|
| 3679 | +Or via environment variable: |
| 3680 | +
|
| 3681 | +```env |
| 3682 | +AUTH0_DOMAIN=example.us.auth0.com |
| 3683 | +``` |
| 3684 | +
|
| 3685 | +### Resolver Mode |
| 3686 | +
|
| 3687 | +#### Basic Setup |
| 3688 | +
|
| 3689 | +Pass a function as the `domain` option to enable resolver mode: |
| 3690 | +
|
| 3691 | +```ts |
| 3692 | +import { Auth0Client } from "@auth0/nextjs-auth0/server"; |
| 3693 | +
|
| 3694 | +export const auth0 = new Auth0Client({ |
| 3695 | + domain: ({ headers }) => { |
| 3696 | + const host = headers.get("host") ?? ""; |
| 3697 | + if (host.startsWith("brand1.")) return "auth.brand1.com"; |
| 3698 | + if (host.startsWith("brand2.")) return "auth.brand2.com"; |
| 3699 | + return "auth.default.com"; |
| 3700 | + } |
| 3701 | +}); |
| 3702 | +``` |
| 3703 | +
|
| 3704 | +> [!IMPORTANT] |
| 3705 | +> In resolver mode, the SDK automatically enforces the `openid` scope to ensure the callback contains an ID token with an `iss` claim for issuer validation. |
| 3706 | +
|
| 3707 | +#### DomainResolver Signature |
| 3708 | +
|
| 3709 | +```ts |
| 3710 | +type DomainResolver = (config: { |
| 3711 | + headers: Headers; |
| 3712 | + url?: URL; |
| 3713 | +}) => Promise<string> | string; |
| 3714 | +``` |
| 3715 | +
|
| 3716 | +| Parameter | Type | Description | |
| 3717 | +|-----------|------|-------------| |
| 3718 | +| `config.headers` | `Headers` | Request headers from the current context | |
| 3719 | +| `config.url` | `URL \| undefined` | Request URL when available. `undefined` in Server Components/Actions | |
| 3720 | +
|
| 3721 | +**Context availability by Next.js environment:** |
| 3722 | +
|
| 3723 | +| Environment | `headers` | `url` | |
| 3724 | +|------------|-----------|-------| |
| 3725 | +| Middleware / Route Handlers | From `NextRequest` | `NextRequest.nextUrl` | |
| 3726 | +| Pages Router (`getServerSideProps`, API Routes) | From `IncomingMessage` | Constructed from `req.url` + Host | |
| 3727 | +| App Router Server Components / Server Actions | Via `headers()` from `next/headers` | `undefined` | |
| 3728 | +
|
| 3729 | +**Return value:** The Auth0 custom domain hostname (e.g., `"auth.brand1.com"`). Must throw on failure — the SDK wraps thrown errors in `DomainResolutionError`. |
| 3730 | +
|
| 3731 | +### Use Cases |
| 3732 | +
|
| 3733 | +#### B2C Multi-Brand |
| 3734 | +
|
| 3735 | +Multiple branded login experiences on a single tenant. Each brand has its own Auth0 custom domain. |
| 3736 | +
|
| 3737 | +```ts |
| 3738 | +export const auth0 = new Auth0Client({ |
| 3739 | + domain: ({ headers }) => { |
| 3740 | + const host = headers.get("host") ?? ""; |
| 3741 | +
|
| 3742 | + const brandDomains: Record<string, string> = { |
| 3743 | + "brand1.example.com": "auth.brand1.com", |
| 3744 | + "brand2.example.com": "auth.brand2.com", |
| 3745 | + }; |
| 3746 | +
|
| 3747 | + const domain = brandDomains[host]; |
| 3748 | + if (!domain) throw new Error(`Unknown brand host: ${host}`); |
| 3749 | + return domain; |
| 3750 | + } |
| 3751 | +}); |
| 3752 | +``` |
| 3753 | +
|
| 3754 | +#### B2B SaaS with Database Lookup |
| 3755 | +
|
| 3756 | +Resolve the Auth0 domain from a tenant database at runtime: |
| 3757 | +
|
| 3758 | +```ts |
| 3759 | +export const auth0 = new Auth0Client({ |
| 3760 | + domain: async ({ headers }) => { |
| 3761 | + const tenantId = headers.get("x-tenant-id"); |
| 3762 | + if (!tenantId) throw new Error("Missing x-tenant-id header"); |
| 3763 | +
|
| 3764 | + const tenant = await db.tenants.findUnique({ where: { id: tenantId } }); |
| 3765 | + if (!tenant?.auth0Domain) { |
| 3766 | + throw new Error(`No Auth0 domain configured for tenant: ${tenantId}`); |
| 3767 | + } |
| 3768 | +
|
| 3769 | + return tenant.auth0Domain; |
| 3770 | + } |
| 3771 | +}); |
| 3772 | +``` |
| 3773 | +
|
| 3774 | +#### URL-Based Routing |
| 3775 | +
|
| 3776 | +Use the request URL to determine the domain (available in Middleware and Route Handlers): |
| 3777 | +
|
| 3778 | +```ts |
| 3779 | +export const auth0 = new Auth0Client({ |
| 3780 | + domain: ({ url }) => { |
| 3781 | + const subdomain = url?.hostname?.split(".")[0]; |
| 3782 | +
|
| 3783 | + switch (subdomain) { |
| 3784 | + case "us": return "auth-us.example.com"; |
| 3785 | + case "eu": return "auth-eu.example.com"; |
| 3786 | + default: return "auth.example.com"; |
| 3787 | + } |
| 3788 | + } |
| 3789 | +}); |
| 3790 | +``` |
| 3791 | +
|
| 3792 | +> [!NOTE] |
| 3793 | +> `url` is `undefined` in Server Components and Server Actions. If your resolver depends on the URL, fall back to parsing `headers.get("host")` or use a cookie/header-based approach for those contexts. |
| 3794 | +
|
| 3795 | +### Discovery Cache Configuration |
| 3796 | +
|
| 3797 | +The SDK caches OIDC discovery metadata (`.well-known/openid-configuration`) to minimize network requests. Configure via the `discoveryCache` option: |
| 3798 | +
|
| 3799 | +```ts |
| 3800 | +export const auth0 = new Auth0Client({ |
| 3801 | + domain: myDomainResolver, |
| 3802 | + discoveryCache: { |
| 3803 | + ttl: 300, // Cache TTL in seconds (default: 600) |
| 3804 | + maxEntries: 50, // Max cached issuers (default: 100, LRU eviction) |
| 3805 | + maxJwksEntries: 20 // Max cached JWKS entries (default: 10, LRU eviction) |
| 3806 | + } |
| 3807 | +}); |
| 3808 | +``` |
| 3809 | +
|
| 3810 | +| Option | Type | Default | Description | |
| 3811 | +|--------|------|---------|-------------| |
| 3812 | +| `ttl` | `number` | `600` | Time-to-live for cached metadata (seconds) | |
| 3813 | +| `maxEntries` | `number` | `100` | Maximum issuers to cache (LRU eviction when exceeded) | |
| 3814 | +| `maxJwksEntries` | `number` | `10` | Maximum JWKS endpoints to cache (independent LRU) | |
| 3815 | +
|
| 3816 | +The cache also deduplicates in-flight requests — multiple concurrent requests for the same domain share a single discovery fetch. |
| 3817 | +
|
| 3818 | +### MCD with Dynamic appBaseUrl |
| 3819 | +
|
| 3820 | +`domain` and `appBaseUrl` are orthogonal: |
| 3821 | +
|
| 3822 | +- **`domain`** resolves which Auth0 custom domain to authenticate against |
| 3823 | +- **`appBaseUrl`** resolves your application's own origin for `redirect_uri` construction |
| 3824 | +
|
| 3825 | +All `appBaseUrl` modes compose with MCD resolver mode: |
| 3826 | +
|
| 3827 | +```ts |
| 3828 | +// B2C Multi-Brand: single app domain, multiple Auth0 domains |
| 3829 | +export const auth0 = new Auth0Client({ |
| 3830 | + appBaseUrl: "https://app.example.com", |
| 3831 | + domain: ({ headers }) => { |
| 3832 | + // Resolve Auth0 domain from a cookie or header |
| 3833 | + const brand = headers.get("x-brand") ?? "default"; |
| 3834 | + return `auth.${brand}.example.com`; |
| 3835 | + } |
| 3836 | +}); |
| 3837 | +``` |
| 3838 | +
|
| 3839 | +```ts |
| 3840 | +// B2B Multi-Tenant: dynamic app origins + dynamic Auth0 domains |
| 3841 | +export const auth0 = new Auth0Client({ |
| 3842 | + appBaseUrl: [ |
| 3843 | + "https://tenant-a.example.com", |
| 3844 | + "https://tenant-b.example.com" |
| 3845 | + ], |
| 3846 | + domain: async ({ headers }) => { |
| 3847 | + const host = headers.get("host") ?? ""; |
| 3848 | + return await resolveTenantDomain(host); |
| 3849 | + } |
| 3850 | +}); |
| 3851 | +``` |
| 3852 | +
|
| 3853 | +```ts |
| 3854 | +// Fully Dynamic: inferred app origin + dynamic Auth0 domains |
| 3855 | +export const auth0 = new Auth0Client({ |
| 3856 | + // appBaseUrl omitted — inferred from request host |
| 3857 | + domain: async ({ headers }) => { |
| 3858 | + return await lookupAuth0Domain(headers); |
| 3859 | + } |
| 3860 | +}); |
| 3861 | +``` |
| 3862 | +
|
| 3863 | +> [!IMPORTANT] |
| 3864 | +> When using MCD with dynamic `appBaseUrl`, ensure all resulting callback and logout URLs are registered in your Auth0 application's **Allowed Callback URLs** and **Allowed Logout URLs**. |
| 3865 | +
|
| 3866 | +### Session Domain Isolation |
| 3867 | +
|
| 3868 | +In resolver mode, sessions are bound to the domain that created them. The SDK stores domain and issuer metadata in the session's internal state: |
| 3869 | +
|
| 3870 | +``` |
| 3871 | +SessionData.internal.mcd = { |
| 3872 | + domain: "auth.brand1.com", |
| 3873 | + issuer: "https://auth.brand1.com/" |
| 3874 | +} |
| 3875 | +``` |
| 3876 | +
|
| 3877 | +**Behavior:** |
| 3878 | +
|
| 3879 | +- When reading a session (`getSession`, `getAccessToken`, middleware), the SDK resolves the current domain and compares it to `session.internal.mcd.domain` |
| 3880 | +- If domains differ, the session is treated as **not found** (returns `null`) — not an error |
| 3881 | +- If domain resolution itself fails, `DomainResolutionError` is thrown |
| 3882 | +- In static mode, no domain check is performed (backward compatible) |
| 3883 | +
|
| 3884 | +This prevents a session created via `auth.brand1.com` from being used when the request resolves to `auth.brand2.com`, even if cookies are shared across subdomains. |
| 3885 | +
|
| 3886 | +### Error Handling {#error-handling-mcd} |
| 3887 | +
|
| 3888 | +MCD introduces three new error classes, all extending `SdkError`: |
| 3889 | +
|
| 3890 | +```ts |
| 3891 | +import { |
| 3892 | + DomainResolutionError, |
| 3893 | + DomainValidationError, |
| 3894 | + IssuerValidationError |
| 3895 | +} from "@auth0/nextjs-auth0/server"; |
| 3896 | +``` |
| 3897 | +
|
| 3898 | +| Error | Code | When Thrown | |
| 3899 | +|-------|------|-------------| |
| 3900 | +| `DomainResolutionError` | `domain_resolution_error` | Resolver throws or returns empty string | |
| 3901 | +| `DomainValidationError` | `domain_validation_error` | Resolved domain is not a valid hostname (IP, localhost, has path/port) | |
| 3902 | +| `IssuerValidationError` | `issuer_validation_error` | ID token `iss` claim doesn't match the expected issuer during callback | |
| 3903 | +
|
| 3904 | +**Handling resolver errors:** |
| 3905 | +
|
| 3906 | +```ts |
| 3907 | +import { auth0 } from "@/lib/auth0"; |
| 3908 | +import { DomainResolutionError } from "@auth0/nextjs-auth0/server"; |
| 3909 | +
|
| 3910 | +export default async function Page() { |
| 3911 | + try { |
| 3912 | + const session = await auth0.getSession(); |
| 3913 | + // ... |
| 3914 | + } catch (error) { |
| 3915 | + if (error instanceof DomainResolutionError) { |
| 3916 | + // Domain resolver failed — possibly missing header or DB error |
| 3917 | + console.error("Domain resolution failed:", error.message, error.cause); |
| 3918 | + // Show fallback or redirect |
| 3919 | + } |
| 3920 | + throw error; |
| 3921 | + } |
| 3922 | +} |
| 3923 | +``` |
| 3924 | +
|
| 3925 | +**Handling issuer validation errors:** |
| 3926 | +
|
| 3927 | +`IssuerValidationError` is thrown during the authentication callback when the ID token's issuer doesn't match the transaction's expected issuer. This indicates a potential cross-domain token confusion attack or misconfiguration. |
| 3928 | +
|
| 3929 | +```ts |
| 3930 | +export const auth0 = new Auth0Client({ |
| 3931 | + domain: myResolver, |
| 3932 | + onCallback: async (error, context, session) => { |
| 3933 | + if (error instanceof IssuerValidationError) { |
| 3934 | + console.error( |
| 3935 | + `Issuer mismatch: expected ${error.expectedIssuer}, got ${error.actualIssuer}` |
| 3936 | + ); |
| 3937 | + return new NextResponse("Authentication failed", { status: 403 }); |
| 3938 | + } |
| 3939 | + // Handle other errors... |
| 3940 | + } |
| 3941 | +}); |
| 3942 | +``` |
| 3943 | +
|
| 3944 | +### Security Considerations {#security-considerations-mcd} |
| 3945 | +
|
| 3946 | +#### Resolver Input Validation |
| 3947 | +
|
| 3948 | +The `DomainResolver` receives request headers and optional URL. The SDK validates the resolver's **output** (domain hostname format), but the resolver is responsible for its own **input** validation: |
| 3949 | +
|
| 3950 | +```ts |
| 3951 | +// GOOD: Validate against an allowlist |
| 3952 | +const auth0 = new Auth0Client({ |
| 3953 | + domain: ({ headers }) => { |
| 3954 | + const host = headers.get("host") ?? ""; |
| 3955 | + const allowed = ["brand1.example.com", "brand2.example.com"]; |
| 3956 | + const match = allowed.find(d => host.includes(d)); |
| 3957 | + if (!match) throw new Error(`Untrusted host: ${host}`); |
| 3958 | + return domainMap[match]; |
| 3959 | + } |
| 3960 | +}); |
| 3961 | +
|
| 3962 | +// BAD: Trusting raw header input without validation |
| 3963 | +const auth0 = new Auth0Client({ |
| 3964 | + domain: ({ headers }) => { |
| 3965 | + return headers.get("x-auth-domain")!; // Never trust raw headers! |
| 3966 | + } |
| 3967 | +}); |
| 3968 | +``` |
| 3969 | +
|
| 3970 | +#### Domain Validation |
| 3971 | +
|
| 3972 | +The SDK rejects domains that are: |
| 3973 | +- IPv4 or IPv6 addresses |
| 3974 | +- `localhost` or `.local` domains (unless `allowInsecureRequests` is enabled for dev) |
| 3975 | +- Hostnames containing paths, ports, or non-HTTPS schemes |
| 3976 | +
|
| 3977 | +#### Issuer Validation |
| 3978 | +
|
| 3979 | +During the authentication callback, the SDK performs dual-layer issuer validation: |
| 3980 | +1. Uses the transaction's `originDomain` for OIDC discovery (not the currently resolved domain) |
| 3981 | +2. Explicitly compares the ID token `iss` claim against the stored `originIssuer` |
| 3982 | +
|
| 3983 | +This prevents cross-domain token confusion where an attacker redirects the callback to a different domain. |
| 3984 | +
|
| 3985 | +### Backward Compatibility |
| 3986 | +
|
| 3987 | +MCD is fully backward compatible: |
| 3988 | +
|
| 3989 | +- **Existing apps**: No changes required. `domain: "string"` and `AUTH0_DOMAIN` env var work as before |
| 3990 | +- **Pre-MCD sessions**: Sessions without `internal.mcd` metadata continue to work in static mode. In resolver mode, pre-MCD sessions are rejected (fail-closed) to prevent domain confusion |
| 3991 | +- **In-flight transactions**: Transaction cookies without `originDomain`/`originIssuer` are handled gracefully during SDK upgrades. The extra issuer validation is skipped for legacy transactions |
| 3992 | +- **Type safety**: `domain` accepts `string | DomainResolver` — existing string configurations are unchanged |
| 3993 | +
|
| 3994 | +### Debugging MCD Issues |
| 3995 | +
|
| 3996 | +**Common issues and solutions:** |
| 3997 | +
|
| 3998 | +| Symptom | Cause | Solution | |
| 3999 | +|---------|-------|----------| |
| 4000 | +| `DomainResolutionError` on every request | Resolver throws or returns empty | Check resolver logic and available headers | |
| 4001 | +| Session returns `null` unexpectedly | Domain mismatch between login and current request | Verify resolver returns the same domain for login and subsequent requests | |
| 4002 | +| `IssuerValidationError` during callback | Token issued by different domain than expected | Ensure the resolver is deterministic for the same user/session | |
| 4003 | +| `DomainValidationError` | Resolver returned an IP, localhost, or invalid hostname | Return a valid FQDN from the resolver | |
| 4004 | +| Pre-MCD sessions rejected | Upgrading to resolver mode with existing sessions | Users need to re-authenticate. Sessions created in static mode lack domain metadata | |
| 4005 | +
|
| 4006 | +**Inspecting session domain metadata:** |
| 4007 | +
|
| 4008 | +```ts |
| 4009 | +const session = await auth0.getSession(); |
| 4010 | +if (session) { |
| 4011 | + console.log("Session domain:", session.internal.mcd?.domain); |
| 4012 | + console.log("Session issuer:", session.internal.mcd?.issuer); |
| 4013 | +} |
| 4014 | +``` |
0 commit comments