diff --git a/auth0-api-java/src/main/java/com/auth0/JWTValidator.java b/auth0-api-java/src/main/java/com/auth0/JWTValidator.java index bcf13e7..129f329 100644 --- a/auth0-api-java/src/main/java/com/auth0/JWTValidator.java +++ b/auth0-api-java/src/main/java/com/auth0/JWTValidator.java @@ -10,18 +10,20 @@ import com.auth0.jwk.Jwk; import com.auth0.jwk.JwkProvider; import com.auth0.jwk.JwkProviderBuilder; -import com.auth0.jwk.UrlJwkProvider; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.models.HttpRequestInfo; import com.auth0.models.RequestContext; +import com.auth0.telemetry.Telemetry; import java.net.MalformedURLException; import java.net.URL; import java.security.interfaces.RSAPublicKey; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.stream.Collectors; /** @@ -36,9 +38,9 @@ class JWTValidator { static final String JWKS_CACHE_PREFIX = "jwks:"; private final AuthOptions authOptions; - private final JwkProvider jwkProvider; private final OidcDiscoveryFetcher discoveryFetcher; private final AuthCache cache; + private final Map telemetryHeaders; /** * Creates a JWT validator with domain and audience. @@ -52,30 +54,9 @@ class JWTValidator { } this.authOptions = authOptions; - this.jwkProvider = authOptions.getDomain() != null - ? new UrlJwkProvider(authOptions.getDomain()) - : null; + this.telemetryHeaders = telemetryHeaders(authOptions); this.cache = resolveCache(authOptions); - this.discoveryFetcher = new OidcDiscoveryFetcher(this.cache); - } - - /** - * Creates a JWT validator with domain, audience, and a custom JwkProvider. - * - * @param authOptions Authentication options containing domain and audience - * @param jwkProvider Custom JwkProvider for key retrieval - */ - JWTValidator(AuthOptions authOptions, JwkProvider jwkProvider) { - if (authOptions == null) { - throw new IllegalArgumentException("AuthOptions cannot be null"); - } - if (jwkProvider == null) { - throw new IllegalArgumentException("JwkProvider cannot be null"); - } - this.authOptions = authOptions; - this.jwkProvider = jwkProvider; - this.cache = resolveCache(authOptions); - this.discoveryFetcher = new OidcDiscoveryFetcher(this.cache); + this.discoveryFetcher = new OidcDiscoveryFetcher(this.cache, authOptions.getTelemetry()); } /** @@ -83,19 +64,32 @@ class JWTValidator { * testing). * * @param authOptions Authentication options - * @param jwkProvider Custom JwkProvider for key retrieval * @param discoveryFetcher Custom OIDC discovery fetcher */ - JWTValidator(AuthOptions authOptions, JwkProvider jwkProvider, OidcDiscoveryFetcher discoveryFetcher) { + JWTValidator(AuthOptions authOptions, OidcDiscoveryFetcher discoveryFetcher) { if (authOptions == null) { throw new IllegalArgumentException("AuthOptions cannot be null"); } this.authOptions = authOptions; - this.jwkProvider = jwkProvider; + this.telemetryHeaders = telemetryHeaders(authOptions); this.cache = resolveCache(authOptions); this.discoveryFetcher = discoveryFetcher != null ? discoveryFetcher - : new OidcDiscoveryFetcher(this.cache); + : new OidcDiscoveryFetcher(this.cache, authOptions.getTelemetry()); + } + + /** + * Builds the {@code Auth0-Client} header map for JWKS requests, or an empty + * map when no telemetry value is available. + */ + private static Map telemetryHeaders(AuthOptions authOptions) { + Map headers = new HashMap<>(); + Telemetry telemetry = authOptions.getTelemetry(); + String value = telemetry != null ? telemetry.getValue() : null; + if (value != null) { + headers.put(Telemetry.HEADER_NAME, value); + } + return headers; } /** @@ -245,10 +239,6 @@ public AuthOptions getAuthOptions() { return authOptions; } - public JwkProvider getJwkProvider() { - return jwkProvider; - } - /** * Performs OIDC Discovery for the given issuer URL *

@@ -286,7 +276,7 @@ private JwkProvider getOrCreateJwkProvider(String jwksUri) throws VerifyAccessTo } try { - JwkProvider provider = new JwkProviderBuilder(new URL(jwksUri)).build(); + JwkProvider provider = new JwkProviderBuilder(new URL(jwksUri)).headers(telemetryHeaders).build(); cache.put(cacheKey, provider); return provider; } catch (MalformedURLException e) { diff --git a/auth0-api-java/src/main/java/com/auth0/OidcDiscoveryFetcher.java b/auth0-api-java/src/main/java/com/auth0/OidcDiscoveryFetcher.java index c5f1ea5..8777321 100644 --- a/auth0-api-java/src/main/java/com/auth0/OidcDiscoveryFetcher.java +++ b/auth0-api-java/src/main/java/com/auth0/OidcDiscoveryFetcher.java @@ -2,6 +2,7 @@ import com.auth0.exception.VerifyAccessTokenException; import com.auth0.models.OidcMetadata; +import com.auth0.telemetry.Telemetry; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -41,6 +42,7 @@ class OidcDiscoveryFetcher implements Closeable { private final AuthCache cache; private final CloseableHttpClient httpClient; private final boolean ownsHttpClient; + private final String telemetryHeader; /** * Creates a fetcher with the provided cache and the default HTTP client. @@ -49,7 +51,17 @@ class OidcDiscoveryFetcher implements Closeable { * @param cache the unified cache instance */ OidcDiscoveryFetcher(AuthCache cache) { - this(cache, HttpClients.createDefault(), true); + this(cache, HttpClients.createDefault(), true, null); + } + + /** + * Creates a fetcher with the provided cache, default HTTP client, and telemetry. + * + * @param cache the unified cache instance + * @param telemetry the telemetry identity whose header rides on discovery requests + */ + OidcDiscoveryFetcher(AuthCache cache, Telemetry telemetry) { + this(cache, HttpClients.createDefault(), true, telemetry); } /** @@ -60,10 +72,23 @@ class OidcDiscoveryFetcher implements Closeable { * @param httpClient the HTTP client to use for discovery requests */ OidcDiscoveryFetcher(AuthCache cache, CloseableHttpClient httpClient) { - this(cache, httpClient, false); + this(cache, httpClient, false, null); + } + + /** + * Creates a fetcher with the provided cache, a custom HTTP client, and telemetry. + * The caller retains ownership of the HTTP client and is responsible for closing it. + * + * @param cache the unified cache instance + * @param httpClient the HTTP client to use for discovery requests + * @param telemetry the telemetry identity whose header rides on discovery requests + */ + OidcDiscoveryFetcher(AuthCache cache, CloseableHttpClient httpClient, Telemetry telemetry) { + this(cache, httpClient, false, telemetry); } - private OidcDiscoveryFetcher(AuthCache cache, CloseableHttpClient httpClient, boolean ownsHttpClient) { + private OidcDiscoveryFetcher(AuthCache cache, CloseableHttpClient httpClient, + boolean ownsHttpClient, Telemetry telemetry) { if (cache == null) { throw new IllegalArgumentException("cache must not be null"); } @@ -73,6 +98,7 @@ private OidcDiscoveryFetcher(AuthCache cache, CloseableHttpClient httpCl this.cache = cache; this.httpClient = httpClient; this.ownsHttpClient = ownsHttpClient; + this.telemetryHeader = telemetry != null ? telemetry.getValue() : null; } /** @@ -106,6 +132,9 @@ private OidcMetadata doFetch(String issuerUrl) throws VerifyAccessTokenException try { HttpGet request = new HttpGet(discoveryUrl); request.setHeader("Accept", "application/json"); + if (telemetryHeader != null) { + request.setHeader(Telemetry.HEADER_NAME, telemetryHeader); + } try (CloseableHttpResponse response = httpClient.execute(request)) { int statusCode = response.getStatusLine().getStatusCode(); diff --git a/auth0-api-java/src/main/java/com/auth0/models/AuthOptions.java b/auth0-api-java/src/main/java/com/auth0/models/AuthOptions.java index e4569c5..d13107a 100644 --- a/auth0-api-java/src/main/java/com/auth0/models/AuthOptions.java +++ b/auth0-api-java/src/main/java/com/auth0/models/AuthOptions.java @@ -3,6 +3,8 @@ import com.auth0.DomainResolver; import com.auth0.AuthCache; import com.auth0.enums.DPoPMode; +import com.auth0.telemetry.Telemetry; +import com.auth0.telemetry.TelemetryProvider; import java.util.ArrayList; import java.util.Collections; @@ -21,6 +23,7 @@ public class AuthOptions { private final int cacheMaxEntries; private final long cacheTtlSeconds; private final AuthCache cache; + private final Telemetry telemetry; public AuthOptions(Builder builder) { this.domain = builder.domain; @@ -37,6 +40,7 @@ public AuthOptions(Builder builder) { this.cacheMaxEntries = builder.cacheMaxEntries; this.cacheTtlSeconds = builder.cacheTtlSeconds; this.cache = builder.cache; + this.telemetry = builder.telemetry != null ? builder.telemetry : TelemetryProvider.getDefault(); } public String getDomain() { @@ -112,6 +116,17 @@ public AuthCache getCache() { return cache; } + /** + * Returns the telemetry identity reported via the {@code Auth0-Client} header. + * Defaults to the core {@code auth0-api-java} identity when not overridden by a + * wrapper library. + * + * @return the {@link Telemetry} identity (never null) + */ + public Telemetry getTelemetry() { + return telemetry; + } + public static class Builder { private String domain; private List domains; @@ -125,6 +140,7 @@ public static class Builder { private int cacheMaxEntries = 100; private long cacheTtlSeconds = 600; private AuthCache cache; + private Telemetry telemetry; public Builder domain(String domain) { this.domain = domain; @@ -249,6 +265,20 @@ public Builder cache(AuthCache cache) { return this; } + /** + * Overrides the telemetry identity reported via the {@code Auth0-Client} + * header. Wrapper libraries use this to report themselves as the top-level + * SDK while nesting the core {@code auth0-api-java} version in {@code env}. + * When not set, the core identity is used. + * + * @param telemetry the telemetry identity + * @return this builder + */ + public Builder telemetry(Telemetry telemetry) { + this.telemetry = telemetry; + return this; + } + public AuthOptions build() { if (domains != null && !domains.isEmpty() && domainsResolver != null) { throw new IllegalArgumentException( diff --git a/auth0-api-java/src/main/java/com/auth0/telemetry/Telemetry.java b/auth0-api-java/src/main/java/com/auth0/telemetry/Telemetry.java new file mode 100644 index 0000000..9897832 --- /dev/null +++ b/auth0-api-java/src/main/java/com/auth0/telemetry/Telemetry.java @@ -0,0 +1,88 @@ +package com.auth0.telemetry; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Immutable Auth0-Client telemetry payload. + * + *

Produces the Base64url-encoded value for the {@code Auth0-Client} header, + * following the Auth0 SDK convention: + * {@code {"name":..,"version":..,"env":{"java":..,"auth0-api-java":..}}}. + * + *

A wrapper library (e.g. the Spring Boot integration) reports itself as + * {@code name}/{@code version} and passes the core library version so it lands + * in {@code env} under {@link #CORE_LIBRARY_KEY}. + */ +public final class Telemetry { + + public static final String HEADER_NAME = "Auth0-Client"; + static final String CORE_LIBRARY_KEY = "auth0-api-java"; + + private final String value; + + /** + * @param name the SDK name to report (required; a null name yields a + * null header value that callers must skip) + * @param version the reporting SDK's version, or null to omit + * @param coreVersion the core {@code auth0-api-java} version to nest in + * {@code env}, or null when the core library is itself + * the reporter + */ + public Telemetry(String name, String version, String coreVersion) { + this.value = name == null ? null : encode(build(name, version, coreVersion)); + } + + /** @return the Base64url header value, or null if no name was provided */ + public String getValue() { + return value; + } + + private static Map build(String name, String version, String coreVersion) { + Map env = new LinkedHashMap<>(); + if (coreVersion != null) { + env.put(CORE_LIBRARY_KEY, coreVersion); + } + env.put("java", System.getProperty("java.specification.version", "unknown")); + + Map payload = new LinkedHashMap<>(); + payload.put("name", name); + if (version != null) { + payload.put("version", version); + } + payload.put("env", env); + return payload; + } + + private static String encode(Map payload) { + String json = toJson(payload); + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } + + @SuppressWarnings("unchecked") + private static String toJson(Map map) { + StringBuilder sb = new StringBuilder("{"); + boolean first = true; + for (Map.Entry e : map.entrySet()) { + if (!first) { + sb.append(","); + } + first = false; + sb.append("\"").append(escape(e.getKey())).append("\":"); + Object v = e.getValue(); + if (v instanceof Map) { + sb.append(toJson((Map) v)); + } else { + sb.append("\"").append(escape(String.valueOf(v))).append("\""); + } + } + return sb.append("}").toString(); + } + + private static String escape(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/auth0-api-java/src/main/java/com/auth0/telemetry/TelemetryProvider.java b/auth0-api-java/src/main/java/com/auth0/telemetry/TelemetryProvider.java index 4ef9718..05f8ea6 100644 --- a/auth0-api-java/src/main/java/com/auth0/telemetry/TelemetryProvider.java +++ b/auth0-api-java/src/main/java/com/auth0/telemetry/TelemetryProvider.java @@ -2,63 +2,62 @@ import java.io.IOException; import java.io.InputStream; -import java.nio.charset.StandardCharsets; import java.util.Properties; /** - * Provides the Base64-encoded Auth0-Client telemetry header value. + * Reads the core library's name and version from the build-filtered + * {@code auth0-client-info.properties} resource and builds the default + * {@link Telemetry} identity for {@code auth0-api-java}. * - * The payload is a JSON object: {"name":"springboot-api","version":"x.y.z","java":"17"} + *

Wrappers construct their own {@link Telemetry} instead of using this. */ public final class TelemetryProvider { private static final String PROPERTIES_FILE = "auth0-client-info.properties"; private static final String UNKNOWN = "unknown"; - private static volatile String cachedHeaderValue; + private static volatile Telemetry cached; private TelemetryProvider() { } - /** - * Returns the Base64url-encoded telemetry header value. - * - * @return the Auth0-Client header value, or null if it cannot be built - */ - public static String getHeaderValue() { - if (cachedHeaderValue != null) { - return cachedHeaderValue; + /** @return the core {@code auth0-api-java} telemetry identity (no nested env core version) */ + public static Telemetry getDefault() { + if (cached != null) { + return cached; } synchronized (TelemetryProvider.class) { - if (cachedHeaderValue != null) { - return cachedHeaderValue; + if (cached == null) { + cached = new Telemetry(readName(), readVersion(), null); } - cachedHeaderValue = buildHeaderValue(); - return cachedHeaderValue; + return cached; } } - private static String buildHeaderValue() { - String name = UNKNOWN; - String version = UNKNOWN; + /** @return the core library version, or {@code "unknown"} if unavailable */ + public static String coreVersion() { + return readVersion(); + } + + private static String readName() { + return read("name"); + } + + private static String readVersion() { + return read("version"); + } + private static String read(String key) { try (InputStream is = TelemetryProvider.class.getClassLoader() .getResourceAsStream(PROPERTIES_FILE)) { if (is != null) { Properties props = new Properties(); props.load(is); - name = props.getProperty("name", UNKNOWN); - version = props.getProperty("version", UNKNOWN); + return props.getProperty(key, UNKNOWN); } } catch (IOException ignored) { - // Fall through with defaults + // fall through } - - String javaVersion = System.getProperty("java.version", UNKNOWN); - - String json = "{\"name\":\"" + name + "\",\"version\":\"" + version + "\",\"java\":\"" + javaVersion + "\"}"; - - return java.util.Base64.getUrlEncoder().withoutPadding() - .encodeToString(json.getBytes(StandardCharsets.UTF_8)); + return UNKNOWN; } } diff --git a/auth0-api-java/src/main/java/com/auth0/validators/JWTValidator.java b/auth0-api-java/src/main/java/com/auth0/validators/JWTValidator.java deleted file mode 100644 index e69de29..0000000 diff --git a/auth0-api-java/src/main/resources/auth0-client-info.properties b/auth0-api-java/src/main/resources/auth0-client-info.properties index 0081a9f..c7d95c2 100644 --- a/auth0-api-java/src/main/resources/auth0-client-info.properties +++ b/auth0-api-java/src/main/resources/auth0-client-info.properties @@ -1,2 +1,2 @@ version=${version} -name=springboot-api +name=auth0-api-java diff --git a/auth0-api-java/src/test/java/com/auth0/JWTValidatorTelemetryTest.java b/auth0-api-java/src/test/java/com/auth0/JWTValidatorTelemetryTest.java new file mode 100644 index 0000000..d3bec9e --- /dev/null +++ b/auth0-api-java/src/test/java/com/auth0/JWTValidatorTelemetryTest.java @@ -0,0 +1,143 @@ +package com.auth0; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.models.AuthOptions; +import com.auth0.models.HttpRequestInfo; +import com.sun.net.httpserver.HttpServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.OutputStream; +import java.math.BigInteger; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.Base64; +import java.util.Date; +import java.util.HashMap; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end guard that the {@code Auth0-Client} telemetry header is attached to the + * live JWKS key-fetch (the {@code getOrCreateJwkProvider} path validateToken actually + * uses), mirroring the discovery-call coverage in {@link OidcDiscoveryFetcherTest}. + * + *

Uses an in-process {@link HttpServer} rather than mocking, because jwks-rsa wraps + * the provider in caching/rate-limiting decorators whose header state is not publicly + * observable — only the outgoing request proves the header survived. + */ +public class JWTValidatorTelemetryTest { + + private HttpServer server; + private String baseUrl; + private RSAPublicKey publicKey; + private RSAPrivateKey privateKey; + + private final AtomicReference jwksAuth0ClientHeader = new AtomicReference<>(); + private static final String KID = "test-kid"; + + @Before + public void setUp() throws Exception { + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(2048); + KeyPair pair = gen.generateKeyPair(); + publicKey = (RSAPublicKey) pair.getPublic(); + privateKey = (RSAPrivateKey) pair.getPrivate(); + + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + int port = server.getAddress().getPort(); + baseUrl = "http://127.0.0.1:" + port; + + server.createContext("/.well-known/openid-configuration", exchange -> { + String body = String.format( + "{\"issuer\":\"%s/\",\"jwks_uri\":\"%s/.well-known/jwks.json\"}", baseUrl, baseUrl); + respond(exchange, body); + }); + + server.createContext("/.well-known/jwks.json", exchange -> { + jwksAuth0ClientHeader.set(exchange.getRequestHeaders().getFirst("Auth0-Client")); + respond(exchange, jwksJson()); + }); + + server.start(); + } + + @After + public void tearDown() { + if (server != null) { + server.stop(0); + } + } + + @Test + public void jwksCall_shouldCarryTelemetryHeader() throws Exception { + AuthOptions options = new AuthOptions.Builder() + .domain(baseUrl) + .audience("https://api.example.com") + .build(); + + JWTValidator validator = new JWTValidator(options); + validator.validateToken(validToken(), httpRequestInfo()); + + String expected = options.getTelemetry().getValue(); + assertThat(expected).isNotNull(); + assertThat(jwksAuth0ClientHeader.get()).isEqualTo(expected); + } + + private String validToken() { + return JWT.create() + .withIssuer(baseUrl + "/") + .withAudience("https://api.example.com") + .withSubject("user") + .withKeyId(KID) + .withIssuedAt(new Date()) + .withExpiresAt(new Date(System.currentTimeMillis() + 60000)) + .sign(Algorithm.RSA256(publicKey, privateKey)); + } + + private HttpRequestInfo httpRequestInfo() throws Exception { + return new HttpRequestInfo("GET", "https://api.example.com/resource", new HashMap<>()); + } + + private String jwksJson() { + String n = base64Url(toUnsignedBytes(publicKey.getModulus())); + String e = base64Url(toUnsignedBytes(publicKey.getPublicExponent())); + return String.format( + "{\"keys\":[{\"kty\":\"RSA\",\"use\":\"sig\",\"alg\":\"RS256\",\"kid\":\"%s\",\"n\":\"%s\",\"e\":\"%s\"}]}", + KID, n, e); + } + + private static byte[] toUnsignedBytes(BigInteger value) { + byte[] bytes = value.toByteArray(); + if (bytes.length > 1 && bytes[0] == 0) { + byte[] trimmed = new byte[bytes.length - 1]; + System.arraycopy(bytes, 1, trimmed, 0, trimmed.length); + return trimmed; + } + return bytes; + } + + private static String base64Url(byte[] bytes) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static void respond(com.sun.net.httpserver.HttpExchange exchange, String body) { + try { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/auth0-api-java/src/test/java/com/auth0/JWTValidatorTest.java b/auth0-api-java/src/test/java/com/auth0/JWTValidatorTest.java index 77e18c3..de6524f 100644 --- a/auth0-api-java/src/test/java/com/auth0/JWTValidatorTest.java +++ b/auth0-api-java/src/test/java/com/auth0/JWTValidatorTest.java @@ -71,8 +71,9 @@ public void setUp() throws Exception { when(mockDiscoveryFetcher.fetch(anyString())) .thenReturn(new OidcMetadata(ISSUER, JWKS_URI)); - // Use the package-private 3-arg constructor for full control - validator = new JWTValidator(options, jwkProvider, mockDiscoveryFetcher); + // Inject the mock discovery fetcher; the mock JwkProvider is supplied via the + // pre-populated cache, which is the path validateToken actually uses. + validator = new JWTValidator(options, mockDiscoveryFetcher); when(jwk.getPublicKey()).thenReturn(publicKey); when(jwkProvider.get(anyString())).thenReturn(jwk); @@ -84,13 +85,8 @@ public void constructor_shouldRejectNullOptions() { } @Test(expected = IllegalArgumentException.class) - public void constructor_shouldRejectNullJwkProvider() { - AuthOptions options = new AuthOptions.Builder() - .domain(DOMAIN) - .audience(AUDIENCE) - .build(); - - new JWTValidator(options, null); + public void constructor_shouldRejectNullOptionsWithDiscoveryFetcher() { + new JWTValidator(null, (OidcDiscoveryFetcher) null); } @Test @@ -215,7 +211,6 @@ public void decodeToken_failure() throws Exception { @Test public void getters_shouldReturnValues() { assertThat(validator.getAuthOptions()).isNotNull(); - assertThat(validator.getJwkProvider()).isNotNull(); } private HttpRequestInfo getHttpRequestInfo() throws InvalidRequestException { diff --git a/auth0-api-java/src/test/java/com/auth0/OidcDiscoveryFetcherTest.java b/auth0-api-java/src/test/java/com/auth0/OidcDiscoveryFetcherTest.java index 27c6000..d7bf470 100644 --- a/auth0-api-java/src/test/java/com/auth0/OidcDiscoveryFetcherTest.java +++ b/auth0-api-java/src/test/java/com/auth0/OidcDiscoveryFetcherTest.java @@ -2,13 +2,16 @@ import com.auth0.exception.VerifyAccessTokenException; import com.auth0.models.OidcMetadata; +import com.auth0.telemetry.Telemetry; import org.apache.http.HttpVersion; import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.message.BasicStatusLine; import org.junit.Before; import org.junit.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -52,6 +55,37 @@ public void fetch_shouldReturnMetadataOnSuccess() throws Exception { assertThat(metadata.getJwksUri()).isEqualTo(JWKS_URI); } + @Test + public void fetch_shouldSendAuth0ClientTelemetryHeader() throws Exception { + Telemetry telemetry = new Telemetry("auth0-api-java", "1.0.0", null); + OidcDiscoveryFetcher telemetryFetcher = new OidcDiscoveryFetcher(cache, httpClient, telemetry); + String discoveryJson = String.format( + "{\"issuer\":\"%s\",\"jwks_uri\":\"%s\"}", ISSUER, JWKS_URI); + mockSuccessResponse(discoveryJson); + + telemetryFetcher.fetch(ISSUER); + + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpUriRequest.class); + verify(httpClient).execute(captor.capture()); + HttpUriRequest sent = captor.getValue(); + assertThat(sent.getFirstHeader("Auth0-Client")).isNotNull(); + assertThat(sent.getFirstHeader("Auth0-Client").getValue()) + .isEqualTo(telemetry.getValue()); + } + + @Test + public void fetch_shouldNotSendTelemetryHeaderWhenAbsent() throws Exception { + String discoveryJson = String.format( + "{\"issuer\":\"%s\",\"jwks_uri\":\"%s\"}", ISSUER, JWKS_URI); + mockSuccessResponse(discoveryJson); + + fetcher.fetch(ISSUER); + + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpUriRequest.class); + verify(httpClient).execute(captor.capture()); + assertThat(captor.getValue().getFirstHeader("Auth0-Client")).isNull(); + } + @Test public void fetch_shouldCacheResultPerDomain() throws Exception { String discoveryJson = String.format( @@ -132,7 +166,7 @@ public void constructor_shouldRejectNullCache() { @Test(expected = IllegalArgumentException.class) public void constructor_shouldRejectNullHttpClient() { - new OidcDiscoveryFetcher(cache, null); + new OidcDiscoveryFetcher(cache, (CloseableHttpClient) null); } @Test diff --git a/auth0-api-java/src/test/java/com/auth0/telemetry/TelemetryProviderTest.java b/auth0-api-java/src/test/java/com/auth0/telemetry/TelemetryProviderTest.java index d726e86..13fb503 100644 --- a/auth0-api-java/src/test/java/com/auth0/telemetry/TelemetryProviderTest.java +++ b/auth0-api-java/src/test/java/com/auth0/telemetry/TelemetryProviderTest.java @@ -13,27 +13,39 @@ public class TelemetryProviderTest { @Test @SuppressWarnings("unchecked") - public void getHeaderValue_returnsValidBase64Json() throws Exception { - String headerValue = TelemetryProvider.getHeaderValue(); + public void getDefault_returnsCoreIdentityAsValidBase64Json() throws Exception { + String headerValue = TelemetryProvider.getDefault().getValue(); assertThat(headerValue).isNotNull().isNotEmpty(); byte[] decoded = Base64.getUrlDecoder().decode(headerValue); String json = new String(decoded, StandardCharsets.UTF_8); ObjectMapper mapper = new ObjectMapper(); - Map payload = mapper.readValue(json, Map.class); + Map payload = mapper.readValue(json, Map.class); assertThat(payload).containsKey("name"); assertThat(payload).containsKey("version"); - assertThat(payload).containsKey("java"); - assertThat(payload.get("name")).isEqualTo("springboot-api"); - assertThat(payload.get("java")).isEqualTo(System.getProperty("java.version")); + assertThat(payload).containsKey("env"); + assertThat(payload.get("name")).isEqualTo("auth0-api-java"); + + Map env = (Map) payload.get("env"); + assertThat(env.get("java")).isEqualTo(System.getProperty("java.specification.version")); } @Test - public void getHeaderValue_isCached() { - String first = TelemetryProvider.getHeaderValue(); - String second = TelemetryProvider.getHeaderValue(); + public void getDefault_isCached() { + Telemetry first = TelemetryProvider.getDefault(); + Telemetry second = TelemetryProvider.getDefault(); assertThat(first).isSameAs(second); } + + @Test + public void coreVersion_matchesDefaultTelemetryVersion() throws Exception { + String headerValue = TelemetryProvider.getDefault().getValue(); + byte[] decoded = Base64.getUrlDecoder().decode(headerValue); + Map payload = new ObjectMapper().readValue( + new String(decoded, StandardCharsets.UTF_8), Map.class); + + assertThat(TelemetryProvider.coreVersion()).isEqualTo(payload.get("version")); + } } diff --git a/auth0-api-java/src/test/java/com/auth0/telemetry/TelemetryTest.java b/auth0-api-java/src/test/java/com/auth0/telemetry/TelemetryTest.java new file mode 100644 index 0000000..b96d6a8 --- /dev/null +++ b/auth0-api-java/src/test/java/com/auth0/telemetry/TelemetryTest.java @@ -0,0 +1,75 @@ +package com.auth0.telemetry; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TelemetryTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @SuppressWarnings("unchecked") + private static Map decode(String value) throws Exception { + byte[] decoded = Base64.getUrlDecoder().decode(value); + return MAPPER.readValue(new String(decoded, StandardCharsets.UTF_8), Map.class); + } + + @Test + public void headerNameIsAuth0Client() { + assertThat(Telemetry.HEADER_NAME).isEqualTo("Auth0-Client"); + } + + @Test + public void coreIdentity_hasNoNestedCoreVersion() throws Exception { + Telemetry telemetry = new Telemetry("auth0-api-java", "1.2.3", null); + Map payload = decode(telemetry.getValue()); + + assertThat(payload.get("name")).isEqualTo("auth0-api-java"); + assertThat(payload.get("version")).isEqualTo("1.2.3"); + + @SuppressWarnings("unchecked") + Map env = (Map) payload.get("env"); + assertThat(env).doesNotContainKey("auth0-api-java"); + assertThat(env).containsKey("java"); + } + + @Test + public void wrapperIdentity_reportsWrapperNameAndNestsCoreVersion() throws Exception { + Telemetry telemetry = new Telemetry("auth0-springboot-api", "2.0.0", "1.2.3"); + Map payload = decode(telemetry.getValue()); + + assertThat(payload.get("name")).isEqualTo("auth0-springboot-api"); + assertThat(payload.get("version")).isEqualTo("2.0.0"); + + @SuppressWarnings("unchecked") + Map env = (Map) payload.get("env"); + assertThat(env.get("auth0-api-java")).isEqualTo("1.2.3"); + assertThat(env).containsKey("java"); + } + + @Test + public void javaEnvUsesSpecificationVersion() throws Exception { + Telemetry telemetry = new Telemetry("auth0-api-java", "1.0.0", null); + Map payload = decode(telemetry.getValue()); + + @SuppressWarnings("unchecked") + Map env = (Map) payload.get("env"); + assertThat(env.get("java")).isEqualTo(System.getProperty("java.specification.version")); + } + + @Test + public void nullName_producesNullValue() { + assertThat(new Telemetry(null, "1.0.0", null).getValue()).isNull(); + } + + @Test + public void base64IsUrlSafeWithoutPadding() { + String value = new Telemetry("auth0-springboot-api", "2.0.0", "1.2.3").getValue(); + assertThat(value).doesNotContain("=").doesNotContain("+").doesNotContain("/"); + } +} diff --git a/auth0-springboot-api/build.gradle b/auth0-springboot-api/build.gradle index c4443f6..a201dba 100644 --- a/auth0-springboot-api/build.gradle +++ b/auth0-springboot-api/build.gradle @@ -44,6 +44,12 @@ repositories { mavenCentral() } +processResources { + filesMatching('auth0-springboot-client-info.properties') { + expand(version: project.version) + } +} + test { useJUnitPlatform() testLogging { diff --git a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0AutoConfiguration.java b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0AutoConfiguration.java index 545be48..0205015 100644 --- a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0AutoConfiguration.java +++ b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0AutoConfiguration.java @@ -77,6 +77,8 @@ public AuthOptions authOptions( } } + builder.telemetry(SpringBootTelemetry.get()); + return builder.build(); } diff --git a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/SpringBootTelemetry.java b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/SpringBootTelemetry.java new file mode 100644 index 0000000..7f211e9 --- /dev/null +++ b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/SpringBootTelemetry.java @@ -0,0 +1,38 @@ +package com.auth0.spring.boot; + +import com.auth0.telemetry.Telemetry; +import com.auth0.telemetry.TelemetryProvider; +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +/** + * Builds the {@link Telemetry} identity for {@code auth0-springboot-api}, reporting the wrapper as + * the top-level SDK and nesting the core {@code auth0-api-java} version under {@code env}. + */ +final class SpringBootTelemetry { + + private static final String PROPERTIES_FILE = "auth0-springboot-client-info.properties"; + private static final String NAME = "auth0-springboot-api"; + private static final String UNKNOWN = "unknown"; + + private SpringBootTelemetry() {} + + static Telemetry get() { + return new Telemetry(NAME, readVersion(), TelemetryProvider.coreVersion()); + } + + private static String readVersion() { + try (InputStream is = + SpringBootTelemetry.class.getClassLoader().getResourceAsStream(PROPERTIES_FILE)) { + if (is != null) { + Properties props = new Properties(); + props.load(is); + return props.getProperty("version", UNKNOWN); + } + } catch (IOException ignored) { + // fall through + } + return UNKNOWN; + } +} diff --git a/auth0-springboot-api/src/main/resources/auth0-springboot-client-info.properties b/auth0-springboot-api/src/main/resources/auth0-springboot-client-info.properties new file mode 100644 index 0000000..f49e967 --- /dev/null +++ b/auth0-springboot-api/src/main/resources/auth0-springboot-client-info.properties @@ -0,0 +1,2 @@ +version=${version} +name=auth0-springboot-api diff --git a/auth0-springboot-api/src/test/java/com/auth0/spring/boot/Auth0AutoConfigurationTest.java b/auth0-springboot-api/src/test/java/com/auth0/spring/boot/Auth0AutoConfigurationTest.java index 11e2ebc..dc721c2 100644 --- a/auth0-springboot-api/src/test/java/com/auth0/spring/boot/Auth0AutoConfigurationTest.java +++ b/auth0-springboot-api/src/test/java/com/auth0/spring/boot/Auth0AutoConfigurationTest.java @@ -6,7 +6,10 @@ import com.auth0.DomainResolver; import com.auth0.enums.DPoPMode; import com.auth0.models.AuthOptions; +import com.auth0.telemetry.Telemetry; +import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.Base64; import java.util.Collections; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -57,6 +60,25 @@ void shouldRegisterAllBeansInContext() { assertTrue(context.containsBean("authClient")); } + @Test + @DisplayName( + "Should report the springboot wrapper identity in telemetry, nesting the core version") + void shouldConfigureWrapperTelemetry() { + Telemetry telemetry = authOptions.getTelemetry(); + assertNotNull(telemetry); + + String value = telemetry.getValue(); + assertNotNull(value); + + String json = new String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8); + // The wrapper must present itself as auth0-springboot-api, not the core auth0-api-java, + // and nest the core library version inside env. + assertTrue(json.contains("\"name\":\"auth0-springboot-api\""), json); + assertFalse(json.contains("\"name\":\"auth0-api-java\""), json); + assertTrue(json.contains("\"env\":"), json); + assertTrue(json.contains("\"auth0-api-java\":"), json); + } + @Nested @SpringBootTest(classes = {Auth0AutoConfiguration.class, Auth0SecurityAutoConfiguration.class}) @TestPropertySource(