diff --git a/README.md b/README.md index c5f4c68..97c8895 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![Java Version](https://img.shields.io/badge/java-8%2B-blue) ![License](https://img.shields.io/badge/license-MIT-green) -A comprehensive Java library for Auth0 JWT authentication with built-in **DPoP (Demonstration of Proof-of-Possession)** support. This project provides Spring Boot integration for secure API development. +A comprehensive Java library for Auth0 JWT authentication with built-in **DPoP (Demonstration of Proof-of-Possession)** and **Multi-Custom Domain (MCD)** support. This project provides Spring Boot integration for secure API development. ## 🏗️ Architecture Overview @@ -49,6 +49,8 @@ It provides: - JWT validation with Auth0 JWKS integration - DPoP proof validation per [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449) +- Multi-Custom Domain (MCD) support — static domain lists, or dynamic resolution at request time +- Extensible caching — pluggable `AuthCache` interface for distributed backends (Redis, Memcached) - Flexible authentication strategies 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..6cdc2a2 100644 --- a/auth0-api-java/src/main/java/com/auth0/AbstractAuthentication.java +++ b/auth0-api-java/src/main/java/com/auth0/AbstractAuthentication.java @@ -7,8 +7,6 @@ import com.auth0.models.AuthToken; import com.auth0.models.AuthenticationContext; import com.auth0.models.HttpRequestInfo; -import com.auth0.validators.DPoPProofValidator; -import com.auth0.validators.JWTValidator; import java.util.HashMap; import java.util.List; @@ -28,21 +26,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 +50,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..851ff78 100644 --- a/auth0-api-java/src/main/java/com/auth0/AllowedDPoPAuthentication.java +++ b/auth0-api-java/src/main/java/com/auth0/AllowedDPoPAuthentication.java @@ -6,10 +6,7 @@ import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.models.AuthenticationContext; import com.auth0.models.HttpRequestInfo; -import com.auth0.validators.DPoPProofValidator; -import com.auth0.validators.JWTValidator; -import java.util.Map; class AllowedDPoPAuthentication extends AbstractAuthentication { public AllowedDPoPAuthentication(JWTValidator jwtValidator, @@ -20,30 +17,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/AuthCache.java b/auth0-api-java/src/main/java/com/auth0/AuthCache.java new file mode 100644 index 0000000..1adb3b6 --- /dev/null +++ b/auth0-api-java/src/main/java/com/auth0/AuthCache.java @@ -0,0 +1,65 @@ +package com.auth0; + +/** + * 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}). Users can implement this interface + * to plug in distributed cache backends (e.g., Redis, Memcached) without + * breaking changes to the SDK's public API. + *

+ * + *

+ * 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/AuthClient.java b/auth0-api-java/src/main/java/com/auth0/AuthClient.java index cd2c99b..6831768 100644 --- a/auth0-api-java/src/main/java/com/auth0/AuthClient.java +++ b/auth0-api-java/src/main/java/com/auth0/AuthClient.java @@ -4,10 +4,6 @@ import com.auth0.models.AuthenticationContext; import com.auth0.models.AuthOptions; import com.auth0.models.HttpRequestInfo; -import com.auth0.validators.DPoPProofValidator; -import com.auth0.validators.JWTValidator; - -import java.util.Map; public class AuthClient { @@ -45,12 +41,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/AuthConstants.java b/auth0-api-java/src/main/java/com/auth0/AuthConstants.java index f4b17a1..48f62b8 100644 --- a/auth0-api-java/src/main/java/com/auth0/AuthConstants.java +++ b/auth0-api-java/src/main/java/com/auth0/AuthConstants.java @@ -1,6 +1,6 @@ package com.auth0; -public class AuthConstants { +class AuthConstants { public static final String AUTHORIZATION_HEADER = "authorization"; public static final String DPOP_HEADER = "dpop"; public static final String BEARER_SCHEME = "bearer"; diff --git a/auth0-api-java/src/main/java/com/auth0/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/validators/ClaimValidator.java b/auth0-api-java/src/main/java/com/auth0/ClaimValidator.java similarity index 94% rename from auth0-api-java/src/main/java/com/auth0/validators/ClaimValidator.java rename to auth0-api-java/src/main/java/com/auth0/ClaimValidator.java index 6a03216..dd9fb17 100644 --- a/auth0-api-java/src/main/java/com/auth0/validators/ClaimValidator.java +++ b/auth0-api-java/src/main/java/com/auth0/ClaimValidator.java @@ -1,4 +1,4 @@ -package com.auth0.validators; +package com.auth0; import com.auth0.exception.*; import com.auth0.jwt.interfaces.DecodedJWT; @@ -6,10 +6,7 @@ import java.util.*; /** - * Utility class for JWT claim validation - * - * Provides functionality to validate JWT claims including scopes and custom - * claim checks. + * Utility class for JWT claim validation. Provides functionality to validate JWT claims including scopes and custom claim checks. * This is the Java equivalent of the TypeScript claim validation utilities. */ class ClaimValidator { @@ -27,13 +24,11 @@ static Set getClaimValues(DecodedJWT jwt, String claimName) throws BaseA throw new VerifyAccessTokenException("Required claim is missing"); } - // Case 1: space-separated string String strValue = jwt.getClaim(claimName).asString(); if (strValue != null) { return new HashSet<>(Arrays.asList(strValue.trim().split("\\s+"))); } - // Case 2: list of strings List listValue = jwt.getClaim(claimName).asList(String.class); if (listValue != null) { return new HashSet<>(listValue); diff --git a/auth0-api-java/src/main/java/com/auth0/validators/DPoPProofValidator.java b/auth0-api-java/src/main/java/com/auth0/DPoPProofValidator.java similarity index 96% rename from auth0-api-java/src/main/java/com/auth0/validators/DPoPProofValidator.java rename to auth0-api-java/src/main/java/com/auth0/DPoPProofValidator.java index 5169417..61c6c88 100644 --- a/auth0-api-java/src/main/java/com/auth0/validators/DPoPProofValidator.java +++ b/auth0-api-java/src/main/java/com/auth0/DPoPProofValidator.java @@ -1,4 +1,4 @@ -package com.auth0.validators; +package com.auth0; import com.auth0.exception.BaseAuthException; import com.auth0.exception.InvalidDpopProofException; @@ -20,13 +20,13 @@ import java.time.Instant; import java.util.*; -public class DPoPProofValidator { +class DPoPProofValidator { private final AuthOptions options; private final ObjectMapper objectMapper = new ObjectMapper();; - public DPoPProofValidator(AuthOptions options) { + DPoPProofValidator(AuthOptions options) { this.options = options; } @@ -38,7 +38,7 @@ public DPoPProofValidator(AuthOptions options) { * @param requestInfo HTTP request info: method and URL * @throws BaseAuthException if the DPoP proof is invalid. */ - public void validate(String dpopProof, DecodedJWT decodedJwtToken, HttpRequestInfo requestInfo) + void validate(String dpopProof, DecodedJWT decodedJwtToken, HttpRequestInfo requestInfo) throws BaseAuthException { DecodedJWT proofJwt = decodeDPoP(dpopProof); @@ -197,7 +197,7 @@ String calculateJwkThumbprint(Map jwk) throws BaseAuthException } } - public static ECPublicKey convertJwkToEcPublicKey(Map jwkMap) + static ECPublicKey convertJwkToEcPublicKey(Map jwkMap) throws JwkException { Jwk jwk = Jwk.fromValues(jwkMap); diff --git a/auth0-api-java/src/main/java/com/auth0/DisabledDPoPAuthentication.java b/auth0-api-java/src/main/java/com/auth0/DisabledDPoPAuthentication.java index 6f166f8..7ff0e20 100644 --- a/auth0-api-java/src/main/java/com/auth0/DisabledDPoPAuthentication.java +++ b/auth0-api-java/src/main/java/com/auth0/DisabledDPoPAuthentication.java @@ -5,9 +5,6 @@ import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.models.AuthenticationContext; import com.auth0.models.HttpRequestInfo; -import com.auth0.validators.JWTValidator; - -import java.util.Map; class DisabledDPoPAuthentication extends AbstractAuthentication { @@ -17,18 +14,16 @@ 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); 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/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/InMemoryAuthCache.java b/auth0-api-java/src/main/java/com/auth0/InMemoryAuthCache.java new file mode 100644 index 0000000..156be5a --- /dev/null +++ b/auth0-api-java/src/main/java/com/auth0/InMemoryAuthCache.java @@ -0,0 +1,156 @@ +package com.auth0; + +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. + * Protected by a {@link ReentrantReadWriteLock}. Because the underlying + * {@link LinkedHashMap} is configured in access-order for LRU eviction, + * {@link #get(String)} mutates the map and therefore acquires the write lock. + * As a result, cache operations ({@code get}, {@code put}, {@code remove}, + * {@code clear}) are serialized; only {@link #size()} uses the read lock for + * concurrent inspection. + *

+ * + * @param the type of cached values + */ +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; + + 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; + } + 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/JWTValidator.java b/auth0-api-java/src/main/java/com/auth0/JWTValidator.java new file mode 100644 index 0000000..bf19328 --- /dev/null +++ b/auth0-api-java/src/main/java/com/auth0/JWTValidator.java @@ -0,0 +1,362 @@ +package com.auth0; + +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 com.auth0.OidcDiscoveryFetcher; + +import java.net.MalformedURLException; +import java.net.URL; +import java.security.interfaces.RSAPublicKey; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * JWT Validator for Auth0 tokens + * + * This class provides functionality to validate JWT tokens using RSA256 + * algorithm + * and JWKS (JSON Web Key Set) for public key retrieval. + */ +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 + */ + JWTValidator(AuthOptions authOptions) { + if (authOptions == null) { + throw new IllegalArgumentException("AuthOptions cannot be null"); + } + + this.authOptions = authOptions; + 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, audience, and a custom JwkProvider. + * + * @param authOptions Authentication options containing domain and audience + * @param jwkProvider Custom JwkProvider for key retrieval + */ + JWTValidator(AuthOptions authOptions, JwkProvider jwkProvider) { + if (authOptions == null) { + throw new IllegalArgumentException("AuthOptions cannot be null"); + } + if (jwkProvider == null) { + throw new IllegalArgumentException("JwkProvider cannot be null"); + } + this.authOptions = authOptions; + this.jwkProvider = jwkProvider; + this.cache = resolveCache(authOptions); + this.discoveryFetcher = new OidcDiscoveryFetcher(this.cache); + } + + /** + * 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()); + } + + /** + * Validates a JWT token + * + * @param token the JWT token to validate + * @return the decoded and verified JWT + * @throws BaseAuthException if validation fails + */ + public DecodedJWT validateToken(String token, HttpRequestInfo httpRequestInfo) throws BaseAuthException { + + if (token == null || token.trim().isEmpty()) { + throw new MissingRequiredArgumentException("access_token"); + } + + try { + 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); + + String normalizedIss = normalizeToUrl(tokenIss); + if (!allowedDomains.contains(normalizedIss)) { + throw new VerifyAccessTokenException("Token issuer is not in the allowed list"); + } + + 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) { + throw new VerifyAccessTokenException("signature verification failed", e); + } + } + + /** + * Validates a JWT and ensures all required scopes are present. + */ + DecodedJWT validateTokenWithRequiredScopes(String token, HttpRequestInfo httpRequestInfo, String... requiredScopes) throws BaseAuthException { + DecodedJWT jwt = validateToken(token, httpRequestInfo); + try { + ClaimValidator.checkRequiredScopes(jwt, requiredScopes); + return jwt; + } catch (Exception e) { + throw wrapAsValidationException(e); + } + } + + /** + * Validates a JWT and ensures it has *any* of the provided scopes. + */ + public DecodedJWT validateTokenWithAnyScope(String token, HttpRequestInfo httpRequestInfo, String... scopes) throws BaseAuthException { + DecodedJWT jwt = validateToken(token, httpRequestInfo); + try { + ClaimValidator.checkAnyScope(jwt, scopes); + return jwt; + } catch (Exception e) { + throw wrapAsValidationException(e); + } + } + + /** + * Validates a JWT and ensures a claim equals the expected value. + */ + public DecodedJWT validateTokenWithClaimEquals(String token, HttpRequestInfo httpRequestInfo, String claim, Object expected) throws BaseAuthException { + DecodedJWT jwt = validateToken(token, httpRequestInfo); + try { + ClaimValidator.checkClaimEquals(jwt, claim, expected); + return jwt; + } catch (Exception e) { + throw wrapAsValidationException(e); + } + } + + /** + * Validates a JWT and ensures a claim includes all expected values. + */ + public DecodedJWT validateTokenWithClaimIncludes(String token, HttpRequestInfo httpRequestInfo, String claim, Object... expectedValues) throws BaseAuthException { + DecodedJWT jwt = validateToken(token, httpRequestInfo); + try { + ClaimValidator.checkClaimIncludes(jwt, claim, expectedValues); + return jwt; + } catch (Exception e) { + throw wrapAsValidationException(e); + } + } + + public DecodedJWT validateTokenWithClaimIncludesAny(String token, HttpRequestInfo httpRequestInfo, String claim, Object... expectedValues) throws BaseAuthException { + DecodedJWT jwt = validateToken(token, httpRequestInfo); + try { + ClaimValidator.checkClaimIncludesAny(jwt, claim, expectedValues); + return jwt; + } catch (Exception e) { + throw wrapAsValidationException(e); + } + } + + public DecodedJWT decodeToken(String token) throws BaseAuthException { + try { + return JWT.decode(token); + } catch (Exception e) { + throw new VerifyAccessTokenException("Failed to decode JWT"); + } + } + + private BaseAuthException wrapAsValidationException(Exception e) { + if (e instanceof BaseAuthException) + return (BaseAuthException) e; + return new VerifyAccessTokenException("JWT claim validation failed"); + } + + public AuthOptions getAuthOptions() { + return authOptions; + } + + 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/OidcDiscoveryFetcher.java b/auth0-api-java/src/main/java/com/auth0/OidcDiscoveryFetcher.java new file mode 100644 index 0000000..e6530f6 --- /dev/null +++ b/auth0-api-java/src/main/java/com/auth0/OidcDiscoveryFetcher.java @@ -0,0 +1,155 @@ +package com.auth0; + +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/main/java/com/auth0/RequiredDPoPAuthentication.java b/auth0-api-java/src/main/java/com/auth0/RequiredDPoPAuthentication.java index 0838815..2fc6a4b 100644 --- a/auth0-api-java/src/main/java/com/auth0/RequiredDPoPAuthentication.java +++ b/auth0-api-java/src/main/java/com/auth0/RequiredDPoPAuthentication.java @@ -5,10 +5,6 @@ import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.models.AuthenticationContext; import com.auth0.models.HttpRequestInfo; -import com.auth0.validators.DPoPProofValidator; -import com.auth0.validators.JWTValidator; - -import java.util.Map; class RequiredDPoPAuthentication extends AbstractAuthentication { @@ -19,20 +15,17 @@ public RequiredDPoPAuthentication(JWTValidator jwtValidator, } /** - * Authenticates the request when DPoP Mode is Allowed (Accepts only DPoP tokens) . - * @param headers request headers + * Authenticates the request when DPoP Mode is Required (Accepts only DPoP tokens) . * @param requestInfo HTTP request info * @return AuthenticationContext with JWT claims * @throws BaseAuthException if validation fails */ @Override - public AuthenticationContext authenticate(Map headers, HttpRequestInfo requestInfo) + public AuthenticationContext authenticate(HttpRequestInfo requestInfo) throws BaseAuthException { - Map normalizedHeader = normalize(headers); - 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 deleted file mode 100644 index cde1962..0000000 --- a/auth0-api-java/src/main/java/com/auth0/examples/Auth0ApiExample.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.auth0.examples; - -import com.auth0.AuthClient; -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.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import com.sun.net.httpserver.HttpServer; - -import java.io.IOException; -import java.io.OutputStream; -import java.net.InetSocketAddress; -import java.util.HashMap; -import java.util.Map; - -public class Auth0ApiExample { - - private static final String DOMAIN = "your-tenant.auth0.com"; - private static final String AUDIENCE = "https://your-api-identifier"; - - public static void main(String[] args) throws Exception { - - // Create simple HTTP server on port 8080 - HttpServer server = HttpServer.create(new InetSocketAddress(8000), 0); - - // Public endpoint - server.createContext("/open-endpoint", new HttpHandler() { - @Override - public void handle(HttpExchange exchange) throws IOException { - sendResponse(exchange, "Open endpoint: no authentication needed", 200); - } - }); - - // Build Auth0 options -> AuthClient - AuthOptions options = new AuthOptions.Builder() - .domain(DOMAIN) - .audience(AUDIENCE) - .dpopMode(DPoPMode.REQUIRED) - .dpopIatOffsetSeconds(300) - .dpopIatLeewaySeconds(30)// or REQUIRED / DISABLED - .build(); - - AuthClient client = AuthClient.from(options); - - // Protected endpoint - server.createContext("/api/protected", - new AuthHandler(client)); - - server.setExecutor(null); // Default executor - server.start(); - - System.out.println("🚀 Server started on http://localhost:8000"); - System.out.println("➡ Try:"); - System.out.println(" curl http://localhost:8000/open-endpoint"); - System.out.println(" curl -H \"Authorization: Bearer \" http://localhost:8000/restricted-endpoint"); - } - - private static void sendResponse(HttpExchange exchange, String response, int statusCode) - throws IOException { - - byte[] bytes = response.getBytes("UTF-8"); - - exchange.sendResponseHeaders(statusCode, bytes.length); - - OutputStream os = exchange.getResponseBody(); - os.write(bytes); - os.close(); - } - - static class AuthHandler implements HttpHandler { - - private final AuthClient authClient; - - public AuthHandler(AuthClient authClient) { - this.authClient = authClient; - } - - @Override - public void handle(HttpExchange exchange) throws IOException { - - // Normalize headers (lowercase keys) - Map headers = new HashMap(); - - String auth = exchange.getRequestHeaders().getFirst("Authorization"); - - headers.put("authorization", auth); - - - String dpopHeader = exchange.getRequestHeaders().getFirst("DPoP"); - - if(dpopHeader != null) { - headers.put("DPoP", dpopHeader); - } - - - // Build HttpRequestInfo (needed for DPoP htm + htu validation) - HttpRequestInfo requestInfo = new HttpRequestInfo( - exchange.getRequestMethod(), - "http://localhost:8000" + exchange.getRequestURI().toString(), null - ); - - System.out.println("Incoming request to " + requestInfo.toString()); - - try { - AuthenticationContext claims = - authClient.verifyRequest(headers, requestInfo); - - String user = (String) claims.getClaims().get("sub"); - - sendResponse(exchange, - "Authenticated access granted! User: " + user, - 200); - - } catch (BaseAuthException e) { - sendResponse(exchange, String.valueOf(e.getHeaders()), - e.getStatusCode()); - - } catch (IllegalArgumentException e) { - sendResponse(exchange, - "Bad request: " + e.getMessage(), - 400); - } - } - } -} \ No newline at end of file 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..e4569c5 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.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,81 @@ 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"); + if (domains != null && !domains.isEmpty() && domainsResolver != null) { + throw new IllegalArgumentException( + "Cannot configure both 'domains' and 'domainsResolver'. Use one or the other."); } + + 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 672857a..513ddb5 100644 --- a/auth0-api-java/src/main/java/com/auth0/models/HttpRequestInfo.java +++ b/auth0-api-java/src/main/java/com/auth0/models/HttpRequestInfo.java @@ -1,17 +1,27 @@ package com.auth0.models; -import java.util.Collections; +import com.auth0.exception.BaseAuthException; +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 +32,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/models/OidcMetadata.java b/auth0-api-java/src/main/java/com/auth0/models/OidcMetadata.java new file mode 100644 index 0000000..e271075 --- /dev/null +++ b/auth0-api-java/src/main/java/com/auth0/models/OidcMetadata.java @@ -0,0 +1,35 @@ +package com.auth0.models; + +/** + * Represents the relevant fields from the OIDC discovery document. + */ +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..7dc322a --- /dev/null +++ b/auth0-api-java/src/main/java/com/auth0/models/RequestContext.java @@ -0,0 +1,100 @@ +package com.auth0.models; + +import java.util.Collections; +import java.util.Map; + +/** + * Contextual information about the incoming API request, provided to the domain resolver. + */ +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. + * + * @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 deleted file mode 100644 index 9d655dc..0000000 --- a/auth0-api-java/src/main/java/com/auth0/validators/JWTValidator.java +++ /dev/null @@ -1,180 +0,0 @@ -package com.auth0.validators; - -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.jwk.Jwk; -import com.auth0.jwk.JwkProvider; -import com.auth0.jwk.UrlJwkProvider; -import com.auth0.jwt.algorithms.Algorithm; -import com.auth0.jwt.interfaces.DecodedJWT; -import com.auth0.models.HttpRequestInfo; - -import java.security.interfaces.RSAPublicKey; -import java.util.Map; - -import static com.auth0.jwt.JWT.require; - -/** - * JWT Validator for Auth0 tokens - * - * This class provides functionality to validate JWT tokens using RSA256 - * algorithm - * and JWKS (JSON Web Key Set) for public key retrieval. - */ -public class JWTValidator { - - private final AuthOptions authOptions; - private final JwkProvider jwkProvider; - - /** - * Creates a JWT validator with domain and audience. - * - * @param authOptions Authentication options containing domain and audience - */ - public JWTValidator(AuthOptions authOptions) { - if (authOptions == null) { - throw new IllegalArgumentException("AuthOptions cannot be null"); - } - - this.authOptions = authOptions; - this.jwkProvider = new UrlJwkProvider(authOptions.getDomain()); - } - - /** - * Creates a JWT validator with domain and audience. - * - * @param authOptions Authentication options containing domain and audience - */ - public JWTValidator(AuthOptions authOptions, JwkProvider jwkProvider) { - if (authOptions == null) { - throw new IllegalArgumentException("AuthOptions cannot be null"); - } - if (jwkProvider == null) { - throw new IllegalArgumentException("JwkProvider cannot be null"); - } - this.authOptions = authOptions; - this.jwkProvider = jwkProvider; - } - - /** - * Validates a JWT token - * - * @param token the JWT token to validate - * @return the decoded and verified JWT - * @throws BaseAuthException if validation fails - */ - public DecodedJWT validateToken(String token, Map headers, HttpRequestInfo httpRequestInfo) throws BaseAuthException { - - if (token == null || token.trim().isEmpty()) { - throw new MissingRequiredArgumentException("access_token"); - } - - 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() + "/") - .withAudience(authOptions.getAudience()) - .build(); - return verifier.verify(token); - - } catch (Exception e) { - throw new VerifyAccessTokenException("signature verification failed", e); - } - } - - /** - * Validates a JWT and ensures all required scopes are present. - */ - public DecodedJWT validateTokenWithRequiredScopes(String token, Map headers, HttpRequestInfo httpRequestInfo, String... requiredScopes) - throws BaseAuthException { - DecodedJWT jwt = validateToken(token, headers, httpRequestInfo); - try { - ClaimValidator.checkRequiredScopes(jwt, requiredScopes); - return jwt; - } catch (Exception e) { - throw wrapAsValidationException(e); - } - } - - /** - * Validates a JWT and ensures it has *any* of the provided scopes. - */ - public DecodedJWT validateTokenWithAnyScope(String token, Map headers, HttpRequestInfo httpRequestInfo, String... scopes) - throws BaseAuthException { - DecodedJWT jwt = validateToken(token, headers, httpRequestInfo); - try { - ClaimValidator.checkAnyScope(jwt, scopes); - return jwt; - } catch (Exception e) { - throw wrapAsValidationException(e); - } - } - - /** - * Validates a JWT and ensures a claim equals the expected value. - */ - public DecodedJWT validateTokenWithClaimEquals(String token, Map headers, HttpRequestInfo httpRequestInfo, String claim, Object expected) - throws BaseAuthException { - DecodedJWT jwt = validateToken(token, headers, httpRequestInfo); - try { - ClaimValidator.checkClaimEquals(jwt, claim, expected); - return jwt; - } catch (Exception e) { - throw wrapAsValidationException(e); - } - } - - /** - * Validates a JWT and ensures a claim includes all expected values. - */ - public DecodedJWT validateTokenWithClaimIncludes(String token, Map headers, HttpRequestInfo httpRequestInfo, String claim, Object... expectedValues) - throws BaseAuthException { - DecodedJWT jwt = validateToken(token, headers, httpRequestInfo); - try { - ClaimValidator.checkClaimIncludes(jwt, claim, expectedValues); - return jwt; - } catch (Exception e) { - throw wrapAsValidationException(e); - } - } - - public DecodedJWT validateTokenWithClaimIncludesAny(String token, Map headers, HttpRequestInfo httpRequestInfo, String claim, Object... expectedValues) - throws BaseAuthException { - DecodedJWT jwt = validateToken(token, headers, httpRequestInfo); - try { - ClaimValidator.checkClaimIncludesAny(jwt, claim, expectedValues); - return jwt; - } catch (Exception e) { - throw wrapAsValidationException(e); - } - } - - - public DecodedJWT decodeToken(String token) throws BaseAuthException { - try { - return JWT.decode(token); - } catch (Exception e) { - throw new VerifyAccessTokenException("Failed to decode JWT"); - } - } - - private BaseAuthException wrapAsValidationException(Exception e) { - if (e instanceof BaseAuthException) return (BaseAuthException) e; - return new VerifyAccessTokenException("JWT claim validation failed"); - } - - public AuthOptions getAuthOptions() { - return authOptions; - } - - public JwkProvider getJwkProvider() { - return jwkProvider; - } -} 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..aad6492 100644 --- a/auth0-api-java/src/test/java/com/auth0/AbstractAuthenticationTest.java +++ b/auth0-api-java/src/test/java/com/auth0/AbstractAuthenticationTest.java @@ -8,8 +8,6 @@ import com.auth0.models.AuthToken; import com.auth0.models.AuthenticationContext; import com.auth0.models.HttpRequestInfo; -import com.auth0.validators.DPoPProofValidator; -import com.auth0.validators.JWTValidator; import org.junit.Before; import org.junit.Test; @@ -18,6 +16,8 @@ import static org.assertj.core.api.Assertions.assertThat; 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,15 +31,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( - Map headers, - HttpRequestInfo requestInfo) { + public AuthenticationContext authenticate(HttpRequestInfo requestInfo) { return null; } } @@ -80,12 +78,13 @@ 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"); + HttpRequestInfo request = new HttpRequestInfo("GET", "https://api.example.com", headers); - DecodedJWT result = authSystem.validateBearerToken(headers, null); + DecodedJWT result = authSystem.validateBearerToken(request); assertThat(result).isSameAs(jwt); } @@ -94,17 +93,16 @@ 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"); + HttpRequestInfo request = new HttpRequestInfo("GET", "https://api.example.com", headers); - DecodedJWT result = authSystem.validateDpopTokenAndProof(headers, request); + 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); @@ -134,10 +132,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 d350589..b7dfeb2 100644 --- a/auth0-api-java/src/test/java/com/auth0/AllowedDPoPAuthenticationTest.java +++ b/auth0-api-java/src/test/java/com/auth0/AllowedDPoPAuthenticationTest.java @@ -7,8 +7,6 @@ import com.auth0.models.AuthToken; import com.auth0.models.AuthenticationContext; import com.auth0.models.HttpRequestInfo; -import com.auth0.validators.DPoPProofValidator; -import com.auth0.validators.JWTValidator; import org.junit.Before; import org.junit.Test; @@ -16,7 +14,10 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +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,64 +37,58 @@ 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) - ); - - Map normalizedHeaders = new HashMap<>(); - normalizedHeaders.put("authorization", "Bearer token"); - - when(jwtValidator.validateToken(eq("token"), eq(normalizedHeaders), any())).thenReturn(jwt); - 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); - AuthenticationContext ctx = auth.authenticate(headers, null); + AuthenticationContext ctx = auth.authenticate(request); assertThat(ctx).isNotNull(); - verify(jwtValidator).validateToken("token", normalizedHeaders, null); + verify(jwtValidator).validateToken(eq("token"), any(HttpRequestInfo.class)); 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(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"), anyMap(), 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(headers, request); + AuthenticationContext ctx = auth.authenticate(request); assertThat(ctx).isNotNull(); 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); - auth.authenticate(headers, null); + assertThatThrownBy(() -> auth.authenticate(request)).isInstanceOf(InvalidAuthSchemeException.class); } @Test @@ -104,12 +99,74 @@ public void authenticate_shouldWrapExceptionWithWwwAuthenticate() throws Excepti Map headers = new HashMap<>(); headers.put("authorization", "Bearer bad"); + HttpRequestInfo request = new HttpRequestInfo("GET", "https://api.example.com", headers); try { - auth.authenticate(headers, null); + auth.authenticate(request); } catch (BaseAuthException ex) { assertThat(ex.getHeaders()) .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/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..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 { @@ -21,7 +20,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 +30,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 +41,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 +50,7 @@ public void process_propagatesException() throws Exception { Map headers = new HashMap<>(); assertThatThrownBy(() -> - orchestrator.process(headers, null) + orchestrator.process(new HttpRequestInfo("GET", "https://api", headers)) ).isSameAs(ex); } } diff --git a/auth0-api-java/src/test/java/com/auth0/validators/ClaimValidatorTest.java b/auth0-api-java/src/test/java/com/auth0/ClaimValidatorTest.java similarity index 99% rename from auth0-api-java/src/test/java/com/auth0/validators/ClaimValidatorTest.java rename to auth0-api-java/src/test/java/com/auth0/ClaimValidatorTest.java index 5d2bd70..2b0c14d 100644 --- a/auth0-api-java/src/test/java/com/auth0/validators/ClaimValidatorTest.java +++ b/auth0-api-java/src/test/java/com/auth0/ClaimValidatorTest.java @@ -1,4 +1,4 @@ -package com.auth0.validators; +package com.auth0; import com.auth0.exception.BaseAuthException; import com.auth0.exception.InsufficientScopeException; diff --git a/auth0-api-java/src/test/java/com/auth0/validators/DPoPProofValidatorTest.java b/auth0-api-java/src/test/java/com/auth0/DPoPProofValidatorTest.java similarity index 99% rename from auth0-api-java/src/test/java/com/auth0/validators/DPoPProofValidatorTest.java rename to auth0-api-java/src/test/java/com/auth0/DPoPProofValidatorTest.java index ab92eee..7d6a85f 100644 --- a/auth0-api-java/src/test/java/com/auth0/validators/DPoPProofValidatorTest.java +++ b/auth0-api-java/src/test/java/com/auth0/DPoPProofValidatorTest.java @@ -1,4 +1,4 @@ -package com.auth0.validators; +package com.auth0; import com.auth0.exception.*; import com.auth0.jwt.JWT; @@ -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/DisabledDPoPAuthenticationTest.java b/auth0-api-java/src/test/java/com/auth0/DisabledDPoPAuthenticationTest.java index 38d3dec..f9b226a 100644 --- a/auth0-api-java/src/test/java/com/auth0/DisabledDPoPAuthenticationTest.java +++ b/auth0-api-java/src/test/java/com/auth0/DisabledDPoPAuthenticationTest.java @@ -1,14 +1,18 @@ 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.validators.JWTValidator; +import com.auth0.models.HttpRequestInfo; import org.junit.Before; 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; @@ -31,37 +35,50 @@ 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"), eq(normalizedHeaders), any())).thenReturn(jwt); Map headers = new HashMap<>(); headers.put("authorization", "Bearer token"); + HttpRequestInfo request = new HttpRequestInfo("GET", "https://api.example.com", 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(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<>(); + headers.put("authorization", "Bearer bad"); + HttpRequestInfo request = new HttpRequestInfo("GET", "https://api.example.com", headers); try { - auth.authenticate(headers, null); + auth.authenticate(request); } catch (BaseAuthException ex) { assertThat(ex.getHeaders()) .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/validators/JWTValidatorTest.java b/auth0-api-java/src/test/java/com/auth0/JWTValidatorTest.java similarity index 77% rename from auth0-api-java/src/test/java/com/auth0/validators/JWTValidatorTest.java rename to auth0-api-java/src/test/java/com/auth0/JWTValidatorTest.java index 2337c16..77e18c3 100644 --- a/auth0-api-java/src/test/java/com/auth0/validators/JWTValidatorTest.java +++ b/auth0-api-java/src/test/java/com/auth0/JWTValidatorTest.java @@ -1,6 +1,7 @@ -package com.auth0.validators; +package com.auth0; 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,8 @@ import com.auth0.jwt.algorithms.Algorithm; 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; @@ -23,6 +26,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 { @@ -40,6 +44,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 { @@ -51,12 +56,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); @@ -81,7 +97,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 +106,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 +117,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 +133,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 +149,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 +165,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 +181,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 +197,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 +218,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-api-java/src/test/java/com/auth0/OidcDiscoveryFetcherTest.java b/auth0-api-java/src/test/java/com/auth0/OidcDiscoveryFetcherTest.java new file mode 100644 index 0000000..27c6000 --- /dev/null +++ b/auth0-api-java/src/test/java/com/auth0/OidcDiscoveryFetcherTest.java @@ -0,0 +1,157 @@ +package com.auth0; + +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-api-java/src/test/java/com/auth0/RequiredDPoPAuthenticationTest.java b/auth0-api-java/src/test/java/com/auth0/RequiredDPoPAuthenticationTest.java index dfd4e02..b328563 100644 --- a/auth0-api-java/src/test/java/com/auth0/RequiredDPoPAuthenticationTest.java +++ b/auth0-api-java/src/test/java/com/auth0/RequiredDPoPAuthenticationTest.java @@ -1,16 +1,19 @@ 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; import com.auth0.models.HttpRequestInfo; -import com.auth0.validators.DPoPProofValidator; -import com.auth0.validators.JWTValidator; import org.junit.Before; 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; @@ -33,21 +36,17 @@ 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(HttpRequestInfo.class))).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,18 +54,49 @@ public void authenticate_shouldAcceptDpopToken() throws Exception { @Test public void authenticate_shouldWrapExceptionWithWwwAuthenticate() throws Exception { - HttpRequestInfo request = - new HttpRequestInfo("POST", "https://api.example.com", null); - when(extractor.extractDPoPProofAndDPoPToken(anyMap())) - .thenThrow(new com.auth0.exception.MissingAuthorizationException()); - Map headers = new HashMap<>(); + HttpRequestInfo request = new HttpRequestInfo("POST", "https://api.example.com", headers); + + when(extractor.extractDPoPProofAndDPoPToken(anyMap())).thenThrow(new MissingAuthorizationException()); try { - auth.authenticate(headers, request); + auth.authenticate(request); } catch (BaseAuthException ex) { assertThat(ex.getHeaders()) .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..89b13c4 --- /dev/null +++ b/auth0-api-java/src/test/java/com/auth0/cache/InMemoryAuthCacheTest.java @@ -0,0 +1,205 @@ +package com.auth0.cache; + +import com.auth0.InMemoryAuthCache; +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..b28ddf8 100644 --- a/auth0-api-java/src/test/java/com/auth0/models/AuthOptionsTest.java +++ b/auth0-api-java/src/test/java/com/auth0/models/AuthOptionsTest.java @@ -1,10 +1,16 @@ package com.auth0.models; +import com.auth0.DomainResolver; +import com.auth0.AuthCache; +import com.auth0.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/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/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-springboot-api-playground/build.gradle b/auth0-springboot-api-playground/build.gradle index 5afd6aa..5234471 100644 --- a/auth0-springboot-api-playground/build.gradle +++ b/auth0-springboot-api-playground/build.gradle @@ -16,10 +16,16 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-security' + + testImplementation 'org.junit.jupiter:junit-jupiter-api' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' } test { useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + } } tasks.named('bootJar') { diff --git a/auth0-springboot-api-playground/src/main/java/com/auth0/playground/McdDomainResolverExample.java b/auth0-springboot-api-playground/src/main/java/com/auth0/playground/McdDomainResolverExample.java new file mode 100644 index 0000000..0ddf36d --- /dev/null +++ b/auth0-springboot-api-playground/src/main/java/com/auth0/playground/McdDomainResolverExample.java @@ -0,0 +1,104 @@ +package com.auth0.playground; + +import com.auth0.DomainResolver; +import com.auth0.models.RequestContext; +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 DomainResolver} to dynamically + * resolve allowed issuer domains at request time. + *

+ * + *

How it works

+ *
    + *
  1. Define a {@link DomainResolver} bean in a {@code @Configuration} + * class
  2. + *
  3. The auto-configuration picks it up and passes it to the SDK's + * domain resolution pipeline
  4. + *
  5. On each request, the resolver receives a {@link RequestContext} + * 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 DomainResolver} 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 DomainResolver + * @see RequestContext + */ +@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 a {@link RequestContext} 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 a {@link DomainResolver} that maps tenant IDs to Auth0 domains + */ + @Bean + public DomainResolver 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 02576ab..294a3a5 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,10 +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 @@ -13,7 +16,7 @@ public class ProfileController { @GetMapping("/protected") public ResponseEntity> protectedEndpoint(Authentication authentication) { - String userId = authentication.getName(); // Returns the 'sub' claim + String userId = authentication.getName(); return ResponseEntity.ok(Map.of( "message", "Access granted!", @@ -28,4 +31,29 @@ public ResponseEntity> publicEndpoint() { "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 a {@link com.auth0.DomainResolver} bean is + * defined, the SDK resolves the allowed issuer domains dynamically. + * This endpoint does not need any MCD-specific code. + *

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

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

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

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

When to use a distributed cache

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

Spring Boot usage

+ *

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

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

Important notes

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

+ * This is a reference implementation showing how to adapt a Redis client + * to the SDK's cache interface. Replace the placeholder Redis operations + * with your actual Redis client (Jedis, Lettuce, Spring RedisTemplate, etc.). + */ + public static class RedisAuthCache implements AuthCache { + + // Replace with your actual Redis client + // e.g., private final RedisTemplate redisTemplate; + // e.g., private final JedisPool jedisPool; + // e.g., private final RedisClient lettuceClient; + + private final long ttlSeconds; + private final String keyPrefix; + + /** + * Creates a Redis-backed auth cache. + * + * @param ttlSeconds time-to-live for cached entries in seconds + */ + public RedisAuthCache(long ttlSeconds) { + this(ttlSeconds, "auth0:"); + } + + /** + * Creates a Redis-backed auth cache with a custom key prefix. + * + * @param ttlSeconds time-to-live for cached entries in seconds + * @param keyPrefix prefix for all Redis keys (e.g., "auth0:" to namespace) + */ + public RedisAuthCache(long ttlSeconds, String keyPrefix) { + this.ttlSeconds = ttlSeconds; + this.keyPrefix = keyPrefix; + } + + @Override + public Object get(String key) { + // Example with Spring RedisTemplate: + // return redisTemplate.opsForValue().get(keyPrefix + key); + // + // Example with Jedis: + // try (Jedis jedis = jedisPool.getResource()) { + // byte[] data = jedis.get((keyPrefix + key).getBytes()); + // return data != null ? deserialize(data) : null; + // } + // + // Must return null for missing/expired keys — never throw + return null; + } + + @Override + public void put(String key, Object value) { + // Example with Spring RedisTemplate: + // redisTemplate.opsForValue().set(keyPrefix + key, value, Duration.ofSeconds(ttlSeconds)); + // + // Example with Jedis: + // try (Jedis jedis = jedisPool.getResource()) { + // jedis.setex((keyPrefix + key).getBytes(), ttlSeconds, serialize(value)); + // } + // + // The SDK stores both OIDC metadata and JWKS providers using + // prefixed keys like "discovery:https://tenant.auth0.com/" + // and "jwks:https://tenant.auth0.com/.well-known/jwks.json" + } + + @Override + public void remove(String key) { + // Example with Spring RedisTemplate: + // redisTemplate.delete(keyPrefix + key); + // + // Example with Jedis: + // try (Jedis jedis = jedisPool.getResource()) { + // jedis.del((keyPrefix + key).getBytes()); + // } + } + + @Override + public void clear() { + // Example with Spring RedisTemplate (scan + delete): + // Set keys = redisTemplate.keys(keyPrefix + "*"); + // if (keys != null && !keys.isEmpty()) { + // redisTemplate.delete(keys); + // } + // + // WARNING: KEYS command is expensive in production. + // Consider using SCAN or maintaining a key set for bulk deletion. + } + + @Override + public int size() { + // Example with Spring RedisTemplate: + // Set keys = redisTemplate.keys(keyPrefix + "*"); + // return keys != null ? keys.size() : 0; + // + // This is an approximation — exact count may vary due to TTL expiry. + return 0; + } + } + +} diff --git a/auth0-springboot-api-playground/src/main/java/com/auth0/playground/SecurityConfig.java b/auth0-springboot-api-playground/src/main/java/com/auth0/playground/SecurityConfig.java index df91a95..788fd92 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 @@ -20,13 +20,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/README.md b/auth0-springboot-api/README.md index 009615f..eae76c6 100644 --- a/auth0-springboot-api/README.md +++ b/auth0-springboot-api/README.md @@ -6,6 +6,8 @@ This library builds on top of the standard Spring Security JWT authentication, p - **Complete Spring Security JWT Functionality** - All features from Spring Security JWT Bearer are available - **Built-in DPoP Support** - Industry-leading proof-of-possession token security per [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449) +- **Multi-Custom Domain (MCD) Support** - Validate tokens from multiple Auth0 custom domains with static lists or dynamic resolution +- **Extensible Caching** - Pluggable `AuthCache` interface for OIDC discovery and JWKS caching with distributed backend support (Redis, Memcached) - **Auto-Configuration** - Spring Boot auto-configuration with minimal setup - **Flexible Authentication Modes** - Bearer-only, DPoP-only, or flexible mode supporting both @@ -173,6 +175,146 @@ curl -H "Authorization: DPoP " \ https://your-api.example.com/api/protected ``` +## Multi-Custom Domain (MCD) Support + +For tenants with multiple custom domains, the SDK can validate tokens from any of the configured issuers. There are three ways to configure domain resolution: + +### Option 1: Static Domain List + +Configure a list of allowed issuer domains in `application.yml`: + +```yaml +auth0: + audience: "https://your-api-identifier" + domains: + - "login.acme.com" + - "auth.partner.com" + - "dev.example.com" +``` + +You can also set a primary domain alongside the list: + +```yaml +auth0: + domain: "primary.auth0.com" + audience: "https://your-api-identifier" + domains: + - "login.acme.com" + - "auth.partner.com" +``` + +### Option 2: Dynamic Domain Resolver + +For scenarios where the allowed issuers depend on runtime context (e.g., tenant headers, database lookups), define a `DomainResolver` bean: + +```java +import com.auth0.DomainResolver; + +@Configuration +public class McdConfig { + + @Bean + public DomainResolver domainResolver(TenantService tenantService) { + return context -> { + // context.getHeaders() — request headers (lowercase keys) + // context.getUrl() — the API request URL + // context.getTokenIssuer() — unverified iss claim (routing hint only) + String tenantId = context.getHeaders().get("x-tenant-id"); + String domain = tenantService.getDomain(tenantId); + return Collections.singletonList(domain); + }; + } +} +``` + +When a `DomainResolver` bean is present, it takes precedence over the static `auth0.domains` list. The single `auth0.domain` can still coexist as a fallback. + +### Option 3: Single Domain (Default) + +For single-tenant setups, just use the `auth0.domain` property: + +```yaml +auth0: + domain: "your-tenant.auth0.com" + audience: "https://your-api-identifier" +``` + +## Extensibility + +### Custom Cache Implementation + +The SDK caches OIDC discovery metadata and JWKS providers using a unified cache with key prefixes (`discovery:{issuerUrl}` and `jwks:{jwksUri}`). By default, it uses a thread-safe in-memory LRU cache. + +You can replace this with a distributed cache (Redis, Memcached, etc.) by implementing the `AuthCache` interface: + +```java +import com.auth0.AuthCache; + +public class RedisAuthCache implements AuthCache { + + private final RedisTemplate redisTemplate; + private final Duration ttl; + + public RedisAuthCache(RedisTemplate redisTemplate, Duration ttl) { + this.redisTemplate = redisTemplate; + this.ttl = ttl; + } + + @Override + public Object get(String key) { + return redisTemplate.opsForValue().get(key); + } + + @Override + public void put(String key, Object value) { + redisTemplate.opsForValue().set(key, value, ttl); + } + + @Override + public void remove(String key) { + redisTemplate.delete(key); + } + + @Override + public void clear() { + Set keys = redisTemplate.keys("discovery:*"); + if (keys != null) redisTemplate.delete(keys); + keys = redisTemplate.keys("jwks:*"); + if (keys != null) redisTemplate.delete(keys); + } + + @Override + public int size() { + return 0; // approximate + } +} +``` + +Then define it as a Spring bean — the auto-configuration picks it up automatically and wires it into `AuthOptions`. No need to create your own `AuthClient` bean: + +```java +@Configuration +public class CacheConfig { + + @Bean + public AuthCache authCache(RedisTemplate redisTemplate) { + return new RedisAuthCache(redisTemplate, Duration.ofMinutes(10)); + } +} +``` + +When an `AuthCache` bean is present, the `cacheMaxEntries` and `cacheTtlSeconds` YAML properties are ignored — your implementation controls its own eviction and TTL. + +### Default Cache Settings + +If no custom cache is provided, the built-in in-memory cache is used with these defaults: + +```yaml +auth0: + cacheMaxEntries: 100 # max entries before LRU eviction + cacheTtlSeconds: 600 # 10-minute TTL per entry +``` + ## Advanced Features ### Custom Claim Validation diff --git a/auth0-springboot-api/build.gradle b/auth0-springboot-api/build.gradle index 4388f34..4cbec7f 100644 --- a/auth0-springboot-api/build.gradle +++ b/auth0-springboot-api/build.gradle @@ -29,7 +29,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' - implementation project(':auth0-api-java') + api project(':auth0-api-java') implementation 'com.fasterxml.jackson.core:jackson-databind' implementation 'org.apache.httpcomponents:httpclient:4.5.14' diff --git a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0AuthenticationFilter.java b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0AuthenticationFilter.java index 447c4ec..d0081e1 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 @@ -43,9 +43,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)); @@ -110,9 +110,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 d859a87..545be48 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,10 @@ package com.auth0.spring.boot; +import com.auth0.AuthCache; 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; @@ -11,21 +14,46 @@ @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 domainResolverProvider optional {@link DomainResolver} bean for dynamic MCD resolution. + * When present, it takes precedence over static YAML config. + * @param cacheProvider optional {@link AuthCache} bean for custom caching (e.g., Redis). When + * present, {@code cacheMaxEntries} and {@code cacheTtlSeconds} properties are ignored. * @return configured AuthOptions instance for creating AuthClient * @see AuthOptions.Builder * @see Auth0Properties */ @Bean - public AuthOptions authOptions(Auth0Properties properties) { + public AuthOptions authOptions( + Auth0Properties properties, + ObjectProvider domainResolverProvider, + ObjectProvider> cacheProvider) { + + DomainResolver domainResolver = domainResolverProvider.getIfAvailable(); + AuthCache cache = cacheProvider.getIfAvailable(); - AuthOptions.Builder builder = - new AuthOptions.Builder().domain(properties.getDomain()).audience(properties.getAudience()); + AuthOptions.Builder builder = new AuthOptions.Builder().audience(properties.getAudience()); + + if (domainResolver != null) { + builder.domainsResolver(domainResolver); + + 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()); @@ -38,6 +66,17 @@ public AuthOptions authOptions(Auth0Properties properties) { builder.dpopIatOffsetSeconds(properties.getDpopIatOffsetSeconds()); } + if (cache != null) { + builder.cache(cache); + } else { + if (properties.getCacheMaxEntries() != null) { + builder.cacheMaxEntries(properties.getCacheMaxEntries()); + } + if (properties.getCacheTtlSeconds() != null) { + builder.cacheTtlSeconds(properties.getCacheTtlSeconds()); + } + } + return builder.build(); } @@ -49,7 +88,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/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..8b13789 --- /dev/null +++ b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0DomainResolver.java @@ -0,0 +1 @@ + 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 e1018fb..4ac42c4 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 @@ -18,19 +18,53 @@ * dpopMode: ALLOWED * dpopIatOffsetSeconds: 300 * dpopIatLeewaySeconds: 60 + * 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. * @@ -49,6 +83,24 @@ 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. * @@ -130,4 +182,48 @@ 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..8b13789 --- /dev/null +++ b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0RequestContext.java @@ -0,0 +1 @@ + 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 7a8c584..f85ca00 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 @@ -1,15 +1,19 @@ package com.auth0.spring.boot; 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; 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; import jakarta.servlet.FilterChain; import java.util.Enumeration; +import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -189,14 +193,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()); @@ -214,18 +220,12 @@ void doFilterInternal_shouldAuthenticateSuccessfully_withValidBearerToken() thro 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); - org.mockito.Mockito.verify(authClient) - .verifyRequest( - org.mockito.ArgumentMatchers.anyMap(), - org.mockito.ArgumentMatchers.any(HttpRequestInfo.class)); - org.mockito.Mockito.verify(filterChain).doFilter(request, response); + verify(authClient).verifyRequest(any(HttpRequestInfo.class)); + verify(filterChain).doFilter(request, response); assertNotNull(SecurityContextHolder.getContext().getAuthentication()); assertTrue( SecurityContextHolder.getContext().getAuthentication() instanceof Auth0AuthenticationToken); @@ -243,14 +243,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()); } @@ -266,7 +263,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()); } @@ -280,16 +277,13 @@ 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))) + 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,16 +297,13 @@ 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))) + 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()); } @@ -334,10 +325,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); @@ -361,10 +349,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); @@ -385,10 +370,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); @@ -416,10 +398,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); 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 df1f1cd..11e2ebc 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 @@ -3,14 +3,20 @@ import static org.junit.jupiter.api.Assertions.*; import com.auth0.AuthClient; +import com.auth0.DomainResolver; import com.auth0.enums.DPoPMode; import com.auth0.models.AuthOptions; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; 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; /** @@ -133,4 +139,154 @@ void shouldConfigureOnlyDPoPMode() { assertEquals(300, authOptions.getDpopIatOffsetSeconds()); } } + + @Nested + @SpringBootTest(classes = {Auth0AutoConfiguration.class, Auth0SecurityAutoConfiguration.class}) + @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(classes = {Auth0AutoConfiguration.class, Auth0SecurityAutoConfiguration.class}) + @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(classes = {Auth0AutoConfiguration.class, Auth0SecurityAutoConfiguration.class}) + @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 DomainResolver 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 DomainResolver 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 DomainResolver testDomainResolver() { + return context -> Collections.singletonList("dynamic.auth0.com"); + } + } + + @Autowired private AuthOptions authOptions; + + @Test + @DisplayName("Should prioritize DomainResolver 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()); + } + } } 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 01687b1..0fdfcd0 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 @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.*; import com.auth0.enums.DPoPMode; +import java.util.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -114,7 +115,60 @@ void shouldAllowZeroValuesForDpopTimingProperties() { @DisplayName("Should reject negative values for DPoP timing properties") void shouldRejectNegativeValuesForDpopTimingProperties() { assertThrows(IllegalArgumentException.class, () -> properties.setDpopIatOffsetSeconds(-100L)); - 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()); + } }