Skip to content

Commit 385a87c

Browse files
authored
Adds support for multiple custom domains (#206)
2 parents 5638bca + c51ef46 commit 385a87c

27 files changed

Lines changed: 4129 additions & 199 deletions

EXAMPLES.md

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- [Organizations](#organizations)
77
- [Extra parameters](#extra-parameters)
88
- [Roles](#roles)
9+
- [Multiple Custom Domain (MCD) Support](#multiple-custom-domain-mcd-support)
910
- [Backchannel Logout](#backchannel-logout)
1011
- [Blazor Server](#blazor-server)
1112

@@ -314,6 +315,162 @@ public IActionResult Admin()
314315
}
315316
```
316317

318+
## Multiple Custom Domain (MCD) Support
319+
320+
Multiple Custom Domains (MCD) lets you resolve the Auth0 domain per request while keeping a single SDK instance. This is useful when one application serves multiple custom domains (for example, `brand-1.my-app.com` and `brand-2.my-app.com`), each mapped to a different `Auth0` custom domain.
321+
322+
`MCD` is enabled by providing a `DomainResolver` function instead of a static domain string, enabling you to dynamically define the `Auth0` custom domain at run-time.
323+
324+
Resolver mode is intended for the custom domains of a single `Auth0` tenant. It is not a supported way to connect multiple `Auth0` tenants to one application.
325+
326+
### Dynamic Domain Resolver
327+
328+
Provide a resolver function to select the domain at runtime. The resolver should return the `Auth0 Custom Domain` (for example, `brand-1.custom-domain.com`). Returning `null` or an empty value throws `InvalidOperationException`.
329+
330+
### Configure with a DomainResolver
331+
332+
Call `WithCustomDomains()` and provide a `DomainResolver` to resolve the domain dynamically based on the incoming request. The domain can be derived from a subdomain, request header, query parameter, or any other request attribute:
333+
334+
```csharp
335+
services.AddAuth0WebAppAuthentication(options =>
336+
{
337+
options.Domain = Configuration["Auth0:Domain"];
338+
options.ClientId = Configuration["Auth0:ClientId"];
339+
})
340+
.WithCustomDomains(options =>
341+
{
342+
// Example: resolve from a custom header
343+
options.DomainResolver = httpContext =>
344+
{
345+
var tenant = httpContext.Request.Headers["X-Tenant-Domain"].FirstOrDefault();
346+
return Task.FromResult(tenant ?? "default-tenant.auth0.com");
347+
};
348+
});
349+
```
350+
351+
### Resolve domain from subdomain
352+
353+
```csharp
354+
services.AddAuth0WebAppAuthentication(options =>
355+
{
356+
options.Domain = Configuration["Auth0:Domain"];
357+
options.ClientId = Configuration["Auth0:ClientId"];
358+
})
359+
.WithCustomDomains(options =>
360+
{
361+
// e.g., "acme.myapp.com" -> "acme.auth0.com"
362+
options.DomainResolver = httpContext =>
363+
{
364+
var host = httpContext.Request.Host.Host;
365+
var subdomain = host.Split('.')[0];
366+
return Task.FromResult($"{subdomain}.auth0.com");
367+
};
368+
});
369+
```
370+
371+
### Redirect URI requirements
372+
373+
When using MCD, the `redirectUri` must be an **absolute URL**. In MCD deployments, you will typically resolve the redirect URI per request so each domain uses the correct callback URL:
374+
375+
```csharp
376+
var authenticationProperties = new LoginAuthenticationPropertiesBuilder()
377+
// Resolve redirect URI based on the incoming request's host
378+
.WithRedirectUri($"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}/callback")
379+
.Build();
380+
381+
await HttpContext.ChallengeAsync(Auth0Constants.AuthenticationScheme, authenticationProperties);
382+
```
383+
384+
You must validate the host and scheme safely for your deployment to prevent open redirect attacks.
385+
386+
### Legacy sessions and migration
387+
388+
When moving from a static domain setup to a `DomainResolver`, existing sessions can continue to work if the resolver returns the same Auth0 custom domain that was used for those legacy sessions.
389+
390+
If the resolver returns a different domain, the SDK treats the session as missing and requires the user to sign in again. This is intentional to keep sessions isolated per domain.
391+
392+
### Security requirements
393+
394+
When configuring the `DomainResolver`, you are responsible for ensuring that all resolved domains are trusted. Mis-configuring the domain resolver is a critical security risk that can lead to authentication bypass on the relying party (RP) or expose the application to Server-Side Request Forgery (SSRF).
395+
396+
**Single tenant limitation:**
397+
The `DomainResolver` is intended solely for multiple custom domains belonging to the same Auth0 tenant. It is not a supported mechanism for connecting multiple Auth0 tenants to a single application.
398+
399+
**Secure proxy requirement:**
400+
When using MCD, your application must be deployed behind a secure edge or reverse proxy (e.g., Cloudflare, Nginx, or AWS ALB). The proxy must be configured to sanitize and overwrite `Host` and `X-Forwarded-Host` headers before they reach your application.
401+
402+
Without a trusted proxy layer to validate these headers, an attacker can manipulate the domain resolution process. This can result in malicious redirects, where users are sent to unauthorized or fraudulent endpoints during the login and logout flows.
403+
404+
### Configuration Manager Cache
405+
406+
You can control how OpenID Connect configuration managers are cached per domain with `ConfigurationManagerCache`.
407+
408+
By default, the SDK uses an in-memory cache with:
409+
- `maxSize: 100` entries
410+
- No expiration (entries remain until evicted by size pressure)
411+
412+
The cache is keyed by the OIDC metadata endpoint URL (e.g., `https://brand-1.custom-domain.com/.well-known/openid-configuration`). Each distinct domain resolved by `DomainResolver` occupies one cache entry.
413+
414+
Most applications can keep the defaults, but you may want to adjust them in the following cases:
415+
- Increase `maxSize` if one process may verify tokens for more than 100 distinct domains during its lifetime.
416+
- Decrease `maxSize` if memory usage matters more than avoiding repeated OIDC discovery setup.
417+
- Set `slidingExpiration` if you want entries that haven't been accessed within a given duration to be evicted automatically.
418+
- Use `NullConfigurationManagerCache` to disable caching entirely (not recommended for production).
419+
420+
Rule of thumb: set `maxSize` to cover the number of distinct domains a single process is expected to serve, with some headroom.
421+
422+
#### MemoryConfigurationManagerCache (Default)
423+
424+
```csharp
425+
.WithCustomDomains(options =>
426+
{
427+
options.DomainResolver = httpContext => { /* ... */ };
428+
429+
options.ConfigurationManagerCache = new MemoryConfigurationManagerCache(
430+
maxSize: 100, // Maximum number of domains to cache
431+
slidingExpiration: TimeSpan.FromHours(1) // Optional: evict entries not accessed within 1 hour
432+
);
433+
});
434+
```
435+
436+
#### NullConfigurationManagerCache
437+
438+
Disables caching entirely — a new configuration manager is created on every request (not recommended for production):
439+
440+
```csharp
441+
.WithCustomDomains(options =>
442+
{
443+
options.DomainResolver = httpContext => { /* ... */ };
444+
options.ConfigurationManagerCache = new NullConfigurationManagerCache();
445+
});
446+
```
447+
448+
#### Custom Cache Implementation
449+
450+
Implement `IConfigurationManagerCache` for custom caching strategies (e.g., a distributed cache):
451+
452+
```csharp
453+
public class MyCustomConfigurationManagerCache : IConfigurationManagerCache
454+
{
455+
public IConfigurationManager<OpenIdConnectConfiguration> GetOrCreate(
456+
string metadataAddress,
457+
Func<string, IConfigurationManager<OpenIdConnectConfiguration>> factory)
458+
{
459+
// Return a cached instance or call factory(metadataAddress) to create one
460+
}
461+
462+
public void Clear() { /* Evict all entries */ }
463+
public void Dispose() { /* Clean up resources */ }
464+
}
465+
466+
// Usage
467+
.WithCustomDomains(options =>
468+
{
469+
options.DomainResolver = httpContext => { /* ... */ };
470+
options.ConfigurationManagerCache = new MyCustomConfigurationManagerCache();
471+
});
472+
```
473+
317474
## Backchannel Logout
318475

319476
Backchannel logout can be configured by calling `WithBackchannelLogout()` when calling `AddAuth0WebAppAuthentication`.

README.md

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ A library based on `Microsoft.AspNetCore.Authentication.OpenIdConnect` to make i
66
![Downloads](https://img.shields.io/nuget/dt/auth0.aspnetcore.authentication)
77
[![License](https://img.shields.io/:license-MIT-blue.svg?style=flat)](https://opensource.org/licenses/MIT)
88
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/auth0/auth0-aspnetcore-authentication)
9-
![AzureDevOps](https://img.shields.io/azure-devops/build/Auth0SDK/Auth0.AspNetCore.Authentication/8)
9+
[![Build and Test](https://github.com/auth0/auth0-aspnetcore-authentication/actions/workflows/build.yml/badge.svg)](https://github.com/auth0/auth0-aspnetcore-authentication/actions/workflows/build.yml)
1010

1111
:books: [Documentation](#documentation) - :rocket: [Getting Started](#getting-started) - :computer: [API Reference](#api-reference) - :speech_balloon: [Feedback](#feedback)
1212

@@ -15,12 +15,12 @@ A library based on `Microsoft.AspNetCore.Authentication.OpenIdConnect` to make i
1515
- [Quickstart](https://auth0.com/docs/quickstart/webapp/aspnet-core) - our interactive guide for quickly adding login, logout and user information to an ASP.NET MVC application using Auth0.
1616
- [Sample App](https://github.com/auth0-samples/auth0-aspnetcore-mvc-samples/tree/master/Quickstart/Sample) - a full-fledged ASP.NET MVC application integrated with Auth0.
1717
- [Examples](https://github.com/auth0/auth0-aspnetcore-authentication/blob/main/EXAMPLES.md) - code samples for common ASP.NET MVC authentication scenario's.
18-
- [Docs site](https://www.auth0.com/docs) - explore our docs site and learn more about
18+
- [Docs site](https://www.auth0.com/docs) - explore our docs site and learn more about Auth0.
1919

2020
## Getting started
2121
### Requirements
2222

23-
This library supports .NET 6.0 and above.
23+
This library supports .NET 6.0, 7.0, 8.0, and 10.0.
2424

2525
### Installation
2626

@@ -114,6 +114,33 @@ For more code samples on how to integrate the **auth0-aspnetcore-authentication*
114114

115115
> This SDK also works with Blazor Server, for more info see [the Blazor Server section in our examples](https://github.com/auth0/auth0-aspnetcore-authentication/blob/main/EXAMPLES.md#blazor-server).
116116
117+
## Multiple Custom Domain (MCD) Support
118+
119+
Multiple Custom Domains (MCD) lets you resolve the Auth0 domain per request while keeping a single SDK instance. This is useful when one application serves multiple custom domains (for example, `brand-1.my-app.com` and `brand-2.my-app.com`), each mapped to a different `Auth0` custom domain.
120+
121+
Resolver mode is intended for the custom domains of a single `Auth0` tenant. It is not a supported way to connect multiple `Auth0` tenants to one application.
122+
123+
### Configuration
124+
125+
```csharp
126+
services.AddAuth0WebAppAuthentication(options =>
127+
{
128+
options.Domain = Configuration["Auth0:Domain"];
129+
options.ClientId = Configuration["Auth0:ClientId"];
130+
})
131+
.WithCustomDomains(options =>
132+
{
133+
// Example: resolve from a custom header
134+
options.DomainResolver = httpContext =>
135+
{
136+
var tenant = httpContext.Request.Headers["X-Tenant-Domain"].FirstOrDefault();
137+
return Task.FromResult(tenant ?? "default-tenant.auth0.com");
138+
};
139+
});
140+
```
141+
142+
For detailed configuration options, caching strategies, security requirements, and more examples, see the [Multiple Custom Domain (MCD) Examples](EXAMPLES.md#multiple-custom-domain-mcd-support).
143+
117144
## API reference
118145
Explore public API's available in auth0-aspnetcore-authentication.
119146

@@ -152,4 +179,4 @@ Please do not report security vulnerabilities on the public GitHub issue tracker
152179
</p>
153180
<p align="center">Auth0 is an easy to implement, adaptable authentication and authorization platform. To learn more checkout <a href="https://auth0.com/why-auth0">Why Auth0?</a></p>
154181
<p align="center">
155-
This project is licensed under the MIT license. See the <a href="https://github.com/auth0/auth0-aspnetcore-authentication/blob/main/LICENSE"> LICENSE</a> file for more info.</p>
182+
This project is licensed under the MIT license. See the <a href="https://github.com/auth0/auth0-aspnetcore-authentication/blob/main/LICENSE">LICENSE</a> file for more info.</p>

src/Auth0.AspNetCore.Authentication/Auth0Constants.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,10 @@ public class Auth0Constants
1414
/// The callback path to which Auth0 should redirect back, used when configuring OpenIdConnect
1515
/// </summary>
1616
internal static string DefaultCallbackPath = "/callback";
17+
18+
/// <summary>
19+
/// Key used to store the resolved domain in the authentication properties.
20+
/// </summary>
21+
internal static readonly string ResolvedDomainKey = "auth0:resolved-domain";
1722
}
1823
}

src/Auth0.AspNetCore.Authentication/Auth0WebAppAuthenticationBuilder.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
22
using Microsoft.Extensions.DependencyInjection;
3+
using Microsoft.Extensions.DependencyInjection.Extensions;
34
using Microsoft.Extensions.Options;
45
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
56
using System;
67
using System.Threading.Tasks;
78
using Auth0.AspNetCore.Authentication.BackchannelLogout;
9+
using Auth0.AspNetCore.Authentication.CustomDomains;
10+
using Microsoft.AspNetCore.Hosting;
811

912
namespace Auth0.AspNetCore.Authentication
1013
{
@@ -64,6 +67,49 @@ public Auth0WebAppAuthenticationBuilder WithBackchannelLogout()
6467
return this;
6568
}
6669

70+
/// <summary>
71+
/// Configures support for multiple Auth0 custom domains with dynamic domain resolution.
72+
/// </summary>
73+
/// <param name="configureOptions">A delegate used to configure the <see cref="Auth0CustomDomainsOptions"/></param>
74+
/// <returns>An instance of <see cref="Auth0WebAppAuthenticationBuilder"/></returns>
75+
public Auth0WebAppAuthenticationBuilder WithCustomDomains(Action<Auth0CustomDomainsOptions> configureOptions)
76+
{
77+
EnableCustomDomains(configureOptions);
78+
return this;
79+
}
80+
81+
private void EnableCustomDomains(Action<Auth0CustomDomainsOptions> configureOptions)
82+
{
83+
var customDomainsOptions = new Auth0CustomDomainsOptions();
84+
configureOptions(customDomainsOptions);
85+
86+
// Validate that DomainResolver is configured
87+
if (customDomainsOptions.DomainResolver == null)
88+
{
89+
throw new InvalidOperationException(
90+
$"DomainResolver must be configured when using {nameof(WithCustomDomains)}. " +
91+
$"Set the {nameof(Auth0CustomDomainsOptions.DomainResolver)} property to provide a function that resolves the Auth0 domain for each request.");
92+
}
93+
94+
// Register the options for this authentication scheme
95+
_services.Configure(_authenticationScheme, configureOptions);
96+
97+
// Register HttpContextAccessor - required for domain resolution
98+
_services.AddHttpContextAccessor();
99+
100+
// Register HttpClient - required for fetching OIDC configuration per domain
101+
_services.AddHttpClient();
102+
103+
// Register the startup filter to resolve domain early in the request pipeline
104+
_services.TryAddEnumerable(
105+
ServiceDescriptor.Singleton<IStartupFilter, Auth0CustomDomainStartupFilter>(
106+
_ => new Auth0CustomDomainStartupFilter(_authenticationScheme)));
107+
108+
// Register the post-configure options to set up custom ConfigurationManager
109+
_services.TryAddEnumerable(
110+
ServiceDescriptor.Singleton<IPostConfigureOptions<OpenIdConnectOptions>, Auth0CustomDomainsOpenIdConnectPostConfigureOptions>());
111+
}
112+
67113
private void EnableWithAccessToken(Action<Auth0WebAppWithAccessTokenOptions> configureOptions)
68114
{
69115
var auth0WithAccessTokensOptions = new Auth0WebAppWithAccessTokenOptions();

src/Auth0.AspNetCore.Authentication/AuthenticationBuilderExtensions.cs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,16 @@ private static Func<CookieValidatePrincipalContext, Task> CreateOnValidatePrinci
156156
{
157157
await VerifyBackchannelLogoutSupport(context.HttpContext, oidcOptions);
158158

159-
var issuer = $"https://{options.Domain}/";
159+
// Prefer issuer from the authenticated principal
160+
var resolvedIssuer = context.HttpContext.User?.FindFirst("iss")?.Value;
161+
162+
// Fall back to the domain resolved by StartupFilter (cached in HttpContext.Items)
163+
if (string.IsNullOrWhiteSpace(resolvedIssuer))
164+
{
165+
resolvedIssuer = context.HttpContext.GetResolvedDomain();
166+
}
167+
168+
var issuer = Utils.ToAuthority(resolvedIssuer ?? $"https://{options.Domain}/");
160169
var sid = context.Principal?.FindFirst("sid")?.Value;
161170

162171
var isLoggedOut = await logoutTokenHandler.IsLoggedOutAsync(issuer, sid);
@@ -196,7 +205,7 @@ private static async Task RefreshTokenIfNeccesary(CookieValidatePrincipalContext
196205

197206
if (isExpired && !string.IsNullOrWhiteSpace(refreshToken))
198207
{
199-
var result = await RefreshTokens(options, refreshToken, oidcOptions.Backchannel);
208+
var result = await RefreshTokens(context.HttpContext, options, refreshToken, oidcOptions.Backchannel);
200209

201210
if (result != null)
202211
{
@@ -239,10 +248,14 @@ private static async Task RefreshTokenIfNeccesary(CookieValidatePrincipalContext
239248
}
240249
}
241250

242-
private static async Task<AccessTokenResponse?> RefreshTokens(Auth0WebAppOptions options, string refreshToken, HttpClient httpClient)
251+
private static async Task<AccessTokenResponse?> RefreshTokens(HttpContext httpContext, Auth0WebAppOptions options, string refreshToken, HttpClient httpClient)
243252
{
244253
var tokenClient = new TokenClient(httpClient);
245-
return await tokenClient.Refresh(options, refreshToken);
254+
255+
// Get the resolved domain from HttpContext if available (for multiple custom domains)
256+
var resolvedDomain = httpContext.GetResolvedDomain();
257+
258+
return await tokenClient.Refresh(options, refreshToken, resolvedDomain);
246259
}
247260

248261
private static async Task VerifyBackchannelLogoutSupport(HttpContext context, OpenIdConnectOptions oidcOptions)

0 commit comments

Comments
 (0)