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: + *

+ * + *

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")); + } +}