From 98c8052cde392c264b0f3ecd2d15fb4968a6729c Mon Sep 17 00:00:00 2001 From: tanya732 Date: Thu, 30 Apr 2026 09:13:29 +0530 Subject: [PATCH 1/2] Feat: Add Configurable http client --- EXAMPLES.md | 135 +++++++++++++++ .../com/auth0/jwk/DefaultJwksHttpClient.java | 75 +++++++++ .../com/auth0/jwk/JwkProviderBuilder.java | 45 ++++- .../java/com/auth0/jwk/JwksHttpClient.java | 49 ++++++ .../java/com/auth0/jwk/JwksHttpResponse.java | 74 ++++++++ .../java/com/auth0/jwk/UrlJwkProvider.java | 43 +++-- .../auth0/jwk/DefaultJwksHttpClientTest.java | 158 ++++++++++++++++++ .../com/auth0/jwk/JwkProviderBuilderTest.java | 64 +++++++ .../com/auth0/jwk/JwksHttpResponseTest.java | 85 ++++++++++ .../com/auth0/jwk/UrlJwkProviderTest.java | 34 ++++ 10 files changed, 745 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/auth0/jwk/DefaultJwksHttpClient.java create mode 100644 src/main/java/com/auth0/jwk/JwksHttpClient.java create mode 100644 src/main/java/com/auth0/jwk/JwksHttpResponse.java create mode 100644 src/test/java/com/auth0/jwk/DefaultJwksHttpClientTest.java create mode 100644 src/test/java/com/auth0/jwk/JwksHttpResponseTest.java diff --git a/EXAMPLES.md b/EXAMPLES.md index 5ad6505..905c586 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -44,6 +44,141 @@ JwkProvider provider = new JwkProviderBuilder("https://samples.auth0.com/") .build(); ``` +### Configure a custom HTTP client + +The `httpClient()` builder method lets you replace the default `java.net.URLConnection`-based HTTP transport with any HTTP library. This solves four common requirements: custom TLS, authenticated proxies, Cache-Control header access, and HTTP/2. + +> When `httpClient()` is set, `proxied()`, `timeouts()`, and `headers()` are ignored — the custom client has full control over the HTTP layer. + +#### Custom TLS + +Force TLS 1.3 for JWKS calls without affecting the rest of your JVM: + +```java +SSLContext tls13 = SSLContext.getInstance("TLSv1.3"); +tls13.init(null, null, null); + +JwksHttpClient tlsClient = url -> { + HttpsURLConnection conn = (HttpsURLConnection) url.openConnection(); + conn.setSSLSocketFactory(tls13.getSocketFactory()); + conn.setRequestProperty("Accept", "application/json"); + try (InputStream in = conn.getInputStream()) { + String body = new String(in.readAllBytes(), StandardCharsets.UTF_8); + return new JwksHttpResponse(body, conn.getHeaderFields()); + } +}; + +JwkProvider provider = new JwkProviderBuilder("https://samples.auth0.com/") + .httpClient(tlsClient) + .build(); +``` + +> **Note:** TLS 1.3 requires Java 11+ or a provider like [Conscrypt](https://github.com/google/conscrypt) on Java 8. + +#### Authenticated Proxy + +Use OkHttp to authenticate with a corporate proxy that requires credentials: + +```java +OkHttpClient okHttp = new OkHttpClient.Builder() + .proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("proxy.corp.com", 8080))) + .proxyAuthenticator((route, response) -> + response.request().newBuilder() + .header("Proxy-Authorization", Credentials.basic("user", "pass")) + .build()) + .connectTimeout(Duration.ofSeconds(5)) + .readTimeout(Duration.ofSeconds(10)) + .build(); + +JwksHttpClient proxyClient = url -> { + Request request = new Request.Builder().url(url).build(); + try (Response response = okHttp.newCall(request).execute()) { + return new JwksHttpResponse( + response.body().string(), + response.headers().toMultimap() + ); + } +}; + +JwkProvider provider = new JwkProviderBuilder("https://samples.auth0.com/") + .httpClient(proxyClient) + .build(); +``` + +#### Cache-Control Headers + +Response headers (including `Cache-Control`) are now accessible via `JwksHttpResponse`: + +```java +JwksHttpClient headerAwareClient = url -> { + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestProperty("Accept", "application/json"); + try (InputStream in = conn.getInputStream()) { + String body = new String(in.readAllBytes(), StandardCharsets.UTF_8); + JwksHttpResponse response = new JwksHttpResponse(body, conn.getHeaderFields()); + + // Headers are now available for inspection + String cacheControl = response.getHeaderValue("Cache-Control"); + // e.g., "max-age=3600" + + return response; + } +}; + +JwkProvider provider = new JwkProviderBuilder("https://samples.auth0.com/") + .httpClient(headerAwareClient) + .build(); +``` + +#### HTTP/2 Support + +**Using Java 11+ HttpClient:** + +```java +java.net.http.HttpClient http2Client = java.net.http.HttpClient.newBuilder() + .version(java.net.http.HttpClient.Version.HTTP_2) + .connectTimeout(Duration.ofSeconds(5)) + .build(); + +JwksHttpClient h2Client = url -> { + java.net.http.HttpRequest request = java.net.http.HttpRequest.newBuilder(url.toURI()) + .header("Accept", "application/json") + .GET() + .build(); + java.net.http.HttpResponse response = http2Client.send( + request, java.net.http.HttpResponse.BodyHandlers.ofString()); + return new JwksHttpResponse(response.body(), response.headers().map()); +}; + +JwkProvider provider = new JwkProviderBuilder("https://samples.auth0.com/") + .httpClient(h2Client) + .build(); +``` + +**Using OkHttp (Java 8 compatible):** + +```java +OkHttpClient okHttp = new OkHttpClient.Builder() + .protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1)) + .connectTimeout(Duration.ofSeconds(5)) + .readTimeout(Duration.ofSeconds(10)) + .build(); + +JwksHttpClient okClient = url -> { + Request request = new Request.Builder().url(url).build(); + try (Response response = okHttp.newCall(request).execute()) { + return new JwksHttpResponse( + response.body().string(), + response.headers().toMultimap() + ); + } +}; + +JwkProvider provider = new JwkProviderBuilder("https://samples.auth0.com/") + .httpClient(okClient) + .build(); +``` + See the [JwkProviderBuilder JavaDocs](https://javadoc.io/doc/com.auth0/jwks-rsa/latest/com/auth0/jwk/JwkProviderBuilder.html) for all available configurations. ## Error handling diff --git a/src/main/java/com/auth0/jwk/DefaultJwksHttpClient.java b/src/main/java/com/auth0/jwk/DefaultJwksHttpClient.java new file mode 100644 index 0000000..83afcd5 --- /dev/null +++ b/src/main/java/com/auth0/jwk/DefaultJwksHttpClient.java @@ -0,0 +1,75 @@ +package com.auth0.jwk; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.Proxy; +import java.net.URL; +import java.net.URLConnection; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Default {@link JwksHttpClient} implementation using {@link java.net.URLConnection}. + * + *

This preserves the exact HTTP behavior the library had before the pluggable + * client interface was introduced. It is used automatically when no custom + * {@link JwksHttpClient} is provided to the builder.

+ */ +final class DefaultJwksHttpClient implements JwksHttpClient { + + private final Integer connectTimeout; + private final Integer readTimeout; + private final Proxy proxy; + private final Map headers; + + /** + * Creates a default HTTP client with the given configuration. + * + * @param connectTimeout connection timeout in milliseconds (null for system default) + * @param readTimeout read timeout in milliseconds (null for system default) + * @param proxy proxy server to use (null for direct connection) + * @param headers request headers to send (null defaults to Accept: application/json) + */ + DefaultJwksHttpClient(Integer connectTimeout, Integer readTimeout, + Proxy proxy, Map headers) { + this.connectTimeout = connectTimeout; + this.readTimeout = readTimeout; + this.proxy = proxy; + this.headers = (headers != null) ? headers : + Collections.singletonMap("Accept", "application/json"); + } + + @Override + public JwksHttpResponse fetch(URL url) throws IOException { + final URLConnection c = (proxy == null) ? url.openConnection() : url.openConnection(proxy); + + if (connectTimeout != null) { + c.setConnectTimeout(connectTimeout); + } + if (readTimeout != null) { + c.setReadTimeout(readTimeout); + } + + for (Map.Entry entry : headers.entrySet()) { + c.setRequestProperty(entry.getKey(), entry.getValue()); + } + + String body; + try (InputStream in = c.getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + body = sb.toString(); + } + + Map> responseHeaders = c.getHeaderFields(); + return new JwksHttpResponse(body, responseHeaders); + } +} diff --git a/src/main/java/com/auth0/jwk/JwkProviderBuilder.java b/src/main/java/com/auth0/jwk/JwkProviderBuilder.java index c353620..4d81683 100644 --- a/src/main/java/com/auth0/jwk/JwkProviderBuilder.java +++ b/src/main/java/com/auth0/jwk/JwkProviderBuilder.java @@ -24,6 +24,7 @@ public class JwkProviderBuilder { private BucketImpl bucket; private boolean rateLimited; private Map headers; + private JwksHttpClient httpClient; /** * Creates a new Builder with the given URL where to load the jwks from. @@ -166,13 +167,55 @@ public JwkProviderBuilder headers(Map headers) { return this; } + /** + * Sets a custom HTTP client for fetching JWKS. + * + *

When a custom client is provided, it takes precedence over configurations set via + * {@link #proxied(Proxy)}, {@link #timeouts(int, int)}, and {@link #headers(Map)} — those + * settings are only used by the default HTTP client.

+ * + *

Example using OkHttp:

+ *
{@code
+     * OkHttpClient ok = new OkHttpClient.Builder()
+     *     .proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("proxy.corp", 8080)))
+     *     .proxyAuthenticator((route, resp) -> resp.request().newBuilder()
+     *         .header("Proxy-Authorization", Credentials.basic("user", "pass")).build())
+     *     .protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1))
+     *     .build();
+     *
+     * JwksHttpClient client = url -> {
+     *     Request req = new Request.Builder().url(url).build();
+     *     try (Response resp = ok.newCall(req).execute()) {
+     *         return new JwksHttpResponse(resp.body().string(), resp.headers().toMultimap());
+     *     }
+     * };
+     *
+     * JwkProvider provider = new JwkProviderBuilder(domain)
+     *     .httpClient(client)
+     *     .build();
+     * }
+ * + * @param httpClient the custom HTTP client to use for fetching JWKS + * @return the builder + * @see JwksHttpClient + */ + public JwkProviderBuilder httpClient(JwksHttpClient httpClient) { + this.httpClient = httpClient; + return this; + } + /** * Creates a {@link JwkProvider} * * @return a newly created {@link JwkProvider} */ public JwkProvider build() { - JwkProvider urlProvider = new UrlJwkProvider(url, connectTimeout, readTimeout, proxy, headers); + JwkProvider urlProvider; + if (this.httpClient != null) { + urlProvider = new UrlJwkProvider(url, this.httpClient); + } else { + urlProvider = new UrlJwkProvider(url, connectTimeout, readTimeout, proxy, headers); + } if (this.rateLimited) { urlProvider = new RateLimitedJwkProvider(urlProvider, bucket); } diff --git a/src/main/java/com/auth0/jwk/JwksHttpClient.java b/src/main/java/com/auth0/jwk/JwksHttpClient.java new file mode 100644 index 0000000..69c2bb3 --- /dev/null +++ b/src/main/java/com/auth0/jwk/JwksHttpClient.java @@ -0,0 +1,49 @@ +package com.auth0.jwk; + +import java.io.IOException; +import java.net.URL; + +/** + * Abstraction for fetching JWKS JSON over HTTP. + * + *

Implement this interface to control how the library makes HTTP requests. + * This allows customization of TLS settings, proxy authentication, HTTP version, + * and any other transport concern.

+ * + *

This is a functional interface, so a lambda can be used:

+ *
{@code
+ * JwksHttpClient client = url -> {
+ *     // Use any HTTP library (OkHttp, Apache HC, Java 11 HttpClient, etc.)
+ *     Request req = new Request.Builder().url(url).build();
+ *     try (Response resp = okHttp.newCall(req).execute()) {
+ *         return new JwksHttpResponse(resp.body().string(), resp.headers().toMultimap());
+ *     }
+ * };
+ *
+ * JwkProvider provider = new JwkProviderBuilder(domain)
+ *     .httpClient(client)
+ *     .build();
+ * }
+ * + *

If no custom client is provided, the library uses a default implementation + * based on {@link java.net.URLConnection}.

+ */ +@FunctionalInterface +public interface JwksHttpClient { + + /** + * Fetch the JWKS JSON from the given URL. + * + *

Implementations should:

+ *
    + *
  • Make an HTTP GET request to the URL
  • + *
  • Return the response body and headers wrapped in a {@link JwksHttpResponse}
  • + *
  • Throw {@link IOException} on any network or protocol error
  • + *
+ * + * @param url the JWKS endpoint URL + * @return the HTTP response containing the body and headers + * @throws IOException on any network or protocol error + */ + JwksHttpResponse fetch(URL url) throws IOException; +} diff --git a/src/main/java/com/auth0/jwk/JwksHttpResponse.java b/src/main/java/com/auth0/jwk/JwksHttpResponse.java new file mode 100644 index 0000000..3a8676a --- /dev/null +++ b/src/main/java/com/auth0/jwk/JwksHttpResponse.java @@ -0,0 +1,74 @@ +package com.auth0.jwk; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Represents the HTTP response from a JWKS endpoint. + * Contains both the JSON body and the response headers. + */ +public final class JwksHttpResponse { + + private final String body; + private final Map> headers; + + /** + * Creates a new response with body and headers. + * + * @param body the response body (JWKS JSON) + * @param headers the response headers (e.g., Cache-Control) + */ + public JwksHttpResponse(String body, Map> headers) { + this.body = body; + this.headers = (headers != null) ? headers : Collections.>emptyMap(); + } + + /** + * Creates a new response with body only (no headers). + * + * @param body the response body (JWKS JSON) + */ + public JwksHttpResponse(String body) { + this(body, Collections.>emptyMap()); + } + + /** + * Returns the response body as a string (the JWKS JSON). + * + * @return the response body + */ + public String getBody() { + return body; + } + + /** + * Returns all response headers. + * + * @return an unmodifiable map of header names to their values + */ + public Map> getHeaders() { + return headers; + } + + /** + * Returns the first value of a response header (case-insensitive lookup). + * + *

Example usage:

+ *
{@code
+     * String cacheControl = response.getHeaderValue("Cache-Control");
+     * }
+ * + * @param name the header name (case-insensitive) + * @return the first header value, or null if not present + */ + public String getHeaderValue(String name) { + for (Map.Entry> entry : headers.entrySet()) { + if (entry.getKey() != null && entry.getKey().equalsIgnoreCase(name)) { + List values = entry.getValue(); + return (values != null && !values.isEmpty()) ? values.get(0) : null; + } + } + return null; + } +} diff --git a/src/main/java/com/auth0/jwk/UrlJwkProvider.java b/src/main/java/com/auth0/jwk/UrlJwkProvider.java index 0494ad3..a2166d9 100644 --- a/src/main/java/com/auth0/jwk/UrlJwkProvider.java +++ b/src/main/java/com/auth0/jwk/UrlJwkProvider.java @@ -4,7 +4,6 @@ import com.fasterxml.jackson.databind.ObjectReader; import java.io.IOException; -import java.io.InputStream; import java.net.*; import java.util.*; import java.util.concurrent.atomic.AtomicReference; @@ -27,6 +26,7 @@ public class UrlJwkProvider implements JwkProvider { final Integer readTimeout; private final ObjectReader reader; + private final JwksHttpClient httpClient; /** * Creates a provider that loads from the given URL @@ -71,6 +71,30 @@ public UrlJwkProvider(URL url, Integer connectTimeout, Integer readTimeout, Prox this.headers = (headers == null) ? Collections.singletonMap("Accept", "application/json") : headers; + this.httpClient = new DefaultJwksHttpClient(connectTimeout, readTimeout, proxy, this.headers); + } + + /** + * Creates a provider that loads from the given URL using a custom HTTP client. + * + *

Use this constructor (or the builder's {@code httpClient()} method) to provide a custom + * {@link JwksHttpClient} implementation for full control over the HTTP transport — including + * TLS configuration, proxy authentication, HTTP/2, and response header access.

+ * + * @param url to load the jwks + * @param httpClient the custom HTTP client to use for fetching JWKS + */ + public UrlJwkProvider(URL url, JwksHttpClient httpClient) { + Util.checkArgument(url != null, "A non-null url is required"); + Util.checkArgument(httpClient != null, "A non-null httpClient is required"); + + this.url = url; + this.proxy = null; + this.connectTimeout = null; + this.readTimeout = null; + this.headers = Collections.singletonMap("Accept", "application/json"); + this.reader = new ObjectMapper().readerFor(Map.class); + this.httpClient = httpClient; } /** @@ -125,21 +149,8 @@ static URL urlForDomain(String domain) { private Map getJwks() throws SigningKeyNotFoundException { try { - final URLConnection c = (proxy == null) ? this.url.openConnection() : this.url.openConnection(proxy); - if (connectTimeout != null) { - c.setConnectTimeout(connectTimeout); - } - if (readTimeout != null) { - c.setReadTimeout(readTimeout); - } - - for (Map.Entry entry : headers.entrySet()) { - c.setRequestProperty(entry.getKey(), entry.getValue()); - } - - try (InputStream inputStream = c.getInputStream()) { - return reader.readValue(inputStream); - } + JwksHttpResponse response = httpClient.fetch(this.url); + return reader.readValue(response.getBody()); } catch (IOException e) { throw new NetworkException("Cannot obtain jwks from url " + url.toString(), e); } diff --git a/src/test/java/com/auth0/jwk/DefaultJwksHttpClientTest.java b/src/test/java/com/auth0/jwk/DefaultJwksHttpClientTest.java new file mode 100644 index 0000000..9de2a95 --- /dev/null +++ b/src/test/java/com/auth0/jwk/DefaultJwksHttpClientTest.java @@ -0,0 +1,158 @@ +package com.auth0.jwk; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.util.*; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.*; + +public class DefaultJwksHttpClientTest { + + @Rule + public ExpectedException expectedException = ExpectedException.none(); + + @Test + public void shouldFetchAndReturnBodyAndHeaders() throws Exception { + String json = "{\"keys\":[]}"; + URLConnection connection = mock(URLConnection.class); + when(connection.getInputStream()).thenReturn( + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + Map> responseHeaders = new HashMap<>(); + responseHeaders.put("Content-Type", Collections.singletonList("application/json")); + when(connection.getHeaderFields()).thenReturn(responseHeaders); + + URL url = createMockUrl(connection); + DefaultJwksHttpClient client = new DefaultJwksHttpClient(null, null, null, null); + + JwksHttpResponse response = client.fetch(url); + + assertThat(response.getBody(), is(json)); + assertThat(response.getHeaders(), is(responseHeaders)); + } + + @Test + public void shouldSetTimeouts() throws Exception { + String json = "{}"; + URLConnection connection = mock(URLConnection.class); + when(connection.getInputStream()).thenReturn( + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + when(connection.getHeaderFields()).thenReturn(Collections.>emptyMap()); + + URL url = createMockUrl(connection); + DefaultJwksHttpClient client = new DefaultJwksHttpClient(5000, 10000, null, null); + + client.fetch(url); + + verify(connection).setConnectTimeout(5000); + verify(connection).setReadTimeout(10000); + } + + @Test + public void shouldNotSetTimeoutsWhenNull() throws Exception { + String json = "{}"; + URLConnection connection = mock(URLConnection.class); + when(connection.getInputStream()).thenReturn( + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + when(connection.getHeaderFields()).thenReturn(Collections.>emptyMap()); + + URL url = createMockUrl(connection); + DefaultJwksHttpClient client = new DefaultJwksHttpClient(null, null, null, null); + + client.fetch(url); + + verify(connection, never()).setConnectTimeout(anyInt()); + verify(connection, never()).setReadTimeout(anyInt()); + } + + @Test + public void shouldSetDefaultAcceptHeader() throws Exception { + String json = "{}"; + URLConnection connection = mock(URLConnection.class); + when(connection.getInputStream()).thenReturn( + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + when(connection.getHeaderFields()).thenReturn(Collections.>emptyMap()); + + URL url = createMockUrl(connection); + DefaultJwksHttpClient client = new DefaultJwksHttpClient(null, null, null, null); + + client.fetch(url); + + verify(connection).setRequestProperty("Accept", "application/json"); + } + + @Test + public void shouldSetCustomHeaders() throws Exception { + String json = "{}"; + URLConnection connection = mock(URLConnection.class); + when(connection.getInputStream()).thenReturn( + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + when(connection.getHeaderFields()).thenReturn(Collections.>emptyMap()); + + URL url = createMockUrl(connection); + Map customHeaders = new LinkedHashMap<>(); + customHeaders.put("Authorization", "Bearer token"); + customHeaders.put("X-Custom", "value"); + DefaultJwksHttpClient client = new DefaultJwksHttpClient(null, null, null, customHeaders); + + client.fetch(url); + + verify(connection).setRequestProperty("Authorization", "Bearer token"); + verify(connection).setRequestProperty("X-Custom", "value"); + } + + @Test + public void shouldThrowIOExceptionOnNetworkError() throws Exception { + expectedException.expect(IOException.class); + + URLConnection connection = mock(URLConnection.class); + when(connection.getInputStream()).thenThrow(new IOException("Connection refused")); + + URL url = createMockUrl(connection); + DefaultJwksHttpClient client = new DefaultJwksHttpClient(null, null, null, null); + + client.fetch(url); + } + + @Test + public void shouldUseProxyWhenProvided() throws Exception { + String json = "{}"; + URLConnection connection = mock(URLConnection.class); + when(connection.getInputStream()).thenReturn( + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + when(connection.getHeaderFields()).thenReturn(Collections.>emptyMap()); + + Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress("proxy.example.com", 8080)); + URL url = createMockUrl(connection, proxy); + DefaultJwksHttpClient client = new DefaultJwksHttpClient(null, null, proxy, null); + + JwksHttpResponse response = client.fetch(url); + assertThat(response.getBody(), is(json)); + } + + private URL createMockUrl(final URLConnection connection) throws Exception { + return createMockUrl(connection, null); + } + + private URL createMockUrl(final URLConnection connection, final Proxy expectedProxy) throws Exception { + URLStreamHandler handler = new URLStreamHandler() { + @Override + protected URLConnection openConnection(URL u) throws IOException { + return connection; + } + + @Override + protected URLConnection openConnection(URL u, Proxy p) throws IOException { + return connection; + } + }; + return new URL("http", "localhost", 80, "/.well-known/jwks.json", handler); + } +} diff --git a/src/test/java/com/auth0/jwk/JwkProviderBuilderTest.java b/src/test/java/com/auth0/jwk/JwkProviderBuilderTest.java index 380b2be..a486a88 100644 --- a/src/test/java/com/auth0/jwk/JwkProviderBuilderTest.java +++ b/src/test/java/com/auth0/jwk/JwkProviderBuilderTest.java @@ -4,6 +4,7 @@ import org.junit.Test; import org.junit.rules.ExpectedException; +import java.io.IOException; import java.net.InetSocketAddress; import java.net.Proxy; import java.net.URL; @@ -195,4 +196,67 @@ public void shouldCreateForUrlWithCustomHeaders() throws Exception { UrlJwkProvider urlJwkProvider = (UrlJwkProvider) provider; assertThat(urlJwkProvider.headers, equalTo(headers)); } + + @Test + public void shouldCreateWithCustomHttpClient() throws Exception { + URL url = new URL(normalizedDomain + WELL_KNOWN_JWKS_PATH); + JwksHttpClient customClient = new JwksHttpClient() { + @Override + public JwksHttpResponse fetch(URL url) throws IOException { + return new JwksHttpResponse("{\"keys\":[]}", Collections.>emptyMap()); + } + }; + JwkProvider provider = new JwkProviderBuilder(url) + .httpClient(customClient) + .rateLimited(false) + .cached(false) + .build(); + assertThat(provider, notNullValue()); + assertThat(provider, instanceOf(UrlJwkProvider.class)); + } + + @Test + public void shouldIgnoreProxyAndTimeoutsWhenCustomClientProvided() throws Exception { + URL url = new URL(normalizedDomain + WELL_KNOWN_JWKS_PATH); + Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress("proxy.hostname", 8080)); + JwksHttpClient customClient = new JwksHttpClient() { + @Override + public JwksHttpResponse fetch(URL url) throws IOException { + return new JwksHttpResponse("{}"); + } + }; + JwkProvider provider = new JwkProviderBuilder(url) + .proxied(proxy) + .timeouts(5000, 10000) + .httpClient(customClient) + .rateLimited(false) + .cached(false) + .build(); + assertThat(provider, notNullValue()); + UrlJwkProvider urlJwkProvider = (UrlJwkProvider) provider; + // When custom client is used, proxy and timeouts on UrlJwkProvider should be null + assertThat(urlJwkProvider.proxy, is(nullValue())); + assertThat(urlJwkProvider.connectTimeout, is(nullValue())); + assertThat(urlJwkProvider.readTimeout, is(nullValue())); + } + + @Test + public void shouldWrapCustomClientWithCacheAndRateLimit() throws Exception { + URL url = new URL(normalizedDomain + WELL_KNOWN_JWKS_PATH); + JwksHttpClient customClient = new JwksHttpClient() { + @Override + public JwksHttpResponse fetch(URL url) throws IOException { + return new JwksHttpResponse("{}"); + } + }; + JwkProvider provider = new JwkProviderBuilder(url) + .httpClient(customClient) + .cached(true) + .rateLimited(true) + .build(); + assertThat(provider, instanceOf(GuavaCachedJwkProvider.class)); + JwkProvider rateLimited = ((GuavaCachedJwkProvider) provider).getBaseProvider(); + assertThat(rateLimited, instanceOf(RateLimitedJwkProvider.class)); + assertThat(((RateLimitedJwkProvider) rateLimited).getBaseProvider(), instanceOf(UrlJwkProvider.class)); + } } \ No newline at end of file diff --git a/src/test/java/com/auth0/jwk/JwksHttpResponseTest.java b/src/test/java/com/auth0/jwk/JwksHttpResponseTest.java new file mode 100644 index 0000000..a4ddfb7 --- /dev/null +++ b/src/test/java/com/auth0/jwk/JwksHttpResponseTest.java @@ -0,0 +1,85 @@ +package com.auth0.jwk; + +import org.junit.Test; + +import java.util.*; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.assertThat; + +public class JwksHttpResponseTest { + + @Test + public void shouldReturnBody() { + JwksHttpResponse response = new JwksHttpResponse("{\"keys\":[]}", Collections.>emptyMap()); + assertThat(response.getBody(), is("{\"keys\":[]}")); + } + + @Test + public void shouldReturnHeaders() { + Map> headers = new HashMap<>(); + headers.put("Cache-Control", Collections.singletonList("max-age=600")); + JwksHttpResponse response = new JwksHttpResponse("{}", headers); + assertThat(response.getHeaders(), is(headers)); + } + + @Test + public void shouldReturnEmptyHeadersWhenNull() { + JwksHttpResponse response = new JwksHttpResponse("{}", null); + assertThat(response.getHeaders(), is(notNullValue())); + assertThat(response.getHeaders().isEmpty(), is(true)); + } + + @Test + public void shouldReturnEmptyHeadersWithBodyOnlyConstructor() { + JwksHttpResponse response = new JwksHttpResponse("{}"); + assertThat(response.getHeaders(), is(notNullValue())); + assertThat(response.getHeaders().isEmpty(), is(true)); + } + + @Test + public void shouldGetHeaderValueCaseInsensitive() { + Map> headers = new HashMap<>(); + headers.put("Cache-Control", Collections.singletonList("max-age=600")); + JwksHttpResponse response = new JwksHttpResponse("{}", headers); + + assertThat(response.getHeaderValue("Cache-Control"), is("max-age=600")); + assertThat(response.getHeaderValue("cache-control"), is("max-age=600")); + assertThat(response.getHeaderValue("CACHE-CONTROL"), is("max-age=600")); + } + + @Test + public void shouldReturnNullForMissingHeader() { + JwksHttpResponse response = new JwksHttpResponse("{}", Collections.>emptyMap()); + assertThat(response.getHeaderValue("X-Missing"), is(nullValue())); + } + + @Test + public void shouldReturnFirstValueWhenMultipleValues() { + Map> headers = new HashMap<>(); + headers.put("X-Multi", Arrays.asList("first", "second")); + JwksHttpResponse response = new JwksHttpResponse("{}", headers); + + assertThat(response.getHeaderValue("X-Multi"), is("first")); + } + + @Test + public void shouldReturnNullForEmptyValuesList() { + Map> headers = new HashMap<>(); + headers.put("X-Empty", Collections.emptyList()); + JwksHttpResponse response = new JwksHttpResponse("{}", headers); + + assertThat(response.getHeaderValue("X-Empty"), is(nullValue())); + } + + @Test + public void shouldHandleNullHeaderKey() { + Map> headers = new HashMap<>(); + headers.put(null, Collections.singletonList("HTTP/1.1 200 OK")); + headers.put("Content-Type", Collections.singletonList("application/json")); + JwksHttpResponse response = new JwksHttpResponse("{}", headers); + + assertThat(response.getHeaderValue("Content-Type"), is("application/json")); + assertThat(response.getHeaderValue("Missing"), is(nullValue())); + } +} diff --git a/src/test/java/com/auth0/jwk/UrlJwkProviderTest.java b/src/test/java/com/auth0/jwk/UrlJwkProviderTest.java index e4cad8f..1d3d2bb 100644 --- a/src/test/java/com/auth0/jwk/UrlJwkProviderTest.java +++ b/src/test/java/com/auth0/jwk/UrlJwkProviderTest.java @@ -20,6 +20,7 @@ import static org.hamcrest.Matchers.*; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; public class UrlJwkProviderTest { @@ -404,4 +405,37 @@ public void shouldFetchIfCacheIsNull() throws Exception { verify(provider, atLeastOnce()).getAll(); // Should definitely be called } + @Test + public void shouldUseCustomHttpClient() throws Exception { + String jwksJson = "{\"keys\":[{\"alg\":\"RS256\",\"kty\":\"RSA\",\"use\":\"sig\"," + + "\"n\":\"test\",\"e\":\"AQAB\",\"kid\":\"custom-kid\"}]}"; + JwksHttpClient customClient = mock(JwksHttpClient.class); + when(customClient.fetch(any(URL.class))).thenReturn(new JwksHttpResponse(jwksJson)); + + URL url = new URL("https://example.com/.well-known/jwks.json"); + UrlJwkProvider provider = new UrlJwkProvider(url, customClient); + + Jwk jwk = provider.get("custom-kid"); + assertNotNull(jwk); + verify(customClient).fetch(url); + } + + @Test + public void shouldThrowNetworkExceptionWhenCustomClientFails() throws Exception { + expectedException.expect(NetworkException.class); + + JwksHttpClient customClient = mock(JwksHttpClient.class); + when(customClient.fetch(any(URL.class))).thenThrow(new IOException("connection failed")); + + URL url = new URL("https://example.com/.well-known/jwks.json"); + UrlJwkProvider provider = new UrlJwkProvider(url, customClient); + provider.get(KID); + } + + @Test + public void shouldFailWithNullHttpClient() { + expectedException.expect(IllegalArgumentException.class); + new UrlJwkProvider(getClass().getResource("/jwks.json"), null); + } + } \ No newline at end of file From d9f071d2f4138080086a1b3bcb3cae43115d7730 Mon Sep 17 00:00:00 2001 From: tanya732 Date: Mon, 4 May 2026 13:16:13 +0530 Subject: [PATCH 2/2] Modified example.md file --- EXAMPLES.md | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 905c586..d8361c2 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -46,7 +46,7 @@ JwkProvider provider = new JwkProviderBuilder("https://samples.auth0.com/") ### Configure a custom HTTP client -The `httpClient()` builder method lets you replace the default `java.net.URLConnection`-based HTTP transport with any HTTP library. This solves four common requirements: custom TLS, authenticated proxies, Cache-Control header access, and HTTP/2. +The `httpClient()` builder method lets you replace the default `java.net.URLConnection`-based HTTP transport with any HTTP library. This solves three common requirements: custom TLS, authenticated proxies, and HTTP/2. > When `httpClient()` is set, `proxied()`, `timeouts()`, and `headers()` are ignored — the custom client has full control over the HTTP layer. @@ -105,31 +105,6 @@ JwkProvider provider = new JwkProviderBuilder("https://samples.auth0.com/") .build(); ``` -#### Cache-Control Headers - -Response headers (including `Cache-Control`) are now accessible via `JwksHttpResponse`: - -```java -JwksHttpClient headerAwareClient = url -> { - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setRequestProperty("Accept", "application/json"); - try (InputStream in = conn.getInputStream()) { - String body = new String(in.readAllBytes(), StandardCharsets.UTF_8); - JwksHttpResponse response = new JwksHttpResponse(body, conn.getHeaderFields()); - - // Headers are now available for inspection - String cacheControl = response.getHeaderValue("Cache-Control"); - // e.g., "max-age=3600" - - return response; - } -}; - -JwkProvider provider = new JwkProviderBuilder("https://samples.auth0.com/") - .httpClient(headerAwareClient) - .build(); -``` - #### HTTP/2 Support **Using Java 11+ HttpClient:**