From b101a3865d7f454a4a2c1469bbe7e0492239a27b Mon Sep 17 00:00:00 2001 From: tanya732 Date: Thu, 19 Feb 2026 21:26:52 +0530 Subject: [PATCH 1/9] Feat: Updated Methods Signature --- .../com/auth0/AbstractAuthentication.java | 16 +++-- .../com/auth0/AllowedDPoPAuthentication.java | 13 ++-- .../src/main/java/com/auth0/AuthClient.java | 5 +- .../com/auth0/AuthenticationOrchestrator.java | 4 +- .../com/auth0/DisabledDPoPAuthentication.java | 7 +-- .../com/auth0/RequiredDPoPAuthentication.java | 7 +-- .../com/auth0/examples/Auth0ApiExample.java | 15 +++-- .../com/auth0/models/HttpRequestInfo.java | 36 ++++++++--- .../com/auth0/validators/JWTValidator.java | 22 +++---- .../com/auth0/AbstractAuthenticationTest.java | 19 +++--- .../auth0/AllowedDPoPAuthenticationTest.java | 29 +++++---- .../test/java/com/auth0/AuthClientTest.java | 36 ++++++----- .../com/auth0/AuthValidatorHelperTest.java | 4 +- .../auth0/AuthenticationOrchestratorTest.java | 11 ++-- .../auth0/DisabledDPoPAuthenticationTest.java | 13 ++-- .../auth0/RequiredDPoPAuthenticationTest.java | 25 ++++---- .../com/auth0/models/HttpRequestInfoTest.java | 50 ++++++++------- .../validators/DPoPProofValidatorTest.java | 2 +- .../auth0/validators/JWTValidatorTest.java | 32 ++++++---- .../boot/Auth0AuthenticationFilter.java | 9 +-- .../spring/boot/Auth0AutoConfiguration.java | 2 +- .../boot/Auth0AuthenticationFilterTest.java | 63 +++++++------------ 22 files changed, 222 insertions(+), 198 deletions(-) diff --git a/auth0-api-java/src/main/java/com/auth0/AbstractAuthentication.java b/auth0-api-java/src/main/java/com/auth0/AbstractAuthentication.java index a091a32..3e84190 100644 --- a/auth0-api-java/src/main/java/com/auth0/AbstractAuthentication.java +++ b/auth0-api-java/src/main/java/com/auth0/AbstractAuthentication.java @@ -28,21 +28,21 @@ protected AbstractAuthentication(JWTValidator jwtValidator, TokenExtractor extra /** * Concrete method to validate Bearer token headers and JWT claims. */ - protected DecodedJWT validateBearerToken(Map headers, HttpRequestInfo httpRequestInfo) throws BaseAuthException { - AuthToken authToken = extractor.extractBearer(headers); - return jwtValidator.validateToken(authToken.getAccessToken(), headers, httpRequestInfo); + protected DecodedJWT validateBearerToken(HttpRequestInfo httpRequestInfo) throws BaseAuthException { + AuthToken authToken = extractor.extractBearer(httpRequestInfo.getHeaders()); + return jwtValidator.validateToken(authToken.getAccessToken(), httpRequestInfo); } /** * Concrete method to validate DPoP token headers, JWT claims, and proof. */ - protected DecodedJWT validateDpopTokenAndProof(Map headers, HttpRequestInfo requestInfo) + protected DecodedJWT validateDpopTokenAndProof(HttpRequestInfo requestInfo) throws BaseAuthException { AuthValidatorHelper.validateHttpMethodAndHttpUrl(requestInfo); - AuthToken authToken = extractor.extractDPoPProofAndDPoPToken(headers); - DecodedJWT decodedJwtToken = jwtValidator.validateToken(authToken.getAccessToken(), headers, requestInfo); + AuthToken authToken = extractor.extractDPoPProofAndDPoPToken(requestInfo.getHeaders()); + DecodedJWT decodedJwtToken = jwtValidator.validateToken(authToken.getAccessToken(), requestInfo); dpopProofValidator.validate(authToken.getProof(), decodedJwtToken, requestInfo); @@ -52,9 +52,7 @@ protected DecodedJWT validateDpopTokenAndProof(Map headers, Http /** * Main abstract method for each concrete strategy. */ - public abstract AuthenticationContext authenticate( - Map headers, - HttpRequestInfo requestInfo + public abstract AuthenticationContext authenticate(HttpRequestInfo requestInfo ) throws BaseAuthException; /** diff --git a/auth0-api-java/src/main/java/com/auth0/AllowedDPoPAuthentication.java b/auth0-api-java/src/main/java/com/auth0/AllowedDPoPAuthentication.java index 5b14fae..cc40a49 100644 --- a/auth0-api-java/src/main/java/com/auth0/AllowedDPoPAuthentication.java +++ b/auth0-api-java/src/main/java/com/auth0/AllowedDPoPAuthentication.java @@ -20,30 +20,27 @@ public AllowedDPoPAuthentication(JWTValidator jwtValidator, /** * Authenticates the request when DPoP Mode is Allowed (Accepts both DPoP and Bearer tokens) . - * @param headers request headers * @param requestInfo HTTP request info * @return AuthenticationContext with JWT claims * @throws BaseAuthException if validation fails */ @Override - public AuthenticationContext authenticate(Map headers, HttpRequestInfo requestInfo) + public AuthenticationContext authenticate(HttpRequestInfo requestInfo) throws BaseAuthException { String scheme = ""; try{ - Map normalizedHeader = normalize(headers); - - scheme = extractor.getScheme(normalizedHeader); + scheme = extractor.getScheme(requestInfo.getHeaders()); if (scheme.equalsIgnoreCase(AuthConstants.BEARER_SCHEME)) { - DecodedJWT jwtToken = validateBearerToken(normalizedHeader, requestInfo); - AuthValidatorHelper.validateNoDpopPresence(normalizedHeader, jwtToken); + DecodedJWT jwtToken = validateBearerToken(requestInfo); + AuthValidatorHelper.validateNoDpopPresence(requestInfo.getHeaders(), jwtToken); return buildContext(jwtToken); } if (scheme.equalsIgnoreCase(AuthConstants.DPOP_SCHEME)) { - DecodedJWT decodedJWT = validateDpopTokenAndProof(normalizedHeader, requestInfo); + DecodedJWT decodedJWT = validateDpopTokenAndProof(requestInfo); return buildContext(decodedJWT); } diff --git a/auth0-api-java/src/main/java/com/auth0/AuthClient.java b/auth0-api-java/src/main/java/com/auth0/AuthClient.java index cd2c99b..7c91086 100644 --- a/auth0-api-java/src/main/java/com/auth0/AuthClient.java +++ b/auth0-api-java/src/main/java/com/auth0/AuthClient.java @@ -45,12 +45,11 @@ public static AuthClient from(AuthOptions options) { /** * Verifies the incoming request headers and HTTP request info. - * @param headers request headers * @param requestInfo HTTP request info * @return AuthenticationContext with JWT claims * @throws BaseAuthException if verification fails */ - public AuthenticationContext verifyRequest(Map headers, HttpRequestInfo requestInfo) throws BaseAuthException { - return orchestrator.process(headers, requestInfo); + public AuthenticationContext verifyRequest(HttpRequestInfo requestInfo) throws BaseAuthException { + return orchestrator.process(requestInfo); } } diff --git a/auth0-api-java/src/main/java/com/auth0/AuthenticationOrchestrator.java b/auth0-api-java/src/main/java/com/auth0/AuthenticationOrchestrator.java index 28037d7..13d39fe 100644 --- a/auth0-api-java/src/main/java/com/auth0/AuthenticationOrchestrator.java +++ b/auth0-api-java/src/main/java/com/auth0/AuthenticationOrchestrator.java @@ -16,8 +16,8 @@ public AuthenticationOrchestrator(AbstractAuthentication authStrategy) { this.authStrategy = authStrategy; } - public AuthenticationContext process(Map headers, HttpRequestInfo requestInfo) + public AuthenticationContext process(HttpRequestInfo requestInfo) throws BaseAuthException { - return authStrategy.authenticate(headers, requestInfo); + return authStrategy.authenticate(requestInfo); } } diff --git a/auth0-api-java/src/main/java/com/auth0/DisabledDPoPAuthentication.java b/auth0-api-java/src/main/java/com/auth0/DisabledDPoPAuthentication.java index 6f166f8..9b29780 100644 --- a/auth0-api-java/src/main/java/com/auth0/DisabledDPoPAuthentication.java +++ b/auth0-api-java/src/main/java/com/auth0/DisabledDPoPAuthentication.java @@ -17,18 +17,17 @@ public DisabledDPoPAuthentication(JWTValidator jwtValidator, TokenExtractor extr /** * Authenticates the request when DPoP Mode is Disabled (Accepts only Bearer tokens) . - * @param headers request headers * @param requestInfo HTTP request info * @return AuthenticationContext with JWT claims * @throws BaseAuthException if validation fails */ @Override - public AuthenticationContext authenticate(Map headers, HttpRequestInfo requestInfo) + public AuthenticationContext authenticate(HttpRequestInfo requestInfo) throws BaseAuthException { - Map normalizedHeader = normalize(headers); +// Map normalizedHeader = normalize(requestInfo.getHeaders()); try { - DecodedJWT jwt = validateBearerToken(normalizedHeader, requestInfo); + DecodedJWT jwt = validateBearerToken(requestInfo); return buildContext(jwt); } catch (BaseAuthException ex){ diff --git a/auth0-api-java/src/main/java/com/auth0/RequiredDPoPAuthentication.java b/auth0-api-java/src/main/java/com/auth0/RequiredDPoPAuthentication.java index 0838815..8dd63f7 100644 --- a/auth0-api-java/src/main/java/com/auth0/RequiredDPoPAuthentication.java +++ b/auth0-api-java/src/main/java/com/auth0/RequiredDPoPAuthentication.java @@ -20,19 +20,18 @@ public RequiredDPoPAuthentication(JWTValidator jwtValidator, /** * Authenticates the request when DPoP Mode is Allowed (Accepts only DPoP tokens) . - * @param headers request headers * @param requestInfo HTTP request info * @return AuthenticationContext with JWT claims * @throws BaseAuthException if validation fails */ @Override - public AuthenticationContext authenticate(Map headers, HttpRequestInfo requestInfo) + public AuthenticationContext authenticate(HttpRequestInfo requestInfo) throws BaseAuthException { - Map normalizedHeader = normalize(headers); +// Map normalizedHeader = normalize(requestInfo.getHeaders()); try { - DecodedJWT decodedJWT = validateDpopTokenAndProof(normalizedHeader, requestInfo); + DecodedJWT decodedJWT = validateDpopTokenAndProof(requestInfo); return buildContext(decodedJWT); } catch (BaseAuthException ex){ diff --git a/auth0-api-java/src/main/java/com/auth0/examples/Auth0ApiExample.java b/auth0-api-java/src/main/java/com/auth0/examples/Auth0ApiExample.java index cde1962..0e1dfa8 100644 --- a/auth0-api-java/src/main/java/com/auth0/examples/Auth0ApiExample.java +++ b/auth0-api-java/src/main/java/com/auth0/examples/Auth0ApiExample.java @@ -97,16 +97,21 @@ public void handle(HttpExchange exchange) throws IOException { // Build HttpRequestInfo (needed for DPoP htm + htu validation) - HttpRequestInfo requestInfo = new HttpRequestInfo( - exchange.getRequestMethod(), - "http://localhost:8000" + exchange.getRequestURI().toString(), null - ); + HttpRequestInfo requestInfo = null; + try { + requestInfo = new HttpRequestInfo( + exchange.getRequestMethod(), + "http://localhost:8000" + exchange.getRequestURI().toString(), headers + ); + } catch (BaseAuthException e) { + throw new RuntimeException(e); + } System.out.println("Incoming request to " + requestInfo.toString()); try { AuthenticationContext claims = - authClient.verifyRequest(headers, requestInfo); + authClient.verifyRequest(requestInfo); String user = (String) claims.getClaims().get("sub"); diff --git a/auth0-api-java/src/main/java/com/auth0/models/HttpRequestInfo.java b/auth0-api-java/src/main/java/com/auth0/models/HttpRequestInfo.java index 672857a..c62dae4 100644 --- a/auth0-api-java/src/main/java/com/auth0/models/HttpRequestInfo.java +++ b/auth0-api-java/src/main/java/com/auth0/models/HttpRequestInfo.java @@ -1,17 +1,26 @@ package com.auth0.models; -import java.util.Collections; +import com.auth0.exception.InvalidRequestException; +import org.apache.http.util.Asserts; + +import java.util.HashMap; import java.util.Map; public class HttpRequestInfo { private final String httpMethod; private final String httpUrl; - private final Map context; + private final Map headers; + + public HttpRequestInfo(String httpMethod, String httpUrl, Map headers) throws InvalidRequestException { + Asserts.notNull(headers, "Headers map cannot be null"); - public HttpRequestInfo(String httpMethod, String httpUrl, Map context) { - this.httpMethod = httpMethod.toUpperCase(); + this.httpMethod = httpMethod != null ? httpMethod.toUpperCase() : null; this.httpUrl = httpUrl; - this.context = context != null ? Collections.unmodifiableMap(context) : Collections.emptyMap(); + this.headers = normalize(headers); + } + + public HttpRequestInfo(Map headers) throws InvalidRequestException { + this(null, null, headers); } public String getHttpMethod() { @@ -22,7 +31,20 @@ public String getHttpUrl() { return httpUrl; } - public Map getContext() { - return context; + public Map getHeaders() { + return headers; + } + + private static Map normalize(Map headers) throws InvalidRequestException { + Map normalized = new HashMap<>(headers.size()); + + for (Map.Entry entry : headers.entrySet()) { + String key = entry.getKey().toLowerCase(); + if (normalized.containsKey(key)) { + throw new InvalidRequestException("Duplicate HTTP header detected"); + } + normalized.put(key, entry.getValue()); + } + return normalized; } } 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 index 9d655dc..0189e8d 100644 --- a/auth0-api-java/src/main/java/com/auth0/validators/JWTValidator.java +++ b/auth0-api-java/src/main/java/com/auth0/validators/JWTValidator.java @@ -67,7 +67,7 @@ public JWTValidator(AuthOptions authOptions, JwkProvider jwkProvider) { * @return the decoded and verified JWT * @throws BaseAuthException if validation fails */ - public DecodedJWT validateToken(String token, Map headers, HttpRequestInfo httpRequestInfo) throws BaseAuthException { + public DecodedJWT validateToken(String token, HttpRequestInfo httpRequestInfo) throws BaseAuthException { if (token == null || token.trim().isEmpty()) { throw new MissingRequiredArgumentException("access_token"); @@ -92,9 +92,9 @@ public DecodedJWT validateToken(String token, Map headers, HttpR /** * Validates a JWT and ensures all required scopes are present. */ - public DecodedJWT validateTokenWithRequiredScopes(String token, Map headers, HttpRequestInfo httpRequestInfo, String... requiredScopes) + public DecodedJWT validateTokenWithRequiredScopes(String token, HttpRequestInfo httpRequestInfo, String... requiredScopes) throws BaseAuthException { - DecodedJWT jwt = validateToken(token, headers, httpRequestInfo); + DecodedJWT jwt = validateToken(token,httpRequestInfo); try { ClaimValidator.checkRequiredScopes(jwt, requiredScopes); return jwt; @@ -106,9 +106,9 @@ public DecodedJWT validateTokenWithRequiredScopes(String token, Map headers, HttpRequestInfo httpRequestInfo, String... scopes) + public DecodedJWT validateTokenWithAnyScope(String token, HttpRequestInfo httpRequestInfo, String... scopes) throws BaseAuthException { - DecodedJWT jwt = validateToken(token, headers, httpRequestInfo); + DecodedJWT jwt = validateToken(token, httpRequestInfo); try { ClaimValidator.checkAnyScope(jwt, scopes); return jwt; @@ -120,9 +120,9 @@ public DecodedJWT validateTokenWithAnyScope(String token, Map he /** * Validates a JWT and ensures a claim equals the expected value. */ - public DecodedJWT validateTokenWithClaimEquals(String token, Map headers, HttpRequestInfo httpRequestInfo, String claim, Object expected) + public DecodedJWT validateTokenWithClaimEquals(String token, HttpRequestInfo httpRequestInfo, String claim, Object expected) throws BaseAuthException { - DecodedJWT jwt = validateToken(token, headers, httpRequestInfo); + DecodedJWT jwt = validateToken(token, httpRequestInfo); try { ClaimValidator.checkClaimEquals(jwt, claim, expected); return jwt; @@ -134,9 +134,9 @@ public DecodedJWT validateTokenWithClaimEquals(String token, Map /** * Validates a JWT and ensures a claim includes all expected values. */ - public DecodedJWT validateTokenWithClaimIncludes(String token, Map headers, HttpRequestInfo httpRequestInfo, String claim, Object... expectedValues) + public DecodedJWT validateTokenWithClaimIncludes(String token, HttpRequestInfo httpRequestInfo, String claim, Object... expectedValues) throws BaseAuthException { - DecodedJWT jwt = validateToken(token, headers, httpRequestInfo); + DecodedJWT jwt = validateToken(token, httpRequestInfo); try { ClaimValidator.checkClaimIncludes(jwt, claim, expectedValues); return jwt; @@ -145,9 +145,9 @@ public DecodedJWT validateTokenWithClaimIncludes(String token, Map headers, HttpRequestInfo httpRequestInfo, String claim, Object... expectedValues) + public DecodedJWT validateTokenWithClaimIncludesAny(String token, HttpRequestInfo httpRequestInfo, String claim, Object... expectedValues) throws BaseAuthException { - DecodedJWT jwt = validateToken(token, headers, httpRequestInfo); + DecodedJWT jwt = validateToken(token, httpRequestInfo); try { ClaimValidator.checkClaimIncludesAny(jwt, claim, expectedValues); return jwt; diff --git a/auth0-api-java/src/test/java/com/auth0/AbstractAuthenticationTest.java b/auth0-api-java/src/test/java/com/auth0/AbstractAuthenticationTest.java index 0881b37..b257f94 100644 --- a/auth0-api-java/src/test/java/com/auth0/AbstractAuthenticationTest.java +++ b/auth0-api-java/src/test/java/com/auth0/AbstractAuthenticationTest.java @@ -17,7 +17,7 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; public class AbstractAuthenticationTest { @@ -38,7 +38,6 @@ private static class TestAuthImpl extends AbstractAuthentication { @Override public AuthenticationContext authenticate( - Map headers, HttpRequestInfo requestInfo) { return null; } @@ -80,12 +79,12 @@ public void validateBearerToken_shouldExtractAndValidate() throws Exception { DecodedJWT jwt = mock(DecodedJWT.class); when(extractor.extractBearer(anyMap())).thenReturn(token); - when(jwtValidator.validateToken(eq("access"), anyMap(), any())).thenReturn(jwt); + when(jwtValidator.validateToken(eq("access"), any(HttpRequestInfo.class))).thenReturn(jwt); Map headers = new HashMap<>(); headers.put("authorization", "Bearer access"); - DecodedJWT result = authSystem.validateBearerToken(headers, null); + DecodedJWT result = authSystem.validateBearerToken(new HttpRequestInfo("GET", "https://api.example.com", headers)); assertThat(result).isSameAs(jwt); } @@ -94,17 +93,17 @@ public void validateBearerToken_shouldExtractAndValidate() throws Exception { public void validateDpopTokenAndProof_shouldValidateEverything() throws Exception { AuthToken token = new AuthToken("access", "proof", null); DecodedJWT jwt = mock(DecodedJWT.class); - HttpRequestInfo request = - new HttpRequestInfo("GET", "https://api.example.com", null); - - when(extractor.extractDPoPProofAndDPoPToken(anyMap())).thenReturn(token); - when(jwtValidator.validateToken(eq("access"), anyMap(), any())).thenReturn(jwt); Map headers = new HashMap<>(); headers.put("authorization", "DPoP access"); headers.put("dpop", "proof"); - DecodedJWT result = authSystem.validateDpopTokenAndProof(headers, request); + HttpRequestInfo request = new HttpRequestInfo("GET", "https://api.example.com", headers); + + when(extractor.extractDPoPProofAndDPoPToken(anyMap())).thenReturn(token); + when(jwtValidator.validateToken(eq("access"), any(HttpRequestInfo.class))).thenReturn(jwt); + + DecodedJWT result = authSystem.validateDpopTokenAndProof(request); verify(dpopProofValidator).validate("proof", jwt, request); assertThat(result).isSameAs(jwt); diff --git a/auth0-api-java/src/test/java/com/auth0/AllowedDPoPAuthenticationTest.java b/auth0-api-java/src/test/java/com/auth0/AllowedDPoPAuthenticationTest.java index d350589..4c6c69b 100644 --- a/auth0-api-java/src/test/java/com/auth0/AllowedDPoPAuthenticationTest.java +++ b/auth0-api-java/src/test/java/com/auth0/AllowedDPoPAuthenticationTest.java @@ -16,7 +16,7 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; public class AllowedDPoPAuthenticationTest { @@ -48,39 +48,38 @@ public void authenticate_shouldAcceptBearerToken() throws Exception { new AuthToken("token", null, null) ); - Map normalizedHeaders = new HashMap<>(); - normalizedHeaders.put("authorization", "Bearer token"); - - when(jwtValidator.validateToken(eq("token"), eq(normalizedHeaders), any())).thenReturn(jwt); + when(jwtValidator.validateToken(eq("token"), any())).thenReturn(jwt); Map headers = new HashMap<>(); headers.put("authorization", "Bearer token"); - AuthenticationContext ctx = auth.authenticate(headers, null); + HttpRequestInfo httpRequestInfo = new HttpRequestInfo("GET", "https://api.example.com", headers); + + AuthenticationContext ctx = auth.authenticate(httpRequestInfo); assertThat(ctx).isNotNull(); - verify(jwtValidator).validateToken("token", normalizedHeaders, null); + verify(jwtValidator).validateToken("token", httpRequestInfo); verifyNoInteractions(dpopProofValidator); } @Test public void authenticate_shouldAcceptDpopToken() throws Exception { DecodedJWT jwt = mock(DecodedJWT.class); - HttpRequestInfo request = - new HttpRequestInfo("GET", "https://api.example.com", null); when(extractor.getScheme(anyMap())).thenReturn(AuthConstants.DPOP_SCHEME); when(extractor.extractDPoPProofAndDPoPToken(anyMap())).thenReturn( new com.auth0.models.AuthToken("token", "proof", null) ); - when(jwtValidator.validateToken(eq("token"), anyMap(), any())).thenReturn(jwt); + when(jwtValidator.validateToken(eq("token"), any())).thenReturn(jwt); Map headers = new HashMap<>(); headers.put("authorization", "DPoP token"); headers.put("dpop", "proof"); + HttpRequestInfo request = new HttpRequestInfo("GET", "https://api.example.com", headers); + when(jwt.getClaims()).thenReturn(new HashMap<>()); - AuthenticationContext ctx = auth.authenticate(headers, request); + AuthenticationContext ctx = auth.authenticate(request); assertThat(ctx).isNotNull(); verify(dpopProofValidator).validate("proof", jwt, request); @@ -93,7 +92,9 @@ public void authenticate_shouldRejectUnknownScheme() throws Exception { Map headers = new HashMap<>(); headers.put("authorization", "Basic abc"); - auth.authenticate(headers, null); + HttpRequestInfo request = new HttpRequestInfo(headers); + + auth.authenticate(request); } @Test @@ -105,8 +106,10 @@ public void authenticate_shouldWrapExceptionWithWwwAuthenticate() throws Excepti Map headers = new HashMap<>(); headers.put("authorization", "Bearer bad"); + HttpRequestInfo request = new HttpRequestInfo(headers); + try { - auth.authenticate(headers, null); + auth.authenticate(request); } catch (BaseAuthException ex) { assertThat(ex.getHeaders()) .containsKey("WWW-Authenticate"); diff --git a/auth0-api-java/src/test/java/com/auth0/AuthClientTest.java b/auth0-api-java/src/test/java/com/auth0/AuthClientTest.java index 43a62e3..2cd4fa8 100644 --- a/auth0-api-java/src/test/java/com/auth0/AuthClientTest.java +++ b/auth0-api-java/src/test/java/com/auth0/AuthClientTest.java @@ -16,8 +16,6 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; public class AuthClientTest { - private static HttpRequestInfo REQUEST = - new HttpRequestInfo("GET", "https://api.example.com/resource", null); @Test public void from_createsClient() { @@ -35,19 +33,21 @@ public void allowedMode_isDefault() { .build() ); + assertThatThrownBy(() -> - client.verifyRequest(Collections.emptyMap(), REQUEST) + client.verifyRequest(getHttpRequestInfo(new HashMap<>())) ).isInstanceOf(MissingAuthorizationException.class); } @Test - public void allowedMode_rejectsUnknownScheme() { + public void allowedMode_rejectsUnknownScheme() throws BaseAuthException { AuthClient client = AuthClient.from(validOptions(DPoPMode.ALLOWED)); Map headers = Collections.singletonMap("authorization", "Basic abc123"); + assertThatThrownBy(() -> - client.verifyRequest(headers, REQUEST) + client.verifyRequest(getHttpRequestInfo(headers)) ).isInstanceOf(InvalidAuthSchemeException.class); } @@ -56,12 +56,12 @@ public void disabledMode_rejectsMissingAuthorization() { AuthClient client = AuthClient.from(validOptions(DPoPMode.DISABLED)); assertThatThrownBy(() -> - client.verifyRequest(Collections.emptyMap(), REQUEST) + client.verifyRequest(getHttpRequestInfo(new HashMap<>())) ).isInstanceOf(MissingAuthorizationException.class); } @Test - public void disabledMode_rejectsDpopScheme() { + public void disabledMode_rejectsDpopScheme() throws BaseAuthException { AuthClient client = AuthClient.from(validOptions(DPoPMode.DISABLED)); Map headers = new HashMap<>(); @@ -69,30 +69,32 @@ public void disabledMode_rejectsDpopScheme() { headers.put("dpop", "proof"); assertThatThrownBy(() -> - client.verifyRequest(headers, REQUEST) + client.verifyRequest(getHttpRequestInfo(headers)) ).isInstanceOf(BaseAuthException.class); } @Test - public void requiredMode_rejectsBearerScheme() { + public void requiredMode_rejectsBearerScheme() throws BaseAuthException { AuthClient client = AuthClient.from(validOptions(DPoPMode.REQUIRED)); - Map headers = Collections.singletonMap("authorization", "Bearer token"); + Map headers = new HashMap<>(); + headers.put("authorization", "Bearer token"); + assertThatThrownBy(() -> - client.verifyRequest(headers, REQUEST) + client.verifyRequest(getHttpRequestInfo(headers)) ).isInstanceOf(BaseAuthException.class); } @Test - public void requiredMode_rejectsMissingProof() { + public void requiredMode_rejectsMissingProof() throws BaseAuthException { AuthClient client = AuthClient.from(validOptions(DPoPMode.REQUIRED)); - Map headers = Collections.singletonMap("authorization", "DPoP token"); - + Map headers = new HashMap<>(); + headers.put("authorization", "DPoP token"); assertThatThrownBy(() -> - client.verifyRequest(headers, REQUEST) + client.verifyRequest(getHttpRequestInfo(headers)) ).isInstanceOf(BaseAuthException.class); } @@ -103,4 +105,8 @@ private static AuthOptions validOptions(DPoPMode mode) { .dpopMode(mode) .build(); } + + private HttpRequestInfo getHttpRequestInfo(Map headers) throws BaseAuthException { + return new HttpRequestInfo("GET", "https://api.example.com/resource", headers); + } } diff --git a/auth0-api-java/src/test/java/com/auth0/AuthValidatorHelperTest.java b/auth0-api-java/src/test/java/com/auth0/AuthValidatorHelperTest.java index ff150be..d36edfe 100644 --- a/auth0-api-java/src/test/java/com/auth0/AuthValidatorHelperTest.java +++ b/auth0-api-java/src/test/java/com/auth0/AuthValidatorHelperTest.java @@ -99,13 +99,13 @@ public void validateNoMultipleProofsPresent_shouldThrowWithMultipleProofs() thro @Test public void validateHttpMethodAndHttpUrl_shouldPassWithValidValues() throws BaseAuthException { - HttpRequestInfo request = new HttpRequestInfo("GET", "https://example.com", null); + HttpRequestInfo request = new HttpRequestInfo("GET", "https://example.com", new HashMap<>()); AuthValidatorHelper.validateHttpMethodAndHttpUrl(request); } @Test(expected = MissingRequiredArgumentException.class) public void validateHttpMethodAndHttpUrl_shouldThrowWithEmptyValues() throws BaseAuthException { - HttpRequestInfo request = new HttpRequestInfo("", "", null); + HttpRequestInfo request = new HttpRequestInfo("", "", new HashMap<>()); AuthValidatorHelper.validateHttpMethodAndHttpUrl(request); } diff --git a/auth0-api-java/src/test/java/com/auth0/AuthenticationOrchestratorTest.java b/auth0-api-java/src/test/java/com/auth0/AuthenticationOrchestratorTest.java index a29f5a3..70e7d39 100644 --- a/auth0-api-java/src/test/java/com/auth0/AuthenticationOrchestratorTest.java +++ b/auth0-api-java/src/test/java/com/auth0/AuthenticationOrchestratorTest.java @@ -21,7 +21,7 @@ public void process_delegatesToStrategy() throws Exception { AbstractAuthentication strategy = mock(AbstractAuthentication.class); AuthenticationContext ctx = mock(AuthenticationContext.class); - when(strategy.authenticate(anyMap(), any())) + when(strategy.authenticate(any())) .thenReturn(ctx); AuthenticationOrchestrator orchestrator = @@ -31,11 +31,10 @@ public void process_delegatesToStrategy() throws Exception { headers.put("authorization", "Bearer token"); AuthenticationContext result = - orchestrator.process(headers, - new HttpRequestInfo("GET", "https://api", null)); + orchestrator.process(new HttpRequestInfo("GET", "https://api", headers)); assertThat(result).isSameAs(ctx); - verify(strategy).authenticate(anyMap(), any()); + verify(strategy).authenticate(any()); } @Test @@ -43,7 +42,7 @@ public void process_propagatesException() throws Exception { AbstractAuthentication strategy = mock(AbstractAuthentication.class); BaseAuthException ex = mock(BaseAuthException.class); - when(strategy.authenticate(anyMap(), any())) + when(strategy.authenticate(any())) .thenThrow(ex); AuthenticationOrchestrator orchestrator = @@ -52,7 +51,7 @@ public void process_propagatesException() throws Exception { Map headers = new HashMap<>(); assertThatThrownBy(() -> - orchestrator.process(headers, null) + orchestrator.process(new HttpRequestInfo(headers)) ).isSameAs(ex); } } diff --git a/auth0-api-java/src/test/java/com/auth0/DisabledDPoPAuthenticationTest.java b/auth0-api-java/src/test/java/com/auth0/DisabledDPoPAuthenticationTest.java index 38d3dec..670c8e7 100644 --- a/auth0-api-java/src/test/java/com/auth0/DisabledDPoPAuthenticationTest.java +++ b/auth0-api-java/src/test/java/com/auth0/DisabledDPoPAuthenticationTest.java @@ -3,6 +3,7 @@ import com.auth0.exception.BaseAuthException; import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.models.AuthenticationContext; +import com.auth0.models.HttpRequestInfo; import com.auth0.validators.JWTValidator; import org.junit.Before; import org.junit.Test; @@ -37,17 +38,19 @@ public void authenticate_shouldAcceptBearerToken() throws Exception { Map normalizedHeaders = new HashMap<>(); normalizedHeaders.put("authorization", "Bearer token"); - when(jwtValidator.validateToken(eq("token"), eq(normalizedHeaders), any())).thenReturn(jwt); + when(jwtValidator.validateToken(eq("token"), any())).thenReturn(jwt); Map headers = new HashMap<>(); headers.put("authorization", "Bearer token"); + HttpRequestInfo request = new HttpRequestInfo(headers); + when(jwt.getClaims()).thenReturn(new HashMap<>()); - AuthenticationContext ctx = auth.authenticate(headers, null); + AuthenticationContext ctx = auth.authenticate(request); assertThat(ctx).isNotNull(); - verify(jwtValidator).validateToken("token", normalizedHeaders, null); + verify(jwtValidator).validateToken("token", request); } @Test @@ -57,8 +60,10 @@ public void authenticate_shouldWrapExceptionWithWwwAuthenticate() throws Excepti Map headers = new HashMap<>(); + HttpRequestInfo request = new HttpRequestInfo(headers); + try { - auth.authenticate(headers, null); + auth.authenticate(request); } catch (BaseAuthException ex) { assertThat(ex.getHeaders()) .containsKey("WWW-Authenticate"); diff --git a/auth0-api-java/src/test/java/com/auth0/RequiredDPoPAuthenticationTest.java b/auth0-api-java/src/test/java/com/auth0/RequiredDPoPAuthenticationTest.java index dfd4e02..5965e65 100644 --- a/auth0-api-java/src/test/java/com/auth0/RequiredDPoPAuthenticationTest.java +++ b/auth0-api-java/src/test/java/com/auth0/RequiredDPoPAuthenticationTest.java @@ -2,6 +2,7 @@ import com.auth0.exception.BaseAuthException; import com.auth0.jwt.interfaces.DecodedJWT; +import com.auth0.models.AuthToken; import com.auth0.models.AuthenticationContext; import com.auth0.models.HttpRequestInfo; import com.auth0.validators.DPoPProofValidator; @@ -33,21 +34,21 @@ public void setUp() { @Test public void authenticate_shouldAcceptDpopToken() throws Exception { DecodedJWT jwt = mock(DecodedJWT.class); - HttpRequestInfo request = - new HttpRequestInfo("POST", "https://api.example.com", null); - - when(extractor.extractDPoPProofAndDPoPToken(anyMap())).thenReturn( - new com.auth0.models.AuthToken("token", "proof", null) - ); - when(jwtValidator.validateToken(eq("token"), anyMap(), any())).thenReturn(jwt); Map headers = new HashMap<>(); headers.put("authorization", "DPoP token"); headers.put("dpop", "proof"); + HttpRequestInfo request = new HttpRequestInfo("POST", "https://api.example.com", headers); + + when(extractor.extractDPoPProofAndDPoPToken(anyMap())).thenReturn( + new AuthToken("token", "proof", null) + ); + when(jwtValidator.validateToken(eq("token"), any())).thenReturn(jwt); + when(jwt.getClaims()).thenReturn(new HashMap<>()); - AuthenticationContext ctx = auth.authenticate(headers, request); + AuthenticationContext ctx = auth.authenticate(request); assertThat(ctx).isNotNull(); verify(dpopProofValidator).validate("proof", jwt, request); @@ -55,15 +56,13 @@ public void authenticate_shouldAcceptDpopToken() throws Exception { @Test public void authenticate_shouldWrapExceptionWithWwwAuthenticate() throws Exception { - HttpRequestInfo request = - new HttpRequestInfo("POST", "https://api.example.com", null); + Map headers = new HashMap<>(); + HttpRequestInfo request = new HttpRequestInfo("POST", "https://api.example.com", headers); when(extractor.extractDPoPProofAndDPoPToken(anyMap())) .thenThrow(new com.auth0.exception.MissingAuthorizationException()); - Map headers = new HashMap<>(); - try { - auth.authenticate(headers, request); + auth.authenticate(request); } catch (BaseAuthException ex) { assertThat(ex.getHeaders()) .containsKey("WWW-Authenticate"); diff --git a/auth0-api-java/src/test/java/com/auth0/models/HttpRequestInfoTest.java b/auth0-api-java/src/test/java/com/auth0/models/HttpRequestInfoTest.java index 4ff9f0d..9c60d41 100644 --- a/auth0-api-java/src/test/java/com/auth0/models/HttpRequestInfoTest.java +++ b/auth0-api-java/src/test/java/com/auth0/models/HttpRequestInfoTest.java @@ -1,5 +1,7 @@ package com.auth0.models; +import com.auth0.exception.BaseAuthException; +import com.auth0.exception.InvalidRequestException; import org.junit.Test; import java.util.Collections; @@ -12,52 +14,54 @@ public class HttpRequestInfoTest { @Test - public void testConstructorInitializesFieldsCorrectly() { - Map context = new HashMap<>(); - context.put("key", "value"); + public void testConstructorInitializesFieldsCorrectly() throws InvalidRequestException { + Map headers = new HashMap<>(); + headers.put("key", "value"); - HttpRequestInfo requestInfo = new HttpRequestInfo("get", "http://example.com", context); + HttpRequestInfo requestInfo = new HttpRequestInfo("get", "http://example.com", headers); assertEquals("GET", requestInfo.getHttpMethod()); assertEquals("http://example.com", requestInfo.getHttpUrl()); - assertEquals(Collections.singletonMap("key", "value"), requestInfo.getContext()); + assertEquals(Collections.singletonMap("key", "value"), requestInfo.getHeaders()); } - @Test - public void testConstructorHandlesNullContext() { - HttpRequestInfo requestInfo = new HttpRequestInfo("post", "http://example.com", null); - - assertEquals("POST", requestInfo.getHttpMethod()); - assertEquals("http://example.com", requestInfo.getHttpUrl()); - assertTrue(requestInfo.getContext().isEmpty()); - } @Test - public void testGetHttpMethod() { - HttpRequestInfo requestInfo = new HttpRequestInfo("put", "http://example.com", null); + public void testGetHttpMethod() throws InvalidRequestException { + HttpRequestInfo requestInfo = new HttpRequestInfo("put", "http://example.com", new HashMap<>()); assertEquals("PUT", requestInfo.getHttpMethod()); } @Test - public void testGetHttpUrl() { - HttpRequestInfo requestInfo = new HttpRequestInfo("delete", "http://example.com", null); + public void testGetHttpUrl() throws InvalidRequestException { + HttpRequestInfo requestInfo = new HttpRequestInfo("delete", "http://example.com", new HashMap<>()); assertEquals("http://example.com", requestInfo.getHttpUrl()); } @Test - public void testGetContextIsImmutable() { - Map context = new HashMap<>(); - context.put("key", "value"); + public void testGetContextIsImmutable() throws InvalidRequestException { + Map headers = new HashMap<>(); + headers.put("key", "value"); - HttpRequestInfo requestInfo = new HttpRequestInfo("get", "http://example.com", context); + HttpRequestInfo requestInfo = new HttpRequestInfo("get", "http://example.com", headers); - Map retrievedContext = requestInfo.getContext(); + Map retrievedHeaders = requestInfo.getHeaders(); try { - retrievedContext.put("newKey", "newValue"); + retrievedHeaders.put("newKey", "newValue"); } catch (UnsupportedOperationException e) { assertTrue(true); } } + + @Test(expected = InvalidRequestException.class) + public void normalize_shouldThrowOnDuplicateHeaders() throws BaseAuthException { + Map headers = new HashMap<>(); + headers.put("Authorization", "a"); + headers.put("authorization", "b"); + + HttpRequestInfo requestInfo = new HttpRequestInfo("get", "http://example.com", headers); + + } } diff --git a/auth0-api-java/src/test/java/com/auth0/validators/DPoPProofValidatorTest.java b/auth0-api-java/src/test/java/com/auth0/validators/DPoPProofValidatorTest.java index ab92eee..e062616 100644 --- a/auth0-api-java/src/test/java/com/auth0/validators/DPoPProofValidatorTest.java +++ b/auth0-api-java/src/test/java/com/auth0/validators/DPoPProofValidatorTest.java @@ -46,7 +46,7 @@ public void setUp() throws Exception { requestInfo = new HttpRequestInfo( "GET", "https://api.example.com/resource", - null + new HashMap<>() ); KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); diff --git a/auth0-api-java/src/test/java/com/auth0/validators/JWTValidatorTest.java b/auth0-api-java/src/test/java/com/auth0/validators/JWTValidatorTest.java index 2337c16..a8b44ed 100644 --- a/auth0-api-java/src/test/java/com/auth0/validators/JWTValidatorTest.java +++ b/auth0-api-java/src/test/java/com/auth0/validators/JWTValidatorTest.java @@ -1,6 +1,7 @@ package com.auth0.validators; import com.auth0.exception.InsufficientScopeException; +import com.auth0.exception.InvalidRequestException; import com.auth0.exception.MissingRequiredArgumentException; import com.auth0.exception.VerifyAccessTokenException; import com.auth0.jwk.Jwk; @@ -9,6 +10,7 @@ import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.models.AuthOptions; +import com.auth0.models.HttpRequestInfo; import org.junit.Before; import org.junit.Test; import org.mockito.Mock; @@ -81,7 +83,7 @@ public void constructor_shouldRejectNullJwkProvider() { public void validateToken_success() throws Exception { String token = validToken(); - DecodedJWT jwt = validator.validateToken(token, null, null); + DecodedJWT jwt = validator.validateToken(token, getHttpRequestInfo()); assertThat(jwt.getIssuer()).isEqualTo(ISSUER); assertThat(jwt.getAudience()).contains(AUDIENCE); @@ -90,7 +92,7 @@ public void validateToken_success() throws Exception { @Test(expected = MissingRequiredArgumentException.class) public void validateToken_shouldRejectNullToken() throws Exception { - validator.validateToken(null, null, null); + validator.validateToken(null, getHttpRequestInfo()); } @Test(expected = VerifyAccessTokenException.class) @@ -101,14 +103,14 @@ public void validateToken_shouldRejectInvalidSignature() throws Exception { when(jwk.getPublicKey()).thenReturn(wrongKey); - validator.validateToken(validToken(), null, null); + validator.validateToken(validToken(), getHttpRequestInfo()); } @Test public void validateTokenWithRequiredScopes_success() throws Exception { String token = tokenWithScopes("read write"); - DecodedJWT jwt = validator.validateTokenWithRequiredScopes(token, new HashMap<>(), null, "read"); + DecodedJWT jwt = validator.validateTokenWithRequiredScopes(token, getHttpRequestInfo(), "read"); assertThat(jwt).isNotNull(); } @@ -117,14 +119,14 @@ public void validateTokenWithRequiredScopes_success() throws Exception { public void validateTokenWithRequiredScopes_failure() throws Exception { String token = tokenWithScopes("read"); - validator.validateTokenWithRequiredScopes(token, new HashMap<>(), null, "admin"); + validator.validateTokenWithRequiredScopes(token, getHttpRequestInfo(), "admin"); } @Test public void validateTokenWithAnyScope_success() throws Exception { String token = tokenWithScopes("read write"); - DecodedJWT jwt = validator.validateTokenWithAnyScope(token, new HashMap<>(), null, "admin", "write"); + DecodedJWT jwt = validator.validateTokenWithAnyScope(token, getHttpRequestInfo(), "admin", "write"); assertThat(jwt).isNotNull(); } @@ -133,14 +135,14 @@ public void validateTokenWithAnyScope_success() throws Exception { public void validateTokenWithAnyScope_failure() throws Exception { String token = tokenWithScopes("read"); - validator.validateTokenWithAnyScope(token, new HashMap<>(), null, "admin"); + validator.validateTokenWithAnyScope(token, getHttpRequestInfo(), "admin"); } @Test public void validateTokenWithClaimEquals_success() throws Exception { String token = tokenWithEmail("a@b.com"); - DecodedJWT jwt = validator.validateTokenWithClaimEquals(token, new HashMap<>(), null, "email", "a@b.com"); + DecodedJWT jwt = validator.validateTokenWithClaimEquals(token, getHttpRequestInfo(), "email", "a@b.com"); assertThat(jwt).isNotNull(); } @@ -149,14 +151,14 @@ public void validateTokenWithClaimEquals_success() throws Exception { public void validateTokenWithClaimEquals_failure() throws Exception { String token = tokenWithEmail("a@b.com"); - validator.validateTokenWithClaimEquals(token, new HashMap<>(), null, "email", "x@y.com"); + validator.validateTokenWithClaimEquals(token, getHttpRequestInfo(), "email", "x@y.com"); } @Test public void validateTokenWithClaimIncludes_success() throws Exception { String token = tokenWithScopes("read write"); - DecodedJWT jwt = validator.validateTokenWithClaimIncludes(token, new HashMap<>(), null, "scope", "read"); + DecodedJWT jwt = validator.validateTokenWithClaimIncludes(token, getHttpRequestInfo(), "scope", "read"); assertThat(jwt).isNotNull(); } @@ -165,14 +167,14 @@ public void validateTokenWithClaimIncludes_success() throws Exception { public void validateTokenWithClaimIncludes_failure() throws Exception { String token = tokenWithScopes("read"); - validator.validateTokenWithClaimIncludes(token, new HashMap<>(), null, "scope", "admin"); + validator.validateTokenWithClaimIncludes(token, getHttpRequestInfo(), "scope", "admin"); } @Test public void validateTokenWithClaimIncludesAny_success() throws Exception { String token = tokenWithScopes("read write"); - DecodedJWT jwt = validator.validateTokenWithClaimIncludesAny(token, new HashMap<>(), null, "scope", "admin", "write"); + DecodedJWT jwt = validator.validateTokenWithClaimIncludesAny(token, getHttpRequestInfo(), "scope", "admin", "write"); assertThat(jwt).isNotNull(); } @@ -181,7 +183,7 @@ public void validateTokenWithClaimIncludesAny_success() throws Exception { public void validateTokenWithClaimIncludesAny_failure() throws Exception { String token = tokenWithScopes("read"); - validator.validateTokenWithClaimIncludesAny(token, new HashMap<>(), null, "scope", "admin"); + validator.validateTokenWithClaimIncludesAny(token, getHttpRequestInfo(), "scope", "admin"); } @Test @@ -202,6 +204,10 @@ public void getters_shouldReturnValues() { assertThat(validator.getJwkProvider()).isNotNull(); } + private HttpRequestInfo getHttpRequestInfo() throws InvalidRequestException { + return new HttpRequestInfo("GET", "https://api.example.com/resource", new HashMap<>()); + } + private String validToken() { return JWT.create() .withIssuer(ISSUER) diff --git a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0AuthenticationFilter.java b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0AuthenticationFilter.java index 7be2917..9242c63 100644 --- a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0AuthenticationFilter.java +++ b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0AuthenticationFilter.java @@ -46,9 +46,9 @@ protected void doFilterInternal( return; } - HttpRequestInfo requestInfo = extractRequestInfo(request); + HttpRequestInfo requestInfo = extractRequestInfo(request, headers); - AuthenticationContext ctx = authClient.verifyRequest(headers, requestInfo); + AuthenticationContext ctx = authClient.verifyRequest(requestInfo); Auth0AuthenticationToken authentication = new Auth0AuthenticationToken(ctx); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); @@ -116,9 +116,10 @@ Map extractHeaders(HttpServletRequest request) return headers; } - HttpRequestInfo extractRequestInfo(HttpServletRequest request) { + HttpRequestInfo extractRequestInfo(HttpServletRequest request, Map headers) throws BaseAuthException { String htu = buildHtu(request); - return new HttpRequestInfo(request.getMethod(), htu, null); + + return new HttpRequestInfo(request.getMethod(), htu, headers); } static String buildHtu(HttpServletRequest request) { 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 e634fdc..79be44f 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 @@ -51,7 +51,7 @@ public AuthOptions authOptions(Auth0Properties properties) { * @param options the AuthOptions configuration for creating the client * @return AuthClient instance configured with the specified options * @see AuthClient#from(AuthOptions) - * @see AuthClient#verifyRequest(java.util.Map, com.auth0.models.HttpRequestInfo) + * @see AuthClient#verifyRequest(com.auth0.models.HttpRequestInfo) */ @Bean @ConditionalOnMissingBean diff --git a/auth0-springboot-api/src/test/java/com/auth0/spring/boot/Auth0AuthenticationFilterTest.java b/auth0-springboot-api/src/test/java/com/auth0/spring/boot/Auth0AuthenticationFilterTest.java index 96471cf..086fb03 100644 --- a/auth0-springboot-api/src/test/java/com/auth0/spring/boot/Auth0AuthenticationFilterTest.java +++ b/auth0-springboot-api/src/test/java/com/auth0/spring/boot/Auth0AuthenticationFilterTest.java @@ -2,6 +2,7 @@ import com.auth0.AuthClient; import com.auth0.enums.DPoPMode; +import com.auth0.exception.BaseAuthException; import com.auth0.exception.MissingAuthorizationException; import com.auth0.models.AuthenticationContext; import com.auth0.models.HttpRequestInfo; @@ -18,9 +19,12 @@ import org.springframework.security.core.context.SecurityContextHolder; import java.util.Enumeration; +import java.util.HashMap; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -191,14 +195,16 @@ void buildHtu_shouldNormalizeSchemeAndHost_toLowerCase() { @Test @DisplayName("Should create HttpRequestInfo with GET method and built HTU") - void extractRequestInfo_shouldCreateHttpRequestInfo_withGetMethod() { + void extractRequestInfo_shouldCreateHttpRequestInfo_withGetMethod() throws BaseAuthException { request.setMethod("GET"); request.setScheme("https"); request.setServerName("api.example.com"); request.setServerPort(443); request.setRequestURI("/api/users"); - HttpRequestInfo requestInfo = filter.extractRequestInfo(request); + Map headers = new HashMap<>(); + + HttpRequestInfo requestInfo = filter.extractRequestInfo(request, headers); assertNotNull(requestInfo); assertEquals("GET", requestInfo.getHttpMethod()); @@ -217,17 +223,15 @@ void doFilterInternal_shouldAuthenticateSuccessfully_withValidBearerToken() thro AuthenticationContext mockContext = org.mockito.Mockito.mock(AuthenticationContext.class); when(authClient.verifyRequest( - org.mockito.ArgumentMatchers.anyMap(), - org.mockito.ArgumentMatchers.any(HttpRequestInfo.class) + any(HttpRequestInfo.class) )).thenReturn(mockContext); filter.doFilterInternal(request, response, filterChain); - org.mockito.Mockito.verify(authClient).verifyRequest( - org.mockito.ArgumentMatchers.anyMap(), - org.mockito.ArgumentMatchers.any(HttpRequestInfo.class) + verify(authClient).verifyRequest( + any(HttpRequestInfo.class) ); - org.mockito.Mockito.verify(filterChain).doFilter(request, response); + verify(filterChain).doFilter(request, response); assertNotNull(SecurityContextHolder.getContext().getAuthentication()); assertTrue(SecurityContextHolder.getContext().getAuthentication() instanceof Auth0AuthenticationToken); } @@ -244,14 +248,11 @@ void doFilterInternal_shouldAuthenticateSuccessfully_withValidDpopToken() throws request.addHeader("DPoP", "dpop_proof_jwt"); AuthenticationContext mockContext = org.mockito.Mockito.mock(AuthenticationContext.class); - when(authClient.verifyRequest( - org.mockito.ArgumentMatchers.anyMap(), - org.mockito.ArgumentMatchers.any(HttpRequestInfo.class) - )).thenReturn(mockContext); + when(authClient.verifyRequest(any(HttpRequestInfo.class))).thenReturn(mockContext); filter.doFilterInternal(request, response, filterChain); - org.mockito.Mockito.verify(filterChain).doFilter(request, response); + verify(filterChain).doFilter(request, response); assertNotNull(SecurityContextHolder.getContext().getAuthentication()); } @@ -267,7 +268,7 @@ void doFilterInternal_shouldReturn200_whenAuthorizationHeaderMissing() throws Ex filter.doFilterInternal(request, response, filterChain); assertEquals(200, response.getStatus()); - org.mockito.Mockito.verify(filterChain).doFilter(request, response); + verify(filterChain).doFilter(request, response); assertNull(SecurityContextHolder.getContext().getAuthentication()); } @@ -281,15 +282,12 @@ void doFilterInternal_shouldReturn401AndClearContext_withInvalidToken() throws E request.setRequestURI("/api/users"); request.addHeader("Authorization", "Bearer invalid_token"); - when(authClient.verifyRequest( - org.mockito.ArgumentMatchers.anyMap(), - org.mockito.ArgumentMatchers.any(HttpRequestInfo.class) - )).thenThrow(new com.auth0.exception.VerifyAccessTokenException("Invalid JWT signature")); + when(authClient.verifyRequest(any(HttpRequestInfo.class))).thenThrow(new com.auth0.exception.VerifyAccessTokenException("Invalid JWT signature")); filter.doFilterInternal(request, response, filterChain); assertEquals(401, response.getStatus()); - org.mockito.Mockito.verify(filterChain, org.mockito.Mockito.never()).doFilter(request, response); + verify(filterChain, org.mockito.Mockito.never()).doFilter(request, response); assertNull(SecurityContextHolder.getContext().getAuthentication()); } @@ -303,15 +301,12 @@ void doFilterInternal_shouldReturn403_withInsufficientScope() throws Exception { request.setRequestURI("/api/admin"); request.addHeader("Authorization", "Bearer valid_token"); - when(authClient.verifyRequest( - org.mockito.ArgumentMatchers.anyMap(), - org.mockito.ArgumentMatchers.any(HttpRequestInfo.class) - )).thenThrow(new com.auth0.exception.InsufficientScopeException("Insufficient scope")); + when(authClient.verifyRequest(any(HttpRequestInfo.class))).thenThrow(new com.auth0.exception.InsufficientScopeException("Insufficient scope")); filter.doFilterInternal(request, response, filterChain); assertEquals(403, response.getStatus()); - org.mockito.Mockito.verify(filterChain, org.mockito.Mockito.never()).doFilter(request, response); + verify(filterChain, org.mockito.Mockito.never()).doFilter(request, response); assertNull(SecurityContextHolder.getContext().getAuthentication()); } @@ -333,10 +328,7 @@ void doFilterInternal_shouldAddWwwAuthenticateHeader_whenPresentInException() th new com.auth0.exception.VerifyAccessTokenException("Token expired"); exception.addHeader("WWW-Authenticate", "Bearer realm=\"api\", error=\"invalid_token\""); - when(authClient.verifyRequest( - org.mockito.ArgumentMatchers.anyMap(), - org.mockito.ArgumentMatchers.any(HttpRequestInfo.class) - )).thenThrow(exception); + when(authClient.verifyRequest(any(HttpRequestInfo.class))).thenThrow(exception); filter.doFilterInternal(request, response, filterChain); @@ -359,10 +351,7 @@ void doFilterInternal_shouldNotAddWwwAuthenticateHeader_whenNotPresentInExceptio com.auth0.exception.VerifyAccessTokenException exception = new com.auth0.exception.VerifyAccessTokenException("Malformed token"); - when(authClient.verifyRequest( - org.mockito.ArgumentMatchers.anyMap(), - org.mockito.ArgumentMatchers.any(HttpRequestInfo.class) - )).thenThrow(exception); + when(authClient.verifyRequest(any(HttpRequestInfo.class))).thenThrow(exception); filter.doFilterInternal(request, response, filterChain); @@ -383,10 +372,7 @@ void doFilterInternal_shouldSetAuthenticationDetails_fromRequest() throws Except request.addHeader("Authorization", "Bearer valid_token"); AuthenticationContext mockContext = org.mockito.Mockito.mock(AuthenticationContext.class); - when(authClient.verifyRequest( - org.mockito.ArgumentMatchers.anyMap(), - org.mockito.ArgumentMatchers.any(HttpRequestInfo.class) - )).thenReturn(mockContext); + when(authClient.verifyRequest(any(HttpRequestInfo.class))).thenReturn(mockContext); filter.doFilterInternal(request, response, filterChain); @@ -414,10 +400,7 @@ void doFilterInternal_shouldHandleDpopValidationException_withProperStatus() thr new com.auth0.exception.InvalidDpopProofException("Invalid DPoP proof"); exception.addHeader("WWW-Authenticate", "DPoP error=\"invalid_dpop_proof\""); - when(authClient.verifyRequest( - org.mockito.ArgumentMatchers.anyMap(), - org.mockito.ArgumentMatchers.any(HttpRequestInfo.class) - )).thenThrow(exception); + when(authClient.verifyRequest(any(HttpRequestInfo.class))).thenThrow(exception); filter.doFilterInternal(request, response, filterChain); From 264a291bb0f1e94caa8a5a87ae3c627c0a03ba5a Mon Sep 17 00:00:00 2001 From: tanya732 Date: Tue, 24 Feb 2026 03:15:28 +0530 Subject: [PATCH 2/9] Feat: Added MCD Support --- .../main/java/com/auth0/DomainResolver.java | 44 ++++ .../main/java/com/auth0/cache/AuthCache.java | 66 +++++ .../com/auth0/cache/InMemoryAuthCache.java | 150 +++++++++++ .../com/auth0/examples/Auth0ApiExample.java | 15 +- .../java/com/auth0/models/AuthOptions.java | 213 +++++++++++++++- .../com/auth0/models/HttpRequestInfo.java | 3 +- .../java/com/auth0/models/OidcMetadata.java | 44 ++++ .../java/com/auth0/models/RequestContext.java | 120 +++++++++ .../com/auth0/validators/JWTValidator.java | 233 ++++++++++++++++-- .../validators/OidcDiscoveryFetcher.java | 156 ++++++++++++ .../com/auth0/AbstractAuthenticationTest.java | 59 ++++- .../auth0/AllowedDPoPAuthenticationTest.java | 114 ++++++--- .../auth0/AuthenticationOrchestratorTest.java | 3 +- .../auth0/DisabledDPoPAuthenticationTest.java | 43 ++-- .../auth0/RequiredDPoPAuthenticationTest.java | 49 +++- .../auth0/cache/InMemoryAuthCacheTest.java | 204 +++++++++++++++ .../com/auth0/models/AuthOptionsTest.java | 231 +++++++++++++++-- .../com/auth0/models/OidcMetadataTest.java | 26 ++ .../auth0/validators/JWTValidatorTest.java | 17 +- .../validators/OidcDiscoveryFetcherTest.java | 158 ++++++++++++ .../playground/McdDomainResolverExample.java | 107 ++++++++ .../auth0/playground/ProfileController.java | 29 +++ .../com/auth0/playground/SecurityConfig.java | 7 +- .../spring/boot/Auth0AutoConfiguration.java | 69 +++++- .../spring/boot/Auth0DomainResolver.java | 69 ++++++ .../auth0/spring/boot/Auth0Properties.java | 167 ++++++++++++- .../spring/boot/Auth0RequestContext.java | 76 ++++++ .../boot/Auth0AutoConfigurationTest.java | 168 ++++++++++++- .../spring/boot/Auth0PropertiesTest.java | 66 ++++- 29 files changed, 2557 insertions(+), 149 deletions(-) create mode 100644 auth0-api-java/src/main/java/com/auth0/DomainResolver.java create mode 100644 auth0-api-java/src/main/java/com/auth0/cache/AuthCache.java create mode 100644 auth0-api-java/src/main/java/com/auth0/cache/InMemoryAuthCache.java create mode 100644 auth0-api-java/src/main/java/com/auth0/models/OidcMetadata.java create mode 100644 auth0-api-java/src/main/java/com/auth0/models/RequestContext.java create mode 100644 auth0-api-java/src/main/java/com/auth0/validators/OidcDiscoveryFetcher.java create mode 100644 auth0-api-java/src/test/java/com/auth0/cache/InMemoryAuthCacheTest.java create mode 100644 auth0-api-java/src/test/java/com/auth0/models/OidcMetadataTest.java create mode 100644 auth0-api-java/src/test/java/com/auth0/validators/OidcDiscoveryFetcherTest.java create mode 100644 auth0-springboot-api-playground/src/main/java/com/auth0/playground/McdDomainResolverExample.java create mode 100644 auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0DomainResolver.java create mode 100644 auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0RequestContext.java diff --git a/auth0-api-java/src/main/java/com/auth0/DomainResolver.java b/auth0-api-java/src/main/java/com/auth0/DomainResolver.java new file mode 100644 index 0000000..e9ebf67 --- /dev/null +++ b/auth0-api-java/src/main/java/com/auth0/DomainResolver.java @@ -0,0 +1,44 @@ +package com.auth0; + +import com.auth0.models.RequestContext; + +import java.util.List; + +/** + * Functional interface for dynamically resolving allowed issuer domains + * based on the incoming request context. + *

+ * Used in multi-custom-domain (MCD) scenarios where the set of valid issuers + * cannot be determined statically at configuration time. The resolver receives + * a {@link RequestContext} containing the request URL, headers, and the + * unverified token issuer, and returns the list of allowed issuer domains. + *

+ * + *
{@code
+ * AuthOptions options = new AuthOptions.Builder()
+ *         .domainsResolver(context -> {
+ *             String host = context.getHeaders().get("host");
+ *             return lookupIssuersForHost(host);
+ *         })
+ *         .audience("https://api.example.com")
+ *         .build();
+ * }
+ * + * @see RequestContext + * @see com.auth0.models.AuthOptions.Builder#domainsResolver(DomainResolver) + */ +@FunctionalInterface +public interface DomainResolver { + + /** + * Resolves the list of allowed issuer domains for the given request context. + * + * @param context the request context containing URL, headers, and unverified + * token issuer + * @return a list of allowed issuer domain strings (e.g., + * {@code ["https://tenant1.auth0.com/"]}); + * may return {@code null} or an empty list if no domains can be + * resolved + */ + List resolveDomains(RequestContext context); +} diff --git a/auth0-api-java/src/main/java/com/auth0/cache/AuthCache.java b/auth0-api-java/src/main/java/com/auth0/cache/AuthCache.java new file mode 100644 index 0000000..df7fafd --- /dev/null +++ b/auth0-api-java/src/main/java/com/auth0/cache/AuthCache.java @@ -0,0 +1,66 @@ +package com.auth0.cache; + +/** + * Cache abstraction for storing authentication-related data such as + * OIDC discovery metadata and JWKS providers. + *

+ * The SDK ships with a default in-memory LRU implementation + * ({@link InMemoryAuthCache}). Developers can implement this interface + * to plug in distributed cache backends (e.g., Redis, Memcached) without + * breaking changes to the SDK's public API. + *

+ * + *

Unified cache with key prefixes

+ *

+ * A single {@code AuthCache} instance can serve as a unified cache + * for both discovery metadata and JWKS providers by using key prefixes: + *

+ *
    + *
  • {@code discovery:{issuerUrl}} — OIDC discovery metadata
  • + *
  • {@code jwks:{jwksUri}} — JwkProvider instances
  • + *
+ * + *

Thread Safety

+ *

+ * All implementations must be thread-safe. + *

+ * + * @param the type of cached values + */ +public interface AuthCache { + + /** + * Retrieves a value from the cache. + * + * @param key the cache key + * @return the cached value, or {@code null} if not present or expired + */ + V get(String key); + + /** + * Stores a value in the cache with the cache's default TTL. + * + * @param key the cache key + * @param value the value to cache + */ + void put(String key, V value); + + /** + * Removes a specific entry from the cache. + * + * @param key the cache key to remove + */ + void remove(String key); + + /** + * Removes all entries from the cache. + */ + void clear(); + + /** + * Returns the number of entries currently in the cache. + * + * @return the cache size + */ + int size(); +} diff --git a/auth0-api-java/src/main/java/com/auth0/cache/InMemoryAuthCache.java b/auth0-api-java/src/main/java/com/auth0/cache/InMemoryAuthCache.java new file mode 100644 index 0000000..1a56b05 --- /dev/null +++ b/auth0-api-java/src/main/java/com/auth0/cache/InMemoryAuthCache.java @@ -0,0 +1,150 @@ +package com.auth0.cache; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +/** + * Thread-safe, in-memory LRU cache with TTL expiration. + *

+ * This is the default {@link AuthCache} implementation shipped with the SDK. + * It uses a {@link LinkedHashMap} in access-order mode for LRU eviction and + * per-entry timestamps for TTL enforcement. + *

+ * + *

Configuration

+ *
    + *
  • maxEntries — maximum number of entries; LRU eviction when exceeded + * (default: 100)
  • + *
  • ttlSeconds — time-to-live per entry in seconds (default: 600 = 10 + * minutes)
  • + *
+ * + *

Thread Safety

+ *

+ * Uses a {@link ReentrantReadWriteLock} so concurrent reads do not block each + * other, + * while writes acquire exclusive access. + *

+ * + * @param the type of cached values + */ +public class InMemoryAuthCache implements AuthCache { + + /** Default maximum number of entries. */ + public static final int DEFAULT_MAX_ENTRIES = 100; + + public static final long DEFAULT_TTL_SECONDS = 600; + + private final long ttlMillis; + private final LinkedHashMap> store; + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + + /** + * Creates a cache with default settings (100 entries, 10-minute TTL). + */ + public InMemoryAuthCache() { + this(DEFAULT_MAX_ENTRIES, DEFAULT_TTL_SECONDS); + } + + /** + * Creates a cache with the specified limits. + * + * @param maxEntries maximum number of entries before LRU eviction + * @param ttlSeconds time-to-live per entry in seconds + */ + public InMemoryAuthCache(int maxEntries, long ttlSeconds) { + if (maxEntries <= 0) { + throw new IllegalArgumentException("maxEntries must be positive"); + } + if (ttlSeconds < 0) { + throw new IllegalArgumentException("ttlSeconds must not be negative"); + } + this.ttlMillis = ttlSeconds * 1000; + // accessOrder=true makes LinkedHashMap maintain LRU order + this.store = new LinkedHashMap>(maxEntries, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry> eldest) { + return size() > maxEntries; + } + }; + } + + @Override + public V get(String key) { + lock.writeLock().lock(); + try { + CacheEntry entry = store.get(key); + if (entry == null) { + return null; + } + if (isExpired(entry)) { + store.remove(key); + return null; + } + return entry.value; + } finally { + lock.writeLock().unlock(); + } + } + + @Override + public void put(String key, V value) { + lock.writeLock().lock(); + try { + store.put(key, new CacheEntry<>(value, System.currentTimeMillis())); + } finally { + lock.writeLock().unlock(); + } + } + + @Override + public void remove(String key) { + lock.writeLock().lock(); + try { + store.remove(key); + } finally { + lock.writeLock().unlock(); + } + } + + @Override + public void clear() { + lock.writeLock().lock(); + try { + store.clear(); + } finally { + lock.writeLock().unlock(); + } + } + + @Override + public int size() { + lock.readLock().lock(); + try { + return store.size(); + } finally { + lock.readLock().unlock(); + } + } + + private boolean isExpired(CacheEntry entry) { + if (ttlMillis == 0) { + return false; // TTL of 0 means no expiration + } + return (System.currentTimeMillis() - entry.createdAt) > ttlMillis; + } + + /** + * Internal wrapper that pairs a value with its insertion timestamp. + */ + private static final class CacheEntry { + final V value; + final long createdAt; + + CacheEntry(V value, long createdAt) { + this.value = value; + this.createdAt = createdAt; + } + } +} diff --git a/auth0-api-java/src/main/java/com/auth0/examples/Auth0ApiExample.java b/auth0-api-java/src/main/java/com/auth0/examples/Auth0ApiExample.java index 0e1dfa8..1ba0e2e 100644 --- a/auth0-api-java/src/main/java/com/auth0/examples/Auth0ApiExample.java +++ b/auth0-api-java/src/main/java/com/auth0/examples/Auth0ApiExample.java @@ -1,11 +1,13 @@ package com.auth0.examples; import com.auth0.AuthClient; +import com.auth0.DomainResolver; import com.auth0.enums.DPoPMode; import com.auth0.exception.BaseAuthException; import com.auth0.models.AuthOptions; import com.auth0.models.AuthenticationContext; import com.auth0.models.HttpRequestInfo; +import com.auth0.models.RequestContext; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; @@ -13,7 +15,10 @@ import java.io.IOException; import java.io.OutputStream; import java.net.InetSocketAddress; +import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; public class Auth0ApiExample { @@ -88,21 +93,18 @@ public void handle(HttpExchange exchange) throws IOException { headers.put("authorization", auth); - String dpopHeader = exchange.getRequestHeaders().getFirst("DPoP"); - if(dpopHeader != null) { + if (dpopHeader != null) { headers.put("DPoP", dpopHeader); } - // Build HttpRequestInfo (needed for DPoP htm + htu validation) HttpRequestInfo requestInfo = null; try { requestInfo = new HttpRequestInfo( exchange.getRequestMethod(), - "http://localhost:8000" + exchange.getRequestURI().toString(), headers - ); + "http://localhost:8000" + exchange.getRequestURI().toString(), headers); } catch (BaseAuthException e) { throw new RuntimeException(e); } @@ -110,8 +112,7 @@ public void handle(HttpExchange exchange) throws IOException { System.out.println("Incoming request to " + requestInfo.toString()); try { - AuthenticationContext claims = - authClient.verifyRequest(requestInfo); + AuthenticationContext claims = authClient.verifyRequest(requestInfo); String user = (String) claims.getClaims().get("sub"); 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 552c0b6..5771701 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 @@ -1,43 +1,168 @@ package com.auth0.models; +import com.auth0.DomainResolver; +import com.auth0.cache.AuthCache; import com.auth0.enums.DPoPMode; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + public class AuthOptions { private final String domain; + private final List domains; + private final DomainResolver domainsResolver; private final String audience; private final DPoPMode dpopMode; private final long dpopIatOffsetSeconds; private final long dpopIatLeewaySeconds; + private final int cacheMaxEntries; + private final long cacheTtlSeconds; + private final AuthCache cache; + public AuthOptions(Builder builder) { this.domain = builder.domain; + this.domains = builder.domains != null + ? Collections.unmodifiableList(new ArrayList<>(builder.domains)) + : null; + this.domainsResolver = builder.domainsResolver; this.audience = builder.audience; this.dpopMode = builder.dpopMode; this.dpopIatOffsetSeconds = builder.dpopIatOffsetSeconds; this.dpopIatLeewaySeconds = builder.dpopIatLeewaySeconds; + + this.cacheMaxEntries = builder.cacheMaxEntries; + this.cacheTtlSeconds = builder.cacheTtlSeconds; + this.cache = builder.cache; + } + + public String getDomain() { + return domain; + } + + /** + * Returns the static list of allowed issuer domains, or {@code null} if not + * configured. + * + * @return unmodifiable list of domain strings, or {@code null} + */ + public List getDomains() { + return domains; + } + + /** + * Returns the dynamic domain resolver, or {@code null} if not configured. + * + * @return the {@link DomainResolver}, or {@code null} + */ + public DomainResolver getDomainsResolver() { + return domainsResolver; + } + + public String getAudience() { + return audience; } - public String getDomain() { return domain; } - public String getAudience() { return audience; } - public DPoPMode getDpopMode() { return dpopMode; } - public long getDpopIatOffsetSeconds() { return dpopIatOffsetSeconds; } - public long getDpopIatLeewaySeconds() { return dpopIatLeewaySeconds; } + public DPoPMode getDpopMode() { + return dpopMode; + } + + public long getDpopIatOffsetSeconds() { + return dpopIatOffsetSeconds; + } + + public long getDpopIatLeewaySeconds() { + return dpopIatLeewaySeconds; + } + + /** + * Returns the maximum number of entries for the in-memory cache. + * Applies when no custom {@link AuthCache} is provided. + * + * @return the max entries limit (default 100) + */ + public int getCacheMaxEntries() { + return cacheMaxEntries; + } + + /** + * Returns the TTL in seconds for cached entries. + * Applies when no custom {@link AuthCache} is provided. + * + * @return the TTL in seconds (default 600 = 10 minutes) + */ + public long getCacheTtlSeconds() { + return cacheTtlSeconds; + } + + /** + * Returns the custom cache implementation, or {@code null} if the default + * in-memory cache should be used. + *

+ * The unified cache stores both OIDC discovery metadata and JWKS providers + * using key prefixes ({@code discovery:} and {@code jwks:}). + *

+ * + * @return the custom cache, or {@code null} + */ + public AuthCache getCache() { + return cache; + } public static class Builder { private String domain; + private List domains; + private DomainResolver domainsResolver; private String audience; private DPoPMode dpopMode = DPoPMode.ALLOWED; private long dpopIatOffsetSeconds = 300; private long dpopIatLeewaySeconds = 30; + private int cacheMaxEntries = 100; + private long cacheTtlSeconds = 600; + private AuthCache cache; + public Builder domain(String domain) { this.domain = domain; return this; } + /** + * Sets a static list of allowed issuer domains for multi-custom-domain support. + *

+ * Cannot be used together with {@link #domainsResolver(DomainResolver)}. + * Can coexist with {@link #domain(String)} for Auth for Agents scenarios, + * in which case this list takes precedence for token validation. + *

+ * + * @param domains list of allowed issuer domain strings + * @return this builder + */ + public Builder domains(List domains) { + this.domains = domains; + return this; + } + + /** + * Sets a dynamic resolver for allowed issuer domains. + *

+ * Cannot be used together with {@link #domains(List)}. + * The resolver receives a {@link RequestContext} with the request URL, + * headers, and unverified token issuer to make routing decisions. + *

+ * + * @param domainsResolver the resolver function + * @return this builder + */ + public Builder domainsResolver(DomainResolver domainsResolver) { + this.domainsResolver = domainsResolver; + return this; + } + public Builder audience(String audience) { this.audience = audience; return this; @@ -64,10 +189,84 @@ public Builder dpopIatLeewaySeconds(long iatLeeway) { return this; } + /** + * Sets the maximum number of entries for the default in-memory cache. + * Both OIDC discovery and JWKS entries count against this limit. + * Default: 100. + *

+ * Ignored if a custom {@link AuthCache} is provided via + * {@link #cache(AuthCache)}. + *

+ * + * @param maxEntries the maximum number of cache entries (must be positive) + * @return this builder + */ + public Builder cacheMaxEntries(int maxEntries) { + if (maxEntries <= 0) { + throw new IllegalArgumentException("cacheMaxEntries must be positive"); + } + this.cacheMaxEntries = maxEntries; + return this; + } + + /** + * Sets the TTL (time-to-live) in seconds for cached entries. + * Default: 600 (10 minutes). + *

+ * Ignored if a custom {@link AuthCache} is provided via + * {@link #cache(AuthCache)}. + *

+ * + * @param ttlSeconds the TTL in seconds (must not be negative) + * @return this builder + */ + public Builder cacheTtlSeconds(long ttlSeconds) { + if (ttlSeconds < 0) { + throw new IllegalArgumentException("cacheTtlSeconds must not be negative"); + } + this.cacheTtlSeconds = ttlSeconds; + return this; + } + + /** + * Sets a custom cache implementation for both OIDC discovery metadata + * and JWKS providers. + *

+ * The cache uses a unified key-prefix scheme: + *

    + *
  • {@code discovery:{issuerUrl}} — for OIDC metadata
  • + *
  • {@code jwks:{jwksUri}} — for JwkProvider instances
  • + *
+ *

+ * When set, {@link #cacheMaxEntries(int)} and {@link #cacheTtlSeconds(long)} + * are ignored — the custom implementation controls its own eviction and TTL. + *

+ * + * @param cache the custom cache implementation + * @return this builder + */ + public Builder cache(AuthCache cache) { + this.cache = cache; + return this; + } + public AuthOptions build() { - if (domain == null || domain.isEmpty()) { - throw new IllegalArgumentException("Domain must not be null or empty"); + // Mutual exclusivity: domains and domainsResolver cannot both be set + if (domains != null && !domains.isEmpty() && domainsResolver != null) { + throw new IllegalArgumentException( + "Cannot configure both 'domains' and 'domainsResolver'. Use one or the other."); } + + // At least one domain source must be provided + boolean hasDomain = domain != null && !domain.isEmpty(); + boolean hasDomains = domains != null && !domains.isEmpty(); + boolean hasResolver = domainsResolver != null; + + if (!hasDomain && !hasDomains && !hasResolver) { + throw new IllegalArgumentException( + "At least one of 'domain', 'domains', or 'domainsResolver' must be configured."); + } + if (audience == null || audience.isEmpty()) { throw new IllegalArgumentException("Audience must not be null or empty"); } diff --git a/auth0-api-java/src/main/java/com/auth0/models/HttpRequestInfo.java b/auth0-api-java/src/main/java/com/auth0/models/HttpRequestInfo.java index c62dae4..a2ab878 100644 --- a/auth0-api-java/src/main/java/com/auth0/models/HttpRequestInfo.java +++ b/auth0-api-java/src/main/java/com/auth0/models/HttpRequestInfo.java @@ -1,5 +1,6 @@ package com.auth0.models; +import com.auth0.exception.BaseAuthException; import com.auth0.exception.InvalidRequestException; import org.apache.http.util.Asserts; @@ -14,7 +15,7 @@ public class HttpRequestInfo { public HttpRequestInfo(String httpMethod, String httpUrl, Map headers) throws InvalidRequestException { Asserts.notNull(headers, "Headers map cannot be null"); - this.httpMethod = httpMethod != null ? httpMethod.toUpperCase() : null; + this.httpMethod = httpMethod.toUpperCase(); this.httpUrl = httpUrl; this.headers = normalize(headers); } diff --git a/auth0-api-java/src/main/java/com/auth0/models/OidcMetadata.java b/auth0-api-java/src/main/java/com/auth0/models/OidcMetadata.java new file mode 100644 index 0000000..76a0083 --- /dev/null +++ b/auth0-api-java/src/main/java/com/auth0/models/OidcMetadata.java @@ -0,0 +1,44 @@ +package com.auth0.models; + +/** + * Represents the relevant fields from an OIDC Discovery document + * ({@code .well-known/openid-configuration}). + *

+ * Only the fields required for JWT validation are extracted: + *

    + *
  • {@code issuer} — the canonical issuer identifier (used for + * double-validation)
  • + *
  • {@code jwks_uri} — the URL of the JSON Web Key Set (used to fetch signing + * keys)
  • + *
+ */ +public class OidcMetadata { + + private final String issuer; + private final String jwksUri; + + public OidcMetadata(String issuer, String jwksUri) { + this.issuer = issuer; + this.jwksUri = jwksUri; + } + + /** + * Returns the {@code issuer} field from the discovery document. + * This must exactly match the token's {@code iss} claim (Requirement 4). + * + * @return the issuer URL + */ + public String getIssuer() { + return issuer; + } + + /** + * Returns the {@code jwks_uri} field from the discovery document. + * This is the URL from which the JWKS (signing keys) should be fetched. + * + * @return the JWKS URI + */ + public String getJwksUri() { + return jwksUri; + } +} diff --git a/auth0-api-java/src/main/java/com/auth0/models/RequestContext.java b/auth0-api-java/src/main/java/com/auth0/models/RequestContext.java new file mode 100644 index 0000000..0f0f74b --- /dev/null +++ b/auth0-api-java/src/main/java/com/auth0/models/RequestContext.java @@ -0,0 +1,120 @@ +package com.auth0.models; + +import java.util.Collections; +import java.util.Map; + +/** + * Provides request context to the {@link com.auth0.DomainResolver} for dynamic + * issuer resolution. + *

+ * This is the "silver platter" passed to the developer's resolver function, + * containing all the + * data needed to make a routing decision in multi-custom-domain scenarios. + *

+ * + *
    + *
  • {@code url} — The URL the API request was made to
  • + *
  • {@code headers} — Relevant request headers (e.g., Host, + * X-Forwarded-Host)
  • + *
  • {@code tokenIssuer} — The unverified {@code iss} claim extracted + * from the incoming JWT
  • + *
+ */ +public class RequestContext { + + private final String url; + private final Map headers; + private final String tokenIssuer; + + private RequestContext(Builder builder) { + this.url = builder.url; + this.headers = builder.headers != null + ? Collections.unmodifiableMap(builder.headers) + : Collections.emptyMap(); + this.tokenIssuer = builder.tokenIssuer; + } + + /** + * Returns the URL the API request was made to. + * + * @return the request URL, or {@code null} if not available + */ + public String getUrl() { + return url; + } + + /** + * Returns an unmodifiable map of relevant request headers. + * + * @return the request headers; never {@code null} + */ + public Map getHeaders() { + return headers; + } + + /** + * Returns the unverified {@code iss} claim from the incoming JWT. + *

+ * Warning: This value has NOT been verified yet. It is provided so the + * resolver + * can use it as a hint for routing decisions, but it must not be trusted on its + * own. + *

+ * + * @return the unverified token issuer, or {@code null} if not available + */ + public String getTokenIssuer() { + return tokenIssuer; + } + + /** + * Builder for {@link RequestContext}. + */ + public static class Builder { + private String url; + private Map headers; + private String tokenIssuer; + + /** + * Sets the URL the API request was made to. + * + * @param url the request URL + * @return this builder + */ + public Builder url(String url) { + this.url = url; + return this; + } + + /** + * Sets the relevant request headers. + * + * @param headers a map of header names to values + * @return this builder + */ + public Builder headers(Map headers) { + this.headers = headers; + return this; + } + + /** + * Sets the unverified {@code iss} claim from the token. + * + * @param tokenIssuer the unverified issuer claim + * @return this builder + */ + public Builder tokenIssuer(String tokenIssuer) { + this.tokenIssuer = tokenIssuer; + return this; + } + + /** + * Builds an immutable {@link RequestContext}. + * + * @return the request context + */ + public RequestContext build() { + return new RequestContext(this); + } + } +} 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 index 0189e8d..052ced4 100644 --- a/auth0-api-java/src/main/java/com/auth0/validators/JWTValidator.java +++ b/auth0-api-java/src/main/java/com/auth0/validators/JWTValidator.java @@ -1,22 +1,29 @@ package com.auth0.validators; +import com.auth0.cache.AuthCache; +import com.auth0.cache.InMemoryAuthCache; import com.auth0.exception.BaseAuthException; import com.auth0.exception.MissingRequiredArgumentException; import com.auth0.exception.VerifyAccessTokenException; import com.auth0.jwt.JWT; import com.auth0.jwt.JWTVerifier; import com.auth0.models.AuthOptions; +import com.auth0.models.OidcMetadata; 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 java.net.MalformedURLException; +import java.net.URL; import java.security.interfaces.RSAPublicKey; -import java.util.Map; - -import static com.auth0.jwt.JWT.require; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; /** * JWT Validator for Auth0 tokens @@ -27,11 +34,16 @@ */ public 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; /** * Creates a JWT validator with domain and audience. + * Uses the default in-memory LRU cache configured via {@link AuthOptions}. * * @param authOptions Authentication options containing domain and audience */ @@ -41,13 +53,18 @@ public JWTValidator(AuthOptions authOptions) { } this.authOptions = authOptions; - this.jwkProvider = new UrlJwkProvider(authOptions.getDomain()); + this.jwkProvider = authOptions.getDomain() != null + ? new UrlJwkProvider(authOptions.getDomain()) + : null; + this.cache = resolveCache(authOptions); + this.discoveryFetcher = new OidcDiscoveryFetcher(this.cache); } /** - * Creates a JWT validator with domain and audience. + * 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 */ public JWTValidator(AuthOptions authOptions, JwkProvider jwkProvider) { if (authOptions == null) { @@ -58,6 +75,39 @@ public JWTValidator(AuthOptions authOptions, JwkProvider jwkProvider) { } this.authOptions = authOptions; this.jwkProvider = jwkProvider; + this.cache = resolveCache(authOptions); + this.discoveryFetcher = new OidcDiscoveryFetcher(this.cache); + } + + /** + * Creates a JWT validator with all dependencies injectable (primarily for + * 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) { + if (authOptions == null) { + throw new IllegalArgumentException("AuthOptions cannot be null"); + } + this.authOptions = authOptions; + this.jwkProvider = jwkProvider; + this.cache = resolveCache(authOptions); + this.discoveryFetcher = discoveryFetcher != null + ? discoveryFetcher + : new OidcDiscoveryFetcher(this.cache); + } + + /** + * Resolves the cache to use: custom from AuthOptions, or a new + * InMemoryAuthCache. + */ + private static AuthCache resolveCache(AuthOptions options) { + if (options.getCache() != null) { + return options.getCache(); + } + return new InMemoryAuthCache<>(options.getCacheMaxEntries(), options.getCacheTtlSeconds()); } /** @@ -74,14 +124,39 @@ public DecodedJWT validateToken(String token, HttpRequestInfo httpRequestInfo) t } try { - DecodedJWT decodedJWT = JWT.decode(token); - Jwk jwk = jwkProvider.get(decodedJWT.getKeyId()); - RSAPublicKey publicKey = (RSAPublicKey) jwk.getPublicKey(); - Algorithm algorithm = Algorithm.RSA256(publicKey, null); - JWTVerifier verifier = require(algorithm) - .withIssuer("https://" + authOptions.getDomain() + "/") + DecodedJWT unverifiedJwt = JWT.decode(token); + String alg = unverifiedJwt.getAlgorithm(); + String tokenIss = unverifiedJwt.getIssuer(); + + if (alg != null && alg.startsWith("HS")) { + throw new VerifyAccessTokenException("Symmetric algorithms are not supported"); + } + + List allowedDomains = resolveAllowedDomains(tokenIss, httpRequestInfo); + + // Normalize the token issuer and allowed domains for consistent comparison + String normalizedIss = normalizeToUrl(tokenIss); + if (!allowedDomains.contains(normalizedIss)) { + throw new VerifyAccessTokenException( + String.format("Token issuer '%s' is not in the allowed list: %s")); + } + + OidcMetadata discovery = performOidcDiscovery(tokenIss); + + if (!tokenIss.equals(discovery.getIssuer())) { + throw new VerifyAccessTokenException("Discovery metadata issuer does not match token issuer"); + } + + JwkProvider dynamicJwkProvider = getOrCreateJwkProvider(discovery.getJwksUri()); + + Jwk jwk = dynamicJwkProvider.get(unverifiedJwt.getKeyId()); + Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey(), null); + + JWTVerifier verifier = JWT.require(algorithm) + .withIssuer(tokenIss) .withAudience(authOptions.getAudience()) .build(); + return verifier.verify(token); } catch (Exception e) { @@ -92,9 +167,8 @@ public DecodedJWT validateToken(String token, HttpRequestInfo httpRequestInfo) t /** * Validates a JWT and ensures all required scopes are present. */ - public DecodedJWT validateTokenWithRequiredScopes(String token, HttpRequestInfo httpRequestInfo, String... requiredScopes) - throws BaseAuthException { - DecodedJWT jwt = validateToken(token,httpRequestInfo); + public DecodedJWT validateTokenWithRequiredScopes(String token, HttpRequestInfo httpRequestInfo, String... requiredScopes) throws BaseAuthException { + DecodedJWT jwt = validateToken(token, httpRequestInfo); try { ClaimValidator.checkRequiredScopes(jwt, requiredScopes); return jwt; @@ -106,8 +180,7 @@ public DecodedJWT validateTokenWithRequiredScopes(String token, HttpRequestInfo /** * Validates a JWT and ensures it has *any* of the provided scopes. */ - public DecodedJWT validateTokenWithAnyScope(String token, HttpRequestInfo httpRequestInfo, String... scopes) - throws BaseAuthException { + public DecodedJWT validateTokenWithAnyScope(String token, HttpRequestInfo httpRequestInfo, String... scopes) throws BaseAuthException { DecodedJWT jwt = validateToken(token, httpRequestInfo); try { ClaimValidator.checkAnyScope(jwt, scopes); @@ -120,8 +193,7 @@ public DecodedJWT validateTokenWithAnyScope(String token, HttpRequestInfo httpRe /** * Validates a JWT and ensures a claim equals the expected value. */ - public DecodedJWT validateTokenWithClaimEquals(String token, HttpRequestInfo httpRequestInfo, String claim, Object expected) - throws BaseAuthException { + public DecodedJWT validateTokenWithClaimEquals(String token, HttpRequestInfo httpRequestInfo, String claim, Object expected) throws BaseAuthException { DecodedJWT jwt = validateToken(token, httpRequestInfo); try { ClaimValidator.checkClaimEquals(jwt, claim, expected); @@ -134,8 +206,7 @@ public DecodedJWT validateTokenWithClaimEquals(String token, HttpRequestInfo htt /** * Validates a JWT and ensures a claim includes all expected values. */ - public DecodedJWT validateTokenWithClaimIncludes(String token, HttpRequestInfo httpRequestInfo, String claim, Object... expectedValues) - throws BaseAuthException { + public DecodedJWT validateTokenWithClaimIncludes(String token, HttpRequestInfo httpRequestInfo, String claim, Object... expectedValues) throws BaseAuthException { DecodedJWT jwt = validateToken(token, httpRequestInfo); try { ClaimValidator.checkClaimIncludes(jwt, claim, expectedValues); @@ -145,8 +216,7 @@ public DecodedJWT validateTokenWithClaimIncludes(String token, HttpRequestInfo h } } - public DecodedJWT validateTokenWithClaimIncludesAny(String token, HttpRequestInfo httpRequestInfo, String claim, Object... expectedValues) - throws BaseAuthException { + public DecodedJWT validateTokenWithClaimIncludesAny(String token, HttpRequestInfo httpRequestInfo, String claim, Object... expectedValues) throws BaseAuthException { DecodedJWT jwt = validateToken(token, httpRequestInfo); try { ClaimValidator.checkClaimIncludesAny(jwt, claim, expectedValues); @@ -156,7 +226,6 @@ public DecodedJWT validateTokenWithClaimIncludesAny(String token, HttpRequestInf } } - public DecodedJWT decodeToken(String token) throws BaseAuthException { try { return JWT.decode(token); @@ -166,7 +235,8 @@ public DecodedJWT decodeToken(String token) throws BaseAuthException { } private BaseAuthException wrapAsValidationException(Exception e) { - if (e instanceof BaseAuthException) return (BaseAuthException) e; + if (e instanceof BaseAuthException) + return (BaseAuthException) e; return new VerifyAccessTokenException("JWT claim validation failed"); } @@ -177,4 +247,119 @@ public AuthOptions getAuthOptions() { public JwkProvider getJwkProvider() { return jwkProvider; } + + /** + * Performs OIDC Discovery for the given issuer URL + *

+ * Fetches {@code GET https:///.well-known/openid-configuration}, + * caches the response per domain, and returns the parsed metadata. + *

+ * + * @param issuerUrl the token's {@code iss} claim + * @return the parsed OIDC discovery metadata + * @throws VerifyAccessTokenException if the discovery fetch or parse fails + */ + private OidcMetadata performOidcDiscovery(String issuerUrl) throws VerifyAccessTokenException { + return discoveryFetcher.fetch(issuerUrl); + } + + /** + * Returns a cached {@link JwkProvider} for the given JWKS URI, creating one + * if it does not yet exist + *

+ * Uses the {@code jwks-rsa} library's {@link JwkProviderBuilder} which provides + * built-in caching and rate-limiting. The provider cache is keyed by + * {@code jwksUri} so each distinct JWKS endpoint gets its own cached provider. + *

+ * + * @param jwksUri the JWKS URI extracted from OIDC Discovery metadata + * @return a JwkProvider that fetches keys from the given URI + * @throws VerifyAccessTokenException if the JWKS URI is malformed + */ + private JwkProvider getOrCreateJwkProvider(String jwksUri) throws VerifyAccessTokenException { + String cacheKey = JWKS_CACHE_PREFIX + jwksUri; + + Object cached = cache.get(cacheKey); + if (cached instanceof JwkProvider) { + return (JwkProvider) cached; + } + + try { + JwkProvider provider = new JwkProviderBuilder(new URL(jwksUri)).build(); + cache.put(cacheKey, provider); + return provider; + } catch (MalformedURLException e) { + throw new VerifyAccessTokenException( + String.format("Invalid JWKS URI '%s' from OIDC discovery", jwksUri), e); + } + } + + /** + * Resolves the list of allowed issuers based on the configured strategy. + * + *

+ * Priority order: + *

    + *
  1. Dynamic resolver ({@code domainsResolver}) — highest priority
  2. + *
  3. Static list ({@code domains})
  4. + *
  5. Legacy single domain ({@code domain}) — backward compatibility + * fallback
  6. + *
+ * + * @param tokenIss the unverified {@code iss} claim from the decoded JWT + * @param httpRequestInfo the HTTP request metadata (method, URL, headers) + * @return a list of normalized issuer URLs (e.g., + * {@code ["https://tenant.auth0.com/"]}) + */ + private List resolveAllowedDomains(String tokenIss, HttpRequestInfo httpRequestInfo) { + + if (authOptions.getDomainsResolver() != null) { + + RequestContext context = new RequestContext.Builder() + .url(httpRequestInfo.getHttpUrl()) + .headers(httpRequestInfo.getHeaders()) + .tokenIssuer(tokenIss) + .build(); + + // Call the user-provided resolver + List resolved = authOptions.getDomainsResolver().resolveDomains(context); + + return resolved != null + ? resolved.stream().map(this::normalizeToUrl).collect(Collectors.toList()) + : Collections.emptyList(); + } + + if (authOptions.getDomains() != null && !authOptions.getDomains().isEmpty()) { + return authOptions.getDomains().stream() + .map(this::normalizeToUrl) + .collect(Collectors.toList()); + } + + // If neither MCD option is used, fall back to the single 'domain' property. + String domain = authOptions.getDomain(); + if (domain != null && !domain.isEmpty()) { + return Collections.singletonList(normalizeToUrl(domain)); + } + + return Collections.emptyList(); + } + + /** + * Normalizes a domain string into a full HTTPS URL with a trailing slash. + * Ensures consistent comparison (e.g., {@code "tenant.auth0.com"} becomes + * {@code "https://tenant.auth0.com/"}). + * + * @param domain the raw domain or URL string + * @return the normalized URL, or {@code null} if input is {@code null} + */ + private String normalizeToUrl(String domain) { + if (domain == null) + return null; + + String url = domain.trim(); + if (!url.toLowerCase().startsWith("http")) { + url = "https://" + url; + } + return url.endsWith("/") ? url : url + "/"; + } } diff --git a/auth0-api-java/src/main/java/com/auth0/validators/OidcDiscoveryFetcher.java b/auth0-api-java/src/main/java/com/auth0/validators/OidcDiscoveryFetcher.java new file mode 100644 index 0000000..2cf73f4 --- /dev/null +++ b/auth0-api-java/src/main/java/com/auth0/validators/OidcDiscoveryFetcher.java @@ -0,0 +1,156 @@ +package com.auth0.validators; + +import com.auth0.cache.AuthCache; +import com.auth0.exception.VerifyAccessTokenException; +import com.auth0.models.OidcMetadata; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; + +import java.io.IOException; + +/** + * Fetches and caches OIDC Discovery metadata + * ({@code .well-known/openid-configuration}) + * from issuer domains. + *

+ * Implements OIDC Discovery with per-domain caching. + * Uses the unified {@link AuthCache} with the key prefix {@code discovery:} + * so discovery and JWKS entries coexist in a single cache. + *

+ *

+ * Thread-safe: delegates thread safety to the {@link AuthCache} implementation. + *

+ */ +class OidcDiscoveryFetcher { + + static final String CACHE_PREFIX = "discovery:"; + private static final String WELL_KNOWN_PATH = ".well-known/openid-configuration"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final AuthCache cache; + private final CloseableHttpClient httpClient; + + /** + * Creates a fetcher with the provided cache and the default HTTP client. + * + * @param cache the unified cache instance + */ + OidcDiscoveryFetcher(AuthCache cache) { + this(cache, HttpClients.createDefault()); + } + + /** + * Creates a fetcher with the provided cache and a custom HTTP client. + * + * @param cache the unified cache instance + * @param httpClient the HTTP client to use for discovery requests + */ + OidcDiscoveryFetcher(AuthCache cache, CloseableHttpClient httpClient) { + if (cache == null) { + throw new IllegalArgumentException("cache must not be null"); + } + if (httpClient == null) { + throw new IllegalArgumentException("httpClient must not be null"); + } + this.cache = cache; + this.httpClient = httpClient; + } + + /** + * Fetches the OIDC Discovery metadata for the given issuer, using a cached + * result if available. + * + * @param issuerUrl the token's {@code iss} claim (e.g., + * {@code "https://tenant.auth0.com/"}) + * @return the parsed {@link OidcMetadata} + * @throws VerifyAccessTokenException if the fetch or parse fails + */ + OidcMetadata fetch(String issuerUrl) throws VerifyAccessTokenException { + String key = CACHE_PREFIX + (issuerUrl.endsWith("/") ? issuerUrl : issuerUrl + "/"); + + Object cached = cache.get(key); + if (cached instanceof OidcMetadata) { + return (OidcMetadata) cached; + } + + OidcMetadata metadata = doFetch(issuerUrl.endsWith("/") ? issuerUrl : issuerUrl + "/"); + cache.put(key, metadata); + return metadata; + } + + /** + * Performs the actual HTTP fetch and JSON parsing. + */ + private OidcMetadata doFetch(String issuerUrl) throws VerifyAccessTokenException { + String discoveryUrl = issuerUrl + WELL_KNOWN_PATH; + + try { + HttpGet request = new HttpGet(discoveryUrl); + request.setHeader("Accept", "application/json"); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != 200) { + throw new VerifyAccessTokenException( + String.format("OIDC discovery failed for issuer '%s': HTTP %d", issuerUrl, statusCode)); + } + + String body = EntityUtils.toString(response.getEntity()); + JsonNode root = OBJECT_MAPPER.readTree(body); + + String issuer = getRequiredField(root, "issuer", issuerUrl); + String jwksUri = getRequiredField(root, "jwks_uri", issuerUrl); + + return new OidcMetadata(issuer, jwksUri); + } + } catch (VerifyAccessTokenException e) { + throw e; + } catch (IOException e) { + throw new VerifyAccessTokenException( + String.format("OIDC discovery request failed for issuer '%s'", issuerUrl), e); + } + } + + /** + * Extracts a required string field from the discovery JSON, throwing a clear + * error if it is missing. + */ + private String getRequiredField(JsonNode root, String fieldName, String issuerUrl) + throws VerifyAccessTokenException { + JsonNode node = root.get(fieldName); + if (node == null || node.isNull() || !node.isTextual()) { + throw new VerifyAccessTokenException( + String.format("OIDC discovery for issuer '%s' is missing required field '%s'", + issuerUrl, fieldName)); + } + return node.asText(); + } + + /** + * Clears the entire cache. Primarily for testing. + */ + void clearCache() { + cache.clear(); + } + + /** + * Returns the total number of cached entries (all types). Primarily for + * testing. + */ + int cacheSize() { + return cache.size(); + } + + /** + * Returns the underlying cache instance. Primarily for testing. + */ + AuthCache getCache() { + return cache; + } +} diff --git a/auth0-api-java/src/test/java/com/auth0/AbstractAuthenticationTest.java b/auth0-api-java/src/test/java/com/auth0/AbstractAuthenticationTest.java index b257f94..fa75587 100644 --- a/auth0-api-java/src/test/java/com/auth0/AbstractAuthenticationTest.java +++ b/auth0-api-java/src/test/java/com/auth0/AbstractAuthenticationTest.java @@ -17,7 +17,9 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; public class AbstractAuthenticationTest { @@ -31,14 +33,13 @@ public class AbstractAuthenticationTest { */ private static class TestAuthImpl extends AbstractAuthentication { TestAuthImpl(JWTValidator jwtValidator, - TokenExtractor extractor, - DPoPProofValidator dpopProofValidator) { + TokenExtractor extractor, + DPoPProofValidator dpopProofValidator) { super(jwtValidator, extractor, dpopProofValidator); } @Override - public AuthenticationContext authenticate( - HttpRequestInfo requestInfo) { + public AuthenticationContext authenticate(HttpRequestInfo requestInfo) { return null; } } @@ -83,8 +84,9 @@ public void validateBearerToken_shouldExtractAndValidate() throws Exception { Map headers = new HashMap<>(); headers.put("authorization", "Bearer access"); + HttpRequestInfo request = new HttpRequestInfo("GET", "https://api.example.com", headers); - DecodedJWT result = authSystem.validateBearerToken(new HttpRequestInfo("GET", "https://api.example.com", headers)); + DecodedJWT result = authSystem.validateBearerToken(request); assertThat(result).isSameAs(jwt); } @@ -97,7 +99,6 @@ public void validateDpopTokenAndProof_shouldValidateEverything() throws Exceptio Map headers = new HashMap<>(); headers.put("authorization", "DPoP access"); headers.put("dpop", "proof"); - HttpRequestInfo request = new HttpRequestInfo("GET", "https://api.example.com", headers); when(extractor.extractDPoPProofAndDPoPToken(anyMap())).thenReturn(token); @@ -133,10 +134,50 @@ public void prepareError_shouldAddWwwAuthenticateHeader() { when(ex.getErrorCode()).thenReturn("invalid_token"); when(ex.getErrorDescription()).thenReturn("desc"); - BaseAuthException result = - authSystem.prepareError(ex, DPoPMode.ALLOWED, "bearer"); + BaseAuthException result = authSystem.prepareError(ex, DPoPMode.ALLOWED, "bearer"); + + verify(ex).addHeader(eq("WWW-Authenticate"), anyString()); + assertThat(result).isSameAs(ex); + } + + @Test + public void prepareError_shouldAddHeaderForDisabledMode() { + BaseAuthException ex = mock(BaseAuthException.class); + when(ex.getErrorCode()).thenReturn("invalid_token"); + when(ex.getErrorDescription()).thenReturn("desc"); + + BaseAuthException result = authSystem.prepareError(ex, DPoPMode.DISABLED, AuthConstants.BEARER_SCHEME); + + verify(ex).addHeader(eq("WWW-Authenticate"), anyString()); + assertThat(result).isSameAs(ex); + } + + @Test + public void prepareError_shouldAddHeaderForRequiredMode() { + BaseAuthException ex = mock(BaseAuthException.class); + when(ex.getErrorCode()).thenReturn("invalid_dpop_proof"); + when(ex.getErrorDescription()).thenReturn("bad proof"); + + BaseAuthException result = authSystem.prepareError(ex, DPoPMode.REQUIRED, AuthConstants.DPOP_SCHEME); verify(ex).addHeader(eq("WWW-Authenticate"), anyString()); assertThat(result).isSameAs(ex); } + + @Test + public void normalize_shouldHandleEmptyHeaders() throws BaseAuthException { + Map headers = new HashMap<>(); + Map result = authSystem.normalize(headers); + assertThat(result).isEmpty(); + } + + @Test + public void buildContext_shouldHandleEmptyClaims() { + DecodedJWT jwt = mock(DecodedJWT.class); + when(jwt.getClaims()).thenReturn(new HashMap<>()); + + AuthenticationContext ctx = authSystem.buildContext(jwt); + + assertThat(ctx.getClaims()).isEmpty(); + } } diff --git a/auth0-api-java/src/test/java/com/auth0/AllowedDPoPAuthenticationTest.java b/auth0-api-java/src/test/java/com/auth0/AllowedDPoPAuthenticationTest.java index 4c6c69b..2320a00 100644 --- a/auth0-api-java/src/test/java/com/auth0/AllowedDPoPAuthenticationTest.java +++ b/auth0-api-java/src/test/java/com/auth0/AllowedDPoPAuthenticationTest.java @@ -16,7 +16,10 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.*; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; public class AllowedDPoPAuthenticationTest { @@ -36,48 +39,42 @@ public void setUp() { @Test public void authenticate_shouldAcceptBearerToken() throws Exception { DecodedJWT jwt = mock(DecodedJWT.class); - - Claim claim = mock(Claim.class); - - when(claim.isNull()).thenReturn(true); - when(jwt.getClaim("cnf")).thenReturn(claim); + Claim cnfClaim = mock(Claim.class); + when(cnfClaim.isNull()).thenReturn(true); + when(cnfClaim.asMap()).thenReturn(null); + when(jwt.getClaim("cnf")).thenReturn(cnfClaim); when(jwt.getClaims()).thenReturn(new HashMap<>()); - when(extractor.getScheme(anyMap())).thenReturn(AuthConstants.BEARER_SCHEME); - when(extractor.extractBearer(anyMap())).thenReturn( - new AuthToken("token", null, null) - ); - - when(jwtValidator.validateToken(eq("token"), any())).thenReturn(jwt); - Map headers = new HashMap<>(); headers.put("authorization", "Bearer token"); + HttpRequestInfo request = new HttpRequestInfo("GET", "https://api.example.com", headers); - HttpRequestInfo httpRequestInfo = new HttpRequestInfo("GET", "https://api.example.com", headers); + when(extractor.getScheme(anyMap())).thenReturn(AuthConstants.BEARER_SCHEME); + when(extractor.extractBearer(anyMap())).thenReturn( + new AuthToken("token", null, null)); + when(jwtValidator.validateToken(eq("token"), any(HttpRequestInfo.class))).thenReturn(jwt); - AuthenticationContext ctx = auth.authenticate(httpRequestInfo); + AuthenticationContext ctx = auth.authenticate(request); assertThat(ctx).isNotNull(); - verify(jwtValidator).validateToken("token", httpRequestInfo); + verify(jwtValidator).validateToken(eq("token"), any(HttpRequestInfo.class)); verifyNoInteractions(dpopProofValidator); } @Test public void authenticate_shouldAcceptDpopToken() throws Exception { DecodedJWT jwt = mock(DecodedJWT.class); + when(jwt.getClaims()).thenReturn(new HashMap<>()); - when(extractor.getScheme(anyMap())).thenReturn(AuthConstants.DPOP_SCHEME); - when(extractor.extractDPoPProofAndDPoPToken(anyMap())).thenReturn( - new com.auth0.models.AuthToken("token", "proof", null) - ); - when(jwtValidator.validateToken(eq("token"), any())).thenReturn(jwt); Map headers = new HashMap<>(); headers.put("authorization", "DPoP token"); headers.put("dpop", "proof"); - HttpRequestInfo request = new HttpRequestInfo("GET", "https://api.example.com", headers); - when(jwt.getClaims()).thenReturn(new HashMap<>()); + when(extractor.getScheme(anyMap())).thenReturn(AuthConstants.DPOP_SCHEME); + when(extractor.extractDPoPProofAndDPoPToken(anyMap())).thenReturn( + new AuthToken("token", "proof", null)); + when(jwtValidator.validateToken(eq("token"), any(HttpRequestInfo.class))).thenReturn(jwt); AuthenticationContext ctx = auth.authenticate(request); @@ -85,16 +82,15 @@ public void authenticate_shouldAcceptDpopToken() throws Exception { verify(dpopProofValidator).validate("proof", jwt, request); } - @Test(expected = InvalidAuthSchemeException.class) + @Test public void authenticate_shouldRejectUnknownScheme() throws Exception { when(extractor.getScheme(anyMap())).thenReturn("basic"); Map headers = new HashMap<>(); headers.put("authorization", "Basic abc"); + HttpRequestInfo request = new HttpRequestInfo("GET", "https://api.example.com", headers); - HttpRequestInfo request = new HttpRequestInfo(headers); - - auth.authenticate(request); + assertThatThrownBy(() -> auth.authenticate(request)).isInstanceOf(InvalidAuthSchemeException.class); } @Test @@ -105,8 +101,7 @@ public void authenticate_shouldWrapExceptionWithWwwAuthenticate() throws Excepti Map headers = new HashMap<>(); headers.put("authorization", "Bearer bad"); - - HttpRequestInfo request = new HttpRequestInfo(headers); + HttpRequestInfo request = new HttpRequestInfo("GET", "https://api.example.com", headers); try { auth.authenticate(request); @@ -115,4 +110,65 @@ public void authenticate_shouldWrapExceptionWithWwwAuthenticate() throws Excepti .containsKey("WWW-Authenticate"); } } + + @Test + public void authenticate_shouldRejectBearerWithDpopProofPresent() throws Exception { + DecodedJWT jwt = mock(DecodedJWT.class); + Claim cnfClaim = mock(Claim.class); + when(cnfClaim.isNull()).thenReturn(true); + when(cnfClaim.asMap()).thenReturn(null); + when(jwt.getClaim("cnf")).thenReturn(cnfClaim); + + Map headers = new HashMap<>(); + headers.put("authorization", "Bearer token"); + headers.put("dpop", "proof"); + HttpRequestInfo request = new HttpRequestInfo("GET", "https://api.example.com", headers); + + when(extractor.getScheme(anyMap())).thenReturn(AuthConstants.BEARER_SCHEME); + when(extractor.extractBearer(anyMap())).thenReturn( + new AuthToken("token", null, null)); + when(jwtValidator.validateToken(eq("token"), any(HttpRequestInfo.class))).thenReturn(jwt); + + assertThatThrownBy(() -> auth.authenticate(request)) + .isInstanceOf(BaseAuthException.class); + } + + @Test + public void authenticate_shouldRejectBearerWithDpopBoundToken() throws Exception { + DecodedJWT jwt = mock(DecodedJWT.class); + Claim cnfClaim = mock(Claim.class); + Map cnfMap = new HashMap<>(); + cnfMap.put("jkt", "thumbprint"); + when(cnfClaim.isNull()).thenReturn(false); + when(cnfClaim.asMap()).thenReturn(cnfMap); + when(jwt.getClaim("cnf")).thenReturn(cnfClaim); + + Map headers = new HashMap<>(); + headers.put("authorization", "Bearer token"); + HttpRequestInfo request = new HttpRequestInfo("GET", "https://api.example.com", headers); + + when(extractor.getScheme(anyMap())).thenReturn(AuthConstants.BEARER_SCHEME); + when(extractor.extractBearer(anyMap())).thenReturn( + new AuthToken("token", null, null)); + when(jwtValidator.validateToken(eq("token"), any(HttpRequestInfo.class))).thenReturn(jwt); + + assertThatThrownBy(() -> auth.authenticate(request)) + .isInstanceOf(BaseAuthException.class); + } + + @Test + public void authenticate_emptyScheme_shouldWrapWithWwwAuthenticate() throws Exception { + when(extractor.getScheme(anyMap())).thenReturn(""); + + Map headers = new HashMap<>(); + headers.put("authorization", ""); + HttpRequestInfo request = new HttpRequestInfo("GET", "https://api.example.com", headers); + + assertThatThrownBy(() -> auth.authenticate(request)) + .isInstanceOf(BaseAuthException.class) + .satisfies(ex -> { + BaseAuthException bae = (BaseAuthException) ex; + assertThat(bae.getHeaders()).containsKey("WWW-Authenticate"); + }); + } } diff --git a/auth0-api-java/src/test/java/com/auth0/AuthenticationOrchestratorTest.java b/auth0-api-java/src/test/java/com/auth0/AuthenticationOrchestratorTest.java index 70e7d39..73ffe32 100644 --- a/auth0-api-java/src/test/java/com/auth0/AuthenticationOrchestratorTest.java +++ b/auth0-api-java/src/test/java/com/auth0/AuthenticationOrchestratorTest.java @@ -11,7 +11,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.Mockito.*; public class AuthenticationOrchestratorTest { @@ -51,7 +50,7 @@ public void process_propagatesException() throws Exception { Map headers = new HashMap<>(); assertThatThrownBy(() -> - orchestrator.process(new HttpRequestInfo(headers)) + orchestrator.process(new HttpRequestInfo("GET", "https://api", headers)) ).isSameAs(ex); } } diff --git a/auth0-api-java/src/test/java/com/auth0/DisabledDPoPAuthenticationTest.java b/auth0-api-java/src/test/java/com/auth0/DisabledDPoPAuthenticationTest.java index 670c8e7..88d75a0 100644 --- a/auth0-api-java/src/test/java/com/auth0/DisabledDPoPAuthenticationTest.java +++ b/auth0-api-java/src/test/java/com/auth0/DisabledDPoPAuthenticationTest.java @@ -1,6 +1,7 @@ package com.auth0; import com.auth0.exception.BaseAuthException; +import com.auth0.exception.MissingAuthorizationException; import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.models.AuthenticationContext; import com.auth0.models.HttpRequestInfo; @@ -9,7 +10,10 @@ import org.junit.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; import java.util.HashMap; @@ -32,35 +36,28 @@ public void authenticate_shouldAcceptBearerToken() throws Exception { DecodedJWT jwt = mock(DecodedJWT.class); when(extractor.extractBearer(anyMap())).thenReturn( - new com.auth0.models.AuthToken("token", null, null) - ); - - Map normalizedHeaders = new HashMap<>(); - normalizedHeaders.put("authorization", "Bearer token"); + new com.auth0.models.AuthToken("token", null, null)); + when(jwtValidator.validateToken(eq("token"), any(HttpRequestInfo.class))).thenReturn(jwt); + when(jwt.getClaims()).thenReturn(new HashMap<>()); - when(jwtValidator.validateToken(eq("token"), any())).thenReturn(jwt); Map headers = new HashMap<>(); headers.put("authorization", "Bearer token"); - - HttpRequestInfo request = new HttpRequestInfo(headers); - - when(jwt.getClaims()).thenReturn(new HashMap<>()); + HttpRequestInfo request = new HttpRequestInfo("GET", "https://api.example.com", headers); AuthenticationContext ctx = auth.authenticate(request); - assertThat(ctx).isNotNull(); - verify(jwtValidator).validateToken("token", request); + verify(jwtValidator).validateToken(eq("token"), any(HttpRequestInfo.class)); } @Test public void authenticate_shouldWrapExceptionWithWwwAuthenticate() throws Exception { when(extractor.extractBearer(anyMap())) - .thenThrow(new com.auth0.exception.MissingAuthorizationException()); + .thenThrow(new MissingAuthorizationException()); Map headers = new HashMap<>(); - - HttpRequestInfo request = new HttpRequestInfo(headers); + headers.put("authorization", "Bearer bad"); + HttpRequestInfo request = new HttpRequestInfo("GET", "https://api.example.com", headers); try { auth.authenticate(request); @@ -69,4 +66,20 @@ public void authenticate_shouldWrapExceptionWithWwwAuthenticate() throws Excepti .containsKey("WWW-Authenticate"); } } + + @Test + public void authenticate_shouldRejectMissingAuthorization() throws Exception { + when(extractor.extractBearer(anyMap())) + .thenThrow(new MissingAuthorizationException()); + + Map headers = new HashMap<>(); + HttpRequestInfo request = new HttpRequestInfo("GET", "https://api.example.com", headers); + + assertThatThrownBy(() -> auth.authenticate(request)) + .isInstanceOf(BaseAuthException.class) + .satisfies(ex -> { + BaseAuthException bae = (BaseAuthException) ex; + assertThat(bae.getHeaders()).containsKey("WWW-Authenticate"); + }); + } } diff --git a/auth0-api-java/src/test/java/com/auth0/RequiredDPoPAuthenticationTest.java b/auth0-api-java/src/test/java/com/auth0/RequiredDPoPAuthenticationTest.java index 5965e65..aed1ea2 100644 --- a/auth0-api-java/src/test/java/com/auth0/RequiredDPoPAuthenticationTest.java +++ b/auth0-api-java/src/test/java/com/auth0/RequiredDPoPAuthenticationTest.java @@ -1,6 +1,7 @@ package com.auth0; import com.auth0.exception.BaseAuthException; +import com.auth0.exception.MissingAuthorizationException; import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.models.AuthToken; import com.auth0.models.AuthenticationContext; @@ -11,7 +12,10 @@ import org.junit.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; import java.util.HashMap; @@ -38,14 +42,10 @@ public void authenticate_shouldAcceptDpopToken() throws Exception { Map headers = new HashMap<>(); headers.put("authorization", "DPoP token"); headers.put("dpop", "proof"); - HttpRequestInfo request = new HttpRequestInfo("POST", "https://api.example.com", headers); - when(extractor.extractDPoPProofAndDPoPToken(anyMap())).thenReturn( - new AuthToken("token", "proof", null) - ); - when(jwtValidator.validateToken(eq("token"), any())).thenReturn(jwt); - + when(extractor.extractDPoPProofAndDPoPToken(anyMap())).thenReturn(new AuthToken("token", "proof", null)); + when(jwtValidator.validateToken(eq("token"), any(HttpRequestInfo.class))).thenReturn(jwt); when(jwt.getClaims()).thenReturn(new HashMap<>()); AuthenticationContext ctx = auth.authenticate(request); @@ -58,8 +58,8 @@ public void authenticate_shouldAcceptDpopToken() throws Exception { public void authenticate_shouldWrapExceptionWithWwwAuthenticate() throws Exception { Map headers = new HashMap<>(); HttpRequestInfo request = new HttpRequestInfo("POST", "https://api.example.com", headers); - when(extractor.extractDPoPProofAndDPoPToken(anyMap())) - .thenThrow(new com.auth0.exception.MissingAuthorizationException()); + + when(extractor.extractDPoPProofAndDPoPToken(anyMap())).thenThrow(new MissingAuthorizationException()); try { auth.authenticate(request); @@ -68,4 +68,37 @@ public void authenticate_shouldWrapExceptionWithWwwAuthenticate() throws Excepti .containsKey("WWW-Authenticate"); } } + + @Test + public void authenticate_shouldRejectMissingDpopProof() throws Exception { + Map headers = new HashMap<>(); + headers.put("authorization", "DPoP token"); + HttpRequestInfo request = new HttpRequestInfo("POST", "https://api.example.com", headers); + + when(extractor.extractDPoPProofAndDPoPToken(anyMap())) + .thenThrow(new com.auth0.exception.InvalidAuthSchemeException()); + + assertThatThrownBy(() -> auth.authenticate(request)) + .isInstanceOf(BaseAuthException.class) + .satisfies(ex -> { + BaseAuthException bae = (BaseAuthException) ex; + assertThat(bae.getHeaders()).containsKey("WWW-Authenticate"); + }); + } + + @Test + public void authenticate_shouldRejectMissingAuthorization() throws Exception { + Map headers = new HashMap<>(); + HttpRequestInfo request = new HttpRequestInfo("POST", "https://api.example.com", headers); + + when(extractor.extractDPoPProofAndDPoPToken(anyMap())) + .thenThrow(new MissingAuthorizationException()); + + assertThatThrownBy(() -> auth.authenticate(request)) + .isInstanceOf(BaseAuthException.class) + .satisfies(ex -> { + BaseAuthException bae = (BaseAuthException) ex; + assertThat(bae.getHeaders()).containsKey("WWW-Authenticate"); + }); + } } diff --git a/auth0-api-java/src/test/java/com/auth0/cache/InMemoryAuthCacheTest.java b/auth0-api-java/src/test/java/com/auth0/cache/InMemoryAuthCacheTest.java new file mode 100644 index 0000000..5c052ed --- /dev/null +++ b/auth0-api-java/src/test/java/com/auth0/cache/InMemoryAuthCacheTest.java @@ -0,0 +1,204 @@ +package com.auth0.cache; + +import org.junit.Before; +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class InMemoryAuthCacheTest { + + private InMemoryAuthCache cache; + + @Before + public void setUp() { + cache = new InMemoryAuthCache<>(); + } + + @Test + public void defaultConstructor_shouldUseDefaultSettings() { + InMemoryAuthCache c = new InMemoryAuthCache<>(); + assertThat(c.size()).isZero(); + } + + @Test + public void constructor_shouldRejectZeroMaxEntries() { + assertThatThrownBy(() -> new InMemoryAuthCache<>(0, 600)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("maxEntries must be positive"); + } + + @Test + public void constructor_shouldRejectNegativeMaxEntries() { + assertThatThrownBy(() -> new InMemoryAuthCache<>(-1, 600)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("maxEntries must be positive"); + } + + @Test + public void constructor_shouldRejectNegativeTtl() { + assertThatThrownBy(() -> new InMemoryAuthCache<>(10, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("ttlSeconds must not be negative"); + } + + @Test + public void constructor_shouldAcceptZeroTtlMeaningNoExpiration() { + InMemoryAuthCache c = new InMemoryAuthCache<>(10, 0); + c.put("key", "value"); + assertThat(c.get("key")).isEqualTo("value"); + } + + @Test + public void put_andGet_shouldStoreAndRetrieveValue() { + cache.put("key1", "value1"); + assertThat(cache.get("key1")).isEqualTo("value1"); + } + + @Test + public void get_shouldReturnNullForMissingKey() { + assertThat(cache.get("nonexistent")).isNull(); + } + + @Test + public void put_shouldOverwriteExistingValue() { + cache.put("key1", "first"); + cache.put("key1", "second"); + assertThat(cache.get("key1")).isEqualTo("second"); + assertThat(cache.size()).isEqualTo(1); + } + + @Test + public void remove_shouldDeleteEntry() { + cache.put("key1", "value1"); + cache.remove("key1"); + assertThat(cache.get("key1")).isNull(); + assertThat(cache.size()).isZero(); + } + + @Test + public void remove_shouldBeNoOpForMissingKey() { + cache.remove("nonexistent"); // should not throw + assertThat(cache.size()).isZero(); + } + + @Test + public void clear_shouldRemoveAllEntries() { + cache.put("a", "1"); + cache.put("b", "2"); + cache.put("c", "3"); + assertThat(cache.size()).isEqualTo(3); + + cache.clear(); + assertThat(cache.size()).isZero(); + assertThat(cache.get("a")).isNull(); + } + + @Test + public void size_shouldReturnNumberOfEntries() { + assertThat(cache.size()).isZero(); + cache.put("a", "1"); + assertThat(cache.size()).isEqualTo(1); + cache.put("b", "2"); + assertThat(cache.size()).isEqualTo(2); + } + + @Test + public void lruEviction_shouldRemoveEldestEntryWhenMaxEntriesExceeded() { + InMemoryAuthCache lruCache = new InMemoryAuthCache<>(3, 600); + + lruCache.put("a", "1"); + lruCache.put("b", "2"); + lruCache.put("c", "3"); + // Cache is full. Adding a 4th entry should evict "a" (least recently used). + lruCache.put("d", "4"); + + assertThat(lruCache.size()).isEqualTo(3); + assertThat(lruCache.get("a")).isNull(); // evicted + assertThat(lruCache.get("b")).isEqualTo("2"); + assertThat(lruCache.get("c")).isEqualTo("3"); + assertThat(lruCache.get("d")).isEqualTo("4"); + } + + @Test + public void lruEviction_accessShouldRefreshOrder() { + InMemoryAuthCache lruCache = new InMemoryAuthCache<>(3, 600); + + lruCache.put("a", "1"); + lruCache.put("b", "2"); + lruCache.put("c", "3"); + + lruCache.get("a"); + + lruCache.put("d", "4"); + + assertThat(lruCache.get("b")).isNull(); + assertThat(lruCache.get("a")).isEqualTo("1"); + assertThat(lruCache.get("c")).isEqualTo("3"); + assertThat(lruCache.get("d")).isEqualTo("4"); + } + + @Test + public void ttlExpiration_shouldEvictExpiredEntries() throws InterruptedException { + InMemoryAuthCache ttlCache = new InMemoryAuthCache<>(100, 1); + + ttlCache.put("key", "value"); + assertThat(ttlCache.get("key")).isEqualTo("value"); + + // Wait for TTL to expire + Thread.sleep(1200); + + assertThat(ttlCache.get("key")).isNull(); + } + + @Test + public void zeroTtl_shouldNeverExpire() throws InterruptedException { + InMemoryAuthCache noExpireCache = new InMemoryAuthCache<>(100, 0); + + noExpireCache.put("key", "value"); + // Even after a short sleep, entries should remain + Thread.sleep(50); + assertThat(noExpireCache.get("key")).isEqualTo("value"); + } + + @Test + public void unifiedCache_shouldSupportDifferentPrefixes() { + InMemoryAuthCache unified = new InMemoryAuthCache<>(); + + unified.put("discovery:https://tenant.auth0.com/", "metadata-object"); + unified.put("jwks:https://tenant.auth0.com/.well-known/jwks.json", "jwk-provider-object"); + + assertThat(unified.get("discovery:https://tenant.auth0.com/")).isEqualTo("metadata-object"); + assertThat(unified.get("jwks:https://tenant.auth0.com/.well-known/jwks.json")).isEqualTo("jwk-provider-object"); + assertThat(unified.size()).isEqualTo(2); + } + + @Test + public void concurrentAccess_shouldNotCorruptState() throws InterruptedException { + InMemoryAuthCache concurrentCache = new InMemoryAuthCache<>(1000, 600); + int threadCount = 10; + int opsPerThread = 100; + Thread[] threads = new Thread[threadCount]; + + for (int t = 0; t < threadCount; t++) { + final int threadId = t; + threads[t] = new Thread(() -> { + for (int i = 0; i < opsPerThread; i++) { + String key = "t" + threadId + "-k" + i; + concurrentCache.put(key, i); + concurrentCache.get(key); + } + }); + } + + for (Thread thread : threads) { + thread.start(); + } + for (Thread thread : threads) { + thread.join(); + } + + assertThat(concurrentCache.size()).isLessThanOrEqualTo(1000); + assertThat(concurrentCache.size()).isGreaterThan(0); + } +} diff --git a/auth0-api-java/src/test/java/com/auth0/models/AuthOptionsTest.java b/auth0-api-java/src/test/java/com/auth0/models/AuthOptionsTest.java index e38af28..2f44323 100644 --- a/auth0-api-java/src/test/java/com/auth0/models/AuthOptionsTest.java +++ b/auth0-api-java/src/test/java/com/auth0/models/AuthOptionsTest.java @@ -1,10 +1,16 @@ package com.auth0.models; +import com.auth0.DomainResolver; +import com.auth0.cache.AuthCache; +import com.auth0.cache.InMemoryAuthCache; import com.auth0.enums.DPoPMode; import org.junit.Test; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertThrows; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.*; public class AuthOptionsTest { @@ -23,41 +29,99 @@ public void testBuilderSetsFieldsCorrectly() { assertEquals(DPoPMode.REQUIRED, options.getDpopMode()); assertEquals(600, options.getDpopIatOffsetSeconds()); assertEquals(60, options.getDpopIatLeewaySeconds()); + assertNull(options.getDomains()); + assertNull(options.getDomainsResolver()); } @Test - public void testBuilderThrowsExceptionForNegativeIatOffset() { - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> - new AuthOptions.Builder().dpopIatOffsetSeconds(-1) - ); - assertEquals("dpopIatOffsetSeconds must not be negative", exception.getMessage()); + public void testBuilderWithDomainsStaticList() { + List domainList = Arrays.asList( + "https://tenant1.auth0.com", + "https://tenant2.auth0.com"); + + AuthOptions options = new AuthOptions.Builder() + .domains(domainList) + .audience("api://default") + .build(); + + assertNull(options.getDomain()); + assertEquals(domainList, options.getDomains()); + assertNull(options.getDomainsResolver()); } @Test - public void testBuilderThrowsExceptionForNegativeIatLeeway() { - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> - new AuthOptions.Builder().dpopIatLeewaySeconds(-1) - ); - assertEquals("dpopIatLeewaySeconds must not be negative", exception.getMessage()); + public void testBuilderWithDomainsResolver() { + DomainResolver resolver = context -> Collections.singletonList("https://resolved.auth0.com"); + + AuthOptions options = new AuthOptions.Builder() + .domainsResolver(resolver) + .audience("api://default") + .build(); + + assertNull(options.getDomain()); + assertNull(options.getDomains()); + assertNotNull(options.getDomainsResolver()); + } + + @Test + public void testBuilderWithDomainAndDomainsCoexist() { + // Auth for Agents scenario: domain + domains can coexist + List domainList = Arrays.asList( + "https://primary.auth0.com", + "https://tenant2.auth0.com"); + + AuthOptions options = new AuthOptions.Builder() + .domain("primary.auth0.com") + .domains(domainList) + .audience("api://default") + .build(); + + assertEquals("primary.auth0.com", options.getDomain()); + assertEquals(domainList, options.getDomains()); } @Test - public void testBuildThrowsExceptionForNullDomain() { - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> - new AuthOptions.Builder() + public void testBuilderThrowsWhenDomainsAndResolverBothSet() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> new AuthOptions.Builder() + .domains(Collections.singletonList("https://tenant1.auth0.com")) + .domainsResolver(context -> Collections.singletonList("https://resolved.auth0.com")) .audience("api://default") - .build() - ); - assertEquals("Domain must not be null or empty", exception.getMessage()); + .build()); + assertEquals("Cannot configure both 'domains' and 'domainsResolver'. Use one or the other.", + exception.getMessage()); + } + + @Test + public void testBuilderThrowsWhenNoDomainSourceProvided() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> new AuthOptions.Builder() + .audience("api://default") + .build()); + assertEquals("At least one of 'domain', 'domains', or 'domainsResolver' must be configured.", + exception.getMessage()); + } + + @Test + public void testBuilderThrowsExceptionForNegativeIatOffset() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> new AuthOptions.Builder().dpopIatOffsetSeconds(-1)); + assertEquals("dpopIatOffsetSeconds must not be negative", exception.getMessage()); + } + + @Test + public void testBuilderThrowsExceptionForNegativeIatLeeway() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> new AuthOptions.Builder().dpopIatLeewaySeconds(-1)); + assertEquals("dpopIatLeewaySeconds must not be negative", exception.getMessage()); } @Test public void testBuildThrowsExceptionForNullAudience() { - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> - new AuthOptions.Builder() + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> new AuthOptions.Builder() .domain("example.com") - .build() - ); + .build()); assertEquals("Audience must not be null or empty", exception.getMessage()); } @@ -72,4 +136,127 @@ public void testDefaultValuesInBuilder() { assertEquals(300, options.getDpopIatOffsetSeconds()); assertEquals(30, options.getDpopIatLeewaySeconds()); } + + @Test + public void testDomainsListIsUnmodifiable() { + List domainList = Arrays.asList("https://tenant1.auth0.com"); + + AuthOptions options = new AuthOptions.Builder() + .domains(domainList) + .audience("api://default") + .build(); + + assertThrows(UnsupportedOperationException.class, () -> options.getDomains().add("https://evil.com")); + } + + // ------------------------------------------------------------------- + // Cache configuration tests + // ------------------------------------------------------------------- + + @Test + public void testDefaultCacheSettings() { + AuthOptions options = new AuthOptions.Builder() + .domain("example.com") + .audience("api://default") + .build(); + + assertEquals(100, options.getCacheMaxEntries()); + assertEquals(600, options.getCacheTtlSeconds()); + assertNull(options.getCache()); + } + + @Test + public void testCustomCacheMaxEntries() { + AuthOptions options = new AuthOptions.Builder() + .domain("example.com") + .audience("api://default") + .cacheMaxEntries(50) + .build(); + + assertEquals(50, options.getCacheMaxEntries()); + } + + @Test + public void testCustomCacheTtlSeconds() { + AuthOptions options = new AuthOptions.Builder() + .domain("example.com") + .audience("api://default") + .cacheTtlSeconds(300) + .build(); + + assertEquals(300, options.getCacheTtlSeconds()); + } + + @Test + public void testCacheTtlZeroMeansNoExpiration() { + AuthOptions options = new AuthOptions.Builder() + .domain("example.com") + .audience("api://default") + .cacheTtlSeconds(0) + .build(); + + assertEquals(0, options.getCacheTtlSeconds()); + } + + @Test + public void testCustomCacheImplementation() { + AuthCache customCache = new InMemoryAuthCache<>(200, 900); + + AuthOptions options = new AuthOptions.Builder() + .domain("example.com") + .audience("api://default") + .cache(customCache) + .build(); + + assertSame(customCache, options.getCache()); + } + + @Test + public void testBuilderThrowsForNonPositiveCacheMaxEntries() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> new AuthOptions.Builder() + .domain("example.com") + .audience("api://default") + .cacheMaxEntries(0)); + assertEquals("cacheMaxEntries must be positive", exception.getMessage()); + } + + @Test + public void testBuilderThrowsForNegativeCacheMaxEntries() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> new AuthOptions.Builder() + .domain("example.com") + .audience("api://default") + .cacheMaxEntries(-5)); + assertEquals("cacheMaxEntries must be positive", exception.getMessage()); + } + + @Test + public void testBuilderThrowsForNegativeCacheTtlSeconds() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> new AuthOptions.Builder() + .domain("example.com") + .audience("api://default") + .cacheTtlSeconds(-1)); + assertEquals("cacheTtlSeconds must not be negative", exception.getMessage()); + } + + @Test + public void testCacheSettingsWithAllCacheOptions() { + AuthCache customCache = new InMemoryAuthCache<>(500, 1200); + + AuthOptions options = new AuthOptions.Builder() + .domain("example.com") + .audience("api://default") + .cacheMaxEntries(250) + .cacheTtlSeconds(900) + .cache(customCache) + .build(); + + // When custom cache is set, it takes precedence + assertSame(customCache, options.getCache()); + // The numeric settings are still stored (used as fallback if cache is null) + assertEquals(250, options.getCacheMaxEntries()); + assertEquals(900, options.getCacheTtlSeconds()); + } } \ No newline at end of file diff --git a/auth0-api-java/src/test/java/com/auth0/models/OidcMetadataTest.java b/auth0-api-java/src/test/java/com/auth0/models/OidcMetadataTest.java new file mode 100644 index 0000000..1263d8f --- /dev/null +++ b/auth0-api-java/src/test/java/com/auth0/models/OidcMetadataTest.java @@ -0,0 +1,26 @@ +package com.auth0.models; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class OidcMetadataTest { + + @Test + public void shouldStoreIssuerAndJwksUri() { + OidcMetadata metadata = new OidcMetadata( + "https://tenant.auth0.com/", + "https://tenant.auth0.com/.well-known/jwks.json"); + + assertThat(metadata.getIssuer()).isEqualTo("https://tenant.auth0.com/"); + assertThat(metadata.getJwksUri()).isEqualTo("https://tenant.auth0.com/.well-known/jwks.json"); + } + + @Test + public void shouldAllowNullValues() { + OidcMetadata metadata = new OidcMetadata(null, null); + + assertThat(metadata.getIssuer()).isNull(); + assertThat(metadata.getJwksUri()).isNull(); + } +} diff --git a/auth0-api-java/src/test/java/com/auth0/validators/JWTValidatorTest.java b/auth0-api-java/src/test/java/com/auth0/validators/JWTValidatorTest.java index a8b44ed..38653d7 100644 --- a/auth0-api-java/src/test/java/com/auth0/validators/JWTValidatorTest.java +++ b/auth0-api-java/src/test/java/com/auth0/validators/JWTValidatorTest.java @@ -1,5 +1,6 @@ package com.auth0.validators; +import com.auth0.cache.InMemoryAuthCache; import com.auth0.exception.InsufficientScopeException; import com.auth0.exception.InvalidRequestException; import com.auth0.exception.MissingRequiredArgumentException; @@ -11,6 +12,7 @@ import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.models.AuthOptions; import com.auth0.models.HttpRequestInfo; +import com.auth0.models.OidcMetadata; import org.junit.Before; import org.junit.Test; import org.mockito.Mock; @@ -25,6 +27,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; public class JWTValidatorTest { @@ -42,6 +45,7 @@ public class JWTValidatorTest { private static final String DOMAIN = "test-domain.auth0.com"; private static final String AUDIENCE = "https://api.example.com"; private static final String ISSUER = "https://test-domain.auth0.com/"; + private static final String JWKS_URI = "https://test-domain.auth0.com/.well-known/jwks.json"; @Before public void setUp() throws Exception { @@ -53,12 +57,23 @@ public void setUp() throws Exception { publicKey = (RSAPublicKey) pair.getPublic(); privateKey = (RSAPrivateKey) pair.getPrivate(); + // Create a cache and pre-populate it with the mock JwkProvider + InMemoryAuthCache cache = new InMemoryAuthCache<>(); + cache.put(JWTValidator.JWKS_CACHE_PREFIX + JWKS_URI, jwkProvider); + AuthOptions options = new AuthOptions.Builder() .domain(DOMAIN) .audience(AUDIENCE) + .cache(cache) .build(); - validator = new JWTValidator(options, jwkProvider); + // Mock OidcDiscoveryFetcher to return metadata matching the token issuer + OidcDiscoveryFetcher mockDiscoveryFetcher = mock(OidcDiscoveryFetcher.class); + 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); when(jwk.getPublicKey()).thenReturn(publicKey); when(jwkProvider.get(anyString())).thenReturn(jwk); diff --git a/auth0-api-java/src/test/java/com/auth0/validators/OidcDiscoveryFetcherTest.java b/auth0-api-java/src/test/java/com/auth0/validators/OidcDiscoveryFetcherTest.java new file mode 100644 index 0000000..bc041c4 --- /dev/null +++ b/auth0-api-java/src/test/java/com/auth0/validators/OidcDiscoveryFetcherTest.java @@ -0,0 +1,158 @@ +package com.auth0.validators; + +import com.auth0.cache.InMemoryAuthCache; +import com.auth0.exception.VerifyAccessTokenException; +import com.auth0.models.OidcMetadata; +import org.apache.http.HttpVersion; +import org.apache.http.client.methods.CloseableHttpResponse; +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.Mock; +import org.mockito.MockitoAnnotations; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class OidcDiscoveryFetcherTest { + + @Mock + private CloseableHttpClient httpClient; + + @Mock + private CloseableHttpResponse httpResponse; + + private OidcDiscoveryFetcher fetcher; + private InMemoryAuthCache cache; + + private static final String ISSUER = "https://tenant.auth0.com/"; + private static final String JWKS_URI = "https://tenant.auth0.com/.well-known/jwks.json"; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + cache = new InMemoryAuthCache<>(); + fetcher = new OidcDiscoveryFetcher(cache, httpClient); + } + + @Test + public void fetch_shouldReturnMetadataOnSuccess() throws Exception { + String discoveryJson = String.format( + "{\"issuer\":\"%s\",\"jwks_uri\":\"%s\"}", ISSUER, JWKS_URI); + mockSuccessResponse(discoveryJson); + + OidcMetadata metadata = fetcher.fetch(ISSUER); + + assertThat(metadata.getIssuer()).isEqualTo(ISSUER); + assertThat(metadata.getJwksUri()).isEqualTo(JWKS_URI); + } + + @Test + public void fetch_shouldCacheResultPerDomain() throws Exception { + String discoveryJson = String.format( + "{\"issuer\":\"%s\",\"jwks_uri\":\"%s\"}", ISSUER, JWKS_URI); + mockSuccessResponse(discoveryJson); + + OidcMetadata first = fetcher.fetch(ISSUER); + OidcMetadata second = fetcher.fetch(ISSUER); + + assertThat(first.getIssuer()).isEqualTo(second.getIssuer()); + assertThat(first.getJwksUri()).isEqualTo(second.getJwksUri()); + verify(httpClient, times(1)).execute(any()); + } + + @Test + public void fetch_shouldUsePrefixedCacheKey() throws Exception { + String discoveryJson = String.format( + "{\"issuer\":\"%s\",\"jwks_uri\":\"%s\"}", ISSUER, JWKS_URI); + mockSuccessResponse(discoveryJson); + + fetcher.fetch(ISSUER); + + // Verify the cache key uses the "discovery:" prefix + assertThat(cache.get(OidcDiscoveryFetcher.CACHE_PREFIX + ISSUER)).isNotNull(); + assertThat(cache.get(OidcDiscoveryFetcher.CACHE_PREFIX + ISSUER)).isInstanceOf(OidcMetadata.class); + } + + @Test + public void fetch_shouldNormalizeIssuerKeyWithTrailingSlash() throws Exception { + String issuerWithoutSlash = "https://tenant.auth0.com"; + String discoveryJson = String.format( + "{\"issuer\":\"%s\",\"jwks_uri\":\"%s\"}", ISSUER, JWKS_URI); + mockSuccessResponse(discoveryJson); + + OidcMetadata metadata = fetcher.fetch(issuerWithoutSlash); + + assertThat(metadata.getIssuer()).isEqualTo(ISSUER); + assertThat(cache.get(OidcDiscoveryFetcher.CACHE_PREFIX + ISSUER)).isNotNull(); + } + + @Test + public void fetch_shouldThrowOnNon200Response() throws Exception { + when(httpResponse.getStatusLine()) + .thenReturn(new BasicStatusLine(HttpVersion.HTTP_1_1, 404, "Not Found")); + when(httpResponse.getEntity()).thenReturn(new StringEntity("")); + when(httpClient.execute(any())).thenReturn(httpResponse); + + assertThatThrownBy(() -> fetcher.fetch(ISSUER)) + .isInstanceOf(VerifyAccessTokenException.class) + .hasMessageContaining("OIDC discovery failed") + .hasMessageContaining("HTTP 404"); + } + + @Test + public void fetch_shouldThrowWhenIssuerFieldMissing() throws Exception { + String json = String.format("{\"jwks_uri\":\"%s\"}", JWKS_URI); + mockSuccessResponse(json); + + assertThatThrownBy(() -> fetcher.fetch(ISSUER)) + .isInstanceOf(VerifyAccessTokenException.class) + .hasMessageContaining("missing required field 'issuer'"); + } + + @Test + public void fetch_shouldThrowWhenJwksUriFieldMissing() throws Exception { + String json = String.format("{\"issuer\":\"%s\"}", ISSUER); + mockSuccessResponse(json); + + assertThatThrownBy(() -> fetcher.fetch(ISSUER)) + .isInstanceOf(VerifyAccessTokenException.class) + .hasMessageContaining("missing required field 'jwks_uri'"); + } + + @Test(expected = IllegalArgumentException.class) + public void constructor_shouldRejectNullCache() { + new OidcDiscoveryFetcher(null, httpClient); + } + + @Test(expected = IllegalArgumentException.class) + public void constructor_shouldRejectNullHttpClient() { + new OidcDiscoveryFetcher(cache, null); + } + + @Test + public void clearCache_shouldEmptyTheCache() throws Exception { + String discoveryJson = String.format( + "{\"issuer\":\"%s\",\"jwks_uri\":\"%s\"}", ISSUER, JWKS_URI); + mockSuccessResponse(discoveryJson); + + fetcher.fetch(ISSUER); + assertThat(fetcher.cacheSize()).isGreaterThan(0); + + fetcher.clearCache(); + assertThat(fetcher.cacheSize()).isEqualTo(0); + } + + private void mockSuccessResponse(String body) throws Exception { + when(httpResponse.getStatusLine()) + .thenReturn(new BasicStatusLine(HttpVersion.HTTP_1_1, 200, "OK")); + when(httpResponse.getEntity()).thenReturn(new StringEntity(body)); + when(httpClient.execute(any())).thenReturn(httpResponse); + } +} diff --git a/auth0-springboot-api-playground/src/main/java/com/auth0/playground/McdDomainResolverExample.java b/auth0-springboot-api-playground/src/main/java/com/auth0/playground/McdDomainResolverExample.java new file mode 100644 index 0000000..fd0baf1 --- /dev/null +++ b/auth0-springboot-api-playground/src/main/java/com/auth0/playground/McdDomainResolverExample.java @@ -0,0 +1,107 @@ +package com.auth0.playground; + +import com.auth0.spring.boot.Auth0DomainResolver; +import com.auth0.spring.boot.Auth0RequestContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Example: Multi-Custom Domain (MCD) configuration with a dynamic domain + * resolver. + *

+ * Demonstrates how an end developer uses {@link Auth0DomainResolver} to + * dynamically + * resolve allowed issuer domains at request time — without any direct + * dependency + * on the core {@code auth0-api-java} module. + *

+ * + *

How it works

+ *
    + *
  1. Define an {@link Auth0DomainResolver} bean in a {@code @Configuration} + * class
  2. + *
  3. The auto-configuration picks it up and bridges it into the SDK's + * internal domain resolution pipeline
  4. + *
  5. On each request, the resolver receives an {@link Auth0RequestContext} + * containing the request URL, headers, and unverified token issuer
  6. + *
  7. The resolver returns the list of allowed issuer domains for that + * request
  8. + *
+ * + *

Activation

+ *

+ * Just define this {@code @Configuration} class in your project. + * The auto-configuration detects the {@link Auth0DomainResolver} bean + * automatically — no extra YAML properties needed. + *

+ * + *

Real-world scenarios

+ *
    + *
  • Tenant routing — resolve domains from a tenant header or + * database
  • + *
  • Host-based routing — map the incoming Host header to an Auth0 + * domain
  • + *
  • Issuer-hint routing — validate the unverified {@code iss} claim + * against a known allowlist
  • + *
+ * + * @see Auth0DomainResolver + * @see Auth0RequestContext + */ +@Configuration +public class McdDomainResolverExample { + + /** + * Simulated tenant → Auth0 domain mapping. + *

+ * In a real application, this would come from a database, external service, + * or configuration store. + *

+ */ + private static final Map> TENANT_DOMAINS = Map.of( + "tanya", Collections.singletonList("login.acme.com"), + "partner", Collections.singletonList("auth.partner.com"), + "default", Arrays.asList("abcd.org", "pqr.com")); + + /** + * Dynamic domain resolver that resolves allowed issuers based on the + * {@code X-Tenant-ID} request header. + *

+ * The resolver receives an {@link Auth0RequestContext} with: + *

    + *
  • {@code context.getUrl()} — the API request URL
  • + *
  • {@code context.getHeaders()} — all request headers (lowercase keys)
  • + *
  • {@code context.getTokenIssuer()} — the unverified {@code iss} + * claim from the JWT (use as a routing hint only)
  • + *
+ * + *

Example request

+ * + *
+     * curl -H "Authorization: Bearer eyJ..." \
+     *      -H "X-Tenant-ID: acme" \
+     *      http://localhost:8080/api/protected
+     * 
+ * + * @return an {@link Auth0DomainResolver} that maps tenant IDs to Auth0 domains + */ + @Bean + public Auth0DomainResolver domainResolver() { + return context -> { + String tenantId = context.getHeaders().get("x-tenant-id"); + + if (tenantId != null && TENANT_DOMAINS.containsKey(tenantId)) { + List domains = TENANT_DOMAINS.get(tenantId); + return domains; + } + + List defaults = TENANT_DOMAINS.get("default"); + return defaults; + }; + } +} diff --git a/auth0-springboot-api-playground/src/main/java/com/auth0/playground/ProfileController.java b/auth0-springboot-api-playground/src/main/java/com/auth0/playground/ProfileController.java index 356f9a7..f405a4a 100644 --- a/auth0-springboot-api-playground/src/main/java/com/auth0/playground/ProfileController.java +++ b/auth0-springboot-api-playground/src/main/java/com/auth0/playground/ProfileController.java @@ -1,9 +1,13 @@ package com.auth0.playground; +import com.auth0.spring.boot.Auth0AuthenticationToken; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; import java.util.Map; @RestController @@ -20,4 +24,29 @@ public String protectedEndpoint(Authentication authentication) { public Map pub() { return Map.of("message", "Public endpoint — no token required"); } + + /** + * MCD-protected endpoint — identical to {@code /api/protected} but + * demonstrates that the same controller works seamlessly with + * Multi-Custom Domain configurations. + *

+ * When an {@link com.auth0.spring.boot.Auth0DomainResolver} bean is + * defined, the SDK resolves the allowed issuer domains dynamically. + * This endpoint does not need any MCD-specific code. + *

+ */ + @GetMapping("/mcd-protected") + public ResponseEntity> mcdProtectedEndpoint(Authentication authentication) { + Map response = new LinkedHashMap<>(); + response.put("message", "MCD access granted!"); + response.put("user", authentication.getName()); + response.put("authenticated", true); + + if (authentication instanceof Auth0AuthenticationToken) { + Auth0AuthenticationToken auth0Token = (Auth0AuthenticationToken) authentication; + response.put("issuer", auth0Token.getClaim("iss")); + } + + return ResponseEntity.ok(response); + } } \ No newline at end of file diff --git a/auth0-springboot-api-playground/src/main/java/com/auth0/playground/SecurityConfig.java b/auth0-springboot-api-playground/src/main/java/com/auth0/playground/SecurityConfig.java index 88298de..6fe6761 100644 --- a/auth0-springboot-api-playground/src/main/java/com/auth0/playground/SecurityConfig.java +++ b/auth0-springboot-api-playground/src/main/java/com/auth0/playground/SecurityConfig.java @@ -18,13 +18,10 @@ SecurityFilterChain apiSecurity( http .csrf(csrf -> csrf.disable()) - .sessionManagement(session -> - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) - ) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/protected").authenticated() - .anyRequest().permitAll() - ) + .anyRequest().permitAll()) .addFilterBefore(authFilter, UsernamePasswordAuthenticationFilter.class); System.out.println("🔐 SecurityConfig: Configured security filter chain for /api/protected endpoint."); 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 79be44f..04346f9 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 @@ -1,7 +1,9 @@ package com.auth0.spring.boot; import com.auth0.AuthClient; +import com.auth0.DomainResolver; import com.auth0.models.AuthOptions; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -9,26 +11,80 @@ /** * Autoconfiguration for Auth0 authentication and JWT validation. + *

+ * Supports three domain configuration modes (mutually exclusive): + *

    + *
  1. Single domain — set {@code auth0.domain} in YAML
  2. + *
  3. Static MCD list — set {@code auth0.domains} in YAML
  4. + *
  5. Dynamic resolver — define an {@link Auth0DomainResolver} bean
  6. + *
+ * + * Dynamic Domain Resolver + *

+ * To dynamically resolve allowed issuer domains at request time, define a bean + * implementing {@link Auth0DomainResolver}: + *

+ * + *
{@code
+ * @Bean
+ * public Auth0DomainResolver domainResolver() {
+ *     return context -> {
+ *         String tenantId = context.getHeaders().get("x-tenant-id");
+ *         return lookupDomainsForTenant(tenantId);
+ *     };
+ * }
+ * }
*/ @AutoConfiguration @EnableConfigurationProperties(Auth0Properties.class) public class Auth0AutoConfiguration { + /** * Creates an {@link AuthOptions} bean from {@link Auth0Properties}. *

* Builds the authentication options configuration. - * @param properties the Auth0 configuration properties from application configuration + * + * @param properties the Auth0 configuration properties from + * application configuration + * @param domainResolverProvider optional {@link Auth0DomainResolver} bean + * for dynamic MCD resolution. When present, + * it takes precedence over static YAML config. * @return configured AuthOptions instance for creating AuthClient * @see AuthOptions.Builder * @see Auth0Properties */ @Bean - public AuthOptions authOptions(Auth0Properties properties) { + public AuthOptions authOptions(Auth0Properties properties, + ObjectProvider domainResolverProvider) { + + Auth0DomainResolver auth0DomainResolver = domainResolverProvider.getIfAvailable(); AuthOptions.Builder builder = new AuthOptions.Builder() - .domain(properties.getDomain()) .audience(properties.getAudience()); + if (auth0DomainResolver != null) { + // Bridge Spring Boot's Auth0DomainResolver → core DomainResolver + DomainResolver coreDomainResolver = coreContext -> { + Auth0RequestContext springContext = new Auth0RequestContext( + coreContext.getUrl(), + coreContext.getHeaders(), + coreContext.getTokenIssuer()); + return auth0DomainResolver.resolveDomains(springContext); + }; + builder.domainsResolver(coreDomainResolver); + + if (properties.getDomain() != null && !properties.getDomain().isEmpty()) { + builder.domain(properties.getDomain()); + } + } else if (properties.getDomains() != null && !properties.getDomains().isEmpty()) { + builder.domains(properties.getDomains()); + if (properties.getDomain() != null && !properties.getDomain().isEmpty()) { + builder.domain(properties.getDomain()); + } + } else { + builder.domain(properties.getDomain()); + } + if (properties.getDpopMode() != null) { builder.dpopMode(properties.getDpopMode()); } @@ -40,6 +96,13 @@ public AuthOptions authOptions(Auth0Properties properties) { builder.dpopIatOffsetSeconds(properties.getDpopIatOffsetSeconds()); } + if (properties.getCacheMaxEntries() != null) { + builder.cacheMaxEntries(properties.getCacheMaxEntries()); + } + if (properties.getCacheTtlSeconds() != null) { + builder.cacheTtlSeconds(properties.getCacheTtlSeconds()); + } + return builder.build(); } diff --git a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0DomainResolver.java b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0DomainResolver.java new file mode 100644 index 0000000..de83413 --- /dev/null +++ b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0DomainResolver.java @@ -0,0 +1,69 @@ +package com.auth0.spring.boot; + +import java.util.List; + +/** + * Functional interface for dynamically resolving allowed issuer domains + * based on the incoming request context. + *

+ * Used in Multi-Custom Domain (MCD) scenarios where the set of valid issuers + * cannot be determined statically at configuration time. Define a Spring bean + * implementing this interface, and the auto-configuration will pick it up + * automatically. + *

+ * + * Example: Tenant-based resolution + * + *
{@code
+ * @Bean
+ * public Auth0DomainResolver domainResolver(TenantService tenantService) {
+ *     return context -> {
+ *         String tenantId = context.getHeaders().get("x-tenant-id");
+ *         String domain = tenantService.getDomain(tenantId);
+ *         return Collections.singletonList(domain);
+ *     };
+ * }
+ * }
+ * + * Example: Issuer-hint based resolution + * + *
{@code
+ * @Bean
+ * public Auth0DomainResolver domainResolver() {
+ *     return context -> {
+ *         // Use the unverified iss claim as a routing hint
+ *         String issuer = context.getTokenIssuer();
+ *         if (issuer != null && allowedIssuers.contains(issuer)) {
+ *             return Collections.singletonList(issuer);
+ *         }
+ *         return Collections.emptyList();
+ *     };
+ * }
+ * }
+ * + * Priority + *

+ * When an {@code Auth0DomainResolver} bean is present, it takes precedence + * over the static {@code auth0.domains} YAML list. The single + * {@code auth0.domain} + * can still coexist as a fallback. + *

+ * + * @see Auth0RequestContext + * @see Auth0Properties + */ +@FunctionalInterface +public interface Auth0DomainResolver { + + /** + * Resolves the list of allowed issuer domains for the given request context. + * + * @param context the request context containing URL, headers, and + * unverified token issuer + * @return a list of allowed issuer domain strings (e.g., + * {@code ["login.acme.com", "auth.partner.com"]}); + * may return {@code null} or an empty list if no domains can be + * resolved + */ + List resolveDomains(Auth0RequestContext context); +} diff --git a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0Properties.java b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0Properties.java index 82896b5..9db706d 100644 --- a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0Properties.java +++ b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0Properties.java @@ -6,10 +6,12 @@ /** * Configuration properties for Auth0 authentication and token validation. *

- * This class binds Spring Boot configuration properties prefixed with {@code auth0} to provide + * This class binds Spring Boot configuration properties prefixed with + * {@code auth0} to provide * configuration for JWT validation, DPoP support, and API access control. *

* Example configuration in {@code application.yml}: + * *

  * auth0:
  *   domain: "random-test.us.auth0.com"
@@ -17,45 +19,138 @@
  *   dpopMode: ALLOWED
  *   dpopIatOffsetSeconds: 300
  *   dpopIatLeewaySeconds: 60
+ *   cacheMaxEntries: 200
+ *   cacheTtlSeconds: 900
  * 
+ * + * Multi-Custom Domain (MCD) Configuration + *

+ * For tenants with multiple custom domains, use the {@code domains} list + * instead of (or in addition to) the single {@code domain} property: + *

+ * + * + * auth0: + * audience: "https://api.example.com/v2/" + * domains: + * - "login.acme.com" + * - "auth.partner.com" + * - "random-test.us.auth0.com" + * cacheMaxEntries: 200 + * cacheTtlSeconds: 900 + * + * + * When {@code domains} is configured, the SDK validates the token's {@code iss} + * claim against all listed domains and performs OIDC discovery for the matching + * issuer. The built-in in-memory cache handles caching of discovery metadata + * and JWKS providers automatically. + * + * + * * @see com.auth0.enums.DPoPMode */ @ConfigurationProperties(prefix = "auth0") public class Auth0Properties { private String domain; + + /** + * Static list of allowed issuer domains for Multi-Custom Domain (MCD) support. + *

+ * When configured, tokens whose {@code iss} claim matches any of these domains + * will be accepted. Cannot be used together with a dynamic + * {@code domainsResolver}. + * Can coexist with {@link #domain} — if both are set, this list takes + * precedence + * for token validation. + *

+ * Example: + * + *
+     * auth0:
+     *   domains:
+     *     - login.acme.com
+     *     - auth.partner.com
+     * 
+ */ + private java.util.List domains; + private String audience; private DPoPMode dpopMode; private Long dpopIatOffsetSeconds; private Long dpopIatLeewaySeconds; + /** + * Maximum number of entries in the unified in-memory cache + * (OIDC discovery + JWKS providers). Default: 100. + */ + private Integer cacheMaxEntries; + + /** + * TTL in seconds for cached entries (OIDC discovery + JWKS providers). + * Default: 600 (10 minutes). + */ + private Long cacheTtlSeconds; + /** * Gets the Auth0 domain configured for this application. + * * @return the Auth0 domain, or {@code null} if not configured */ - public String getDomain() { return domain; } + public String getDomain() { + return domain; + } /** * Sets the Auth0 domain for this application. + * * @param domain the Auth0 domain to configure */ - public void setDomain(String domain) { this.domain = domain; } + public void setDomain(String domain) { + this.domain = domain; + } + + /** + * Gets the list of allowed issuer domains for MCD support. + * + * @return the configured domains list, or {@code null} if not set + */ + public java.util.List getDomains() { + return domains; + } + + /** + * Sets the list of allowed issuer domains for MCD support. + * + * @param domains list of allowed issuer domain strings + */ + public void setDomains(java.util.List domains) { + this.domains = domains; + } /** * Gets the audience (API identifier) for token validation. + * * @return the configured audience, or {@code null} if not set */ - public String getAudience() { return audience; } + public String getAudience() { + return audience; + } /** * Sets the audience (API identifier). + * * @param audience the audience to configure */ - public void setAudience(String audience) { this.audience = audience; } + public void setAudience(String audience) { + this.audience = audience; + } /** * Gets the DPoP mode for token validation. - * @return the configured DPoP mode ({@code DISABLED}, {@code ALLOWED}, or {@code REQUIRED}), or {@code null} if not set + * + * @return the configured DPoP mode ({@code DISABLED}, {@code ALLOWED}, or + * {@code REQUIRED}), or {@code null} if not set */ public DPoPMode getDpopMode() { return dpopMode; @@ -63,7 +158,9 @@ public DPoPMode getDpopMode() { /** * Sets the DPoP mode for token validation. - * @param dpopMode the DPoP mode to configure ({@code DISABLED}, {@code ALLOWED}, or {@code REQUIRED}) + * + * @param dpopMode the DPoP mode to configure ({@code DISABLED}, + * {@code ALLOWED}, or {@code REQUIRED}) */ public void setDpopMode(DPoPMode dpopMode) { this.dpopMode = dpopMode; @@ -71,6 +168,7 @@ public void setDpopMode(DPoPMode dpopMode) { /** * Gets the DPoP proof iat (issued-at) offset in seconds. + * * @return the configured offset in seconds, or {@code null} if not set */ public Long getDpopIatOffsetSeconds() { @@ -79,7 +177,9 @@ public Long getDpopIatOffsetSeconds() { /** * Sets the DPoP proof iat (issued-at) offset in seconds. - * @param dpopIatOffsetSeconds the offset in seconds to configure (must be non-negative) + * + * @param dpopIatOffsetSeconds the offset in seconds to configure (must be + * non-negative) * @throws IllegalArgumentException if the value is negative */ public void setDpopIatOffsetSeconds(Long dpopIatOffsetSeconds) { @@ -91,6 +191,7 @@ public void setDpopIatOffsetSeconds(Long dpopIatOffsetSeconds) { /** * Gets the DPoP proof iat (issued-at) leeway in seconds. + * * @return the configured leeway in seconds, or {@code null} if not set */ public Long getDpopIatLeewaySeconds() { @@ -99,7 +200,9 @@ public Long getDpopIatLeewaySeconds() { /** * Sets the DPoP proof iat (issued-at) leeway in seconds. - * @param dpopIatLeewaySeconds the leeway in seconds to configure (must be non-negative) + * + * @param dpopIatLeewaySeconds the leeway in seconds to configure (must be + * non-negative) * @throws IllegalArgumentException if the value is negative */ public void setDpopIatLeewaySeconds(Long dpopIatLeewaySeconds) { @@ -108,4 +211,50 @@ public void setDpopIatLeewaySeconds(Long dpopIatLeewaySeconds) { } this.dpopIatLeewaySeconds = dpopIatLeewaySeconds; } + + /** + * Gets the maximum number of entries for the in-memory cache. + * + * @return the configured max entries, or {@code null} if not set (uses default + * of 100) + */ + public Integer getCacheMaxEntries() { + return cacheMaxEntries; + } + + /** + * Sets the maximum number of entries for the unified in-memory cache. + * + * @param cacheMaxEntries the max entries to configure (must be positive) + * @throws IllegalArgumentException if the value is not positive + */ + public void setCacheMaxEntries(Integer cacheMaxEntries) { + if (cacheMaxEntries != null && cacheMaxEntries <= 0) { + throw new IllegalArgumentException("cacheMaxEntries must be positive"); + } + this.cacheMaxEntries = cacheMaxEntries; + } + + /** + * Gets the TTL in seconds for cached entries. + * + * @return the configured TTL in seconds, or {@code null} if not set (uses + * default of 600) + */ + public Long getCacheTtlSeconds() { + return cacheTtlSeconds; + } + + /** + * Sets the TTL in seconds for cached entries. + * + * @param cacheTtlSeconds the TTL in seconds to configure (must not be negative) + * @throws IllegalArgumentException if the value is negative + */ + public void setCacheTtlSeconds(Long cacheTtlSeconds) { + if (cacheTtlSeconds != null && cacheTtlSeconds < 0) { + throw new IllegalArgumentException("cacheTtlSeconds must not be negative"); + } + this.cacheTtlSeconds = cacheTtlSeconds; + } } diff --git a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0RequestContext.java b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0RequestContext.java new file mode 100644 index 0000000..6849d4b --- /dev/null +++ b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0RequestContext.java @@ -0,0 +1,76 @@ +package com.auth0.spring.boot; + +import java.util.Collections; +import java.util.Map; + +/** + * Immutable request context passed to {@link Auth0DomainResolver} for dynamic + * domain resolution in Multi-Custom Domain (MCD) scenarios. + *

+ * Contains all the information a resolver needs to determine which issuer + * domains are valid for the incoming request: + *

    + *
  • {@code url} — the URL the API request was made to
  • + *
  • {@code headers} — relevant HTTP request headers (lowercase keys)
  • + *
  • {@code tokenIssuer} — the unverified {@code iss} claim from the + * JWT
  • + *
+ * + *

+ * Warning: The {@code tokenIssuer} has NOT been verified yet. It is + * provided as a routing hint only and must not be trusted on its own. + *

+ * + * @see Auth0DomainResolver + */ +public final class Auth0RequestContext { + + private final String url; + private final Map headers; + private final String tokenIssuer; + + /** + * Creates a new request context. + * + * @param url the request URL + * @param headers the request headers (will be wrapped as unmodifiable) + * @param tokenIssuer the unverified {@code iss} claim from the JWT + */ + public Auth0RequestContext(String url, Map headers, String tokenIssuer) { + this.url = url; + this.headers = headers != null + ? Collections.unmodifiableMap(headers) + : Collections.emptyMap(); + this.tokenIssuer = tokenIssuer; + } + + /** + * Returns the URL the API request was made to. + * + * @return the request URL, or {@code null} if not available + */ + public String getUrl() { + return url; + } + + /** + * Returns an unmodifiable map of request headers (lowercase keys). + * + * @return the request headers; never {@code null} + */ + public Map getHeaders() { + return headers; + } + + /** + * Returns the unverified {@code iss} claim from the incoming JWT. + *

+ * Warning: This value has NOT been verified. Use it only as a + * routing hint (e.g., to look up tenant configuration). + * + * @return the unverified issuer, or {@code null} if not available + */ + public String getTokenIssuer() { + return tokenIssuer; + } +} 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 d13fe08..17ed0c8 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 @@ -8,11 +8,17 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; import org.springframework.test.context.TestPropertySource; import static org.junit.jupiter.api.Assertions.*; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + /** * Test cases for Auth0AutoConfiguration *

@@ -91,7 +97,6 @@ void shouldConfigureDPoPIatOffsetSeconds() { } } - @Nested @SpringBootTest @TestPropertySource(properties = { @@ -136,4 +141,165 @@ void shouldConfigureOnlyDPoPMode() { assertEquals(300, authOptions.getDpopIatOffsetSeconds()); } } + + @Nested + @SpringBootTest + @TestPropertySource(properties = { + "auth0.domain=", + "auth0.audience=https://api.mcd.com", + "auth0.domains[0]=login.acme.com", + "auth0.domains[1]=auth.partner.com", + "auth0.domains[2]=dev.example.com" + }) + class McdDomainsConfigurationTest { + + @Autowired + private AuthOptions authOptions; + + @Test + @DisplayName("Should configure AuthOptions with domains list from YAML") + void shouldConfigureDomainsFromYaml() { + assertNotNull(authOptions); + List domains = authOptions.getDomains(); + assertNotNull(domains); + assertEquals(3, domains.size()); + assertEquals("login.acme.com", domains.get(0)); + assertEquals("auth.partner.com", domains.get(1)); + assertEquals("dev.example.com", domains.get(2)); + } + + @Test + @DisplayName("Should not set single domain when only domains list is configured") + void shouldNotSetSingleDomainWhenOnlyDomainsConfigured() { + assertNull(authOptions.getDomain()); + } + } + + @Nested + @SpringBootTest + @TestPropertySource(properties = { + "auth0.domain=primary.auth0.com", + "auth0.audience=https://api.mcd.com", + "auth0.domains[0]=login.acme.com", + "auth0.domains[1]=auth.partner.com" + }) + class McdDomainsWithPrimaryDomainTest { + + @Autowired + private AuthOptions authOptions; + + @Test + @DisplayName("Should configure both domain and domains when both are set") + void shouldConfigureBothDomainAndDomains() { + assertNotNull(authOptions); + // Primary domain is also set for Auth for Agents scenarios + assertEquals("primary.auth0.com", authOptions.getDomain()); + + List domains = authOptions.getDomains(); + assertNotNull(domains); + assertEquals(2, domains.size()); + assertEquals("login.acme.com", domains.get(0)); + assertEquals("auth.partner.com", domains.get(1)); + } + } + + @Nested + @SpringBootTest + @TestPropertySource(properties = { + "auth0.domain=single.auth0.com", + "auth0.audience=https://api.single.com" + }) + class SingleDomainFallbackTest { + + @Autowired + private AuthOptions authOptions; + + @Test + @DisplayName("Should fall back to single domain when domains list is not configured") + void shouldFallBackToSingleDomain() { + assertNotNull(authOptions); + assertEquals("single.auth0.com", authOptions.getDomain()); + assertNull(authOptions.getDomains()); + } + } + + @Nested + @SpringBootTest(classes = { + Auth0AutoConfiguration.class, + DomainResolverBeanTest.TestConfig.class + }) + @TestPropertySource(properties = { + "auth0.domain=fallback.auth0.com", + "auth0.audience=https://api.resolver.com" + }) + class DomainResolverBeanTest { + + @TestConfiguration + static class TestConfig { + @Bean + public Auth0DomainResolver testDomainResolver() { + return context -> { + String tenant = context.getHeaders().get("x-tenant-id"); + if ("acme".equals(tenant)) { + return Collections.singletonList("login.acme.com"); + } + return Arrays.asList("default1.auth0.com", "default2.auth0.com"); + }; + } + } + + @Autowired + private AuthOptions authOptions; + + @Test + @DisplayName("Should use Auth0DomainResolver bean when present, taking priority over domains list") + void shouldUseResolverBeanWhenPresent() { + assertNotNull(authOptions); + // When a resolver is present, domainsResolver should be set + assertNotNull(authOptions.getDomainsResolver()); + // Static domains list should NOT be set (resolver takes priority) + assertNull(authOptions.getDomains()); + } + + @Test + @DisplayName("Should preserve single domain as fallback alongside resolver") + void shouldPreserveSingleDomainWithResolver() { + assertEquals("fallback.auth0.com", authOptions.getDomain()); + } + } + + @Nested + @SpringBootTest(classes = { + Auth0AutoConfiguration.class, + ResolverPriorityOverDomainsTest.TestConfig.class + }) + @TestPropertySource(properties = { + "auth0.domain=primary.auth0.com", + "auth0.audience=https://api.priority.com", + "auth0.domains[0]=static1.auth0.com", + "auth0.domains[1]=static2.auth0.com" + }) + class ResolverPriorityOverDomainsTest { + + @TestConfiguration + static class TestConfig { + @Bean + public Auth0DomainResolver testDomainResolver() { + return context -> Collections.singletonList("dynamic.auth0.com"); + } + } + + @Autowired + private AuthOptions authOptions; + + @Test + @DisplayName("Should prioritize Auth0DomainResolver over static domains list") + void shouldPrioritizeResolverOverStaticDomains() { + assertNotNull(authOptions); + // Resolver should win over static domains + assertNotNull(authOptions.getDomainsResolver()); + // Static domains should NOT be set since resolver takes priority + assertNull(authOptions.getDomains()); + } + } } \ No newline at end of file diff --git a/auth0-springboot-api/src/test/java/com/auth0/spring/boot/Auth0PropertiesTest.java b/auth0-springboot-api/src/test/java/com/auth0/spring/boot/Auth0PropertiesTest.java index bdca5f1..a05c16c 100644 --- a/auth0-springboot-api/src/test/java/com/auth0/spring/boot/Auth0PropertiesTest.java +++ b/auth0-springboot-api/src/test/java/com/auth0/spring/boot/Auth0PropertiesTest.java @@ -5,6 +5,10 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.DisplayName; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + import static org.junit.jupiter.api.Assertions.*; /** @@ -115,12 +119,62 @@ void shouldAllowZeroValuesForDpopTimingProperties() { @Test @DisplayName("Should reject negative values for DPoP timing properties") void shouldRejectNegativeValuesForDpopTimingProperties() { - assertThrows(IllegalArgumentException.class, () -> - properties.setDpopIatOffsetSeconds(-100L) - ); + assertThrows(IllegalArgumentException.class, () -> properties.setDpopIatOffsetSeconds(-100L)); + + assertThrows(IllegalArgumentException.class, () -> properties.setDpopIatLeewaySeconds(-50L)); + } - assertThrows(IllegalArgumentException.class, () -> - properties.setDpopIatLeewaySeconds(-50L) - ); + // ── Multi-Custom Domain (MCD) Tests ────────────────────────────────── + + @Test + @DisplayName("Should set and get domains list for MCD support") + void shouldSetAndGetDomains() { + List domains = Arrays.asList("login.acme.com", "auth.partner.com"); + properties.setDomains(domains); + + assertEquals(domains, properties.getDomains()); + assertEquals(2, properties.getDomains().size()); + assertEquals("login.acme.com", properties.getDomains().get(0)); + assertEquals("auth.partner.com", properties.getDomains().get(1)); + } + + @Test + @DisplayName("Should have null default value for domains") + void shouldHaveNullDefaultForDomains() { + assertNull(properties.getDomains()); + } + + @Test + @DisplayName("Should handle empty domains list") + void shouldHandleEmptyDomainsList() { + properties.setDomains(Collections.emptyList()); + assertNotNull(properties.getDomains()); + assertTrue(properties.getDomains().isEmpty()); + } + + @Test + @DisplayName("Should handle single domain in domains list") + void shouldHandleSingleDomainInList() { + properties.setDomains(Collections.singletonList("login.acme.com")); + assertEquals(1, properties.getDomains().size()); + assertEquals("login.acme.com", properties.getDomains().get(0)); + } + + @Test + @DisplayName("Should allow domains and domain to coexist") + void shouldAllowDomainsAndDomainToCoexist() { + properties.setDomain("primary.auth0.com"); + properties.setDomains(Arrays.asList("login.acme.com", "auth.partner.com")); + + assertEquals("primary.auth0.com", properties.getDomain()); + assertEquals(2, properties.getDomains().size()); + } + + @Test + @DisplayName("Should handle null domains value") + void shouldHandleNullDomainsValue() { + properties.setDomains(Arrays.asList("login.acme.com")); + properties.setDomains(null); + assertNull(properties.getDomains()); } } \ No newline at end of file From a3949c198cc5fd6da78cbf1234feebfc59b11da3 Mon Sep 17 00:00:00 2001 From: tanya732 Date: Thu, 26 Feb 2026 10:49:45 +0530 Subject: [PATCH 3/9] Feat: Added Transitive dependency --- .../com/auth0/AbstractAuthentication.java | 2 - .../com/auth0/AllowedDPoPAuthentication.java | 3 - .../java/com/auth0/{cache => }/AuthCache.java | 2 +- .../src/main/java/com/auth0/AuthClient.java | 4 - .../main/java/com/auth0/AuthConstants.java | 2 +- .../{validators => }/ClaimValidator.java | 2 +- .../{validators => }/DPoPProofValidator.java | 10 +- .../com/auth0/DisabledDPoPAuthentication.java | 3 - .../auth0/{cache => }/InMemoryAuthCache.java | 2 +- .../auth0/{validators => }/JWTValidator.java | 15 +- .../OidcDiscoveryFetcher.java | 3 +- .../com/auth0/RequiredDPoPAuthentication.java | 4 - .../java/com/auth0/models/AuthOptions.java | 2 +- .../com/auth0/AbstractAuthenticationTest.java | 2 - .../auth0/AllowedDPoPAuthenticationTest.java | 2 - .../{validators => }/ClaimValidatorTest.java | 2 +- .../DPoPProofValidatorTest.java | 2 +- .../auth0/DisabledDPoPAuthenticationTest.java | 1 - .../{validators => }/JWTValidatorTest.java | 3 +- .../OidcDiscoveryFetcherTest.java | 3 +- .../auth0/RequiredDPoPAuthenticationTest.java | 2 - .../auth0/cache/InMemoryAuthCacheTest.java | 1 + .../com/auth0/models/AuthOptionsTest.java | 4 +- auth0-springboot-api-playground/build.gradle | 6 + .../playground/McdDomainResolverExample.java | 31 ++-- .../auth0/playground/ProfileController.java | 2 +- .../auth0/playground/RedisCacheExample.java | 154 ++++++++++++++++++ auth0-springboot-api/build.gradle | 2 +- .../spring/boot/Auth0AutoConfiguration.java | 49 +++--- .../spring/boot/Auth0DomainResolver.java | 69 -------- .../spring/boot/Auth0RequestContext.java | 76 --------- .../boot/Auth0AutoConfigurationTest.java | 9 +- 32 files changed, 232 insertions(+), 242 deletions(-) rename auth0-api-java/src/main/java/com/auth0/{cache => }/AuthCache.java (98%) rename auth0-api-java/src/main/java/com/auth0/{validators => }/ClaimValidator.java (99%) rename auth0-api-java/src/main/java/com/auth0/{validators => }/DPoPProofValidator.java (96%) rename auth0-api-java/src/main/java/com/auth0/{cache => }/InMemoryAuthCache.java (99%) rename auth0-api-java/src/main/java/com/auth0/{validators => }/JWTValidator.java (96%) rename auth0-api-java/src/main/java/com/auth0/{validators => }/OidcDiscoveryFetcher.java (98%) rename auth0-api-java/src/test/java/com/auth0/{validators => }/ClaimValidatorTest.java (99%) rename auth0-api-java/src/test/java/com/auth0/{validators => }/DPoPProofValidatorTest.java (99%) rename auth0-api-java/src/test/java/com/auth0/{validators => }/JWTValidatorTest.java (99%) rename auth0-api-java/src/test/java/com/auth0/{validators => }/OidcDiscoveryFetcherTest.java (98%) create mode 100644 auth0-springboot-api-playground/src/main/java/com/auth0/playground/RedisCacheExample.java delete mode 100644 auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0DomainResolver.java delete mode 100644 auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0RequestContext.java diff --git a/auth0-api-java/src/main/java/com/auth0/AbstractAuthentication.java b/auth0-api-java/src/main/java/com/auth0/AbstractAuthentication.java index 3e84190..6cdc2a2 100644 --- a/auth0-api-java/src/main/java/com/auth0/AbstractAuthentication.java +++ b/auth0-api-java/src/main/java/com/auth0/AbstractAuthentication.java @@ -7,8 +7,6 @@ import com.auth0.models.AuthToken; import com.auth0.models.AuthenticationContext; import com.auth0.models.HttpRequestInfo; -import com.auth0.validators.DPoPProofValidator; -import com.auth0.validators.JWTValidator; import java.util.HashMap; import java.util.List; diff --git a/auth0-api-java/src/main/java/com/auth0/AllowedDPoPAuthentication.java b/auth0-api-java/src/main/java/com/auth0/AllowedDPoPAuthentication.java index cc40a49..851ff78 100644 --- a/auth0-api-java/src/main/java/com/auth0/AllowedDPoPAuthentication.java +++ b/auth0-api-java/src/main/java/com/auth0/AllowedDPoPAuthentication.java @@ -6,10 +6,7 @@ import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.models.AuthenticationContext; import com.auth0.models.HttpRequestInfo; -import com.auth0.validators.DPoPProofValidator; -import com.auth0.validators.JWTValidator; -import java.util.Map; class AllowedDPoPAuthentication extends AbstractAuthentication { public AllowedDPoPAuthentication(JWTValidator jwtValidator, diff --git a/auth0-api-java/src/main/java/com/auth0/cache/AuthCache.java b/auth0-api-java/src/main/java/com/auth0/AuthCache.java similarity index 98% rename from auth0-api-java/src/main/java/com/auth0/cache/AuthCache.java rename to auth0-api-java/src/main/java/com/auth0/AuthCache.java index df7fafd..8cb1a96 100644 --- a/auth0-api-java/src/main/java/com/auth0/cache/AuthCache.java +++ b/auth0-api-java/src/main/java/com/auth0/AuthCache.java @@ -1,4 +1,4 @@ -package com.auth0.cache; +package com.auth0; /** * Cache abstraction for storing authentication-related data such as diff --git a/auth0-api-java/src/main/java/com/auth0/AuthClient.java b/auth0-api-java/src/main/java/com/auth0/AuthClient.java index 7c91086..6831768 100644 --- a/auth0-api-java/src/main/java/com/auth0/AuthClient.java +++ b/auth0-api-java/src/main/java/com/auth0/AuthClient.java @@ -4,10 +4,6 @@ import com.auth0.models.AuthenticationContext; import com.auth0.models.AuthOptions; import com.auth0.models.HttpRequestInfo; -import com.auth0.validators.DPoPProofValidator; -import com.auth0.validators.JWTValidator; - -import java.util.Map; public class AuthClient { diff --git a/auth0-api-java/src/main/java/com/auth0/AuthConstants.java b/auth0-api-java/src/main/java/com/auth0/AuthConstants.java index f4b17a1..48f62b8 100644 --- a/auth0-api-java/src/main/java/com/auth0/AuthConstants.java +++ b/auth0-api-java/src/main/java/com/auth0/AuthConstants.java @@ -1,6 +1,6 @@ package com.auth0; -public class AuthConstants { +class AuthConstants { public static final String AUTHORIZATION_HEADER = "authorization"; public static final String DPOP_HEADER = "dpop"; public static final String BEARER_SCHEME = "bearer"; diff --git a/auth0-api-java/src/main/java/com/auth0/validators/ClaimValidator.java b/auth0-api-java/src/main/java/com/auth0/ClaimValidator.java similarity index 99% rename from auth0-api-java/src/main/java/com/auth0/validators/ClaimValidator.java rename to auth0-api-java/src/main/java/com/auth0/ClaimValidator.java index 6a03216..9db74b0 100644 --- a/auth0-api-java/src/main/java/com/auth0/validators/ClaimValidator.java +++ b/auth0-api-java/src/main/java/com/auth0/ClaimValidator.java @@ -1,4 +1,4 @@ -package com.auth0.validators; +package com.auth0; import com.auth0.exception.*; import com.auth0.jwt.interfaces.DecodedJWT; diff --git a/auth0-api-java/src/main/java/com/auth0/validators/DPoPProofValidator.java b/auth0-api-java/src/main/java/com/auth0/DPoPProofValidator.java similarity index 96% rename from auth0-api-java/src/main/java/com/auth0/validators/DPoPProofValidator.java rename to auth0-api-java/src/main/java/com/auth0/DPoPProofValidator.java index 5169417..61c6c88 100644 --- a/auth0-api-java/src/main/java/com/auth0/validators/DPoPProofValidator.java +++ b/auth0-api-java/src/main/java/com/auth0/DPoPProofValidator.java @@ -1,4 +1,4 @@ -package com.auth0.validators; +package com.auth0; import com.auth0.exception.BaseAuthException; import com.auth0.exception.InvalidDpopProofException; @@ -20,13 +20,13 @@ import java.time.Instant; import java.util.*; -public class DPoPProofValidator { +class DPoPProofValidator { private final AuthOptions options; private final ObjectMapper objectMapper = new ObjectMapper();; - public DPoPProofValidator(AuthOptions options) { + DPoPProofValidator(AuthOptions options) { this.options = options; } @@ -38,7 +38,7 @@ public DPoPProofValidator(AuthOptions options) { * @param requestInfo HTTP request info: method and URL * @throws BaseAuthException if the DPoP proof is invalid. */ - public void validate(String dpopProof, DecodedJWT decodedJwtToken, HttpRequestInfo requestInfo) + void validate(String dpopProof, DecodedJWT decodedJwtToken, HttpRequestInfo requestInfo) throws BaseAuthException { DecodedJWT proofJwt = decodeDPoP(dpopProof); @@ -197,7 +197,7 @@ String calculateJwkThumbprint(Map jwk) throws BaseAuthException } } - public static ECPublicKey convertJwkToEcPublicKey(Map jwkMap) + static ECPublicKey convertJwkToEcPublicKey(Map jwkMap) throws JwkException { Jwk jwk = Jwk.fromValues(jwkMap); diff --git a/auth0-api-java/src/main/java/com/auth0/DisabledDPoPAuthentication.java b/auth0-api-java/src/main/java/com/auth0/DisabledDPoPAuthentication.java index 9b29780..b8f2ae9 100644 --- a/auth0-api-java/src/main/java/com/auth0/DisabledDPoPAuthentication.java +++ b/auth0-api-java/src/main/java/com/auth0/DisabledDPoPAuthentication.java @@ -5,9 +5,6 @@ import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.models.AuthenticationContext; import com.auth0.models.HttpRequestInfo; -import com.auth0.validators.JWTValidator; - -import java.util.Map; class DisabledDPoPAuthentication extends AbstractAuthentication { diff --git a/auth0-api-java/src/main/java/com/auth0/cache/InMemoryAuthCache.java b/auth0-api-java/src/main/java/com/auth0/InMemoryAuthCache.java similarity index 99% rename from auth0-api-java/src/main/java/com/auth0/cache/InMemoryAuthCache.java rename to auth0-api-java/src/main/java/com/auth0/InMemoryAuthCache.java index 1a56b05..96a3a60 100644 --- a/auth0-api-java/src/main/java/com/auth0/cache/InMemoryAuthCache.java +++ b/auth0-api-java/src/main/java/com/auth0/InMemoryAuthCache.java @@ -1,4 +1,4 @@ -package com.auth0.cache; +package com.auth0; import java.util.LinkedHashMap; import java.util.Map; diff --git a/auth0-api-java/src/main/java/com/auth0/validators/JWTValidator.java b/auth0-api-java/src/main/java/com/auth0/JWTValidator.java similarity index 96% rename from auth0-api-java/src/main/java/com/auth0/validators/JWTValidator.java rename to auth0-api-java/src/main/java/com/auth0/JWTValidator.java index 052ced4..fb732f0 100644 --- a/auth0-api-java/src/main/java/com/auth0/validators/JWTValidator.java +++ b/auth0-api-java/src/main/java/com/auth0/JWTValidator.java @@ -1,7 +1,5 @@ -package com.auth0.validators; +package com.auth0; -import com.auth0.cache.AuthCache; -import com.auth0.cache.InMemoryAuthCache; import com.auth0.exception.BaseAuthException; import com.auth0.exception.MissingRequiredArgumentException; import com.auth0.exception.VerifyAccessTokenException; @@ -17,6 +15,7 @@ import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.models.HttpRequestInfo; import com.auth0.models.RequestContext; +import com.auth0.OidcDiscoveryFetcher; import java.net.MalformedURLException; import java.net.URL; @@ -32,7 +31,7 @@ * algorithm * and JWKS (JSON Web Key Set) for public key retrieval. */ -public class JWTValidator { +class JWTValidator { static final String JWKS_CACHE_PREFIX = "jwks:"; @@ -47,7 +46,7 @@ public class JWTValidator { * * @param authOptions Authentication options containing domain and audience */ - public JWTValidator(AuthOptions authOptions) { + JWTValidator(AuthOptions authOptions) { if (authOptions == null) { throw new IllegalArgumentException("AuthOptions cannot be null"); } @@ -66,7 +65,7 @@ public JWTValidator(AuthOptions authOptions) { * @param authOptions Authentication options containing domain and audience * @param jwkProvider Custom JwkProvider for key retrieval */ - public JWTValidator(AuthOptions authOptions, JwkProvider jwkProvider) { + JWTValidator(AuthOptions authOptions, JwkProvider jwkProvider) { if (authOptions == null) { throw new IllegalArgumentException("AuthOptions cannot be null"); } @@ -117,7 +116,7 @@ private static AuthCache resolveCache(AuthOptions options) { * @return the decoded and verified JWT * @throws BaseAuthException if validation fails */ - public DecodedJWT validateToken(String token, HttpRequestInfo httpRequestInfo) throws BaseAuthException { + public DecodedJWT validateToken(String token, HttpRequestInfo httpRequestInfo) throws BaseAuthException { if (token == null || token.trim().isEmpty()) { throw new MissingRequiredArgumentException("access_token"); @@ -167,7 +166,7 @@ public DecodedJWT validateToken(String token, HttpRequestInfo httpRequestInfo) t /** * Validates a JWT and ensures all required scopes are present. */ - public DecodedJWT validateTokenWithRequiredScopes(String token, HttpRequestInfo httpRequestInfo, String... requiredScopes) throws BaseAuthException { + DecodedJWT validateTokenWithRequiredScopes(String token, HttpRequestInfo httpRequestInfo, String... requiredScopes) throws BaseAuthException { DecodedJWT jwt = validateToken(token, httpRequestInfo); try { ClaimValidator.checkRequiredScopes(jwt, requiredScopes); diff --git a/auth0-api-java/src/main/java/com/auth0/validators/OidcDiscoveryFetcher.java b/auth0-api-java/src/main/java/com/auth0/OidcDiscoveryFetcher.java similarity index 98% rename from auth0-api-java/src/main/java/com/auth0/validators/OidcDiscoveryFetcher.java rename to auth0-api-java/src/main/java/com/auth0/OidcDiscoveryFetcher.java index 2cf73f4..e6530f6 100644 --- a/auth0-api-java/src/main/java/com/auth0/validators/OidcDiscoveryFetcher.java +++ b/auth0-api-java/src/main/java/com/auth0/OidcDiscoveryFetcher.java @@ -1,6 +1,5 @@ -package com.auth0.validators; +package com.auth0; -import com.auth0.cache.AuthCache; import com.auth0.exception.VerifyAccessTokenException; import com.auth0.models.OidcMetadata; import com.fasterxml.jackson.databind.JsonNode; diff --git a/auth0-api-java/src/main/java/com/auth0/RequiredDPoPAuthentication.java b/auth0-api-java/src/main/java/com/auth0/RequiredDPoPAuthentication.java index 8dd63f7..eb135a5 100644 --- a/auth0-api-java/src/main/java/com/auth0/RequiredDPoPAuthentication.java +++ b/auth0-api-java/src/main/java/com/auth0/RequiredDPoPAuthentication.java @@ -5,10 +5,6 @@ import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.models.AuthenticationContext; import com.auth0.models.HttpRequestInfo; -import com.auth0.validators.DPoPProofValidator; -import com.auth0.validators.JWTValidator; - -import java.util.Map; class RequiredDPoPAuthentication extends AbstractAuthentication { 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 5771701..95747ea 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 @@ -1,7 +1,7 @@ package com.auth0.models; import com.auth0.DomainResolver; -import com.auth0.cache.AuthCache; +import com.auth0.AuthCache; import com.auth0.enums.DPoPMode; import java.util.ArrayList; diff --git a/auth0-api-java/src/test/java/com/auth0/AbstractAuthenticationTest.java b/auth0-api-java/src/test/java/com/auth0/AbstractAuthenticationTest.java index fa75587..aad6492 100644 --- a/auth0-api-java/src/test/java/com/auth0/AbstractAuthenticationTest.java +++ b/auth0-api-java/src/test/java/com/auth0/AbstractAuthenticationTest.java @@ -8,8 +8,6 @@ import com.auth0.models.AuthToken; import com.auth0.models.AuthenticationContext; import com.auth0.models.HttpRequestInfo; -import com.auth0.validators.DPoPProofValidator; -import com.auth0.validators.JWTValidator; import org.junit.Before; import org.junit.Test; diff --git a/auth0-api-java/src/test/java/com/auth0/AllowedDPoPAuthenticationTest.java b/auth0-api-java/src/test/java/com/auth0/AllowedDPoPAuthenticationTest.java index 2320a00..b7dfeb2 100644 --- a/auth0-api-java/src/test/java/com/auth0/AllowedDPoPAuthenticationTest.java +++ b/auth0-api-java/src/test/java/com/auth0/AllowedDPoPAuthenticationTest.java @@ -7,8 +7,6 @@ import com.auth0.models.AuthToken; import com.auth0.models.AuthenticationContext; import com.auth0.models.HttpRequestInfo; -import com.auth0.validators.DPoPProofValidator; -import com.auth0.validators.JWTValidator; import org.junit.Before; import org.junit.Test; diff --git a/auth0-api-java/src/test/java/com/auth0/validators/ClaimValidatorTest.java b/auth0-api-java/src/test/java/com/auth0/ClaimValidatorTest.java similarity index 99% rename from auth0-api-java/src/test/java/com/auth0/validators/ClaimValidatorTest.java rename to auth0-api-java/src/test/java/com/auth0/ClaimValidatorTest.java index 5d2bd70..2b0c14d 100644 --- a/auth0-api-java/src/test/java/com/auth0/validators/ClaimValidatorTest.java +++ b/auth0-api-java/src/test/java/com/auth0/ClaimValidatorTest.java @@ -1,4 +1,4 @@ -package com.auth0.validators; +package com.auth0; import com.auth0.exception.BaseAuthException; import com.auth0.exception.InsufficientScopeException; diff --git a/auth0-api-java/src/test/java/com/auth0/validators/DPoPProofValidatorTest.java b/auth0-api-java/src/test/java/com/auth0/DPoPProofValidatorTest.java similarity index 99% rename from auth0-api-java/src/test/java/com/auth0/validators/DPoPProofValidatorTest.java rename to auth0-api-java/src/test/java/com/auth0/DPoPProofValidatorTest.java index e062616..7d6a85f 100644 --- a/auth0-api-java/src/test/java/com/auth0/validators/DPoPProofValidatorTest.java +++ b/auth0-api-java/src/test/java/com/auth0/DPoPProofValidatorTest.java @@ -1,4 +1,4 @@ -package com.auth0.validators; +package com.auth0; import com.auth0.exception.*; import com.auth0.jwt.JWT; diff --git a/auth0-api-java/src/test/java/com/auth0/DisabledDPoPAuthenticationTest.java b/auth0-api-java/src/test/java/com/auth0/DisabledDPoPAuthenticationTest.java index 88d75a0..f9b226a 100644 --- a/auth0-api-java/src/test/java/com/auth0/DisabledDPoPAuthenticationTest.java +++ b/auth0-api-java/src/test/java/com/auth0/DisabledDPoPAuthenticationTest.java @@ -5,7 +5,6 @@ import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.models.AuthenticationContext; import com.auth0.models.HttpRequestInfo; -import com.auth0.validators.JWTValidator; import org.junit.Before; import org.junit.Test; diff --git a/auth0-api-java/src/test/java/com/auth0/validators/JWTValidatorTest.java b/auth0-api-java/src/test/java/com/auth0/JWTValidatorTest.java similarity index 99% rename from auth0-api-java/src/test/java/com/auth0/validators/JWTValidatorTest.java rename to auth0-api-java/src/test/java/com/auth0/JWTValidatorTest.java index 38653d7..77e18c3 100644 --- a/auth0-api-java/src/test/java/com/auth0/validators/JWTValidatorTest.java +++ b/auth0-api-java/src/test/java/com/auth0/JWTValidatorTest.java @@ -1,6 +1,5 @@ -package com.auth0.validators; +package com.auth0; -import com.auth0.cache.InMemoryAuthCache; import com.auth0.exception.InsufficientScopeException; import com.auth0.exception.InvalidRequestException; import com.auth0.exception.MissingRequiredArgumentException; diff --git a/auth0-api-java/src/test/java/com/auth0/validators/OidcDiscoveryFetcherTest.java b/auth0-api-java/src/test/java/com/auth0/OidcDiscoveryFetcherTest.java similarity index 98% rename from auth0-api-java/src/test/java/com/auth0/validators/OidcDiscoveryFetcherTest.java rename to auth0-api-java/src/test/java/com/auth0/OidcDiscoveryFetcherTest.java index bc041c4..27c6000 100644 --- a/auth0-api-java/src/test/java/com/auth0/validators/OidcDiscoveryFetcherTest.java +++ b/auth0-api-java/src/test/java/com/auth0/OidcDiscoveryFetcherTest.java @@ -1,6 +1,5 @@ -package com.auth0.validators; +package com.auth0; -import com.auth0.cache.InMemoryAuthCache; import com.auth0.exception.VerifyAccessTokenException; import com.auth0.models.OidcMetadata; import org.apache.http.HttpVersion; diff --git a/auth0-api-java/src/test/java/com/auth0/RequiredDPoPAuthenticationTest.java b/auth0-api-java/src/test/java/com/auth0/RequiredDPoPAuthenticationTest.java index aed1ea2..b328563 100644 --- a/auth0-api-java/src/test/java/com/auth0/RequiredDPoPAuthenticationTest.java +++ b/auth0-api-java/src/test/java/com/auth0/RequiredDPoPAuthenticationTest.java @@ -6,8 +6,6 @@ import com.auth0.models.AuthToken; import com.auth0.models.AuthenticationContext; import com.auth0.models.HttpRequestInfo; -import com.auth0.validators.DPoPProofValidator; -import com.auth0.validators.JWTValidator; import org.junit.Before; import org.junit.Test; diff --git a/auth0-api-java/src/test/java/com/auth0/cache/InMemoryAuthCacheTest.java b/auth0-api-java/src/test/java/com/auth0/cache/InMemoryAuthCacheTest.java index 5c052ed..89b13c4 100644 --- a/auth0-api-java/src/test/java/com/auth0/cache/InMemoryAuthCacheTest.java +++ b/auth0-api-java/src/test/java/com/auth0/cache/InMemoryAuthCacheTest.java @@ -1,5 +1,6 @@ package com.auth0.cache; +import com.auth0.InMemoryAuthCache; import org.junit.Before; import org.junit.Test; diff --git a/auth0-api-java/src/test/java/com/auth0/models/AuthOptionsTest.java b/auth0-api-java/src/test/java/com/auth0/models/AuthOptionsTest.java index 2f44323..b28ddf8 100644 --- a/auth0-api-java/src/test/java/com/auth0/models/AuthOptionsTest.java +++ b/auth0-api-java/src/test/java/com/auth0/models/AuthOptionsTest.java @@ -1,8 +1,8 @@ package com.auth0.models; import com.auth0.DomainResolver; -import com.auth0.cache.AuthCache; -import com.auth0.cache.InMemoryAuthCache; +import com.auth0.AuthCache; +import com.auth0.InMemoryAuthCache; import com.auth0.enums.DPoPMode; import org.junit.Test; diff --git a/auth0-springboot-api-playground/build.gradle b/auth0-springboot-api-playground/build.gradle index 135ff1f..9f8561b 100644 --- a/auth0-springboot-api-playground/build.gradle +++ b/auth0-springboot-api-playground/build.gradle @@ -16,10 +16,16 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-security' + + testImplementation 'org.junit.jupiter:junit-jupiter-api' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' } test { useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + } } tasks.named('bootJar') { diff --git a/auth0-springboot-api-playground/src/main/java/com/auth0/playground/McdDomainResolverExample.java b/auth0-springboot-api-playground/src/main/java/com/auth0/playground/McdDomainResolverExample.java index fd0baf1..0ddf36d 100644 --- a/auth0-springboot-api-playground/src/main/java/com/auth0/playground/McdDomainResolverExample.java +++ b/auth0-springboot-api-playground/src/main/java/com/auth0/playground/McdDomainResolverExample.java @@ -1,7 +1,7 @@ package com.auth0.playground; -import com.auth0.spring.boot.Auth0DomainResolver; -import com.auth0.spring.boot.Auth0RequestContext; +import com.auth0.DomainResolver; +import com.auth0.models.RequestContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -14,20 +14,17 @@ * Example: Multi-Custom Domain (MCD) configuration with a dynamic domain * resolver. *

- * Demonstrates how an end developer uses {@link Auth0DomainResolver} to - * dynamically - * resolve allowed issuer domains at request time — without any direct - * dependency - * on the core {@code auth0-api-java} module. + * Demonstrates how an end developer uses {@link DomainResolver} to dynamically + * resolve allowed issuer domains at request time. *

* *

How it works

*
    - *
  1. Define an {@link Auth0DomainResolver} bean in a {@code @Configuration} + *
  2. Define a {@link DomainResolver} bean in a {@code @Configuration} * class
  3. - *
  4. The auto-configuration picks it up and bridges it into the SDK's - * internal domain resolution pipeline
  5. - *
  6. On each request, the resolver receives an {@link Auth0RequestContext} + *
  7. The auto-configuration picks it up and passes it to the SDK's + * domain resolution pipeline
  8. + *
  9. On each request, the resolver receives a {@link RequestContext} * containing the request URL, headers, and unverified token issuer
  10. *
  11. The resolver returns the list of allowed issuer domains for that * request
  12. @@ -36,7 +33,7 @@ *

    Activation

    *

    * Just define this {@code @Configuration} class in your project. - * The auto-configuration detects the {@link Auth0DomainResolver} bean + * The auto-configuration detects the {@link DomainResolver} bean * automatically — no extra YAML properties needed. *

    * @@ -50,8 +47,8 @@ * against a known allowlist * * - * @see Auth0DomainResolver - * @see Auth0RequestContext + * @see DomainResolver + * @see RequestContext */ @Configuration public class McdDomainResolverExample { @@ -72,7 +69,7 @@ public class McdDomainResolverExample { * Dynamic domain resolver that resolves allowed issuers based on the * {@code X-Tenant-ID} request header. *

    - * The resolver receives an {@link Auth0RequestContext} with: + * The resolver receives a {@link RequestContext} with: *

      *
    • {@code context.getUrl()} — the API request URL
    • *
    • {@code context.getHeaders()} — all request headers (lowercase keys)
    • @@ -88,10 +85,10 @@ public class McdDomainResolverExample { * http://localhost:8080/api/protected * * - * @return an {@link Auth0DomainResolver} that maps tenant IDs to Auth0 domains + * @return a {@link DomainResolver} that maps tenant IDs to Auth0 domains */ @Bean - public Auth0DomainResolver domainResolver() { + public DomainResolver domainResolver() { return context -> { String tenantId = context.getHeaders().get("x-tenant-id"); diff --git a/auth0-springboot-api-playground/src/main/java/com/auth0/playground/ProfileController.java b/auth0-springboot-api-playground/src/main/java/com/auth0/playground/ProfileController.java index f405a4a..6d3c773 100644 --- a/auth0-springboot-api-playground/src/main/java/com/auth0/playground/ProfileController.java +++ b/auth0-springboot-api-playground/src/main/java/com/auth0/playground/ProfileController.java @@ -30,7 +30,7 @@ public Map pub() { * demonstrates that the same controller works seamlessly with * Multi-Custom Domain configurations. *

      - * When an {@link com.auth0.spring.boot.Auth0DomainResolver} bean is + * When a {@link com.auth0.DomainResolver} bean is * defined, the SDK resolves the allowed issuer domains dynamically. * This endpoint does not need any MCD-specific code. *

      diff --git a/auth0-springboot-api-playground/src/main/java/com/auth0/playground/RedisCacheExample.java b/auth0-springboot-api-playground/src/main/java/com/auth0/playground/RedisCacheExample.java new file mode 100644 index 0000000..477d4b2 --- /dev/null +++ b/auth0-springboot-api-playground/src/main/java/com/auth0/playground/RedisCacheExample.java @@ -0,0 +1,154 @@ +package com.auth0.playground; + +import com.auth0.AuthCache; + +/** + * Example: Using Redis as a distributed cache for OIDC discovery metadata and JWKS. + *

      + * The SDK uses a unified cache with key prefixes: + *

        + *
      • {@code discovery:{issuerUrl}} — OIDC discovery metadata
      • + *
      • {@code jwks:{jwksUri}} — JWKS provider instances
      • + *
      + *

      + * By implementing {@link AuthCache}, you can replace the default in-memory LRU + * cache with any distributed backend (Redis, Memcached, etc.) without changing + * any SDK internals. + * + *

      When to use a distributed cache

      + *
        + *
      • Multi-instance deployments where each node shouldn't fetch OIDC/JWKS independently
      • + *
      • Reducing cold-start latency after deployments
      • + *
      • Centralised cache invalidation across all API instances
      • + *
      + * + *

      Spring Boot usage

      + *

      + * Just define an {@link AuthCache} bean — the auto-configuration picks it up + * automatically and wires it into {@code AuthOptions}. No need to create your + * own {@code AuthClient} bean. When an {@code AuthCache} bean is present, + * the {@code cacheMaxEntries} and {@code cacheTtlSeconds} YAML properties + * are ignored. + *

      + *
      {@code
      + * @Configuration
      + * public class CacheConfig {
      + *     @Bean
      + *     public AuthCache authCache(RedisTemplate redisTemplate) {
      + *         return new RedisAuthCache(redisTemplate, 600);
      + *     }
      + * }
      + * }
      + *
      + * 

      Important notes

      + *
        + *
      • The cache stores mixed value types (OidcMetadata, JwkProvider) — the Redis + * serializer must handle this (e.g., Java serialization or a type-aware JSON strategy)
      • + *
      • Implementations must be thread-safe
      • + *
      • {@code get()} must return {@code null} for missing or expired keys — never throw
      • + *
      + */ +public class RedisCacheExample { + + /** + * Example Redis-backed implementation of {@link AuthCache}. + *

      + * This is a reference implementation showing how to adapt a Redis client + * to the SDK's cache interface. Replace the placeholder Redis operations + * with your actual Redis client (Jedis, Lettuce, Spring RedisTemplate, etc.). + */ + public static class RedisAuthCache implements AuthCache { + + // Replace with your actual Redis client + // e.g., private final RedisTemplate redisTemplate; + // e.g., private final JedisPool jedisPool; + // e.g., private final RedisClient lettuceClient; + + private final long ttlSeconds; + private final String keyPrefix; + + /** + * Creates a Redis-backed auth cache. + * + * @param ttlSeconds time-to-live for cached entries in seconds + */ + public RedisAuthCache(long ttlSeconds) { + this(ttlSeconds, "auth0:"); + } + + /** + * Creates a Redis-backed auth cache with a custom key prefix. + * + * @param ttlSeconds time-to-live for cached entries in seconds + * @param keyPrefix prefix for all Redis keys (e.g., "auth0:" to namespace) + */ + public RedisAuthCache(long ttlSeconds, String keyPrefix) { + this.ttlSeconds = ttlSeconds; + this.keyPrefix = keyPrefix; + } + + @Override + public Object get(String key) { + // Example with Spring RedisTemplate: + // return redisTemplate.opsForValue().get(keyPrefix + key); + // + // Example with Jedis: + // try (Jedis jedis = jedisPool.getResource()) { + // byte[] data = jedis.get((keyPrefix + key).getBytes()); + // return data != null ? deserialize(data) : null; + // } + // + // Must return null for missing/expired keys — never throw + return null; + } + + @Override + public void put(String key, Object value) { + // Example with Spring RedisTemplate: + // redisTemplate.opsForValue().set(keyPrefix + key, value, Duration.ofSeconds(ttlSeconds)); + // + // Example with Jedis: + // try (Jedis jedis = jedisPool.getResource()) { + // jedis.setex((keyPrefix + key).getBytes(), ttlSeconds, serialize(value)); + // } + // + // The SDK stores both OIDC metadata and JWKS providers using + // prefixed keys like "discovery:https://tenant.auth0.com/" + // and "jwks:https://tenant.auth0.com/.well-known/jwks.json" + } + + @Override + public void remove(String key) { + // Example with Spring RedisTemplate: + // redisTemplate.delete(keyPrefix + key); + // + // Example with Jedis: + // try (Jedis jedis = jedisPool.getResource()) { + // jedis.del((keyPrefix + key).getBytes()); + // } + } + + @Override + public void clear() { + // Example with Spring RedisTemplate (scan + delete): + // Set keys = redisTemplate.keys(keyPrefix + "*"); + // if (keys != null && !keys.isEmpty()) { + // redisTemplate.delete(keys); + // } + // + // WARNING: KEYS command is expensive in production. + // Consider using SCAN or maintaining a key set for bulk deletion. + } + + @Override + public int size() { + // Example with Spring RedisTemplate: + // Set keys = redisTemplate.keys(keyPrefix + "*"); + // return keys != null ? keys.size() : 0; + // + // This is an approximation — exact count may vary due to TTL expiry. + return 0; + } + } + +} diff --git a/auth0-springboot-api/build.gradle b/auth0-springboot-api/build.gradle index 87fa857..21abbb4 100644 --- a/auth0-springboot-api/build.gradle +++ b/auth0-springboot-api/build.gradle @@ -22,7 +22,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' - implementation project(':auth0-api-java') + api project(':auth0-api-java') implementation 'com.fasterxml.jackson.core:jackson-databind' implementation 'org.apache.httpcomponents:httpclient:4.5.14' 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 04346f9..f60505f 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 @@ -1,5 +1,6 @@ package com.auth0.spring.boot; +import com.auth0.AuthCache; import com.auth0.AuthClient; import com.auth0.DomainResolver; import com.auth0.models.AuthOptions; @@ -16,18 +17,18 @@ *
        *
      1. Single domain — set {@code auth0.domain} in YAML
      2. *
      3. Static MCD list — set {@code auth0.domains} in YAML
      4. - *
      5. Dynamic resolver — define an {@link Auth0DomainResolver} bean
      6. + *
      7. Dynamic resolver — define a {@link DomainResolver} bean
      8. *
      * * Dynamic Domain Resolver *

      * To dynamically resolve allowed issuer domains at request time, define a bean - * implementing {@link Auth0DomainResolver}: + * implementing {@link DomainResolver}: *

      - * + * *
      {@code
        * @Bean
      - * public Auth0DomainResolver domainResolver() {
      + * public DomainResolver domainResolver() {
        *     return context -> {
        *         String tenantId = context.getHeaders().get("x-tenant-id");
        *         return lookupDomainsForTenant(tenantId);
      @@ -43,35 +44,33 @@ public class Auth0AutoConfiguration {
            * Creates an {@link AuthOptions} bean from {@link Auth0Properties}.
            * 

      * Builds the authentication options configuration. - * + * * @param properties the Auth0 configuration properties from * application configuration - * @param domainResolverProvider optional {@link Auth0DomainResolver} bean + * @param domainResolverProvider optional {@link DomainResolver} bean * for dynamic MCD resolution. When present, * it takes precedence over static YAML config. + * @param cacheProvider optional {@link AuthCache} bean for custom + * caching (e.g., Redis). When present, + * {@code cacheMaxEntries} and {@code cacheTtlSeconds} + * properties are ignored. * @return configured AuthOptions instance for creating AuthClient * @see AuthOptions.Builder * @see Auth0Properties */ @Bean public AuthOptions authOptions(Auth0Properties properties, - ObjectProvider domainResolverProvider) { + ObjectProvider domainResolverProvider, + ObjectProvider> cacheProvider) { - Auth0DomainResolver auth0DomainResolver = domainResolverProvider.getIfAvailable(); + DomainResolver domainResolver = domainResolverProvider.getIfAvailable(); + AuthCache cache = cacheProvider.getIfAvailable(); AuthOptions.Builder builder = new AuthOptions.Builder() .audience(properties.getAudience()); - if (auth0DomainResolver != null) { - // Bridge Spring Boot's Auth0DomainResolver → core DomainResolver - DomainResolver coreDomainResolver = coreContext -> { - Auth0RequestContext springContext = new Auth0RequestContext( - coreContext.getUrl(), - coreContext.getHeaders(), - coreContext.getTokenIssuer()); - return auth0DomainResolver.resolveDomains(springContext); - }; - builder.domainsResolver(coreDomainResolver); + if (domainResolver != null) { + builder.domainsResolver(domainResolver); if (properties.getDomain() != null && !properties.getDomain().isEmpty()) { builder.domain(properties.getDomain()); @@ -96,11 +95,15 @@ public AuthOptions authOptions(Auth0Properties properties, builder.dpopIatOffsetSeconds(properties.getDpopIatOffsetSeconds()); } - if (properties.getCacheMaxEntries() != null) { - builder.cacheMaxEntries(properties.getCacheMaxEntries()); - } - if (properties.getCacheTtlSeconds() != null) { - builder.cacheTtlSeconds(properties.getCacheTtlSeconds()); + if (cache != null) { + builder.cache(cache); + } else { + if (properties.getCacheMaxEntries() != null) { + builder.cacheMaxEntries(properties.getCacheMaxEntries()); + } + if (properties.getCacheTtlSeconds() != null) { + builder.cacheTtlSeconds(properties.getCacheTtlSeconds()); + } } return builder.build(); diff --git a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0DomainResolver.java b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0DomainResolver.java deleted file mode 100644 index de83413..0000000 --- a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0DomainResolver.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.auth0.spring.boot; - -import java.util.List; - -/** - * Functional interface for dynamically resolving allowed issuer domains - * based on the incoming request context. - *

      - * Used in Multi-Custom Domain (MCD) scenarios where the set of valid issuers - * cannot be determined statically at configuration time. Define a Spring bean - * implementing this interface, and the auto-configuration will pick it up - * automatically. - *

      - * - * Example: Tenant-based resolution - * - *
      {@code
      - * @Bean
      - * public Auth0DomainResolver domainResolver(TenantService tenantService) {
      - *     return context -> {
      - *         String tenantId = context.getHeaders().get("x-tenant-id");
      - *         String domain = tenantService.getDomain(tenantId);
      - *         return Collections.singletonList(domain);
      - *     };
      - * }
      - * }
      - * - * Example: Issuer-hint based resolution - * - *
      {@code
      - * @Bean
      - * public Auth0DomainResolver domainResolver() {
      - *     return context -> {
      - *         // Use the unverified iss claim as a routing hint
      - *         String issuer = context.getTokenIssuer();
      - *         if (issuer != null && allowedIssuers.contains(issuer)) {
      - *             return Collections.singletonList(issuer);
      - *         }
      - *         return Collections.emptyList();
      - *     };
      - * }
      - * }
      - * - * Priority - *

      - * When an {@code Auth0DomainResolver} bean is present, it takes precedence - * over the static {@code auth0.domains} YAML list. The single - * {@code auth0.domain} - * can still coexist as a fallback. - *

      - * - * @see Auth0RequestContext - * @see Auth0Properties - */ -@FunctionalInterface -public interface Auth0DomainResolver { - - /** - * Resolves the list of allowed issuer domains for the given request context. - * - * @param context the request context containing URL, headers, and - * unverified token issuer - * @return a list of allowed issuer domain strings (e.g., - * {@code ["login.acme.com", "auth.partner.com"]}); - * may return {@code null} or an empty list if no domains can be - * resolved - */ - List resolveDomains(Auth0RequestContext context); -} diff --git a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0RequestContext.java b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0RequestContext.java deleted file mode 100644 index 6849d4b..0000000 --- a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0RequestContext.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.auth0.spring.boot; - -import java.util.Collections; -import java.util.Map; - -/** - * Immutable request context passed to {@link Auth0DomainResolver} for dynamic - * domain resolution in Multi-Custom Domain (MCD) scenarios. - *

      - * Contains all the information a resolver needs to determine which issuer - * domains are valid for the incoming request: - *

        - *
      • {@code url} — the URL the API request was made to
      • - *
      • {@code headers} — relevant HTTP request headers (lowercase keys)
      • - *
      • {@code tokenIssuer} — the unverified {@code iss} claim from the - * JWT
      • - *
      - * - *

      - * Warning: The {@code tokenIssuer} has NOT been verified yet. It is - * provided as a routing hint only and must not be trusted on its own. - *

      - * - * @see Auth0DomainResolver - */ -public final class Auth0RequestContext { - - private final String url; - private final Map headers; - private final String tokenIssuer; - - /** - * Creates a new request context. - * - * @param url the request URL - * @param headers the request headers (will be wrapped as unmodifiable) - * @param tokenIssuer the unverified {@code iss} claim from the JWT - */ - public Auth0RequestContext(String url, Map headers, String tokenIssuer) { - this.url = url; - this.headers = headers != null - ? Collections.unmodifiableMap(headers) - : Collections.emptyMap(); - this.tokenIssuer = tokenIssuer; - } - - /** - * Returns the URL the API request was made to. - * - * @return the request URL, or {@code null} if not available - */ - public String getUrl() { - return url; - } - - /** - * Returns an unmodifiable map of request headers (lowercase keys). - * - * @return the request headers; never {@code null} - */ - public Map getHeaders() { - return headers; - } - - /** - * Returns the unverified {@code iss} claim from the incoming JWT. - *

      - * Warning: This value has NOT been verified. Use it only as a - * routing hint (e.g., to look up tenant configuration). - * - * @return the unverified issuer, or {@code null} if not available - */ - public String getTokenIssuer() { - return tokenIssuer; - } -} 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 17ed0c8..16646fe 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 @@ -1,6 +1,7 @@ package com.auth0.spring.boot; import com.auth0.AuthClient; +import com.auth0.DomainResolver; import com.auth0.models.AuthOptions; import com.auth0.enums.DPoPMode; import org.junit.jupiter.api.DisplayName; @@ -237,7 +238,7 @@ class DomainResolverBeanTest { @TestConfiguration static class TestConfig { @Bean - public Auth0DomainResolver testDomainResolver() { + public DomainResolver testDomainResolver() { return context -> { String tenant = context.getHeaders().get("x-tenant-id"); if ("acme".equals(tenant)) { @@ -252,7 +253,7 @@ public Auth0DomainResolver testDomainResolver() { private AuthOptions authOptions; @Test - @DisplayName("Should use Auth0DomainResolver bean when present, taking priority over domains list") + @DisplayName("Should use DomainResolver bean when present, taking priority over domains list") void shouldUseResolverBeanWhenPresent() { assertNotNull(authOptions); // When a resolver is present, domainsResolver should be set @@ -284,7 +285,7 @@ class ResolverPriorityOverDomainsTest { @TestConfiguration static class TestConfig { @Bean - public Auth0DomainResolver testDomainResolver() { + public DomainResolver testDomainResolver() { return context -> Collections.singletonList("dynamic.auth0.com"); } } @@ -293,7 +294,7 @@ public Auth0DomainResolver testDomainResolver() { private AuthOptions authOptions; @Test - @DisplayName("Should prioritize Auth0DomainResolver over static domains list") + @DisplayName("Should prioritize DomainResolver over static domains list") void shouldPrioritizeResolverOverStaticDomains() { assertNotNull(authOptions); // Resolver should win over static domains From 53a9f2a0c847e17028968db52b5ea2c8ae6d1b2a Mon Sep 17 00:00:00 2001 From: tanya732 Date: Thu, 26 Feb 2026 10:50:41 +0530 Subject: [PATCH 4/9] Updated Readme --- README.md | 4 +- auth0-springboot-api/README.md | 142 +++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0a95c31..b828a5e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![Java Version](https://img.shields.io/badge/java-8%2B-blue) ![License](https://img.shields.io/badge/license-MIT-green) -A comprehensive Java library for Auth0 JWT authentication with built-in **DPoP (Demonstration of Proof-of-Possession)** support. This multi-module project provides both a core authentication library and Spring Boot integration for secure API development. +A comprehensive Java library for Auth0 JWT authentication with built-in **DPoP (Demonstration of Proof-of-Possession)** and **Multi-Custom Domain (MCD)** support. This project provides both a core authentication library and Spring Boot integration for secure API development. ## 🏗️ Architecture Overview @@ -49,6 +49,8 @@ The core library (`auth0-api-java`) is currently an internal module used by the - JWT validation with Auth0 JWKS integration - DPoP proof validation per [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449) +- Multi-Custom Domain (MCD) support — static domain lists, or dynamic resolution at request time +- Extensible caching — pluggable `AuthCache` interface for distributed backends (Redis, Memcached) - Flexible authentication strategies diff --git a/auth0-springboot-api/README.md b/auth0-springboot-api/README.md index a7f470c..c9e68ca 100644 --- a/auth0-springboot-api/README.md +++ b/auth0-springboot-api/README.md @@ -6,6 +6,8 @@ This library builds on top of the standard Spring Security JWT authentication, p - **Complete Spring Security JWT Functionality** - All features from Spring Security JWT Bearer are available - **Built-in DPoP Support** - Industry-leading proof-of-possession token security per [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449) +- **Multi-Custom Domain (MCD) Support** - Validate tokens from multiple Auth0 custom domains with static lists or dynamic resolution +- **Extensible Caching** - Pluggable `AuthCache` interface for OIDC discovery and JWKS caching with distributed backend support (Redis, Memcached) - **Auto-Configuration** - Spring Boot auto-configuration with minimal setup - **Flexible Authentication Modes** - Bearer-only, DPoP-only, or flexible mode supporting both @@ -174,6 +176,146 @@ curl -H "Authorization: DPoP " \ https://your-api.example.com/api/protected ``` +## Multi-Custom Domain (MCD) Support + +For tenants with multiple custom domains, the SDK can validate tokens from any of the configured issuers. There are three ways to configure domain resolution: + +### Option 1: Static Domain List + +Configure a list of allowed issuer domains in `application.yml`: + +```yaml +auth0: + audience: "https://your-api-identifier" + domains: + - "login.acme.com" + - "auth.partner.com" + - "dev.example.com" +``` + +You can also set a primary domain alongside the list: + +```yaml +auth0: + domain: "primary.auth0.com" + audience: "https://your-api-identifier" + domains: + - "login.acme.com" + - "auth.partner.com" +``` + +### Option 2: Dynamic Domain Resolver + +For scenarios where the allowed issuers depend on runtime context (e.g., tenant headers, database lookups), define a `DomainResolver` bean: + +```java +import com.auth0.DomainResolver; + +@Configuration +public class McdConfig { + + @Bean + public DomainResolver domainResolver(TenantService tenantService) { + return context -> { + // context.getHeaders() — request headers (lowercase keys) + // context.getUrl() — the API request URL + // context.getTokenIssuer() — unverified iss claim (routing hint only) + String tenantId = context.getHeaders().get("x-tenant-id"); + String domain = tenantService.getDomain(tenantId); + return Collections.singletonList(domain); + }; + } +} +``` + +When a `DomainResolver` bean is present, it takes precedence over the static `auth0.domains` list. The single `auth0.domain` can still coexist as a fallback. + +### Option 3: Single Domain (Default) + +For single-tenant setups, just use the `auth0.domain` property: + +```yaml +auth0: + domain: "your-tenant.auth0.com" + audience: "https://your-api-identifier" +``` + +## Extensibility + +### Custom Cache Implementation + +The SDK caches OIDC discovery metadata and JWKS providers using a unified cache with key prefixes (`discovery:{issuerUrl}` and `jwks:{jwksUri}`). By default, it uses a thread-safe in-memory LRU cache. + +You can replace this with a distributed cache (Redis, Memcached, etc.) by implementing the `AuthCache` interface: + +```java +import com.auth0.AuthCache; + +public class RedisAuthCache implements AuthCache { + + private final RedisTemplate redisTemplate; + private final Duration ttl; + + public RedisAuthCache(RedisTemplate redisTemplate, Duration ttl) { + this.redisTemplate = redisTemplate; + this.ttl = ttl; + } + + @Override + public Object get(String key) { + return redisTemplate.opsForValue().get(key); + } + + @Override + public void put(String key, Object value) { + redisTemplate.opsForValue().set(key, value, ttl); + } + + @Override + public void remove(String key) { + redisTemplate.delete(key); + } + + @Override + public void clear() { + Set keys = redisTemplate.keys("discovery:*"); + if (keys != null) redisTemplate.delete(keys); + keys = redisTemplate.keys("jwks:*"); + if (keys != null) redisTemplate.delete(keys); + } + + @Override + public int size() { + return 0; // approximate + } +} +``` + +Then define it as a Spring bean — the auto-configuration picks it up automatically and wires it into `AuthOptions`. No need to create your own `AuthClient` bean: + +```java +@Configuration +public class CacheConfig { + + @Bean + public AuthCache authCache(RedisTemplate redisTemplate) { + return new RedisAuthCache(redisTemplate, Duration.ofMinutes(10)); + } +} +``` + +When an `AuthCache` bean is present, the `cacheMaxEntries` and `cacheTtlSeconds` YAML properties are ignored — your implementation controls its own eviction and TTL. + +### Default Cache Settings + +If no custom cache is provided, the built-in in-memory cache is used with these defaults: + +```yaml +auth0: + cacheMaxEntries: 100 # max entries before LRU eviction + cacheTtlSeconds: 600 # 10-minute TTL per entry +``` + ## Advanced Features ### Manual JWT Validation From 792d5e8c2df232e35ea7c298990774926c3ef761 Mon Sep 17 00:00:00 2001 From: tanya732 Date: Tue, 17 Mar 2026 10:49:22 +0530 Subject: [PATCH 5/9] Updated comments and removed commented code --- .../src/main/java/com/auth0/DisabledDPoPAuthentication.java | 1 - .../src/main/java/com/auth0/RequiredDPoPAuthentication.java | 4 +--- .../src/main/java/com/auth0/cache/InMemoryAuthCache.java | 6 ++++++ .../src/main/java/com/auth0/models/HttpRequestInfo.java | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/auth0-api-java/src/main/java/com/auth0/DisabledDPoPAuthentication.java b/auth0-api-java/src/main/java/com/auth0/DisabledDPoPAuthentication.java index 9b29780..4396297 100644 --- a/auth0-api-java/src/main/java/com/auth0/DisabledDPoPAuthentication.java +++ b/auth0-api-java/src/main/java/com/auth0/DisabledDPoPAuthentication.java @@ -25,7 +25,6 @@ public DisabledDPoPAuthentication(JWTValidator jwtValidator, TokenExtractor extr public AuthenticationContext authenticate(HttpRequestInfo requestInfo) throws BaseAuthException { -// Map normalizedHeader = normalize(requestInfo.getHeaders()); try { DecodedJWT jwt = validateBearerToken(requestInfo); diff --git a/auth0-api-java/src/main/java/com/auth0/RequiredDPoPAuthentication.java b/auth0-api-java/src/main/java/com/auth0/RequiredDPoPAuthentication.java index 8dd63f7..d5adcd8 100644 --- a/auth0-api-java/src/main/java/com/auth0/RequiredDPoPAuthentication.java +++ b/auth0-api-java/src/main/java/com/auth0/RequiredDPoPAuthentication.java @@ -19,7 +19,7 @@ public RequiredDPoPAuthentication(JWTValidator jwtValidator, } /** - * Authenticates the request when DPoP Mode is Allowed (Accepts only DPoP tokens) . + * Authenticates the request when DPoP Mode is Required (Accepts only DPoP tokens) . * @param requestInfo HTTP request info * @return AuthenticationContext with JWT claims * @throws BaseAuthException if validation fails @@ -28,8 +28,6 @@ public RequiredDPoPAuthentication(JWTValidator jwtValidator, public AuthenticationContext authenticate(HttpRequestInfo requestInfo) throws BaseAuthException { -// Map normalizedHeader = normalize(requestInfo.getHeaders()); - try { DecodedJWT decodedJWT = validateDpopTokenAndProof(requestInfo); return buildContext(decodedJWT); diff --git a/auth0-api-java/src/main/java/com/auth0/cache/InMemoryAuthCache.java b/auth0-api-java/src/main/java/com/auth0/cache/InMemoryAuthCache.java index c288780..cffd6e1 100644 --- a/auth0-api-java/src/main/java/com/auth0/cache/InMemoryAuthCache.java +++ b/auth0-api-java/src/main/java/com/auth0/cache/InMemoryAuthCache.java @@ -25,6 +25,12 @@ * Uses a {@link ReentrantReadWriteLock} so concurrent reads do not block each * other, * while writes acquire exclusive access. + * Protected by a {@link ReentrantReadWriteLock}. Because the underlying + * {@link LinkedHashMap} is configured in access-order for LRU eviction, + * {@link #get(String)} mutates the map and therefore acquires the write lock. + * As a result, cache operations ({@code get}, {@code put}, {@code remove}, + * {@code clear}) are serialized; only {@link #size()} uses the read lock for + * concurrent inspection. *

      * * @param the type of cached values diff --git a/auth0-api-java/src/main/java/com/auth0/models/HttpRequestInfo.java b/auth0-api-java/src/main/java/com/auth0/models/HttpRequestInfo.java index a2ab878..513ddb5 100644 --- a/auth0-api-java/src/main/java/com/auth0/models/HttpRequestInfo.java +++ b/auth0-api-java/src/main/java/com/auth0/models/HttpRequestInfo.java @@ -15,7 +15,7 @@ public class HttpRequestInfo { public HttpRequestInfo(String httpMethod, String httpUrl, Map headers) throws InvalidRequestException { Asserts.notNull(headers, "Headers map cannot be null"); - this.httpMethod = httpMethod.toUpperCase(); + this.httpMethod = httpMethod != null ? httpMethod.toUpperCase() : null; this.httpUrl = httpUrl; this.headers = normalize(headers); } From 0c4c34e75433530f750b5caa038c2827166f5e49 Mon Sep 17 00:00:00 2001 From: tanya732 Date: Thu, 26 Mar 2026 18:19:40 +0530 Subject: [PATCH 6/9] Remove Empty files --- .../src/main/java/com/auth0/JWTValidator.java | 62 ++++++- .../java/com/auth0/OidcDiscoveryFetcher.java | 29 +++- auth0-springboot-api/EXAMPLES.md | 159 +++++++++++++++++- .../spring/boot/Auth0DomainResolver.java | 1 - .../spring/boot/Auth0RequestContext.java | 1 - 5 files changed, 241 insertions(+), 11 deletions(-) delete mode 100644 auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0DomainResolver.java delete mode 100644 auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0RequestContext.java 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 bf19328..bcf13e7 100644 --- a/auth0-api-java/src/main/java/com/auth0/JWTValidator.java +++ b/auth0-api-java/src/main/java/com/auth0/JWTValidator.java @@ -15,13 +15,13 @@ import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.models.HttpRequestInfo; import com.auth0.models.RequestContext; -import com.auth0.OidcDiscoveryFetcher; import java.net.MalformedURLException; import java.net.URL; import java.security.interfaces.RSAPublicKey; import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.stream.Collectors; /** @@ -138,12 +138,14 @@ public DecodedJWT validateToken(String token, HttpRequestInfo httpRequestInfo) t throw new VerifyAccessTokenException("Token issuer is not in the allowed list"); } - OidcMetadata discovery = performOidcDiscovery(tokenIss); + OidcMetadata discovery = performOidcDiscovery(normalizedIss); - if (!tokenIss.equals(discovery.getIssuer())) { + if (!normalizedIss.equals(normalizeToUrl(discovery.getIssuer()))) { throw new VerifyAccessTokenException("Discovery metadata issuer does not match token issuer"); } + validateJwksUriHost(normalizedIss, discovery.getJwksUri()); + JwkProvider dynamicJwkProvider = getOrCreateJwkProvider(discovery.getJwksUri()); Jwk jwk = dynamicJwkProvider.get(unverifiedJwt.getKeyId()); @@ -156,6 +158,8 @@ public DecodedJWT validateToken(String token, HttpRequestInfo httpRequestInfo) t return verifier.verify(token); + } catch (BaseAuthException e) { + throw e; } catch (Exception e) { throw new VerifyAccessTokenException("signature verification failed", e); } @@ -341,10 +345,37 @@ private List resolveAllowedDomains(String tokenIss, HttpRequestInfo http return Collections.emptyList(); } + /** + * Validates that the JWKS URI host matches the issuer host. + *

      + * Prevents a compromised OIDC discovery endpoint from redirecting JWKS + * fetches to an attacker-controlled domain. + *

      + * + * @param issuerUrl the normalized issuer URL + * @param jwksUri the {@code jwks_uri} from discovery metadata + * @throws VerifyAccessTokenException if the hosts do not match + */ + private void validateJwksUriHost(String issuerUrl, String jwksUri) throws VerifyAccessTokenException { + try { + String issuerHost = new URL(issuerUrl).getHost().toLowerCase(Locale.ROOT); + String jwksHost = new URL(jwksUri).getHost().toLowerCase(Locale.ROOT); + if (!issuerHost.equals(jwksHost)) { + throw new VerifyAccessTokenException("JWKS URI host does not match issuer host"); + } + } catch (MalformedURLException e) { + throw new VerifyAccessTokenException("Invalid URL during JWKS URI host validation", e); + } + } + /** * Normalizes a domain string into a full HTTPS URL with a trailing slash. - * Ensures consistent comparison (e.g., {@code "tenant.auth0.com"} becomes + *

      + * Lowercases the scheme and host per RFC 3986 (scheme and host are + * case-insensitive). Ensures consistent comparison regardless of how the + * domain was configured (e.g., {@code "Tenant.Auth0.com"} becomes * {@code "https://tenant.auth0.com/"}). + *

      * * @param domain the raw domain or URL string * @return the normalized URL, or {@code null} if input is {@code null} @@ -354,9 +385,28 @@ private String normalizeToUrl(String domain) { return null; String url = domain.trim(); - if (!url.toLowerCase().startsWith("http")) { + if (!url.toLowerCase(Locale.ROOT).startsWith("http")) { url = "https://" + url; } - return url.endsWith("/") ? url : url + "/"; + + try { + URL parsed = new URL(url); + StringBuilder normalized = new StringBuilder(); + normalized.append(parsed.getProtocol().toLowerCase(Locale.ROOT)); + normalized.append("://"); + normalized.append(parsed.getHost().toLowerCase(Locale.ROOT)); + int port = parsed.getPort(); + if (port > 0 && port != parsed.getDefaultPort()) { + normalized.append(":").append(port); + } + String path = parsed.getPath(); + normalized.append(path == null || path.isEmpty() ? "/" : path); + String result = normalized.toString(); + return result.endsWith("/") ? result : result + "/"; + } catch (MalformedURLException e) { + // Fallback: simple lowercase normalization + url = url.toLowerCase(Locale.ROOT); + return url.endsWith("/") ? url : url + "/"; + } } } 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 e6530f6..c5f1ea5 100644 --- a/auth0-api-java/src/main/java/com/auth0/OidcDiscoveryFetcher.java +++ b/auth0-api-java/src/main/java/com/auth0/OidcDiscoveryFetcher.java @@ -11,6 +11,7 @@ import org.apache.http.impl.client.HttpClients; import org.apache.http.util.EntityUtils; +import java.io.Closeable; import java.io.IOException; /** @@ -25,8 +26,13 @@ *

      * Thread-safe: delegates thread safety to the {@link AuthCache} implementation. *

      + *

      + * Implements {@link Closeable} to manage the lifecycle of the internal HTTP client. + * Only self-created HTTP clients are closed; externally-provided clients are left + * to the caller. + *

      */ -class OidcDiscoveryFetcher { +class OidcDiscoveryFetcher implements Closeable { static final String CACHE_PREFIX = "discovery:"; private static final String WELL_KNOWN_PATH = ".well-known/openid-configuration"; @@ -34,23 +40,30 @@ class OidcDiscoveryFetcher { private final AuthCache cache; private final CloseableHttpClient httpClient; + private final boolean ownsHttpClient; /** * Creates a fetcher with the provided cache and the default HTTP client. + * The fetcher owns the HTTP client and will close it when {@link #close()} is called. * * @param cache the unified cache instance */ OidcDiscoveryFetcher(AuthCache cache) { - this(cache, HttpClients.createDefault()); + this(cache, HttpClients.createDefault(), true); } /** * Creates a fetcher with the provided cache and a custom HTTP client. + * 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 */ OidcDiscoveryFetcher(AuthCache cache, CloseableHttpClient httpClient) { + this(cache, httpClient, false); + } + + private OidcDiscoveryFetcher(AuthCache cache, CloseableHttpClient httpClient, boolean ownsHttpClient) { if (cache == null) { throw new IllegalArgumentException("cache must not be null"); } @@ -59,6 +72,7 @@ class OidcDiscoveryFetcher { } this.cache = cache; this.httpClient = httpClient; + this.ownsHttpClient = ownsHttpClient; } /** @@ -152,4 +166,15 @@ int cacheSize() { AuthCache getCache() { return cache; } + + /** + * Closes the HTTP client if this fetcher owns it. + * Externally-provided HTTP clients are not closed — the caller manages their lifecycle. + */ + @Override + public void close() throws IOException { + if (ownsHttpClient) { + httpClient.close(); + } + } } diff --git a/auth0-springboot-api/EXAMPLES.md b/auth0-springboot-api/EXAMPLES.md index 3effcc1..d5f7cb7 100644 --- a/auth0-springboot-api/EXAMPLES.md +++ b/auth0-springboot-api/EXAMPLES.md @@ -280,18 +280,164 @@ public class AdminController { } ``` +## Multiple Custom Domains (MCD) + +For APIs that accept tokens from multiple Auth0 custom domains (e.g., multi-tenant SaaS, domain migrations). + +### 1. Static Domain List + +Configure a fixed set of allowed issuer domains in `application.yml`: + +```yaml +auth0: + audience: "https://api.example.com" + domains: + - "login.acme.com" + - "auth.partner.com" + - "dev.example.com" +``` + +Tokens whose `iss` claim matches any of these domains will be accepted. No code changes required. + +### 2. Dynamic Domain Resolver + +For scenarios where the allowed issuers depend on runtime context (tenant headers, database lookups, etc.), define a `DomainResolver` bean: + +```java +import com.auth0.DomainResolver; +import com.auth0.models.RequestContext; + +@Configuration +public class McdConfig { + + @Bean + public DomainResolver domainResolver(TenantService tenantService) { + return context -> { + // context.getHeaders() — request headers (lowercase keys) + // context.getUrl() — the API request URL + // context.getTokenIssuer() — unverified iss claim (routing hint only) + String tenantId = context.getHeaders().get("x-tenant-id"); + List domains = tenantService.getDomainsForTenant(tenantId); + return domains; + }; + } +} +``` + +When a `DomainResolver` bean is present, it takes priority over the static `domains` list. The resolver receives a `RequestContext` with the request URL, headers, and the unverified `iss` claim from the token. + +### 3. Domain + Domains Coexistence (Auth for Agents) + +For Auth for Agents scenarios, `domain` and `domains` can coexist. The `domain` is used for Auth for Agents flows (token exchange, authorization), while `domains` is used for token validation: + +```yaml +auth0: + domain: "primary-tenant.auth0.com" # For Auth for Agents flows + audience: "https://api.example.com" + domains: # For token validation + - "primary-tenant.auth0.com" + - "tenant2.auth0.com" + - "tenant3.auth0.com" +``` + +When both are present, the SDK always uses `domains` for token verification. + +## Caching + +The SDK caches OIDC discovery metadata and JWKS providers in a unified cache. By default, it uses a thread-safe in-memory LRU cache. + +### Cache Configuration + +```yaml +auth0: + domain: "your-tenant.auth0.com" + audience: "https://api.example.com" + cache-max-entries: 100 # Max entries before LRU eviction (default: 100) + cache-ttl-seconds: 600 # TTL per entry in seconds (default: 600 = 10 minutes) +``` + +Both OIDC discovery and JWKS entries count against the `cache-max-entries` limit. + +### Custom Cache Implementation + +Replace the default in-memory cache with a distributed backend (Redis, Memcached, etc.) by implementing the `AuthCache` interface and registering it as a Spring bean: + +```java +import com.auth0.AuthCache; + +public class RedisAuthCache implements AuthCache { + + private final RedisTemplate redisTemplate; + private final Duration ttl; + + public RedisAuthCache(RedisTemplate redisTemplate, Duration ttl) { + this.redisTemplate = redisTemplate; + this.ttl = ttl; + } + + @Override + public Object get(String key) { + return redisTemplate.opsForValue().get(key); + } + + @Override + public void put(String key, Object value) { + redisTemplate.opsForValue().set(key, value, ttl); + } + + @Override + public void remove(String key) { + redisTemplate.delete(key); + } + + @Override + public void clear() { + Set keys = redisTemplate.keys("discovery:*"); + if (keys != null) redisTemplate.delete(keys); + keys = redisTemplate.keys("jwks:*"); + if (keys != null) redisTemplate.delete(keys); + } + + @Override + public int size() { + return 0; // approximate + } +} +``` + +Register it as a bean — the auto-configuration picks it up automatically: + +```java +@Configuration +public class CacheConfig { + + @Bean + public AuthCache authCache(RedisTemplate redisTemplate) { + return new RedisAuthCache(redisTemplate, Duration.ofMinutes(10)); + } +} +``` + +When a custom `AuthCache` bean is present, the `cache-max-entries` and `cache-ttl-seconds` properties are ignored — your implementation controls its own eviction and TTL. + ## Configuration Reference ### Complete Configuration Example ```yaml auth0: - # Required: Your Auth0 domain + # Required (unless domains or domainsResolver is set): Your Auth0 domain domain: "your-tenant.auth0.com" # Required: API identifier/audience audience: "https://api.example.com" + # Optional: Static list of allowed issuer domains (MCD) + # Mutually exclusive with domainsResolver bean + domains: + - "login.acme.com" + - "auth.partner.com" + # Optional: DPoP mode (DISABLED, ALLOWED, REQUIRED) # Default: ALLOWED dpop-mode: ALLOWED @@ -303,6 +449,14 @@ auth0: # Optional: DPoP proof time leeway in seconds # Default: 30 (30 seconds) dpop-iat-leeway-seconds: 30 + + # Optional: Max cache entries before LRU eviction + # Default: 100 + cache-max-entries: 100 + + # Optional: Cache TTL in seconds + # Default: 600 (10 minutes) + cache-ttl-seconds: 600 ``` ### Environment Variables @@ -312,9 +466,12 @@ You can also configure using environment variables: ```bash AUTH0_DOMAIN=your-tenant.auth0.com AUTH0_AUDIENCE=https://api.example.com +AUTH0_DOMAINS=login.acme.com,auth.partner.com AUTH0_DPOPMODE=ALLOWED AUTH0_DPOPIATOFFSETSECONDS=300 AUTH0_DPOPIATLEEWAYSSECONDS=30 +AUTH0_CACHEMAXENTRIES=100 +AUTH0_CACHETTLSECONDS=600 ``` > **Note:** Spring Boot environment variable binding removes dashes and is case-insensitive. Do not use underscores to separate words within a property name (e.g., use `AUTH0_DPOPMODE`, not `AUTH0_DPOP_MODE`). diff --git a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0DomainResolver.java b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0DomainResolver.java deleted file mode 100644 index 8b13789..0000000 --- a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0DomainResolver.java +++ /dev/null @@ -1 +0,0 @@ - diff --git a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0RequestContext.java b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0RequestContext.java deleted file mode 100644 index 8b13789..0000000 --- a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0RequestContext.java +++ /dev/null @@ -1 +0,0 @@ - From a10b4eb16868608498f53d4d9ae3ca0fbd5b6c3f Mon Sep 17 00:00:00 2001 From: tanya732 Date: Wed, 8 Apr 2026 19:22:13 +0530 Subject: [PATCH 7/9] Updated Example and Readme --- README.md | 2 +- auth0-springboot-api/EXAMPLES.md | 27 ++++++++++----------------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 97c8895..ac9e07e 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ It provides: - JWT validation with Auth0 JWKS integration - DPoP proof validation per [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449) -- Multi-Custom Domain (MCD) support — static domain lists, or dynamic resolution at request time +- Multiple Custom Domain (MCD) support — static domain lists, or dynamic resolution at request time - Extensible caching — pluggable `AuthCache` interface for distributed backends (Redis, Memcached) - Flexible authentication strategies diff --git a/auth0-springboot-api/EXAMPLES.md b/auth0-springboot-api/EXAMPLES.md index d5f7cb7..522bb21 100644 --- a/auth0-springboot-api/EXAMPLES.md +++ b/auth0-springboot-api/EXAMPLES.md @@ -282,7 +282,16 @@ public class AdminController { ## Multiple Custom Domains (MCD) -For APIs that accept tokens from multiple Auth0 custom domains (e.g., multi-tenant SaaS, domain migrations). +Multiple Custom Domains (MCD) support enables a single API application to accept access tokens issued by multiple domains associated with the same **Auth0 tenant**, including the canonical domain and its custom domains. + +This is commonly required in scenarios such as: + +1. Multi-brand applications (B2C) where each brand uses a different custom domain but they all share the same API. +2. A single API serves multiple frontend applications that use different custom domains. +3. A gradual migration from the canonical domain to a custom domain, where both domains need to be supported during the transition period. + +In these cases, your API must trust and validate tokens from multiple issuers instead of a single domain. +The SDK supports two approaches for configuring multiple domains, Static Domain List and Dynamic Domain Resolver. ### 1. Static Domain List @@ -326,22 +335,6 @@ public class McdConfig { When a `DomainResolver` bean is present, it takes priority over the static `domains` list. The resolver receives a `RequestContext` with the request URL, headers, and the unverified `iss` claim from the token. -### 3. Domain + Domains Coexistence (Auth for Agents) - -For Auth for Agents scenarios, `domain` and `domains` can coexist. The `domain` is used for Auth for Agents flows (token exchange, authorization), while `domains` is used for token validation: - -```yaml -auth0: - domain: "primary-tenant.auth0.com" # For Auth for Agents flows - audience: "https://api.example.com" - domains: # For token validation - - "primary-tenant.auth0.com" - - "tenant2.auth0.com" - - "tenant3.auth0.com" -``` - -When both are present, the SDK always uses `domains` for token verification. - ## Caching The SDK caches OIDC discovery metadata and JWKS providers in a unified cache. By default, it uses a thread-safe in-memory LRU cache. From b37cdeb3e89f8712ff52c1b04a61f5e81ff443f0 Mon Sep 17 00:00:00 2001 From: tanya732 Date: Wed, 8 Apr 2026 19:26:32 +0530 Subject: [PATCH 8/9] Updated Example and Readme --- auth0-springboot-api/EXAMPLES.md | 2 +- auth0-springboot-api/README.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/auth0-springboot-api/EXAMPLES.md b/auth0-springboot-api/EXAMPLES.md index 522bb21..3b10aec 100644 --- a/auth0-springboot-api/EXAMPLES.md +++ b/auth0-springboot-api/EXAMPLES.md @@ -282,7 +282,7 @@ public class AdminController { ## Multiple Custom Domains (MCD) -Multiple Custom Domains (MCD) support enables a single API application to accept access tokens issued by multiple domains associated with the same **Auth0 tenant**, including the canonical domain and its custom domains. +Multiple Custom Domains (MCD) support enables a single API application to accept access tokens issued by multiple domains associated with the same Auth0 tenant, including the canonical domain and its custom domains. This is commonly required in scenarios such as: diff --git a/auth0-springboot-api/README.md b/auth0-springboot-api/README.md index eae76c6..48e46bb 100644 --- a/auth0-springboot-api/README.md +++ b/auth0-springboot-api/README.md @@ -6,7 +6,7 @@ This library builds on top of the standard Spring Security JWT authentication, p - **Complete Spring Security JWT Functionality** - All features from Spring Security JWT Bearer are available - **Built-in DPoP Support** - Industry-leading proof-of-possession token security per [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449) -- **Multi-Custom Domain (MCD) Support** - Validate tokens from multiple Auth0 custom domains with static lists or dynamic resolution +- **Multiple Custom Domain (MCD) Support** - Validate tokens from multiple Auth0 custom domains with static lists or dynamic resolution - **Extensible Caching** - Pluggable `AuthCache` interface for OIDC discovery and JWKS caching with distributed backend support (Redis, Memcached) - **Auto-Configuration** - Spring Boot auto-configuration with minimal setup - **Flexible Authentication Modes** - Bearer-only, DPoP-only, or flexible mode supporting both @@ -175,9 +175,9 @@ curl -H "Authorization: DPoP " \ https://your-api.example.com/api/protected ``` -## Multi-Custom Domain (MCD) Support +## Multiple Custom Domain (MCD) Support -For tenants with multiple custom domains, the SDK can validate tokens from any of the configured issuers. There are three ways to configure domain resolution: +For tenant with multiple custom domains, the SDK can validate tokens from any of the configured issuers. There are three ways to configure domain resolution: ### Option 1: Static Domain List From f27d4aec750ba92e141da73c72294154b1455cd2 Mon Sep 17 00:00:00 2001 From: tanya732 Date: Wed, 8 Apr 2026 20:51:57 +0530 Subject: [PATCH 9/9] Updated Example and Readme --- auth0-springboot-api/EXAMPLES.md | 6 +++--- auth0-springboot-api/README.md | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/auth0-springboot-api/EXAMPLES.md b/auth0-springboot-api/EXAMPLES.md index 3b10aec..fb08388 100644 --- a/auth0-springboot-api/EXAMPLES.md +++ b/auth0-springboot-api/EXAMPLES.md @@ -301,9 +301,9 @@ Configure a fixed set of allowed issuer domains in `application.yml`: auth0: audience: "https://api.example.com" domains: - - "login.acme.com" - - "auth.partner.com" - - "dev.example.com" + - "brandA.acme.com" + - "brandB.acme.com" + - "brandC.acme.com" ``` Tokens whose `iss` claim matches any of these domains will be accepted. No code changes required. diff --git a/auth0-springboot-api/README.md b/auth0-springboot-api/README.md index 48e46bb..cb1ce68 100644 --- a/auth0-springboot-api/README.md +++ b/auth0-springboot-api/README.md @@ -199,8 +199,9 @@ auth0: domain: "primary.auth0.com" audience: "https://your-api-identifier" domains: - - "login.acme.com" - - "auth.partner.com" + - "brandA.acme.com" + - "brandB.acme.com" + - "brandC.acme.com" ``` ### Option 2: Dynamic Domain Resolver