Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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