diff --git a/.fernignore b/.fernignore
index 9956c031..364030b6 100644
--- a/.fernignore
+++ b/.fernignore
@@ -49,6 +49,8 @@ src/main/java/com/auth0/client/ClientCredentialsTokenProvider.java
src/main/java/com/auth0/client/ManagementApiWithTokenProvider.java
src/main/java/com/auth0/client/TokenProvider.java
src/main/java/com/auth0/client/interceptors/
+src/main/java/com/auth0/client/mgmt/core/CustomDomainInterceptor.java
+ src/main/java/com/auth0/client/mgmt/CustomDomainHeader.java
# Custom OAuth client credentials support
src/main/java/com/auth0/client/mgmt/core/RequestOptions.java
@@ -63,6 +65,8 @@ src/main/java/com/auth0/client/mgmt/ManagementApiBuilder.java
src/test/java/com/auth0/client/mgmt/DynamicTokenManagementTest.java
src/test/java/com/auth0/client/mgmt/OAuthTokenSupplierTest.java
src/test/java/com/auth0/client/mgmt/ManagementApiBuilderTest.java
+src/test/java/com/auth0/client/mgmt/CustomDomainInterceptorTest.java
+src/test/java/com/auth0/client/mgmt/CustomDomainHeaderIntegrationTest.java
# Configuration files from auth0-real
.codecov.yml
diff --git a/src/main/java/com/auth0/client/mgmt/CustomDomainHeader.java b/src/main/java/com/auth0/client/mgmt/CustomDomainHeader.java
new file mode 100644
index 00000000..1d297ebe
--- /dev/null
+++ b/src/main/java/com/auth0/client/mgmt/CustomDomainHeader.java
@@ -0,0 +1,38 @@
+package com.auth0.client.mgmt;
+
+import com.auth0.client.mgmt.core.CustomDomainInterceptor;
+import com.auth0.client.mgmt.core.RequestOptions;
+
+/**
+ * Convenience helper for creating per-request custom domain overrides.
+ *
+ *
Use this to override the global custom domain for a specific API call.
+ * The header is only sent to whitelisted endpoints that generate user-facing links.
+ *
+ *
Example usage:
+ *
{@code
+ * // Override the custom domain for a specific request
+ * client.users().list(CustomDomainHeader.of("other.mycompany.com"));
+ *
+ * // Use with tickets
+ * client.tickets().createEmailVerification(request, CustomDomainHeader.of("login.mycompany.com"));
+ * }
+ *
+ * @see ManagementApiBuilder#customDomain(String) for setting a global custom domain
+ */
+public final class CustomDomainHeader {
+
+ private CustomDomainHeader() {}
+
+ /**
+ * Creates a {@link RequestOptions} instance with the Auth0-Custom-Domain header set.
+ *
+ * @param domain The custom domain to use (e.g., "login.mycompany.com")
+ * @return RequestOptions with the custom domain header configured
+ */
+ public static RequestOptions of(String domain) {
+ return RequestOptions.builder()
+ .addHeader(CustomDomainInterceptor.HEADER_NAME, domain)
+ .build();
+ }
+}
diff --git a/src/main/java/com/auth0/client/mgmt/ManagementApiBuilder.java b/src/main/java/com/auth0/client/mgmt/ManagementApiBuilder.java
index 8e2c6099..d6973247 100644
--- a/src/main/java/com/auth0/client/mgmt/ManagementApiBuilder.java
+++ b/src/main/java/com/auth0/client/mgmt/ManagementApiBuilder.java
@@ -4,6 +4,7 @@
package com.auth0.client.mgmt;
import com.auth0.client.mgmt.core.ClientOptions;
+import com.auth0.client.mgmt.core.CustomDomainInterceptor;
import com.auth0.client.mgmt.core.Environment;
import com.auth0.client.mgmt.core.OAuthTokenSupplier;
import java.util.HashMap;
@@ -25,6 +26,8 @@ public class ManagementApiBuilder {
private OkHttpClient httpClient;
+ private String customDomain = null;
+
// Domain-based initialization fields
private String domain = null;
private String clientId = null;
@@ -107,6 +110,40 @@ public ManagementApiBuilder audience(String audience) {
return this;
}
+ /**
+ * Sets the custom domain for the Auth0-Custom-Domain header.
+ * When configured, the header is automatically sent on whitelisted API endpoints
+ * that generate user-facing links (email verification, password change, invitations, etc.).
+ *
+ * The header is only sent to whitelisted endpoints:
+ *
+ * - {@code /jobs/verification-email}
+ * - {@code /tickets/email-verification}
+ * - {@code /tickets/password-change}
+ * - {@code /organizations/{id}/invitations}
+ * - {@code /users} and {@code /users/{id}}
+ * - {@code /guardian/enrollments/ticket}
+ * - {@code /self-service-profiles/{id}/sso-ticket}
+ *
+ *
+ * Example:
+ *
{@code
+ * ManagementApi client = ManagementApi.builder()
+ * .domain("your-tenant.auth0.com")
+ * .token("YOUR_TOKEN")
+ * .customDomain("login.mycompany.com")
+ * .build();
+ * }
+ *
+ * @param customDomain The custom domain (e.g., "login.mycompany.com")
+ * @return This builder for method chaining
+ * @see CustomDomainHeader#of(String) for per-request custom domain overrides
+ */
+ public ManagementApiBuilder customDomain(String customDomain) {
+ this.customDomain = customDomain;
+ return this;
+ }
+
/**
* Sets the timeout (in seconds) for the client. Defaults to 60 seconds.
*/
@@ -154,6 +191,10 @@ protected ClientOptions buildClientOptions() {
for (Map.Entry header : this.customHeaders.entrySet()) {
builder.addHeader(header.getKey(), header.getValue());
}
+ if (this.customDomain != null) {
+ builder.addHeader(CustomDomainInterceptor.HEADER_NAME, this.customDomain);
+ builder.addInterceptor(new CustomDomainInterceptor());
+ }
setAdditional(builder);
return builder.build();
}
diff --git a/src/main/java/com/auth0/client/mgmt/core/ClientOptions.java b/src/main/java/com/auth0/client/mgmt/core/ClientOptions.java
index 255e34d0..be6f38de 100644
--- a/src/main/java/com/auth0/client/mgmt/core/ClientOptions.java
+++ b/src/main/java/com/auth0/client/mgmt/core/ClientOptions.java
@@ -4,11 +4,14 @@
package com.auth0.client.mgmt.core;
import com.auth0.net.Telemetry;
+import java.util.ArrayList;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
+import okhttp3.Interceptor;
import okhttp3.OkHttpClient;
public final class ClientOptions {
@@ -100,6 +103,8 @@ public static class Builder {
private final Map> headerSuppliers = new HashMap<>();
+ private final List interceptors = new ArrayList<>();
+
private int maxRetries = 2;
private Optional timeout = Optional.empty();
@@ -150,6 +155,14 @@ public Builder httpClient(OkHttpClient httpClient) {
return this;
}
+ /**
+ * Add an OkHttp interceptor to the client.
+ */
+ public Builder addInterceptor(Interceptor interceptor) {
+ this.interceptors.add(interceptor);
+ return this;
+ }
+
public ClientOptions build() {
OkHttpClient.Builder httpClientBuilder =
this.httpClient != null ? this.httpClient.newBuilder() : new OkHttpClient.Builder();
@@ -169,6 +182,10 @@ public ClientOptions build() {
.addInterceptor(new RetryInterceptor(this.maxRetries));
}
+ for (Interceptor interceptor : this.interceptors) {
+ httpClientBuilder.addInterceptor(interceptor);
+ }
+
this.httpClient = httpClientBuilder.build();
this.timeout = Optional.of(httpClient.callTimeoutMillis() / 1000);
diff --git a/src/main/java/com/auth0/client/mgmt/core/CustomDomainInterceptor.java b/src/main/java/com/auth0/client/mgmt/core/CustomDomainInterceptor.java
new file mode 100644
index 00000000..9f26dc8a
--- /dev/null
+++ b/src/main/java/com/auth0/client/mgmt/core/CustomDomainInterceptor.java
@@ -0,0 +1,61 @@
+package com.auth0.client.mgmt.core;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+import okhttp3.Interceptor;
+import okhttp3.Request;
+import okhttp3.Response;
+
+/**
+ * OkHttp interceptor that enforces the Auth0-Custom-Domain header whitelist.
+ *
+ * The Auth0-Custom-Domain header is only sent to specific API endpoints that generate
+ * user-facing links (email verification, password change, invitations, etc.). This interceptor
+ * strips the header from requests to non-whitelisted paths.
+ *
+ *
Whitelisted endpoints:
+ *
+ * - {@code /jobs/verification-email}
+ * - {@code /tickets/email-verification}
+ * - {@code /tickets/password-change}
+ * - {@code /organizations/{id}/invitations}
+ * - {@code /users} and {@code /users/{id}}
+ * - {@code /guardian/enrollments/ticket}
+ * - {@code /self-service-profiles/{id}/sso-ticket}
+ *
+ */
+public class CustomDomainInterceptor implements Interceptor {
+
+ public static final String HEADER_NAME = "Auth0-Custom-Domain";
+
+ private static final List WHITELISTED_PATHS = Arrays.asList(
+ Pattern.compile(".*/jobs/verification-email$"),
+ Pattern.compile(".*/tickets/email-verification$"),
+ Pattern.compile(".*/tickets/password-change$"),
+ Pattern.compile(".*/organizations/[^/]+/invitations(/[^/]+)?$"),
+ Pattern.compile(".*/users(/[^/]+)?$"),
+ Pattern.compile(".*/guardian/enrollments/ticket$"),
+ Pattern.compile(".*/self-service-profiles/[^/]+/sso-ticket(/[^/]+/revoke)?$"));
+
+ @Override
+ public Response intercept(Chain chain) throws IOException {
+ Request request = chain.request();
+
+ if (request.header(HEADER_NAME) != null && !isWhitelisted(request.url().encodedPath())) {
+ request = request.newBuilder().removeHeader(HEADER_NAME).build();
+ }
+
+ return chain.proceed(request);
+ }
+
+ public static boolean isWhitelisted(String path) {
+ for (Pattern pattern : WHITELISTED_PATHS) {
+ if (pattern.matcher(path).matches()) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/test/java/com/auth0/client/mgmt/CustomDomainHeaderIntegrationTest.java b/src/test/java/com/auth0/client/mgmt/CustomDomainHeaderIntegrationTest.java
new file mode 100644
index 00000000..9436cc4d
--- /dev/null
+++ b/src/test/java/com/auth0/client/mgmt/CustomDomainHeaderIntegrationTest.java
@@ -0,0 +1,154 @@
+package com.auth0.client.mgmt;
+
+import com.auth0.client.mgmt.core.CustomDomainInterceptor;
+import com.auth0.client.mgmt.core.RequestOptions;
+import com.auth0.client.mgmt.types.ListUsersRequestParameters;
+import com.auth0.client.mgmt.types.VerifyEmailTicketRequestContent;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Integration tests for the Auth0-Custom-Domain header feature using MockWebServer.
+ */
+public class CustomDomainHeaderIntegrationTest {
+
+ private static final String USERS_RESPONSE = "{\"start\":0,\"limit\":50,\"length\":0,\"total\":0,\"users\":[]}";
+ private static final String CONNECTIONS_RESPONSE = "{\"connections\":[]}";
+ private static final String CUSTOM_DOMAINS_RESPONSE = "[]";
+ private static final String TICKET_RESPONSE = "{\"ticket\":\"https://example.com\"}";
+
+ private MockWebServer server;
+
+ @BeforeEach
+ public void setup() throws Exception {
+ server = new MockWebServer();
+ server.start();
+ }
+
+ @AfterEach
+ public void teardown() throws Exception {
+ server.shutdown();
+ }
+
+ @Test
+ public void testGlobalCustomDomainHeaderOnWhitelistedPath() throws Exception {
+ ManagementApi client = ManagementApi.builder()
+ .url(server.url("/").toString())
+ .token("test-token")
+ .customDomain("login.mycompany.com")
+ .build();
+
+ server.enqueue(new MockResponse().setResponseCode(200).setBody(USERS_RESPONSE));
+ client.users().list();
+
+ RecordedRequest request = server.takeRequest();
+ Assertions.assertEquals("login.mycompany.com", request.getHeader(CustomDomainInterceptor.HEADER_NAME));
+ }
+
+ @Test
+ public void testGlobalCustomDomainHeaderStrippedOnNonWhitelistedPath() throws Exception {
+ ManagementApi client = ManagementApi.builder()
+ .url(server.url("/").toString())
+ .token("test-token")
+ .customDomain("login.mycompany.com")
+ .build();
+
+ server.enqueue(new MockResponse().setResponseCode(200).setBody(CUSTOM_DOMAINS_RESPONSE));
+ client.customDomains().list();
+
+ RecordedRequest request = server.takeRequest();
+ Assertions.assertNull(request.getHeader(CustomDomainInterceptor.HEADER_NAME));
+ }
+
+ @Test
+ public void testPerRequestCustomDomainOverride() throws Exception {
+ ManagementApi client = ManagementApi.builder()
+ .url(server.url("/").toString())
+ .token("test-token")
+ .customDomain("login.mycompany.com")
+ .build();
+
+ server.enqueue(new MockResponse().setResponseCode(200).setBody(USERS_RESPONSE));
+ client.users().list(ListUsersRequestParameters.builder().build(), CustomDomainHeader.of("other.mycompany.com"));
+
+ RecordedRequest request = server.takeRequest();
+ Assertions.assertEquals("other.mycompany.com", request.getHeader(CustomDomainInterceptor.HEADER_NAME));
+ }
+
+ @Test
+ public void testNoCustomDomainConfigured() throws Exception {
+ ManagementApi client = ManagementApi.builder()
+ .url(server.url("/").toString())
+ .token("test-token")
+ .build();
+
+ server.enqueue(new MockResponse().setResponseCode(200).setBody(USERS_RESPONSE));
+ client.users().list();
+
+ RecordedRequest request = server.takeRequest();
+ Assertions.assertNull(request.getHeader(CustomDomainInterceptor.HEADER_NAME));
+ }
+
+ @Test
+ public void testPerRequestCustomDomainWithoutGlobal() throws Exception {
+ ManagementApi client = ManagementApi.builder()
+ .url(server.url("/").toString())
+ .token("test-token")
+ .build();
+
+ server.enqueue(new MockResponse().setResponseCode(200).setBody(USERS_RESPONSE));
+
+ RequestOptions options = RequestOptions.builder()
+ .addHeader(CustomDomainInterceptor.HEADER_NAME, "login.mycompany.com")
+ .build();
+ client.users().list(ListUsersRequestParameters.builder().build(), options);
+
+ RecordedRequest request = server.takeRequest();
+ // Without the interceptor registered (no customDomain on builder), the header passes through as-is
+ Assertions.assertEquals("login.mycompany.com", request.getHeader(CustomDomainInterceptor.HEADER_NAME));
+ }
+
+ @Test
+ public void testCustomDomainHeaderOnTicketsEndpoint() throws Exception {
+ ManagementApi client = ManagementApi.builder()
+ .url(server.url("/").toString())
+ .token("test-token")
+ .customDomain("login.mycompany.com")
+ .build();
+
+ server.enqueue(new MockResponse().setResponseCode(200).setBody(TICKET_RESPONSE));
+ client.tickets()
+ .verifyEmail(VerifyEmailTicketRequestContent.builder()
+ .userId("auth0|123")
+ .build());
+
+ RecordedRequest request = server.takeRequest();
+ Assertions.assertEquals("login.mycompany.com", request.getHeader(CustomDomainInterceptor.HEADER_NAME));
+ }
+
+ @Test
+ public void testCustomDomainHeaderOnConnectionsStripped() throws Exception {
+ ManagementApi client = ManagementApi.builder()
+ .url(server.url("/").toString())
+ .token("test-token")
+ .customDomain("login.mycompany.com")
+ .build();
+
+ server.enqueue(new MockResponse().setResponseCode(200).setBody(CONNECTIONS_RESPONSE));
+ client.connections().list();
+
+ RecordedRequest request = server.takeRequest();
+ Assertions.assertNull(request.getHeader(CustomDomainInterceptor.HEADER_NAME));
+ }
+
+ @Test
+ public void testCustomDomainHeaderHelperCreatesValidRequestOptions() {
+ RequestOptions options = CustomDomainHeader.of("login.mycompany.com");
+ Assertions.assertEquals("login.mycompany.com", options.getHeaders().get(CustomDomainInterceptor.HEADER_NAME));
+ }
+}
diff --git a/src/test/java/com/auth0/client/mgmt/CustomDomainInterceptorTest.java b/src/test/java/com/auth0/client/mgmt/CustomDomainInterceptorTest.java
new file mode 100644
index 00000000..b91f2a1e
--- /dev/null
+++ b/src/test/java/com/auth0/client/mgmt/CustomDomainInterceptorTest.java
@@ -0,0 +1,125 @@
+package com.auth0.client.mgmt;
+
+import com.auth0.client.mgmt.core.CustomDomainInterceptor;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link CustomDomainInterceptor} path whitelisting logic.
+ */
+public class CustomDomainInterceptorTest {
+
+ // --- Whitelisted paths ---
+
+ @Test
+ public void testJobsVerificationEmail() {
+ Assertions.assertTrue(CustomDomainInterceptor.isWhitelisted("/api/v2/jobs/verification-email"));
+ }
+
+ @Test
+ public void testTicketsEmailVerification() {
+ Assertions.assertTrue(CustomDomainInterceptor.isWhitelisted("/api/v2/tickets/email-verification"));
+ }
+
+ @Test
+ public void testTicketsPasswordChange() {
+ Assertions.assertTrue(CustomDomainInterceptor.isWhitelisted("/api/v2/tickets/password-change"));
+ }
+
+ @Test
+ public void testOrganizationsInvitations() {
+ Assertions.assertTrue(CustomDomainInterceptor.isWhitelisted("/api/v2/organizations/org_123/invitations"));
+ }
+
+ @Test
+ public void testOrganizationsInvitationsWithId() {
+ Assertions.assertTrue(
+ CustomDomainInterceptor.isWhitelisted("/api/v2/organizations/org_123/invitations/inv_456"));
+ }
+
+ @Test
+ public void testUsers() {
+ Assertions.assertTrue(CustomDomainInterceptor.isWhitelisted("/api/v2/users"));
+ }
+
+ @Test
+ public void testUsersWithId() {
+ Assertions.assertTrue(CustomDomainInterceptor.isWhitelisted("/api/v2/users/auth0|123456"));
+ }
+
+ @Test
+ public void testGuardianEnrollmentsTicket() {
+ Assertions.assertTrue(CustomDomainInterceptor.isWhitelisted("/api/v2/guardian/enrollments/ticket"));
+ }
+
+ @Test
+ public void testSelfServiceProfilesSsoTicket() {
+ Assertions.assertTrue(
+ CustomDomainInterceptor.isWhitelisted("/api/v2/self-service-profiles/ssp_123/sso-ticket"));
+ }
+
+ @Test
+ public void testSelfServiceProfilesSsoTicketRevoke() {
+ Assertions.assertTrue(CustomDomainInterceptor.isWhitelisted(
+ "/api/v2/self-service-profiles/ssp_123/sso-ticket/tkt_456/revoke"));
+ }
+
+ // --- Non-whitelisted paths ---
+
+ @Test
+ public void testClientsNotWhitelisted() {
+ Assertions.assertFalse(CustomDomainInterceptor.isWhitelisted("/api/v2/clients"));
+ }
+
+ @Test
+ public void testConnectionsNotWhitelisted() {
+ Assertions.assertFalse(CustomDomainInterceptor.isWhitelisted("/api/v2/connections"));
+ }
+
+ @Test
+ public void testCustomDomainsNotWhitelisted() {
+ Assertions.assertFalse(CustomDomainInterceptor.isWhitelisted("/api/v2/custom-domains"));
+ }
+
+ @Test
+ public void testRulesNotWhitelisted() {
+ Assertions.assertFalse(CustomDomainInterceptor.isWhitelisted("/api/v2/rules"));
+ }
+
+ @Test
+ public void testGuardianEnrollmentsWithIdNotWhitelisted() {
+ Assertions.assertFalse(CustomDomainInterceptor.isWhitelisted("/api/v2/guardian/enrollments/enr_123"));
+ }
+
+ @Test
+ public void testJobsUsersExportsNotWhitelisted() {
+ Assertions.assertFalse(CustomDomainInterceptor.isWhitelisted("/api/v2/jobs/users-exports"));
+ }
+
+ @Test
+ public void testSelfServiceProfilesListNotWhitelisted() {
+ Assertions.assertFalse(CustomDomainInterceptor.isWhitelisted("/api/v2/self-service-profiles"));
+ }
+
+ @Test
+ public void testOrganizationsWithoutInvitationsNotWhitelisted() {
+ Assertions.assertFalse(CustomDomainInterceptor.isWhitelisted("/api/v2/organizations/org_123"));
+ }
+
+ @Test
+ public void testOrganizationsMembersNotWhitelisted() {
+ Assertions.assertFalse(CustomDomainInterceptor.isWhitelisted("/api/v2/organizations/org_123/members"));
+ }
+
+ // --- Paths without /api/v2 prefix (direct base URL) ---
+
+ @Test
+ public void testUsersWithoutApiPrefix() {
+ Assertions.assertTrue(CustomDomainInterceptor.isWhitelisted("/users"));
+ }
+
+ @Test
+ public void testTicketsEmailVerificationWithoutApiPrefix() {
+ Assertions.assertTrue(CustomDomainInterceptor.isWhitelisted("/tickets/email-verification"));
+ }
+}