Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,8 @@ src/Auth0.ManagementApi/Types/ResourceServerTokenEncryptionKey.cs

src/Auth0.ManagementApi/Wrapper
src/Auth0.ManagementApi/CHANGELOG.md

src/Auth0.ManagementApi/Core/CustomDomainInterceptor.cs
src/Auth0.ManagementApi/CustomDomainHeader.cs
tests/Auth0.ManagementApi.Test/Core/CustomDomainInterceptorTest.cs
tests/Auth0.ManagementApi.Test/Unit/MockServer/CustomDomainHeaderTest.cs
152 changes: 149 additions & 3 deletions Examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,10 @@ await authClient.DeleteMfaAuthenticatorAsync(
- [4.3 Get a specific Network ACL configuration](#43-get-a-specific-network-acl-configuration)
- [4.4 Update Network ACL with a PATCH request](#44-update-network-acl-with-a-patch-request)
- [4.5 Update Network ACL with a PUT request](#45-update-network-acl-with-a-put-request)
- [5. Using a Custom Domain with the Management API](#5-using-a-custom-domain-with-the-management-api)
- [5.1 Let the client manage the connection (simplest)](#51-let-the-client-manage-the-connection-simplest)
- [5.2 Bring your own connection](#52-bring-your-own-connection)
- [5. Multiple Custom Domain (MCD) Header](#5-multiple-custom-domain-mcd-header)
- [5.1 Global configuration via ManagementClient (recommended)](#51-global-configuration-via-managementclient-recommended)
- [5.2 Per-request override](#52-per-request-override)
- [5.3 Global configuration via ManagementApiClient](#53-global-configuration-via-managementapiclient)

## 1. Management Client Initialization

Expand Down Expand Up @@ -566,3 +567,148 @@ public async Task SetNetworkAcl(string aclId)
```

[Go to Top](#)

## 5. Multiple Custom Domain (MCD) Header

Auth0 tenants with Multiple Custom Domains enabled must supply the `Auth0-Custom-Domain` header
on the Management API endpoints that generate user-facing links. The affected endpoints are:

- `POST /api/v2/tickets/email-verification`
- `POST /api/v2/tickets/password-change`
- `POST /api/v2/organizations/{id}/invitations`
- `POST /api/v2/guardian/enrollments/ticket`
- `POST /api/v2/jobs/verification-email`
- `POST /api/v2/jobs/users-imports` (when `verify_email: true`)
- `POST /api/v2/users` (Create)
- `PATCH /api/v2/users/{id}` (Update, when `verify_email: true`)

### 5.1 Global configuration via ManagementClient (recommended)

When `CustomDomain` is set on `ManagementClientOptions` and no custom `HttpClient` is provided,
the SDK automatically configures a `CustomDomainInterceptor` that strips the header from any
endpoint not on the whitelist above.

```csharp
using Auth0.ManagementApi;

public async Task UseCustomDomainGlobal()
{
var client = new ManagementClient(new ManagementClientOptions
{
Domain = "my.auth0.domain",
TokenProvider = new ClientCredentialsTokenProvider(
domain: "my.auth0.domain",
clientId: "clientId",
clientSecret: "clientSecret"
),
CustomDomain = "login.mycompany.com"
});

// Auth0-Custom-Domain header is sent automatically on whitelisted endpoints
// and stripped from all others.
var ticket = await client.Tickets.VerifyEmailAsync(
new VerifyEmailTicketRequestContent { UserId = "auth0|abc123" });

Console.WriteLine($"Ticket URL: {ticket.Ticket}");
}
```

[Go to Top](#)

### 5.2 Per-request override

Use `CustomDomainHeader.For()` to supply the header for a single call without configuring it
globally. This is useful when only a subset of calls require the header, or when you need to
use a different domain for a specific request.

```csharp
using Auth0.ManagementApi;

public async Task UseCustomDomainPerRequest()
{
// Client without a global custom domain
var client = new ManagementClient(new ManagementClientOptions
{
Domain = "my.auth0.domain",
TokenProvider = new ClientCredentialsTokenProvider(
domain: "my.auth0.domain",
clientId: "clientId",
clientSecret: "clientSecret"
)
});

// Supply the header only for this call
var ticket = await client.Tickets.VerifyEmailAsync(
new VerifyEmailTicketRequestContent { UserId = "auth0|abc123" },
CustomDomainHeader.For("login.mycompany.com"));

Console.WriteLine($"Ticket URL: {ticket.Ticket}");

// Works with any whitelisted endpoint
var invitation = await client.Organizations.Invitations.CreateAsync(
"org_123",
new CreateOrganizationInvitationRequestContent
{
Inviter = new OrganizationInvitationInviter { Name = "Admin" },
Invitee = new OrganizationInvitationInvitee { Email = "user@example.com" },
ClientId = "clientId"
},
CustomDomainHeader.For("login.mycompany.com"));
}
```

> **Combining with other per-request options:** `CustomDomainHeader.For()` is a convenience
> helper that returns a `RequestOptions` pre-populated with only the `Auth0-Custom-Domain`
> header. When you also need to set other options on the same call (additional headers, timeout,
> retries), construct `RequestOptions` directly instead:
>
> ```csharp
> await client.Tickets.VerifyEmailAsync(
> new VerifyEmailTicketRequestContent { UserId = "auth0|abc123" },
> new RequestOptions
> {
> AdditionalHeaders = new[]
> {
> new KeyValuePair<string, string?>("Auth0-Custom-Domain", "login.mycompany.com"),
> new KeyValuePair<string, string?>("X-Correlation-Id", "abc-123"),
> },
> MaxRetries = 1,
> });
> ```

[Go to Top](#)

### 5.3 Global configuration via ManagementApiClient

If you manage tokens yourself using `ManagementApiClient` directly, pass a `CustomDomainInterceptor`
as the `HttpClient` handler to enable automatic header stripping.

```csharp
using Auth0.ManagementApi;
using Auth0.ManagementApi.Core;

public async Task UseCustomDomainWithManagementApiClient()
{
// CustomDomainInterceptor strips the header from non-whitelisted endpoints.
var client = new ManagementApiClient(
token: "your-access-token",
clientOptions: new ClientOptions
{
BaseUrl = "https://my.auth0.domain/api/v2",
CustomDomain = "login.mycompany.com",
HttpClient = new HttpClient(new CustomDomainInterceptor())
});

var ticket = await client.Tickets.VerifyEmailAsync(
new VerifyEmailTicketRequestContent { UserId = "auth0|abc123" });

Console.WriteLine($"Ticket URL: {ticket.Ticket}");
}
```

> **Note:** If you supply your own `HttpClient` alongside `CustomDomain`, the
> `CustomDomainInterceptor` is **not** injected automatically — you must add it yourself
> as shown above. Without it the header is still sent, but it will be present on every
> request rather than only whitelisted ones.

[Go to Top](#)
88 changes: 88 additions & 0 deletions src/Auth0.ManagementApi/Core/CustomDomainInterceptor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using System.Text.RegularExpressions;

namespace Auth0.ManagementApi.Core;

/// <summary>
/// A <see cref="DelegatingHandler"/> that enforces the <c>Auth0-Custom-Domain</c> header whitelist.
///
/// <para>
/// The <c>Auth0-Custom-Domain</c> header is only meaningful for specific Management API endpoints
/// that generate user-facing links (email verification, password reset, invitations, etc.).
/// This handler strips the header from requests to any path not on the whitelist, preventing
/// it from leaking to unrelated endpoints.
/// </para>
///
/// <para>Whitelisted paths:</para>
/// <list type="bullet">
/// <item><c>POST /tickets/email-verification</c></item>
/// <item><c>POST /tickets/password-change</c></item>
/// <item><c>POST /organizations/{id}/invitations</c></item>
/// <item><c>POST /guardian/enrollments/ticket</c></item>
/// <item><c>POST /jobs/verification-email</c></item>
/// <item><c>POST /jobs/users-imports</c></item>
/// <item><c>POST /users</c> and <c>PATCH /users/{id}</c></item>
/// <item><c>POST /self-service-profiles/{id}/sso-ticket</c></item>
/// </list>
/// </summary>
public class CustomDomainInterceptor : DelegatingHandler
{
/// <summary>
/// The name of the custom domain header.
/// </summary>
public const string HeaderName = "Auth0-Custom-Domain";

private static readonly Regex[] WhitelistedPaths =
{
new Regex(@".*/tickets/email-verification$", RegexOptions.Compiled),
new Regex(@".*/tickets/password-change$", RegexOptions.Compiled),
new Regex(@".*/organizations/[^/]+/invitations(/[^/]+)?$", RegexOptions.Compiled),
new Regex(@".*/guardian/enrollments/ticket$", RegexOptions.Compiled),
new Regex(@".*/jobs/verification-email$", RegexOptions.Compiled),
new Regex(@".*/jobs/users-imports$", RegexOptions.Compiled),
new Regex(@".*/users(/[^/]+)?$", RegexOptions.Compiled),
new Regex(@".*/self-service-profiles/[^/]+/sso-ticket(/[^/]+/revoke)?$", RegexOptions.Compiled),
};

/// <summary>
/// Initializes a new instance of <see cref="CustomDomainInterceptor"/> with a default
/// <see cref="HttpClientHandler"/> as the inner handler.
/// </summary>
public CustomDomainInterceptor()
: base(new HttpClientHandler()) { }

/// <summary>
/// Initializes a new instance of <see cref="CustomDomainInterceptor"/> wrapping the
/// specified inner handler.
/// </summary>
/// <param name="innerHandler">The inner <see cref="HttpMessageHandler"/> to delegate to.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="innerHandler"/> is null.</exception>
public CustomDomainInterceptor(HttpMessageHandler innerHandler)
: base(innerHandler ?? throw new ArgumentNullException(nameof(innerHandler))) { }

/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
if (request.Headers.Contains(HeaderName) &&
!IsWhitelisted(request.RequestUri?.AbsolutePath))
{
request.Headers.Remove(HeaderName);
}

return base.SendAsync(request, cancellationToken);
}

/// <summary>
/// Returns <c>true</c> if <paramref name="path"/> matches one of the whitelisted endpoint patterns.
/// Used internally by <see cref="SendAsync"/> and exposed for unit testing via <c>InternalsVisibleTo</c>.
/// </summary>
/// <param name="path">The URL absolute path to test (e.g. <c>/api/v2/tickets/email-verification</c>).</param>
internal static bool IsWhitelisted(string? path)
{
if (string.IsNullOrEmpty(path))
return false;

return WhitelistedPaths.Any(p => p.IsMatch(path));
}
}
49 changes: 49 additions & 0 deletions src/Auth0.ManagementApi/Core/Public/ClientOptions.Custom.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,59 @@
using System.Text;
using System.Text.Json;
using Auth0.ManagementApi.Core;

namespace Auth0.ManagementApi;

public partial class ClientOptions
{
private string? _customDomain;

/// <summary>
/// Sets the <c>Auth0-Custom-Domain</c> header value sent on Management API requests that
/// generate user-facing links (email verification, password reset, invitations, etc.).
///
/// <para>
/// When set, the header is included in <em>every</em> outgoing request. To restrict the
/// header to only the whitelisted endpoints and strip it from all others, also configure
/// your <see cref="HttpClient"/> with a <see cref="CustomDomainInterceptor"/> handler:
/// <code>
/// new ClientOptions
/// {
/// CustomDomain = "login.mycompany.com",
/// HttpClient = new HttpClient(new CustomDomainInterceptor())
/// }
/// </code>
/// </para>
///
/// <para>
/// If you are using <see cref="ManagementClient"/>, stripping is configured automatically
/// when no custom <see cref="HttpClient"/> is provided - prefer that path.
/// </para>
/// </summary>
/// <exception cref="ArgumentException">Thrown when the value is non-null but empty or contains only whitespace.</exception>
public string? CustomDomain
{
get => _customDomain;
#if NET5_0_OR_GREATER

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix indentation, if needed

init
#else
set
#endif
{
if (!string.IsNullOrWhiteSpace(value))
{
_customDomain = value;
Headers[CustomDomainInterceptor.HeaderName] = value;
}
else if (value != null)
{
throw new ArgumentException(
"CustomDomain must not be empty or contain only whitespace.",
nameof(value));
}
}
}

public ClientOptions()
{
Headers["Auth0-Client"] = CreateAgentString();
Expand Down
65 changes: 65 additions & 0 deletions src/Auth0.ManagementApi/CustomDomainHeader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using Auth0.ManagementApi.Core;

namespace Auth0.ManagementApi;

/// <summary>
/// Convenience helper for creating per-request <c>Auth0-Custom-Domain</c> overrides.
///
/// <para>
/// Use this when you need to specify a custom domain for a single API call without
/// configuring it globally on the client. The header is forwarded only to whitelisted
/// endpoints — if you have a <see cref="CustomDomainInterceptor"/> configured on your
/// <see cref="System.Net.Http.HttpClient"/>, it is stripped automatically from any
/// non-whitelisted path.
/// </para>
///
/// <para>
/// When you need the custom domain header <em>and</em> other <see cref="RequestOptions"/>
/// settings (extra headers, timeout, retries) in the same call, construct
/// <see cref="RequestOptions"/> directly instead of using this helper:
/// <code>
/// await client.Tickets.VerifyEmailAsync(request, new RequestOptions
/// {
/// AdditionalHeaders = new[]
/// {
/// new KeyValuePair&lt;string, string?&gt;(CustomDomainInterceptor.HeaderName, "login.mycompany.com"),
/// new KeyValuePair&lt;string, string?&gt;("X-Correlation-Id", "abc-123"),
/// },
/// MaxRetries = 1,
/// });
/// </code>
/// </para>
///
/// <example>
/// <code>
/// // Override for a specific request
/// await client.Tickets.VerifyEmailAsync(request, CustomDomainHeader.For("login.mycompany.com"));
///
/// // Override for organization invitations
/// await client.Organizations.Invitations.CreateAsync(orgId, invitation, CustomDomainHeader.For("login.mycompany.com"));
/// </code>
/// </example>
/// </summary>
public static class CustomDomainHeader
{
/// <summary>
/// Creates a <see cref="RequestOptions"/> with the <c>Auth0-Custom-Domain</c> header set
/// to <paramref name="domain"/>.
/// </summary>
/// <param name="domain">The custom domain (e.g. <c>"login.mycompany.com"</c>).</param>
/// <returns>A <see cref="RequestOptions"/> carrying the custom domain header.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="domain"/> is null, empty, or whitespace.</exception>
public static RequestOptions For(string domain)
{
if (string.IsNullOrWhiteSpace(domain))
throw new ArgumentException("Domain must not be null or whitespace.", nameof(domain));

return new RequestOptions
{
AdditionalHeaders = new[]
{
new KeyValuePair<string, string?>(CustomDomainInterceptor.HeaderName, domain),
},
};
}
}
Loading