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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
38 changes: 38 additions & 0 deletions src/main/java/com/auth0/client/mgmt/CustomDomainHeader.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>Example usage:
* <pre>{@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"));
* }</pre>
*
* @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();
}
}
41 changes: 41 additions & 0 deletions src/main/java/com/auth0/client/mgmt/ManagementApiBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.).
*
* <p>The header is only sent to whitelisted endpoints:
* <ul>
* <li>{@code /jobs/verification-email}</li>
* <li>{@code /tickets/email-verification}</li>
* <li>{@code /tickets/password-change}</li>
* <li>{@code /organizations/{id}/invitations}</li>
* <li>{@code /users} and {@code /users/{id}}</li>
* <li>{@code /guardian/enrollments/ticket}</li>
* <li>{@code /self-service-profiles/{id}/sso-ticket}</li>
* </ul>
*
* <p>Example:
* <pre>{@code
* ManagementApi client = ManagementApi.builder()
* .domain("your-tenant.auth0.com")
* .token("YOUR_TOKEN")
* .customDomain("login.mycompany.com")
* .build();
* }</pre>
*
* @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.
*/
Expand Down Expand Up @@ -154,6 +191,10 @@ protected ClientOptions buildClientOptions() {
for (Map.Entry<String, String> 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();
}
Expand Down
17 changes: 17 additions & 0 deletions src/main/java/com/auth0/client/mgmt/core/ClientOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -100,6 +103,8 @@ public static class Builder {

private final Map<String, Supplier<String>> headerSuppliers = new HashMap<>();

private final List<Interceptor> interceptors = new ArrayList<>();

private int maxRetries = 2;

private Optional<Integer> timeout = Optional.empty();
Expand Down Expand Up @@ -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();
Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>Whitelisted endpoints:
* <ul>
* <li>{@code /jobs/verification-email}</li>
* <li>{@code /tickets/email-verification}</li>
* <li>{@code /tickets/password-change}</li>
* <li>{@code /organizations/{id}/invitations}</li>
* <li>{@code /users} and {@code /users/{id}}</li>
* <li>{@code /guardian/enrollments/ticket}</li>
* <li>{@code /self-service-profiles/{id}/sso-ticket}</li>
* </ul>
*/
public class CustomDomainInterceptor implements Interceptor {

public static final String HEADER_NAME = "Auth0-Custom-Domain";

private static final List<Pattern> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading
Loading