Skip to content

Commit 94eda61

Browse files
docs: add docs for mcd
1 parent 0333212 commit 94eda61

File tree

4 files changed

+484
-37
lines changed

4 files changed

+484
-37
lines changed

EXAMPLES.md

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,23 @@
140140
- [Step-up Authentication](#step-up-authentication)
141141
- [Handling `MfaRequiredError`](#handling-mfarequirederror)
142142
- [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)
143160

144161
## Passing authorization parameters
145162

@@ -3628,3 +3645,370 @@ The SDK provides typed error classes for all MFA operations:
36283645
| `MfaTokenNotFoundError` | `mfa_token_not_found` | No MFA context for token | Token not in session |
36293646
| `MfaTokenExpiredError` | `mfa_token_expired` | Token TTL exceeded | Context expired |
36303647
| `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

Comments
 (0)