From 95d857407bdf1359df0ff23d0d4f505e5658a50f Mon Sep 17 00:00:00 2001 From: Kailash B Date: Wed, 22 Apr 2026 14:46:34 +0530 Subject: [PATCH] Adds AUth0-Custom-Header on allow-listed Management API endpoints --- .fernignore | 5 + Examples.md | 152 ++++++++++- .../Core/CustomDomainInterceptor.cs | 88 ++++++ .../Core/Public/ClientOptions.Custom.cs | 49 ++++ src/Auth0.ManagementApi/CustomDomainHeader.cs | 65 +++++ .../Wrapper/ManagementClient.cs | 16 +- .../Wrapper/ManagementClientOptions.cs | 14 + .../Core/CustomDomainInterceptorTest.cs | 185 +++++++++++++ .../Unit/MockServer/CustomDomainHeaderTest.cs | 251 ++++++++++++++++++ 9 files changed, 821 insertions(+), 4 deletions(-) create mode 100644 src/Auth0.ManagementApi/Core/CustomDomainInterceptor.cs create mode 100644 src/Auth0.ManagementApi/CustomDomainHeader.cs create mode 100644 tests/Auth0.ManagementApi.Test/Core/CustomDomainInterceptorTest.cs create mode 100644 tests/Auth0.ManagementApi.Test/Unit/MockServer/CustomDomainHeaderTest.cs diff --git a/.fernignore b/.fernignore index 07cfccb12..86084f00c 100644 --- a/.fernignore +++ b/.fernignore @@ -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 diff --git a/Examples.md b/Examples.md index 2164f2566..d9504e2d2 100644 --- a/Examples.md +++ b/Examples.md @@ -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 @@ -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("Auth0-Custom-Domain", "login.mycompany.com"), +> new KeyValuePair("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](#) diff --git a/src/Auth0.ManagementApi/Core/CustomDomainInterceptor.cs b/src/Auth0.ManagementApi/Core/CustomDomainInterceptor.cs new file mode 100644 index 000000000..2a6125fbc --- /dev/null +++ b/src/Auth0.ManagementApi/Core/CustomDomainInterceptor.cs @@ -0,0 +1,88 @@ +using System.Text.RegularExpressions; + +namespace Auth0.ManagementApi.Core; + +/// +/// A that enforces the Auth0-Custom-Domain header whitelist. +/// +/// +/// The Auth0-Custom-Domain 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. +/// +/// +/// Whitelisted paths: +/// +/// POST /tickets/email-verification +/// POST /tickets/password-change +/// POST /organizations/{id}/invitations +/// POST /guardian/enrollments/ticket +/// POST /jobs/verification-email +/// POST /jobs/users-imports +/// POST /users and PATCH /users/{id} +/// POST /self-service-profiles/{id}/sso-ticket +/// +/// +public class CustomDomainInterceptor : DelegatingHandler +{ + /// + /// The name of the custom domain header. + /// + 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), + }; + + /// + /// Initializes a new instance of with a default + /// as the inner handler. + /// + public CustomDomainInterceptor() + : base(new HttpClientHandler()) { } + + /// + /// Initializes a new instance of wrapping the + /// specified inner handler. + /// + /// The inner to delegate to. + /// Thrown when is null. + public CustomDomainInterceptor(HttpMessageHandler innerHandler) + : base(innerHandler ?? throw new ArgumentNullException(nameof(innerHandler))) { } + + /// + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (request.Headers.Contains(HeaderName) && + !IsWhitelisted(request.RequestUri?.AbsolutePath)) + { + request.Headers.Remove(HeaderName); + } + + return base.SendAsync(request, cancellationToken); + } + + /// + /// Returns true if matches one of the whitelisted endpoint patterns. + /// Used internally by and exposed for unit testing via InternalsVisibleTo. + /// + /// The URL absolute path to test (e.g. /api/v2/tickets/email-verification). + internal static bool IsWhitelisted(string? path) + { + if (string.IsNullOrEmpty(path)) + return false; + + return WhitelistedPaths.Any(p => p.IsMatch(path)); + } +} diff --git a/src/Auth0.ManagementApi/Core/Public/ClientOptions.Custom.cs b/src/Auth0.ManagementApi/Core/Public/ClientOptions.Custom.cs index 725d7e464..7398bfeb7 100644 --- a/src/Auth0.ManagementApi/Core/Public/ClientOptions.Custom.cs +++ b/src/Auth0.ManagementApi/Core/Public/ClientOptions.Custom.cs @@ -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; + + /// + /// Sets the Auth0-Custom-Domain header value sent on Management API requests that + /// generate user-facing links (email verification, password reset, invitations, etc.). + /// + /// + /// When set, the header is included in every outgoing request. To restrict the + /// header to only the whitelisted endpoints and strip it from all others, also configure + /// your with a handler: + /// + /// new ClientOptions + /// { + /// CustomDomain = "login.mycompany.com", + /// HttpClient = new HttpClient(new CustomDomainInterceptor()) + /// } + /// + /// + /// + /// + /// If you are using , stripping is configured automatically + /// when no custom is provided - prefer that path. + /// + /// + /// Thrown when the value is non-null but empty or contains only whitespace. + public string? CustomDomain + { + get => _customDomain; +#if NET5_0_OR_GREATER + 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(); diff --git a/src/Auth0.ManagementApi/CustomDomainHeader.cs b/src/Auth0.ManagementApi/CustomDomainHeader.cs new file mode 100644 index 000000000..47363f209 --- /dev/null +++ b/src/Auth0.ManagementApi/CustomDomainHeader.cs @@ -0,0 +1,65 @@ +using Auth0.ManagementApi.Core; + +namespace Auth0.ManagementApi; + +/// +/// Convenience helper for creating per-request Auth0-Custom-Domain overrides. +/// +/// +/// 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 configured on your +/// , it is stripped automatically from any +/// non-whitelisted path. +/// +/// +/// +/// When you need the custom domain header and other +/// settings (extra headers, timeout, retries) in the same call, construct +/// directly instead of using this helper: +/// +/// await client.Tickets.VerifyEmailAsync(request, new RequestOptions +/// { +/// AdditionalHeaders = new[] +/// { +/// new KeyValuePair<string, string?>(CustomDomainInterceptor.HeaderName, "login.mycompany.com"), +/// new KeyValuePair<string, string?>("X-Correlation-Id", "abc-123"), +/// }, +/// MaxRetries = 1, +/// }); +/// +/// +/// +/// +/// +/// // 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")); +/// +/// +/// +public static class CustomDomainHeader +{ + /// + /// Creates a with the Auth0-Custom-Domain header set + /// to . + /// + /// The custom domain (e.g. "login.mycompany.com"). + /// A carrying the custom domain header. + /// Thrown when is null, empty, or whitespace. + 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(CustomDomainInterceptor.HeaderName, domain), + }, + }; + } +} diff --git a/src/Auth0.ManagementApi/Wrapper/ManagementClient.cs b/src/Auth0.ManagementApi/Wrapper/ManagementClient.cs index e33883928..30d1662da 100644 --- a/src/Auth0.ManagementApi/Wrapper/ManagementClient.cs +++ b/src/Auth0.ManagementApi/Wrapper/ManagementClient.cs @@ -1,3 +1,5 @@ +using Auth0.ManagementApi.Core; + namespace Auth0.ManagementApi; /// @@ -65,12 +67,21 @@ private static ManagementClientOptions Validate(ManagementClientOptions options) return options; } + private static HttpClient CreateHttpClient(ManagementClientOptions options) => + options.CustomDomain != null + ? new HttpClient(new CustomDomainInterceptor()) + : new HttpClient(); + private static ClientOptions BuildClientOptions(ManagementClientOptions options) { + // When a custom domain is configured and no HttpClient is supplied, automatically + // inject CustomDomainInterceptor so the header is stripped from non-whitelisted paths. + var httpClient = options.HttpClient ?? CreateHttpClient(options); + var clientOptions = new ClientOptions { BaseUrl = $"https://{options.Domain}/api/v2", - HttpClient = options.HttpClient ?? new HttpClient(), + HttpClient = httpClient, Timeout = options.Timeout ?? TimeSpan.FromSeconds(30), MaxRetries = options.MaxRetries ?? 2, }; @@ -83,6 +94,9 @@ private static ClientOptions BuildClientOptions(ManagementClientOptions options) } } + if (!string.IsNullOrEmpty(options.CustomDomain)) + clientOptions.Headers[CustomDomainInterceptor.HeaderName] = options.CustomDomain; + return clientOptions; } diff --git a/src/Auth0.ManagementApi/Wrapper/ManagementClientOptions.cs b/src/Auth0.ManagementApi/Wrapper/ManagementClientOptions.cs index a794ea71c..57cba72e6 100644 --- a/src/Auth0.ManagementApi/Wrapper/ManagementClientOptions.cs +++ b/src/Auth0.ManagementApi/Wrapper/ManagementClientOptions.cs @@ -43,4 +43,18 @@ public class ManagementClientOptions /// Additional headers to include with every request. /// public IDictionary? AdditionalHeaders { get; init; } + + /// + /// Custom domain to include as the Auth0-Custom-Domain header on Management API + /// endpoints that generate user-facing links (email verification, password reset, + /// invitations, MFA enrollment tickets, etc.). + /// + /// + /// When set and no is provided, a + /// is automatically configured to strip the header from non-applicable endpoints. + /// When a custom is supplied, the header is still included on + /// every request but stripping is the caller's responsibility. + /// + /// + public string? CustomDomain { get; init; } } diff --git a/tests/Auth0.ManagementApi.Test/Core/CustomDomainInterceptorTest.cs b/tests/Auth0.ManagementApi.Test/Core/CustomDomainInterceptorTest.cs new file mode 100644 index 000000000..ca62e3109 --- /dev/null +++ b/tests/Auth0.ManagementApi.Test/Core/CustomDomainInterceptorTest.cs @@ -0,0 +1,185 @@ +using Auth0.ManagementApi.Core; +using NUnit.Framework; + +namespace Auth0.ManagementApi.Test.Core; + +[TestFixture] +public class CustomDomainInterceptorTest +{ + [Test] + [TestCase("/tickets/email-verification")] + [TestCase("/api/v2/tickets/email-verification")] + public void IsWhitelisted_EmailVerificationTicket_ReturnsTrue(string path) => + Assert.That(CustomDomainInterceptor.IsWhitelisted(path), Is.True); + + [Test] + [TestCase("/tickets/password-change")] + [TestCase("/api/v2/tickets/password-change")] + public void IsWhitelisted_PasswordChangeTicket_ReturnsTrue(string path) => + Assert.That(CustomDomainInterceptor.IsWhitelisted(path), Is.True); + + [Test] + [TestCase("/organizations/org_abc123/invitations")] + [TestCase("/api/v2/organizations/org_abc123/invitations")] + [TestCase("/organizations/org_abc123/invitations/inv_xyz789")] + [TestCase("/api/v2/organizations/org_abc123/invitations/inv_xyz789")] + public void IsWhitelisted_OrganizationInvitations_ReturnsTrue(string path) => + Assert.That(CustomDomainInterceptor.IsWhitelisted(path), Is.True); + + [Test] + [TestCase("/guardian/enrollments/ticket")] + [TestCase("/api/v2/guardian/enrollments/ticket")] + public void IsWhitelisted_GuardianEnrollmentTicket_ReturnsTrue(string path) => + Assert.That(CustomDomainInterceptor.IsWhitelisted(path), Is.True); + + [Test] + [TestCase("/jobs/verification-email")] + [TestCase("/api/v2/jobs/verification-email")] + public void IsWhitelisted_VerificationEmailJob_ReturnsTrue(string path) => + Assert.That(CustomDomainInterceptor.IsWhitelisted(path), Is.True); + + [Test] + [TestCase("/jobs/users-imports")] + [TestCase("/api/v2/jobs/users-imports")] + public void IsWhitelisted_UsersImportsJob_ReturnsTrue(string path) => + Assert.That(CustomDomainInterceptor.IsWhitelisted(path), Is.True); + + [Test] + [TestCase("/users")] + [TestCase("/api/v2/users")] + [TestCase("/users/auth0|abc123")] + [TestCase("/api/v2/users/auth0|abc123")] + public void IsWhitelisted_Users_ReturnsTrue(string path) => + Assert.That(CustomDomainInterceptor.IsWhitelisted(path), Is.True); + + [Test] + [TestCase("/self-service-profiles/ssp_1/sso-ticket")] + [TestCase("/api/v2/self-service-profiles/ssp_1/sso-ticket")] + [TestCase("/self-service-profiles/ssp_1/sso-ticket/t_1/revoke")] + [TestCase("/api/v2/self-service-profiles/ssp_1/sso-ticket/t_1/revoke")] + public void IsWhitelisted_SelfServiceProfileSsoTicket_ReturnsTrue(string path) => + Assert.That(CustomDomainInterceptor.IsWhitelisted(path), Is.True); + + [Test] + [TestCase("/clients")] + [TestCase("/api/v2/clients")] + [TestCase("/clients/client_123")] + [TestCase("/connections")] + [TestCase("/api/v2/connections/conn_abc/status")] + [TestCase("/roles")] + [TestCase("/logs")] + [TestCase("/stats/active-users")] + [TestCase("/api/v2/organizations/org_abc123/members")] + [TestCase("/api/v2/organizations/org_abc123/connections")] + [TestCase("/jobs/users-exports")] + [TestCase("/api/v2/jobs/abc123")] + [TestCase("/guardian/enrollments")] + [TestCase("/api/v2/guardian/factors")] + [TestCase("/api/v2/users/auth0|abc123/roles")] + [TestCase("/api/v2/users/auth0|abc123/permissions")] + [TestCase("/users-by-email")] + [TestCase("/api/v2/users-by-email")] + public void IsWhitelisted_NonWhitelistedPath_ReturnsFalse(string path) => + Assert.That(CustomDomainInterceptor.IsWhitelisted(path), Is.False); + + [Test] + public void IsWhitelisted_NullPath_ReturnsFalse() => + Assert.That(CustomDomainInterceptor.IsWhitelisted(null), Is.False); + + [Test] + public void IsWhitelisted_EmptyPath_ReturnsFalse() => + Assert.That(CustomDomainInterceptor.IsWhitelisted(string.Empty), Is.False); + + [Test] + public async Task SendAsync_NonWhitelistedPath_StripsHeader() + { + var testHandler = new TestHandler(); + var interceptor = new CustomDomainInterceptor(testHandler); + var httpClient = new HttpClient(interceptor); + + var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/clients"); + request.Headers.Add(CustomDomainInterceptor.HeaderName, "login.mycompany.com"); + + await httpClient.SendAsync(request); + + Assert.That(testHandler.LastRequest!.Headers.Contains(CustomDomainInterceptor.HeaderName), Is.False); + } + + [Test] + public async Task SendAsync_WhitelistedPath_PreservesHeader() + { + var testHandler = new TestHandler(); + var interceptor = new CustomDomainInterceptor(testHandler); + var httpClient = new HttpClient(interceptor); + + var request = new HttpRequestMessage(HttpMethod.Post, + "http://localhost/tickets/email-verification"); + request.Headers.Add(CustomDomainInterceptor.HeaderName, "login.mycompany.com"); + + await httpClient.SendAsync(request); + + Assert.That(testHandler.LastRequest!.Headers.Contains(CustomDomainInterceptor.HeaderName), Is.True); + Assert.That( + testHandler.LastRequest.Headers.GetValues(CustomDomainInterceptor.HeaderName).First(), + Is.EqualTo("login.mycompany.com")); + } + + [Test] + public async Task SendAsync_NoHeader_DoesNotAddHeader() + { + var testHandler = new TestHandler(); + var interceptor = new CustomDomainInterceptor(testHandler); + var httpClient = new HttpClient(interceptor); + + var request = new HttpRequestMessage(HttpMethod.Post, + "http://localhost/tickets/email-verification"); + + await httpClient.SendAsync(request); + + Assert.That(testHandler.LastRequest!.Headers.Contains(CustomDomainInterceptor.HeaderName), Is.False); + } + + [Test] + public async Task SendAsync_NonWhitelistedPath_OnlyStripsCustomDomainHeader_LeavesOtherHeadersIntact() + { + // Verifies the interceptor is surgical: it removes only Auth0-Custom-Domain and does + // not disturb any other headers present on the same request. + var testHandler = new TestHandler(); + var interceptor = new CustomDomainInterceptor(testHandler); + var httpClient = new HttpClient(interceptor); + + var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/clients"); + request.Headers.Add(CustomDomainInterceptor.HeaderName, "login.mycompany.com"); + request.Headers.Add("X-Correlation-Id", "abc-123"); + request.Headers.Add("X-Custom-Header", "custom-value"); + + await httpClient.SendAsync(request); + + Assert.That(testHandler.LastRequest!.Headers.Contains(CustomDomainInterceptor.HeaderName), + Is.False, "Auth0-Custom-Domain should be stripped"); + Assert.That(testHandler.LastRequest.Headers.Contains("X-Correlation-Id"), + Is.True, "X-Correlation-Id should be preserved"); + Assert.That(testHandler.LastRequest.Headers.GetValues("X-Correlation-Id").First(), + Is.EqualTo("abc-123")); + Assert.That(testHandler.LastRequest.Headers.Contains("X-Custom-Header"), + Is.True, "X-Custom-Header should be preserved"); + Assert.That(testHandler.LastRequest.Headers.GetValues("X-Custom-Header").First(), + Is.EqualTo("custom-value")); + } + + /// + /// Helper handler to capture the outgoing request after passing through the interceptor for assertion. + /// + private sealed class TestHandler : HttpMessageHandler + { + public HttpRequestMessage? LastRequest { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)); + } + } +} diff --git a/tests/Auth0.ManagementApi.Test/Unit/MockServer/CustomDomainHeaderTest.cs b/tests/Auth0.ManagementApi.Test/Unit/MockServer/CustomDomainHeaderTest.cs new file mode 100644 index 000000000..f2c05c8e7 --- /dev/null +++ b/tests/Auth0.ManagementApi.Test/Unit/MockServer/CustomDomainHeaderTest.cs @@ -0,0 +1,251 @@ +using Auth0.ManagementApi.Core; +using NUnit.Framework; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using WireMock.Settings; +using WireMock.Logging; + +namespace Auth0.ManagementApi.Test.Unit.MockServer; + +/// +/// End-to-end tests for the Auth0-Custom-Domain header feature using WireMock as the HTTP backend. +/// +[TestFixture] +public class CustomDomainHeaderTest +{ + private WireMockServer _server = null!; + + [OneTimeSetUp] + public void GlobalSetup() + { + _server = WireMockServer.Start( + new WireMockServerSettings { Logger = new WireMockConsoleLogger() }); + } + + [OneTimeTearDown] + public void GlobalTeardown() + { + _server.Stop(); + _server.Dispose(); + } + + [SetUp] + public void Setup() => _server.Reset(); + + private ManagementApiClient BuildClient(string? customDomain = null, bool withInterceptor = false) + { + var httpClient = withInterceptor + ? new HttpClient(new CustomDomainInterceptor(new HttpClientHandler())) + : new HttpClient(); + + return new ManagementApiClient( + "TOKEN", + new ClientOptions + { + BaseUrl = _server.Urls[0], + MaxRetries = 0, + CustomDomain = customDomain, + HttpClient = httpClient, + }); + } + + private void StubAny(string path, string method = "GET") => + _server + .Given(Request.Create().WithPath(path).UsingMethod(method)) + .RespondWith(Response.Create().WithStatusCode(200).WithBody("{}")); + + private bool HasCustomDomainHeader(string domain) => + _server.LogEntries.Any(e => + e.RequestMessage.Headers.ContainsKey(CustomDomainInterceptor.HeaderName) && + e.RequestMessage.Headers[CustomDomainInterceptor.HeaderName] + .Contains(domain, StringComparer.OrdinalIgnoreCase)); + + private bool HasNoCustomDomainHeader() => + _server.LogEntries.All(e => + !e.RequestMessage.Headers.ContainsKey(CustomDomainInterceptor.HeaderName)); + + [Test] + public async Task GlobalConfig_WhitelistedEndpoint_HeaderPresent() + { + StubAny("/tickets/email-verification", "POST"); + var client = BuildClient(customDomain: "login.mycompany.com"); + + try + { + await client.Tickets.VerifyEmailAsync( + new VerifyEmailTicketRequestContent { UserId = "auth0|123" }); + } + catch { /* ignore deserialization of empty body */ } + + Assert.That(_server.LogEntries, Has.Count.GreaterThan(0)); + Assert.That(HasCustomDomainHeader("login.mycompany.com"), Is.True); + } + + [Test] + public async Task GlobalConfig_NoInterceptor_NonWhitelistedEndpoint_HeaderStillSent() + { + // Without the interceptor, the header is present on ALL requests (including non-whitelisted). + // Servers ignore unknown headers, so this is acceptable. Document the behaviour here. + StubAny("/connections/conn_1/status"); + var client = BuildClient(customDomain: "login.mycompany.com"); + + await client.Connections.CheckStatusAsync("conn_1"); + + Assert.That(_server.LogEntries, Has.Count.GreaterThan(0)); + Assert.That(HasCustomDomainHeader("login.mycompany.com"), Is.True, + "Without CustomDomainInterceptor the header is present on all requests."); + } + + [Test] + public async Task GlobalConfig_WithInterceptor_WhitelistedEndpoint_HeaderPresent() + { + StubAny("/tickets/email-verification", "POST"); + var client = BuildClient(customDomain: "login.mycompany.com", withInterceptor: true); + + try + { + await client.Tickets.VerifyEmailAsync( + new VerifyEmailTicketRequestContent { UserId = "auth0|123" }); + } + catch { /* ignore deserialization of empty body */ } + + Assert.That(_server.LogEntries, Has.Count.GreaterThan(0)); + Assert.That(HasCustomDomainHeader("login.mycompany.com"), Is.True); + } + + [Test] + public async Task GlobalConfig_WithInterceptor_NonWhitelistedEndpoint_HeaderStripped() + { + StubAny("/connections/conn_1/status"); + var client = BuildClient(customDomain: "login.mycompany.com", withInterceptor: true); + + await client.Connections.CheckStatusAsync("conn_1"); + + Assert.That(_server.LogEntries, Has.Count.GreaterThan(0)); + Assert.That(HasNoCustomDomainHeader(), Is.True, + "CustomDomainInterceptor should strip the header from non-whitelisted paths."); + } + + [Test] + public async Task PerRequest_WhitelistedEndpoint_HeaderPresent() + { + StubAny("/tickets/email-verification", "POST"); + var client = BuildClient(); // no global custom domain + + try + { + await client.Tickets.VerifyEmailAsync( + new VerifyEmailTicketRequestContent { UserId = "auth0|123" }, + CustomDomainHeader.For("login.mycompany.com")); + } + catch { /* ignore deserialization of empty body */ } + + Assert.That(_server.LogEntries, Has.Count.GreaterThan(0)); + Assert.That(HasCustomDomainHeader("login.mycompany.com"), Is.True); + } + + [Test] + public async Task PerRequest_OverridesGlobalDomain() + { + StubAny("/tickets/email-verification", "POST"); + var client = BuildClient(customDomain: "global.mycompany.com"); + + try + { + await client.Tickets.VerifyEmailAsync( + new VerifyEmailTicketRequestContent { UserId = "auth0|123" }, + CustomDomainHeader.For("override.mycompany.com")); + } + catch { /* ignore deserialization of empty body */ } + + Assert.That(_server.LogEntries, Has.Count.GreaterThan(0)); + // The per-request value should appear (AdditionalHeaders override Options.Headers) + Assert.That(HasCustomDomainHeader("override.mycompany.com"), Is.True); + } + + [Test] + public async Task NoCustomDomain_HeaderAbsent() + { + StubAny("/connections/conn_1/status"); + var client = BuildClient(); // no custom domain + + await client.Connections.CheckStatusAsync("conn_1"); + + Assert.That(_server.LogEntries, Has.Count.GreaterThan(0)); + Assert.That(HasNoCustomDomainHeader(), Is.True); + } + + [Test] + public async Task PerRequest_WithOtherAdditionalHeaders_AllHeadersPresent() + { + // Users who need custom domain AND other per-request headers construct RequestOptions + // directly rather than using CustomDomainHeader.For(), which only sets the custom domain. + StubAny("/tickets/email-verification", "POST"); + var client = BuildClient(); + + try + { + await client.Tickets.VerifyEmailAsync( + new VerifyEmailTicketRequestContent { UserId = "auth0|123" }, + new RequestOptions + { + AdditionalHeaders = new[] + { + new KeyValuePair( + CustomDomainInterceptor.HeaderName, "login.mycompany.com"), + new KeyValuePair( + "X-Correlation-Id", "test-correlation-id"), + }, + }); + } + catch { /* ignore deserialization of empty body */ } + + Assert.That(_server.LogEntries, Has.Count.GreaterThan(0)); + + var headers = _server.LogEntries[0].RequestMessage.Headers; + + Assert.That(HasCustomDomainHeader("login.mycompany.com"), Is.True, + "Auth0-Custom-Domain header should be present"); + Assert.That(headers.ContainsKey("X-Correlation-Id"), Is.True, + "X-Correlation-Id should be present alongside Auth0-Custom-Domain"); + Assert.That(headers["X-Correlation-Id"].First(), Is.EqualTo("test-correlation-id")); + } + + [Test] + public async Task GlobalConfig_WithInterceptor_NonWhitelistedEndpoint_OtherHeadersPreserved() + { + // The interceptor must only strip Auth0-Custom-Domain; it must not affect other + // custom headers the caller has added to the request. + _server + .Given(Request.Create().WithPath("/connections/conn_1/status").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody("{}")); + + var httpClient = new HttpClient(new CustomDomainInterceptor(new HttpClientHandler())); + var client = new ManagementApiClient( + "TOKEN", + new ClientOptions + { + BaseUrl = _server.Urls[0], + MaxRetries = 0, + CustomDomain = "login.mycompany.com", + HttpClient = httpClient, + AdditionalHeaders = new[] + { + new KeyValuePair("X-Custom-Global", "global-value"), + }, + }); + + await client.Connections.CheckStatusAsync("conn_1"); + + Assert.That(_server.LogEntries, Has.Count.GreaterThan(0)); + + var headers = _server.LogEntries[0].RequestMessage.Headers; + + Assert.That(headers.ContainsKey(CustomDomainInterceptor.HeaderName), Is.False, + "Auth0-Custom-Domain should be stripped from non-whitelisted endpoint"); + Assert.That(headers.ContainsKey("X-Custom-Global"), Is.True, + "Other custom headers must not be removed by the interceptor"); + Assert.That(headers["X-Custom-Global"].First(), Is.EqualTo("global-value")); + } +}