From b101a3865d7f454a4a2c1469bbe7e0492239a27b Mon Sep 17 00:00:00 2001 From: tanya732 Date: Thu, 19 Feb 2026 21:26:52 +0530 Subject: [PATCH 1/2] 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/2] 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