From ccc6817a576c2f097563d3d0838a7ea9ddc2de43 Mon Sep 17 00:00:00 2001 From: Kailash B Date: Tue, 24 Mar 2026 00:33:08 +0530 Subject: [PATCH 1/4] Add support for optional custom domain header on whitelisted endpoints --- src/Auth0.ManagementApi/AssemblyInfo.cs | 1 + src/Auth0.ManagementApi/CustomDomainHeader.cs | 16 ++++ .../HttpClientManagementConnection.cs | 53 +++++++++++++- .../ManagementApiClient.cs | 40 +++++++++- .../HttpClientManagementConnectionTests.cs | 73 +++++++++++++++++++ .../ManagementApiClientTests.cs | 22 ++++++ 6 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 src/Auth0.ManagementApi/CustomDomainHeader.cs diff --git a/src/Auth0.ManagementApi/AssemblyInfo.cs b/src/Auth0.ManagementApi/AssemblyInfo.cs index 6f15ab4f6..752e9e340 100644 --- a/src/Auth0.ManagementApi/AssemblyInfo.cs +++ b/src/Auth0.ManagementApi/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Auth0.ManagementApi.IntegrationTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100453e57bfa7549abf0f1775df9384d2f279d25c2ab4c78d5a69d7e6da9567d2b984da533229a0d530a3b75c7f5a12c341799b448102995b8a123d1288aa12ca3c1c354c3da97e64626d1223ca7c6e95cba845bce6edcee8b326c2cd015cc84995e5b630ef5c7fa69928dea64a53ee71a493267de7e18d0e9f31e1e00bb8e01cae")] +[assembly: InternalsVisibleTo("Auth0.Core.UnitTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100453e57bfa7549abf0f1775df9384d2f279d25c2ab4c78d5a69d7e6da9567d2b984da533229a0d530a3b75c7f5a12c341799b448102995b8a123d1288aa12ca3c1c354c3da97e64626d1223ca7c6e95cba845bce6edcee8b326c2cd015cc84995e5b630ef5c7fa69928dea64a53ee71a493267de7e18d0e9f31e1e00bb8e01cae")] diff --git a/src/Auth0.ManagementApi/CustomDomainHeader.cs b/src/Auth0.ManagementApi/CustomDomainHeader.cs new file mode 100644 index 000000000..9ea8d54ea --- /dev/null +++ b/src/Auth0.ManagementApi/CustomDomainHeader.cs @@ -0,0 +1,16 @@ +namespace Auth0.ManagementApi; + +/// +/// Provides constants related to the Auth0 custom domain HTTP header. +/// +/// +/// Use this when constructing a with a custom domain. +/// The header is automatically included on whitelisted endpoints only. +/// +public static class CustomDomainHeader +{ + /// + /// The HTTP header name used to specify an Auth0 custom domain. + /// + public const string HeaderName = "Auth0-Custom-Domain"; +} diff --git a/src/Auth0.ManagementApi/HttpClientManagementConnection.cs b/src/Auth0.ManagementApi/HttpClientManagementConnection.cs index c28edbfba..9081668ad 100644 --- a/src/Auth0.ManagementApi/HttpClientManagementConnection.cs +++ b/src/Auth0.ManagementApi/HttpClientManagementConnection.cs @@ -6,6 +6,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Text; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Auth0.Core; @@ -19,8 +20,20 @@ public class HttpClientManagementConnection : IManagementConnection, IDisposable { private static readonly JsonSerializerSettings jsonSerializerSettings = new() { NullValueHandling = NullValueHandling.Ignore, DateParseHandling = DateParseHandling.DateTime }; + private static readonly Regex[] CustomDomainWhitelist = new[] + { + new Regex(@"^jobs/verification-email(/|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"^tickets/email-verification(/|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"^tickets/password-change(/|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"^organizations/[^/]+/invitations(/|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"^users(/[^/]+)?/$", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"^guardian/enrollments/ticket(/|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"^self-service-profiles/[^/]+/sso-ticket(/|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + }; + private readonly HttpClient httpClient; private readonly HttpClientManagementConnectionOptions options; + private readonly string? customDomain; private bool ownHttpClient; private readonly ConcurrentRandom random = new(); @@ -38,16 +51,30 @@ public class HttpClientManagementConnection : IManagementConnection, IDisposable /// be created and be used for all requests made by this instance. /// Optional to use. public HttpClientManagementConnection(HttpClient httpClient = null, HttpClientManagementConnectionOptions options = null) + : this(httpClient, options, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// Optional to use. If not specified one will + /// be created and be used for all requests made by this instance. + /// Optional to use. + /// Optional Auth0 custom domain. When set, the Auth0-Custom-Domain + /// header is automatically added to requests targeting whitelisted endpoints. + public HttpClientManagementConnection(HttpClient? httpClient, HttpClientManagementConnectionOptions? options, string? customDomain) { ownHttpClient = httpClient == null; this.httpClient = httpClient ?? new HttpClient(); this.options = options ?? new HttpClientManagementConnectionOptions(); + this.customDomain = customDomain; } /// /// Initializes a new instance of the class. /// - /// to use with the managed + /// to use with the managed /// that will be created and used for all requests made /// by this instance. /// Optional to use. @@ -74,6 +101,7 @@ private async Task GetAsyncInternal(Uri uri, IDictionary h using (var request = new HttpRequestMessage(HttpMethod.Get, uri)) { ApplyHeaders(request.Headers, headers); + ApplyCustomDomainHeader(request.Headers, uri); return await SendRequest(request, converters, cancellationToken).ConfigureAwait(false); } } @@ -83,6 +111,7 @@ private async Task SendAsyncInternal(HttpMethod method, Uri uri, object bo using (var request = new HttpRequestMessage(method, uri) { Content = BuildMessageContent(body, files) }) { ApplyHeaders(request.Headers, headers); + ApplyCustomDomainHeader(request.Headers, uri); return await SendRequest(request, converters, cancellationToken).ConfigureAwait(false); } } @@ -138,6 +167,28 @@ internal void ApplyHeaders(HttpHeaders current, IDictionary inpu current.Add(pair.Key, pair.Value); } + private void ApplyCustomDomainHeader(HttpHeaders current, Uri uri) + { + if (!string.IsNullOrEmpty(customDomain) && IsCustomDomainWhitelisted(uri)) + current.Add(CustomDomainHeader.HeaderName, customDomain); + } + + private static bool IsCustomDomainWhitelisted(Uri uri) + { + var path = uri.AbsolutePath; + const string apiPrefix = "/api/v2/"; + var index = path.IndexOf(apiPrefix, StringComparison.OrdinalIgnoreCase); + var relativePath = index >= 0 ? path.Substring(index + apiPrefix.Length) : path.TrimStart('/'); + + var pathToMatch = relativePath.TrimEnd('/') + "/"; + + foreach (var pattern in CustomDomainWhitelist) + if (pattern.IsMatch(pathToMatch)) + return true; + + return false; + } + private HttpContent BuildMessageContent(object body, IList files = null) { if (body == null) diff --git a/src/Auth0.ManagementApi/ManagementApiClient.cs b/src/Auth0.ManagementApi/ManagementApiClient.cs index 2b0887b41..9ab9efc11 100644 --- a/src/Auth0.ManagementApi/ManagementApiClient.cs +++ b/src/Auth0.ManagementApi/ManagementApiClient.cs @@ -192,10 +192,27 @@ public class ManagementApiClient : IManagementApiClient /// of the tenant to manage. /// to facilitate communication with server. public ManagementApiClient(string token, Uri baseUri, IManagementConnection managementConnection = null) + : this(token, baseUri, managementConnection, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// A valid Auth0 Management API v2 token. + /// of the tenant to manage. + /// to facilitate communication with server. + /// + /// Optional Auth0 custom domain to include via the Auth0-Custom-Domain header on + /// whitelisted endpoints. When set, the header is automatically sent on requests to + /// endpoints that support it (e.g., /api/v2/users, /api/v2/tickets/email-verification). + /// It is silently omitted from all other endpoints. + /// + public ManagementApiClient(string token, Uri baseUri, IManagementConnection managementConnection, string? customDomain) { if (managementConnection == null) { - var ownedManagementConnection = new HttpClientManagementConnection(); + var ownedManagementConnection = new HttpClientManagementConnection(null, null, customDomain); managementConnection = ownedManagementConnection; connectionToDispose = ownedManagementConnection; } @@ -246,9 +263,26 @@ public ManagementApiClient(string token, Uri baseUri, IManagementConnection mana /// /// A valid Auth0 Management API v2 token. /// Your Auth0 domain. tenant.auth0.com - /// + /// to facilitate communication with server. public ManagementApiClient(string token, string domain, IManagementConnection connection = null) - : this(token, new Uri($"https://{domain}/api/v2"), connection) + : this(token, new Uri($"https://{domain}/api/v2"), connection, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// A valid Auth0 Management API v2 token. + /// Your Auth0 domain. tenant.auth0.com + /// to facilitate communication with server. + /// + /// Optional Auth0 custom domain to include via the Auth0-Custom-Domain header on + /// whitelisted endpoints. When set, the header is automatically sent on requests to + /// endpoints that support it (e.g., /api/v2/users, /api/v2/tickets/email-verification). + /// It is silently omitted from all other endpoints. + /// + public ManagementApiClient(string token, string domain, IManagementConnection connection, string? customDomain) + : this(token, new Uri($"https://{domain}/api/v2"), connection, customDomain) { } diff --git a/tests/Auth0.Core.UnitTests/HttpClientManagementConnectionTests.cs b/tests/Auth0.Core.UnitTests/HttpClientManagementConnectionTests.cs index 9a3736e75..8bd1d5f86 100644 --- a/tests/Auth0.Core.UnitTests/HttpClientManagementConnectionTests.cs +++ b/tests/Auth0.Core.UnitTests/HttpClientManagementConnectionTests.cs @@ -4,8 +4,11 @@ using Moq; using Moq.Protected; using System; +using System.Collections.Generic; +using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -138,6 +141,76 @@ public void Should_Not_Retry_When_Limit_Set_To_Zero() amountOfTimesCalled.Should().Be(1); } + [Theory] + [InlineData("https://tenant.auth0.com/api/v2/jobs/verification-email")] + [InlineData("https://tenant.auth0.com/api/v2/tickets/email-verification")] + [InlineData("https://tenant.auth0.com/api/v2/tickets/password-change")] + [InlineData("https://tenant.auth0.com/api/v2/organizations/org_123/invitations")] + [InlineData("https://tenant.auth0.com/api/v2/users")] + [InlineData("https://tenant.auth0.com/api/v2/users/auth0|123")] + [InlineData("https://tenant.auth0.com/api/v2/guardian/enrollments/ticket")] + [InlineData("https://tenant.auth0.com/api/v2/self-service-profiles/ssp_123/sso-ticket")] + public async Task Custom_domain_header_sent_on_whitelisted_endpoint(string url) + { + HttpRequestMessage capturedRequest = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("", Encoding.UTF8, "application/json") }); + + var connection = new HttpClientManagementConnection(new HttpClient(mockHandler.Object), null, "custom.example.com"); + await connection.GetAsync(new Uri(url), null); + + Assert.NotNull(capturedRequest); + Assert.True(capturedRequest.Headers.Contains(CustomDomainHeader.HeaderName)); + Assert.Equal("custom.example.com", capturedRequest.Headers.GetValues(CustomDomainHeader.HeaderName).Single()); + } + + [Theory] + [InlineData("https://tenant.auth0.com/api/v2/clients")] + [InlineData("https://tenant.auth0.com/api/v2/clients/client_123")] + [InlineData("https://tenant.auth0.com/api/v2/connections")] + [InlineData("https://tenant.auth0.com/api/v2/tenant/settings")] + [InlineData("https://tenant.auth0.com/api/v2/logs")] + [InlineData("https://tenant.auth0.com/api/v2/users/auth0|123/roles")] + [InlineData("https://tenant.auth0.com/api/v2/users/auth0|123/permissions")] + [InlineData("https://tenant.auth0.com/api/v2/users/auth0|123/logs")] + [InlineData("https://tenant.auth0.com/api/v2/users/auth0|123/identities")] + [InlineData("https://tenant.auth0.com/api/v2/users/auth0|123/enrollments")] + public async Task Custom_domain_header_not_sent_on_non_whitelisted_endpoint(string url) + { + HttpRequestMessage capturedRequest = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("", Encoding.UTF8, "application/json") }); + + var connection = new HttpClientManagementConnection(new HttpClient(mockHandler.Object), null, "custom.example.com"); + await connection.GetAsync(new Uri(url), null); + + Assert.NotNull(capturedRequest); + Assert.False(capturedRequest.Headers.Contains(CustomDomainHeader.HeaderName)); + } + + [Fact] + public async Task Custom_domain_header_not_sent_when_not_configured() + { + HttpRequestMessage capturedRequest = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("", Encoding.UTF8, "application/json") }); + + var connection = new HttpClientManagementConnection(new HttpClient(mockHandler.Object)); + await connection.GetAsync(new Uri("https://tenant.auth0.com/api/v2/users"), null); + + Assert.NotNull(capturedRequest); + Assert.False(capturedRequest.Headers.Contains(CustomDomainHeader.HeaderName)); + } + [Fact] public void Should_Not_Retry_When_Not_A_RateLimitApiException() { diff --git a/tests/Auth0.ManagementApi.IntegrationTests/ManagementApiClientTests.cs b/tests/Auth0.ManagementApi.IntegrationTests/ManagementApiClientTests.cs index e42e622b2..40cf4aa45 100644 --- a/tests/Auth0.ManagementApi.IntegrationTests/ManagementApiClientTests.cs +++ b/tests/Auth0.ManagementApi.IntegrationTests/ManagementApiClientTests.cs @@ -108,6 +108,28 @@ public void Auth0Client_has_a_target_inside_env() Assert.NotNull(payload["env"]["target"].ToString()); } + [Fact] + public async Task Custom_domain_header_not_present_in_default_headers() + { + var customDomainGrabber = new HeaderGrabberConnection(); + var client = new ManagementApiClient("fake", GetVariable("AUTH0_MANAGEMENT_API_URL"), customDomainGrabber, "custom.example.com"); + + await client.TenantSettings.GetAsync(); + + Assert.DoesNotContain(customDomainGrabber.LastHeaders, k => k.Key == "Auth0-Custom-Domain"); + } + + [Fact] + public async Task Custom_domain_header_absent_from_default_headers_when_not_set() + { + var customDomainGrabber = new HeaderGrabberConnection(); + var client = new ManagementApiClient("fake", GetVariable("AUTH0_MANAGEMENT_API_URL"), customDomainGrabber); + + await client.TenantSettings.GetAsync(); + + Assert.DoesNotContain(customDomainGrabber.LastHeaders, k => k.Key == "Auth0-Custom-Domain"); + } + private class HeaderGrabberConnection : IManagementConnection { public IDictionary LastHeaders { get; private set; } = new Dictionary(); From 43d66ab297b89e859d051dc151ce093108fa22fd Mon Sep 17 00:00:00 2001 From: Kailash B Date: Tue, 24 Mar 2026 14:20:15 +0530 Subject: [PATCH 2/4] Handle edge cases and update tests --- src/Auth0.ManagementApi/CustomDomainHeader.cs | 2 +- .../HttpClientManagementConnection.cs | 43 ++-- .../ManagementApiClient.cs | 4 +- .../HttpClientManagementConnectionTests.cs | 224 +++++++++++++++++- .../ManagementApiClientTests.cs | 22 -- 5 files changed, 243 insertions(+), 52 deletions(-) diff --git a/src/Auth0.ManagementApi/CustomDomainHeader.cs b/src/Auth0.ManagementApi/CustomDomainHeader.cs index 9ea8d54ea..8c26189c2 100644 --- a/src/Auth0.ManagementApi/CustomDomainHeader.cs +++ b/src/Auth0.ManagementApi/CustomDomainHeader.cs @@ -5,7 +5,7 @@ namespace Auth0.ManagementApi; /// /// /// Use this when constructing a with a custom domain. -/// The header is automatically included on whitelisted endpoints only. +/// The header is automatically included on allowed endpoints only. /// public static class CustomDomainHeader { diff --git a/src/Auth0.ManagementApi/HttpClientManagementConnection.cs b/src/Auth0.ManagementApi/HttpClientManagementConnection.cs index 9081668ad..f7ee35aac 100644 --- a/src/Auth0.ManagementApi/HttpClientManagementConnection.cs +++ b/src/Auth0.ManagementApi/HttpClientManagementConnection.cs @@ -20,15 +20,15 @@ public class HttpClientManagementConnection : IManagementConnection, IDisposable { private static readonly JsonSerializerSettings jsonSerializerSettings = new() { NullValueHandling = NullValueHandling.Ignore, DateParseHandling = DateParseHandling.DateTime }; - private static readonly Regex[] CustomDomainWhitelist = new[] + private static readonly Regex[] CustomDomainAllowlist = new[] { - new Regex(@"^jobs/verification-email(/|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"^tickets/email-verification(/|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"^tickets/password-change(/|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"^organizations/[^/]+/invitations(/|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"^users(/[^/]+)?/$", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"^guardian/enrollments/ticket(/|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"^self-service-profiles/[^/]+/sso-ticket(/|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"^jobs/verification-email/?$", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"^tickets/email-verification/?$", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"^tickets/password-change/?$", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"^organizations/[^/]+/invitations/?$", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"^users(/[^/]+)?/?$", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"^guardian/enrollments/ticket/?$", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"^self-service-profiles/[^/]+/sso-ticket/?$", RegexOptions.Compiled | RegexOptions.IgnoreCase), }; private readonly HttpClient httpClient; @@ -62,9 +62,21 @@ public HttpClientManagementConnection(HttpClient httpClient = null, HttpClientMa /// be created and be used for all requests made by this instance. /// Optional to use. /// Optional Auth0 custom domain. When set, the Auth0-Custom-Domain - /// header is automatically added to requests targeting whitelisted endpoints. + /// header is automatically added to requests targeting allowed endpoints. + /// Thrown when contains a URI scheme or whitespace. public HttpClientManagementConnection(HttpClient? httpClient, HttpClientManagementConnectionOptions? options, string? customDomain) { + if (customDomain != null) + { + customDomain = customDomain.Trim(); + if (string.IsNullOrWhiteSpace(customDomain)) + throw new ArgumentException("Custom domain must not be empty or whitespace.", nameof(customDomain)); + if (customDomain.Contains("://")) + throw new ArgumentException("Custom domain must be a domain name without a URI scheme (e.g., 'login.example.com').", nameof(customDomain)); + if (customDomain.Contains('/')) + throw new ArgumentException("Custom domain must be a domain name without a path (e.g., 'login.example.com').", nameof(customDomain)); + } + ownHttpClient = httpClient == null; this.httpClient = httpClient ?? new HttpClient(); this.options = options ?? new HttpClientManagementConnectionOptions(); @@ -169,21 +181,20 @@ internal void ApplyHeaders(HttpHeaders current, IDictionary inpu private void ApplyCustomDomainHeader(HttpHeaders current, Uri uri) { - if (!string.IsNullOrEmpty(customDomain) && IsCustomDomainWhitelisted(uri)) + if (!string.IsNullOrWhiteSpace(customDomain) && IsCustomDomainAllowed(uri)) current.Add(CustomDomainHeader.HeaderName, customDomain); } - private static bool IsCustomDomainWhitelisted(Uri uri) + private static bool IsCustomDomainAllowed(Uri uri) { var path = uri.AbsolutePath; const string apiPrefix = "/api/v2/"; var index = path.IndexOf(apiPrefix, StringComparison.OrdinalIgnoreCase); - var relativePath = index >= 0 ? path.Substring(index + apiPrefix.Length) : path.TrimStart('/'); - - var pathToMatch = relativePath.TrimEnd('/') + "/"; + if (index < 0) return false; + var relativePath = path.Substring(index + apiPrefix.Length); - foreach (var pattern in CustomDomainWhitelist) - if (pattern.IsMatch(pathToMatch)) + foreach (var pattern in CustomDomainAllowlist) + if (pattern.IsMatch(relativePath)) return true; return false; diff --git a/src/Auth0.ManagementApi/ManagementApiClient.cs b/src/Auth0.ManagementApi/ManagementApiClient.cs index 9ab9efc11..6a3004b5b 100644 --- a/src/Auth0.ManagementApi/ManagementApiClient.cs +++ b/src/Auth0.ManagementApi/ManagementApiClient.cs @@ -204,7 +204,7 @@ public ManagementApiClient(string token, Uri baseUri, IManagementConnection mana /// to facilitate communication with server. /// /// Optional Auth0 custom domain to include via the Auth0-Custom-Domain header on - /// whitelisted endpoints. When set, the header is automatically sent on requests to + /// allowed endpoints. When set, the header is automatically sent on requests to /// endpoints that support it (e.g., /api/v2/users, /api/v2/tickets/email-verification). /// It is silently omitted from all other endpoints. /// @@ -277,7 +277,7 @@ public ManagementApiClient(string token, string domain, IManagementConnection co /// to facilitate communication with server. /// /// Optional Auth0 custom domain to include via the Auth0-Custom-Domain header on - /// whitelisted endpoints. When set, the header is automatically sent on requests to + /// allowed endpoints. When set, the header is automatically sent on requests to /// endpoints that support it (e.g., /api/v2/users, /api/v2/tickets/email-verification). /// It is silently omitted from all other endpoints. /// diff --git a/tests/Auth0.Core.UnitTests/HttpClientManagementConnectionTests.cs b/tests/Auth0.Core.UnitTests/HttpClientManagementConnectionTests.cs index 8bd1d5f86..6d49d8db6 100644 --- a/tests/Auth0.Core.UnitTests/HttpClientManagementConnectionTests.cs +++ b/tests/Auth0.Core.UnitTests/HttpClientManagementConnectionTests.cs @@ -142,15 +142,23 @@ public void Should_Not_Retry_When_Limit_Set_To_Zero() } [Theory] - [InlineData("https://tenant.auth0.com/api/v2/jobs/verification-email")] - [InlineData("https://tenant.auth0.com/api/v2/tickets/email-verification")] - [InlineData("https://tenant.auth0.com/api/v2/tickets/password-change")] - [InlineData("https://tenant.auth0.com/api/v2/organizations/org_123/invitations")] - [InlineData("https://tenant.auth0.com/api/v2/users")] - [InlineData("https://tenant.auth0.com/api/v2/users/auth0|123")] - [InlineData("https://tenant.auth0.com/api/v2/guardian/enrollments/ticket")] - [InlineData("https://tenant.auth0.com/api/v2/self-service-profiles/ssp_123/sso-ticket")] - public async Task Custom_domain_header_sent_on_whitelisted_endpoint(string url) + [InlineData("https://tenant.auth0.com/api/v2/jobs/verification-email", "GET")] + [InlineData("https://tenant.auth0.com/api/v2/jobs/verification-email", "POST")] + [InlineData("https://tenant.auth0.com/api/v2/tickets/email-verification", "GET")] + [InlineData("https://tenant.auth0.com/api/v2/tickets/email-verification", "POST")] + [InlineData("https://tenant.auth0.com/api/v2/tickets/password-change", "GET")] + [InlineData("https://tenant.auth0.com/api/v2/tickets/password-change", "POST")] + [InlineData("https://tenant.auth0.com/api/v2/organizations/org_123/invitations", "GET")] + [InlineData("https://tenant.auth0.com/api/v2/organizations/org_123/invitations", "POST")] + [InlineData("https://tenant.auth0.com/api/v2/users", "GET")] + [InlineData("https://tenant.auth0.com/api/v2/users", "POST")] + [InlineData("https://tenant.auth0.com/api/v2/users/auth0|123", "GET")] + [InlineData("https://tenant.auth0.com/api/v2/users/auth0|123", "POST")] + [InlineData("https://tenant.auth0.com/api/v2/guardian/enrollments/ticket", "GET")] + [InlineData("https://tenant.auth0.com/api/v2/guardian/enrollments/ticket", "POST")] + [InlineData("https://tenant.auth0.com/api/v2/self-service-profiles/ssp_123/sso-ticket", "GET")] + [InlineData("https://tenant.auth0.com/api/v2/self-service-profiles/ssp_123/sso-ticket", "POST")] + public async Task Custom_domain_header_sent_on_allowed_endpoint(string url, string httpMethod) { HttpRequestMessage capturedRequest = null; var mockHandler = new Mock(MockBehavior.Strict); @@ -160,7 +168,11 @@ public async Task Custom_domain_header_sent_on_whitelisted_endpoint(string url) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("", Encoding.UTF8, "application/json") }); var connection = new HttpClientManagementConnection(new HttpClient(mockHandler.Object), null, "custom.example.com"); - await connection.GetAsync(new Uri(url), null); + var method = new HttpMethod(httpMethod); + if (method == HttpMethod.Get) + await connection.GetAsync(new Uri(url), null); + else + await connection.SendAsync(method, new Uri(url), null, null); Assert.NotNull(capturedRequest); Assert.True(capturedRequest.Headers.Contains(CustomDomainHeader.HeaderName)); @@ -178,7 +190,7 @@ public async Task Custom_domain_header_sent_on_whitelisted_endpoint(string url) [InlineData("https://tenant.auth0.com/api/v2/users/auth0|123/logs")] [InlineData("https://tenant.auth0.com/api/v2/users/auth0|123/identities")] [InlineData("https://tenant.auth0.com/api/v2/users/auth0|123/enrollments")] - public async Task Custom_domain_header_not_sent_on_non_whitelisted_endpoint(string url) + public async Task Custom_domain_header_not_sent_on_non_allowed_endpoint(string url) { HttpRequestMessage capturedRequest = null; var mockHandler = new Mock(MockBehavior.Strict); @@ -211,6 +223,196 @@ public async Task Custom_domain_header_not_sent_when_not_configured() Assert.False(capturedRequest.Headers.Contains(CustomDomainHeader.HeaderName)); } + [Theory] + [InlineData(" ")] + [InlineData(" ")] + [InlineData("\t")] + public void Custom_domain_throws_when_whitespace(string customDomain) + { + Assert.Throws(() => + new HttpClientManagementConnection(new HttpClient(), null, customDomain)); + } + + [Theory] + [InlineData("https://login.example.com")] + [InlineData("http://login.example.com")] + public void Custom_domain_throws_when_contains_scheme(string customDomain) + { + Assert.Throws(() => + new HttpClientManagementConnection(new HttpClient(), null, customDomain)); + } + + [Theory] + [InlineData("login.example.com/path")] + [InlineData("login.example.com/")] + public void Custom_domain_throws_when_contains_path(string customDomain) + { + Assert.Throws(() => + new HttpClientManagementConnection(new HttpClient(), null, customDomain)); + } + + [Fact] + public async Task Custom_domain_header_sent_via_management_client_convenience_constructor() + { + HttpRequestMessage capturedRequest = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("[]", Encoding.UTF8, "application/json") }); + + // Exercises ManagementApiClient(token, domain, customDomain:) — no explicit connection passed, + // so the client creates its own HttpClientManagementConnection with the custom domain wired in. + var client = new ManagementApiClient("fake", "tenant.auth0.com", new HttpClientManagementConnection(new HttpClient(mockHandler.Object), null, "custom.example.com"), null); + + await client.Users.GetAllAsync(new ManagementApi.Models.GetUsersRequest(), new ManagementApi.Paging.PaginationInfo()); + + Assert.NotNull(capturedRequest); + Assert.True(capturedRequest.Headers.Contains(CustomDomainHeader.HeaderName)); + Assert.Equal("custom.example.com", capturedRequest.Headers.GetValues(CustomDomainHeader.HeaderName).Single()); + } + + [Fact] + public async Task Custom_domain_header_sent_on_allowed_endpoint_via_management_client() + { + HttpRequestMessage capturedRequest = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("[]", Encoding.UTF8, "application/json") }); + + var connection = new HttpClientManagementConnection(new HttpClient(mockHandler.Object), null, "custom.example.com"); + var client = new ManagementApiClient("fake", "tenant.auth0.com", connection); + + await client.Users.GetAllAsync(new ManagementApi.Models.GetUsersRequest(), new ManagementApi.Paging.PaginationInfo()); + + Assert.NotNull(capturedRequest); + Assert.True(capturedRequest.Headers.Contains(CustomDomainHeader.HeaderName)); + Assert.Equal("custom.example.com", capturedRequest.Headers.GetValues(CustomDomainHeader.HeaderName).Single()); + } + + [Fact] + public async Task Custom_domain_header_not_sent_on_non_allowed_endpoint_via_management_client() + { + HttpRequestMessage capturedRequest = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") }); + + var connection = new HttpClientManagementConnection(new HttpClient(mockHandler.Object), null, "custom.example.com"); + var client = new ManagementApiClient("fake", "tenant.auth0.com", connection); + + await client.TenantSettings.GetAsync(); + + Assert.NotNull(capturedRequest); + Assert.False(capturedRequest.Headers.Contains(CustomDomainHeader.HeaderName)); + } + + [Theory] + [InlineData("https://tenant.auth0.com/api/v2/users?page=0&per_page=10")] + [InlineData("https://tenant.auth0.com/api/v2/users/auth0%7C123")] + [InlineData("https://tenant.auth0.com/api/v2/users/")] + public async Task Custom_domain_header_sent_on_allowed_endpoint_with_query_and_encoding(string url) + { + HttpRequestMessage capturedRequest = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("", Encoding.UTF8, "application/json") }); + + var connection = new HttpClientManagementConnection(new HttpClient(mockHandler.Object), null, "custom.example.com"); + await connection.GetAsync(new Uri(url), null); + + Assert.NotNull(capturedRequest); + Assert.True(capturedRequest.Headers.Contains(CustomDomainHeader.HeaderName)); + } + + [Theory] + [InlineData("https://tenant.auth0.com/api/v2/organizations/org_123/invitations/inv_456")] + [InlineData("https://tenant.auth0.com/api/v2/guardian/enrollments/ticket/extra")] + [InlineData("https://tenant.auth0.com/api/v2/jobs/verification-email/extra")] + public async Task Custom_domain_header_not_sent_on_sub_paths_of_allowed_endpoints(string url) + { + HttpRequestMessage capturedRequest = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("", Encoding.UTF8, "application/json") }); + + var connection = new HttpClientManagementConnection(new HttpClient(mockHandler.Object), null, "custom.example.com"); + await connection.GetAsync(new Uri(url), null); + + Assert.NotNull(capturedRequest); + Assert.False(capturedRequest.Headers.Contains(CustomDomainHeader.HeaderName)); + } + + [Fact] + public void Custom_domain_throws_when_empty_string() + { + Assert.Throws(() => + new HttpClientManagementConnection(new HttpClient(), null, "")); + } + + [Fact] + public void Custom_domain_trims_whitespace() + { + var connection = new HttpClientManagementConnection(new HttpClient(), null, " custom.example.com "); + // Should not throw — whitespace is trimmed, not rejected + } + + [Theory] + [InlineData("https://tenant.auth0.com/api/v2/USERS")] + [InlineData("https://tenant.auth0.com/api/v2/Users/auth0|123")] + [InlineData("https://tenant.auth0.com/api/v2/TICKETS/Password-Change")] + [InlineData("https://tenant.auth0.com/api/v2/Jobs/Verification-Email")] + public async Task Custom_domain_header_sent_on_allowed_endpoint_case_insensitive(string url) + { + HttpRequestMessage capturedRequest = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("", Encoding.UTF8, "application/json") }); + + var connection = new HttpClientManagementConnection(new HttpClient(mockHandler.Object), null, "custom.example.com"); + await connection.GetAsync(new Uri(url), null); + + Assert.NotNull(capturedRequest); + Assert.True(capturedRequest.Headers.Contains(CustomDomainHeader.HeaderName)); + } + + [Theory] + [InlineData("https://evil.com/users")] + [InlineData("https://example.com/tickets/password-change")] + [InlineData("https://example.com/some/path/users")] + public async Task Custom_domain_header_not_sent_when_api_v2_prefix_missing(string url) + { + HttpRequestMessage capturedRequest = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("", Encoding.UTF8, "application/json") }); + + var connection = new HttpClientManagementConnection(new HttpClient(mockHandler.Object), null, "custom.example.com"); + await connection.GetAsync(new Uri(url), null); + + Assert.NotNull(capturedRequest); + Assert.False(capturedRequest.Headers.Contains(CustomDomainHeader.HeaderName)); + } + + [Fact] + public void Custom_domain_accepts_domain_with_port() + { + var connection = new HttpClientManagementConnection(new HttpClient(), null, "login.example.com:8443"); + // Should not throw — port numbers are valid in domain specifications + } + [Fact] public void Should_Not_Retry_When_Not_A_RateLimitApiException() { diff --git a/tests/Auth0.ManagementApi.IntegrationTests/ManagementApiClientTests.cs b/tests/Auth0.ManagementApi.IntegrationTests/ManagementApiClientTests.cs index 40cf4aa45..e42e622b2 100644 --- a/tests/Auth0.ManagementApi.IntegrationTests/ManagementApiClientTests.cs +++ b/tests/Auth0.ManagementApi.IntegrationTests/ManagementApiClientTests.cs @@ -108,28 +108,6 @@ public void Auth0Client_has_a_target_inside_env() Assert.NotNull(payload["env"]["target"].ToString()); } - [Fact] - public async Task Custom_domain_header_not_present_in_default_headers() - { - var customDomainGrabber = new HeaderGrabberConnection(); - var client = new ManagementApiClient("fake", GetVariable("AUTH0_MANAGEMENT_API_URL"), customDomainGrabber, "custom.example.com"); - - await client.TenantSettings.GetAsync(); - - Assert.DoesNotContain(customDomainGrabber.LastHeaders, k => k.Key == "Auth0-Custom-Domain"); - } - - [Fact] - public async Task Custom_domain_header_absent_from_default_headers_when_not_set() - { - var customDomainGrabber = new HeaderGrabberConnection(); - var client = new ManagementApiClient("fake", GetVariable("AUTH0_MANAGEMENT_API_URL"), customDomainGrabber); - - await client.TenantSettings.GetAsync(); - - Assert.DoesNotContain(customDomainGrabber.LastHeaders, k => k.Key == "Auth0-Custom-Domain"); - } - private class HeaderGrabberConnection : IManagementConnection { public IDictionary LastHeaders { get; private set; } = new Dictionary(); From a8df6f654dac4e168c5f61da4da65ce23ec2290d Mon Sep 17 00:00:00 2001 From: Kailash B Date: Tue, 24 Mar 2026 17:46:21 +0530 Subject: [PATCH 3/4] Address review comments --- .../HttpClientManagementConnection.cs | 4 +- .../HttpClientManagementConnectionTests.cs | 63 +++++++++++++++---- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/src/Auth0.ManagementApi/HttpClientManagementConnection.cs b/src/Auth0.ManagementApi/HttpClientManagementConnection.cs index f7ee35aac..b0714c749 100644 --- a/src/Auth0.ManagementApi/HttpClientManagementConnection.cs +++ b/src/Auth0.ManagementApi/HttpClientManagementConnection.cs @@ -26,14 +26,14 @@ public class HttpClientManagementConnection : IManagementConnection, IDisposable new Regex(@"^tickets/email-verification/?$", RegexOptions.Compiled | RegexOptions.IgnoreCase), new Regex(@"^tickets/password-change/?$", RegexOptions.Compiled | RegexOptions.IgnoreCase), new Regex(@"^organizations/[^/]+/invitations/?$", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"^users(/[^/]+)?/?$", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"^users(/[^/]+/?)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase), new Regex(@"^guardian/enrollments/ticket/?$", RegexOptions.Compiled | RegexOptions.IgnoreCase), new Regex(@"^self-service-profiles/[^/]+/sso-ticket/?$", RegexOptions.Compiled | RegexOptions.IgnoreCase), }; private readonly HttpClient httpClient; private readonly HttpClientManagementConnectionOptions options; - private readonly string? customDomain; + internal readonly string? customDomain; private bool ownHttpClient; private readonly ConcurrentRandom random = new(); diff --git a/tests/Auth0.Core.UnitTests/HttpClientManagementConnectionTests.cs b/tests/Auth0.Core.UnitTests/HttpClientManagementConnectionTests.cs index 6d49d8db6..0c1da2b67 100644 --- a/tests/Auth0.Core.UnitTests/HttpClientManagementConnectionTests.cs +++ b/tests/Auth0.Core.UnitTests/HttpClientManagementConnectionTests.cs @@ -185,6 +185,7 @@ public async Task Custom_domain_header_sent_on_allowed_endpoint(string url, stri [InlineData("https://tenant.auth0.com/api/v2/connections")] [InlineData("https://tenant.auth0.com/api/v2/tenant/settings")] [InlineData("https://tenant.auth0.com/api/v2/logs")] + [InlineData("https://tenant.auth0.com/api/v2/users/")] [InlineData("https://tenant.auth0.com/api/v2/users/auth0|123/roles")] [InlineData("https://tenant.auth0.com/api/v2/users/auth0|123/permissions")] [InlineData("https://tenant.auth0.com/api/v2/users/auth0|123/logs")] @@ -252,7 +253,25 @@ public void Custom_domain_throws_when_contains_path(string customDomain) } [Fact] - public async Task Custom_domain_header_sent_via_management_client_convenience_constructor() + public void Custom_domain_wired_into_internally_created_connection_when_null_connection_passed() + { + // Exercises ManagementApiClient(token, domain, connection: null, customDomain:) — the client + // creates its own HttpClientManagementConnection and must wire the custom domain into it. + var client = new InspectableManagementApiClient("fake", "tenant.auth0.com", null, "custom.example.com"); + + var conn = client.ExposedConnection as HttpClientManagementConnection; + Assert.NotNull(conn); + Assert.Equal("custom.example.com", conn.customDomain); + } + + private class InspectableManagementApiClient(string token, string domain, IManagementConnection conn, string customDomain) + : ManagementApiClient(token, domain, conn, customDomain) + { + public IManagementConnection ExposedConnection => connection; + } + + [Fact] + public async Task Custom_domain_header_sent_on_allowed_endpoint_via_management_client() { HttpRequestMessage capturedRequest = null; var mockHandler = new Mock(MockBehavior.Strict); @@ -261,9 +280,8 @@ public async Task Custom_domain_header_sent_via_management_client_convenience_co .Callback((req, _) => capturedRequest = req) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("[]", Encoding.UTF8, "application/json") }); - // Exercises ManagementApiClient(token, domain, customDomain:) — no explicit connection passed, - // so the client creates its own HttpClientManagementConnection with the custom domain wired in. - var client = new ManagementApiClient("fake", "tenant.auth0.com", new HttpClientManagementConnection(new HttpClient(mockHandler.Object), null, "custom.example.com"), null); + var connection = new HttpClientManagementConnection(new HttpClient(mockHandler.Object), null, "custom.example.com"); + var client = new ManagementApiClient("fake", "tenant.auth0.com", connection); await client.Users.GetAllAsync(new ManagementApi.Models.GetUsersRequest(), new ManagementApi.Paging.PaginationInfo()); @@ -273,18 +291,39 @@ public async Task Custom_domain_header_sent_via_management_client_convenience_co } [Fact] - public async Task Custom_domain_header_sent_on_allowed_endpoint_via_management_client() + public async Task Custom_domain_header_not_sent_on_non_allowed_endpoint_via_management_client() { HttpRequestMessage capturedRequest = null; var mockHandler = new Mock(MockBehavior.Strict); mockHandler.Protected() .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .Callback((req, _) => capturedRequest = req) - .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("[]", Encoding.UTF8, "application/json") }); + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") }); var connection = new HttpClientManagementConnection(new HttpClient(mockHandler.Object), null, "custom.example.com"); var client = new ManagementApiClient("fake", "tenant.auth0.com", connection); + await client.TenantSettings.GetAsync(); + + Assert.NotNull(capturedRequest); + Assert.False(capturedRequest.Headers.Contains(CustomDomainHeader.HeaderName)); + } + + [Fact] + public async Task Custom_domain_header_sent_when_connection_and_custom_domain_both_provided_and_connection_has_custom_domain() + { + HttpRequestMessage capturedRequest = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("[]", Encoding.UTF8, "application/json") }); + + // Both connection (with custom domain wired in) and customDomain are provided. + // The connection's own custom domain takes effect; the constructor customDomain parameter is redundant but harmless. + var connection = new HttpClientManagementConnection(new HttpClient(mockHandler.Object), null, "custom.example.com"); + var client = new ManagementApiClient("fake", "tenant.auth0.com", connection, "custom.example.com"); + await client.Users.GetAllAsync(new ManagementApi.Models.GetUsersRequest(), new ManagementApi.Paging.PaginationInfo()); Assert.NotNull(capturedRequest); @@ -293,19 +332,20 @@ public async Task Custom_domain_header_sent_on_allowed_endpoint_via_management_c } [Fact] - public async Task Custom_domain_header_not_sent_on_non_allowed_endpoint_via_management_client() + public async Task Custom_domain_header_not_sent_when_connection_provided_without_custom_domain() { HttpRequestMessage capturedRequest = null; var mockHandler = new Mock(MockBehavior.Strict); mockHandler.Protected() .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .Callback((req, _) => capturedRequest = req) - .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") }); + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("[]", Encoding.UTF8, "application/json") }); - var connection = new HttpClientManagementConnection(new HttpClient(mockHandler.Object), null, "custom.example.com"); - var client = new ManagementApiClient("fake", "tenant.auth0.com", connection); + // Connection has no custom domain; customDomain on the constructor is ignored when a connection is supplied. + var connection = new HttpClientManagementConnection(new HttpClient(mockHandler.Object)); + var client = new ManagementApiClient("fake", "tenant.auth0.com", connection, "custom.example.com"); - await client.TenantSettings.GetAsync(); + await client.Users.GetAllAsync(new ManagementApi.Models.GetUsersRequest(), new ManagementApi.Paging.PaginationInfo()); Assert.NotNull(capturedRequest); Assert.False(capturedRequest.Headers.Contains(CustomDomainHeader.HeaderName)); @@ -314,7 +354,6 @@ public async Task Custom_domain_header_not_sent_on_non_allowed_endpoint_via_mana [Theory] [InlineData("https://tenant.auth0.com/api/v2/users?page=0&per_page=10")] [InlineData("https://tenant.auth0.com/api/v2/users/auth0%7C123")] - [InlineData("https://tenant.auth0.com/api/v2/users/")] public async Task Custom_domain_header_sent_on_allowed_endpoint_with_query_and_encoding(string url) { HttpRequestMessage capturedRequest = null; From 2aa4aa083636e71e099463ab4061c65417e0e35d Mon Sep 17 00:00:00 2001 From: Kailash B Date: Tue, 24 Mar 2026 17:53:20 +0530 Subject: [PATCH 4/4] Update Examples --- Examples.md | 76 +++++++++++++++++++ .../HttpClientManagementConnectionTests.cs | 6 +- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/Examples.md b/Examples.md index 43c905600..70423cec9 100644 --- a/Examples.md +++ b/Examples.md @@ -290,6 +290,9 @@ public async Task LoginWithClientCredentialsAndMonitorClientQuota() - [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) ## 1. Management Client Initialization @@ -570,3 +573,76 @@ public async void UpdateNetworkAcl() { ``` ⬆️ [Go to Top](#) + +## 5. Using a Custom Domain with the Management API + +When your tenant uses a [custom domain](https://auth0.com/docs/customize/custom-domains), certain Management API endpoints accept an `Auth0-Custom-Domain` header so that generated URLs (e.g. in email verification tickets or password-change tickets) use your custom domain instead of the default Auth0 domain. The SDK can add this header automatically on the [supported endpoints](#supported-endpoints). + +### 5.1 Let the client manage the connection (simplest) + +Pass `customDomain` to the `ManagementApiClient` constructor and omit the `connection` argument. The client creates its own `HttpClientManagementConnection` internally with the custom domain already wired in. + +```csharp +public async Task InitializeWithCustomDomain() +{ + var authClient = new AuthenticationApiClient("tenant.auth0.com"); + + var accessTokenResponse = await authClient.GetTokenAsync(new ClientCredentialsTokenRequest() + { + Audience = "https://tenant.auth0.com/api/v2/", + ClientId = "clientId", + ClientSecret = "clientSecret", + }); + + // No explicit connection — the client creates one internally and wires in the custom domain. + var managementClient = new ManagementApiClient( + accessTokenResponse.AccessToken, + "tenant.auth0.com", + connection: null, + customDomain: "login.example.com"); +} +``` + +### 5.2 Bring your own connection + +If you already supply an `HttpClientManagementConnection` (e.g. to configure retries or inject an `HttpClient`), pass the custom domain directly to the connection constructor instead. + +```csharp +public async Task InitializeWithCustomDomainAndOwnConnection() +{ + var authClient = new AuthenticationApiClient("tenant.auth0.com"); + + var accessTokenResponse = await authClient.GetTokenAsync(new ClientCredentialsTokenRequest() + { + Audience = "https://tenant.auth0.com/api/v2/", + ClientId = "clientId", + ClientSecret = "clientSecret", + }); + + var connection = new HttpClientManagementConnection( + httpClient: null, + options: new HttpClientManagementConnectionOptions { NumberOfHttpRetries = 5 }, + customDomain: "login.example.com"); + + var managementClient = new ManagementApiClient( + accessTokenResponse.AccessToken, + "tenant.auth0.com", + connection); +} +``` + +### Supported endpoints + +The `Auth0-Custom-Domain` header is automatically included only on the following endpoints. It is silently omitted from all others. + +| Endpoint | +|---| +| `POST /api/v2/jobs/verification-email` | +| `POST /api/v2/tickets/email-verification` | +| `POST /api/v2/tickets/password-change` | +| `GET/POST /api/v2/organizations/{id}/invitations` | +| `GET/POST /api/v2/users` and `GET /api/v2/users/{id}` | +| `POST /api/v2/guardian/enrollments/ticket` | +| `POST /api/v2/self-service-profiles/{id}/sso-ticket` | + +⬆️ [Go to Top](#) diff --git a/tests/Auth0.Core.UnitTests/HttpClientManagementConnectionTests.cs b/tests/Auth0.Core.UnitTests/HttpClientManagementConnectionTests.cs index 0c1da2b67..4735480d6 100644 --- a/tests/Auth0.Core.UnitTests/HttpClientManagementConnectionTests.cs +++ b/tests/Auth0.Core.UnitTests/HttpClientManagementConnectionTests.cs @@ -255,8 +255,6 @@ public void Custom_domain_throws_when_contains_path(string customDomain) [Fact] public void Custom_domain_wired_into_internally_created_connection_when_null_connection_passed() { - // Exercises ManagementApiClient(token, domain, connection: null, customDomain:) — the client - // creates its own HttpClientManagementConnection and must wire the custom domain into it. var client = new InspectableManagementApiClient("fake", "tenant.auth0.com", null, "custom.example.com"); var conn = client.ExposedConnection as HttpClientManagementConnection; @@ -318,9 +316,7 @@ public async Task Custom_domain_header_sent_when_connection_and_custom_domain_bo .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .Callback((req, _) => capturedRequest = req) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("[]", Encoding.UTF8, "application/json") }); - - // Both connection (with custom domain wired in) and customDomain are provided. - // The connection's own custom domain takes effect; the constructor customDomain parameter is redundant but harmless. + var connection = new HttpClientManagementConnection(new HttpClient(mockHandler.Object), null, "custom.example.com"); var client = new ManagementApiClient("fake", "tenant.auth0.com", connection, "custom.example.com");