001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.rest.server.auth.oauth; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.io.*; 022import java.net.*; 023import java.security.*; 024import java.time.*; 025import java.util.*; 026import java.util.function.*; 027import java.util.stream.*; 028 029import org.apache.juneau.commons.utils.*; 030import org.apache.juneau.rest.server.auth.*; 031 032import com.nimbusds.oauth2.sdk.*; 033import com.nimbusds.oauth2.sdk.auth.*; 034import com.nimbusds.oauth2.sdk.http.*; 035import com.nimbusds.oauth2.sdk.id.*; 036import com.nimbusds.oauth2.sdk.token.*; 037 038/** 039 * {@link TokenValidator} that validates opaque OAuth 2.0 access tokens via the 040 * <a class="doclink" href="https://datatracker.ietf.org/doc/html/rfc7662">RFC 7662</a> Token Introspection 041 * endpoint. 042 * 043 * <p> 044 * Wraps Nimbus's {@code TokenIntrospectionRequest} / {@code TokenIntrospectionResponse} for the HTTP 045 * round-trip and the JSON parsing. A {@link TokenCache} (default: bounded LRU cache) short-circuits 046 * the round-trip on repeated lookups within the configured TTL window. 047 * 048 * <h5 class='topic'>Usage</h5> 049 * 050 * <p class='bjava'> 051 * <jk>var</jk> validator = OAuthIntrospectionValidator.<jsm>create</jsm>() 052 * .introspectionEndpoint(URI.<jsm>create</jsm>(<js>"https://idp.example.com/oauth2/introspect"</js>)) 053 * .clientId(<js>"api-server"</js>) 054 * .clientSecret(<js>"..."</js>) 055 * .requiredScopes(<js>"read:orders"</js>) 056 * .build(); 057 * </p> 058 * 059 * <h5 class='topic'>Defaults</h5> 060 * <ul> 061 * <li>Cache TTL: 5 minutes, capped at 1 hour. 062 * <li>Cache size: 1000 entries. 063 * <li>Required scopes: none (scope enforcement lives on the validator, not the filter). 064 * </ul> 065 * 066 * <h5 class='section'>See Also:</h5> 067 * <ul> 068 * <li class='jc'>{@link OAuthFilter} 069 * <li class='jc'>{@link TokenValidator} 070 * <li class='link'><a class="doclink" href="https://datatracker.ietf.org/doc/html/rfc7662">RFC 7662 — OAuth 2.0 Token Introspection</a> 071 * </ul> 072 * 073 * @since 10.0.0 074 */ 075@SuppressWarnings({ 076 "java:S1192" // Duplicate string literals are OAuth introspection response field names; intentional 077}) 078public class OAuthIntrospectionValidator implements TokenValidator { 079 080 /** Default cache TTL. */ 081 public static final Duration DEFAULT_CACHE_TTL = Duration.ofMinutes(5); 082 083 /** Maximum cache TTL. */ 084 public static final Duration MAX_CACHE_TTL = Duration.ofHours(1); 085 086 /** 087 * Static creator. 088 * 089 * @return A new builder. 090 */ 091 public static Builder create() { 092 return new Builder(); 093 } 094 095 /** 096 * Builder for {@link OAuthIntrospectionValidator}. 097 */ 098 public static class Builder { 099 private URI introspectionEndpoint; 100 private String clientId; 101 private Supplier<String> clientSecretSupplier; 102 private TokenCache tokenCache; 103 private Duration cacheTtl = DEFAULT_CACHE_TTL; 104 private final Set<String> requiredScopes = st(); 105 private final Set<String> expectedAudiences = st(); 106 private Clock clock = Clock.systemUTC(); 107 private Consumer<HTTPRequest> httpRequestConfigurator; 108 109 /** Constructor. */ 110 protected Builder() {} 111 112 /** 113 * Sets the introspection endpoint URL. Required. 114 * 115 * <p> 116 * Each introspection call transmits the client secret (HTTP Basic) and the access token under 117 * validation to this endpoint, so the URL must use <js>"https"</js> or target a loopback host; a 118 * plaintext transport to a non-loopback host is rejected. 119 * 120 * @param value The endpoint URL. Must not be <jk>null</jk> and must use HTTPS or target a loopback host. 121 * @return This object. 122 */ 123 public Builder introspectionEndpoint(URI value) { 124 introspectionEndpoint = UriUtils.assertSecureOrLoopback(reqnn("value", value)); 125 return this; 126 } 127 128 /** 129 * Sets the OAuth client ID used for HTTP Basic auth against the introspection endpoint. 130 * 131 * @param value The client ID. Must not be <jk>null</jk> or blank. 132 * @return This object. 133 */ 134 public Builder clientId(String value) { 135 clientId = reqnb("value", value); 136 return this; 137 } 138 139 /** 140 * Sets the OAuth client secret as a literal string. 141 * 142 * <p> 143 * Mutually exclusive with {@link #clientSecretSupplier(Supplier)}. 144 * 145 * @param value The client secret. Must not be <jk>null</jk> or blank. 146 * @return This object. 147 */ 148 public Builder clientSecret(String value) { 149 reqnb("value", value); 150 clientSecretSupplier = () -> value; 151 return this; 152 } 153 154 /** 155 * Sets the OAuth client secret via a supplier (useful for rotating secrets). 156 * 157 * @param value The supplier. Must not be <jk>null</jk>. 158 * @return This object. 159 */ 160 public Builder clientSecretSupplier(Supplier<String> value) { 161 clientSecretSupplier = reqnn("value", value); 162 return this; 163 } 164 165 /** 166 * Sets a custom token cache. Defaults to a {@link BoundedLruTokenCache} with 1000 entries. 167 * 168 * @param value The cache. Must not be <jk>null</jk>. 169 * @return This object. 170 */ 171 public Builder tokenCache(TokenCache value) { 172 tokenCache = reqnn("value", value); 173 return this; 174 } 175 176 /** 177 * Sets the cache TTL. Defaults to {@link #DEFAULT_CACHE_TTL 5 minutes}; capped at 178 * {@link #MAX_CACHE_TTL 1 hour}. Capped by the token's own {@code exp} when shorter. 179 * 180 * @param value The TTL. Must be positive and not exceed 1 hour. 181 * @return This object. 182 */ 183 public Builder cacheTtl(Duration value) { 184 reqnn("value", value); 185 if (value.isZero() || value.isNegative()) 186 throw iaex("cacheTtl must be positive"); 187 if (value.compareTo(MAX_CACHE_TTL) > 0) 188 throw iaex("cacheTtl must not exceed 1 hour (was %s)", value); 189 cacheTtl = value; 190 return this; 191 } 192 193 /** 194 * Adds required scopes (scope enforcement lives on the validator, not the filter). 195 * 196 * <p> 197 * If any required scope is missing from the introspection response's {@code scope} claim, the 198 * validator throws {@link AuthenticationException} with 199 * {@code WWW-Authenticate: Bearer error="insufficient_scope"}. 200 * 201 * @param scopes The required scopes. Must contain at least one non-blank entry. 202 * @return This object. 203 */ 204 public Builder requiredScopes(String... scopes) { 205 reqnn("scopes", scopes); 206 for (var s : scopes) { 207 reqnb("scope", s); 208 requiredScopes.add(s); 209 } 210 return this; 211 } 212 213 /** 214 * Adds expected audience values (RFC 7662 {@code aud}) that a token must carry to be accepted. 215 * 216 * <p> 217 * When at least one value is configured here (via this method or {@link #resource(String...)}), the 218 * introspection response's {@code aud} claim must contain <b>at least one</b> matching value, or the 219 * token is rejected as {@code invalid_token} — even when {@code active=true} and required scopes 220 * match. This binds the token to the resource server it was minted for, so a token issued for a 221 * different API cannot be replayed against this one. 222 * 223 * <p> 224 * When left unset (the default), no audience/resource check is performed: today's 225 * {@code active} + optional-required-scopes behavior is preserved unchanged. 226 * 227 * @param values The expected audience values. Must contain at least one non-blank entry. 228 * @return This object. 229 */ 230 public Builder audience(String... values) { 231 reqnn("values", values); 232 for (var v : values) { 233 reqnb("audience", v); 234 expectedAudiences.add(v); 235 } 236 return this; 237 } 238 239 /** 240 * Adds expected resource-indicator values (RFC 8707). 241 * 242 * <p> 243 * Alias for {@link #audience(String...)}: RFC 8707 resource indicators are echoed back by 244 * conformant authorization servers in the same introspection {@code aud} claim as RFC 7662 245 * audiences, so both methods add to the same expected-value set. 246 * 247 * @param values The expected resource values. Must contain at least one non-blank entry. 248 * @return This object. 249 */ 250 public Builder resource(String... values) { 251 return audience(values); 252 } 253 254 /** 255 * Overrides the {@link Clock} used for cache expiry. Useful in tests. 256 * 257 * @param value The clock. Must not be <jk>null</jk>. 258 * @return This object. 259 */ 260 public Builder clock(Clock value) { 261 clock = reqnn("value", value); 262 return this; 263 } 264 265 /** 266 * Sets a callback that customizes the Nimbus {@link HTTPRequest} before it is sent. 267 * 268 * <p> 269 * This is the escape hatch for custom HTTP behavior (proxies, TLS overrides, 270 * timeouts). The callback is invoked on every introspection call. 271 * 272 * @param value The configurator callback. Must not be <jk>null</jk>. 273 * @return This object. 274 */ 275 public Builder httpRequestConfigurator(Consumer<HTTPRequest> value) { 276 httpRequestConfigurator = reqnn("value", value); 277 return this; 278 } 279 280 /** 281 * Builds the validator. 282 * 283 * @return A new {@link OAuthIntrospectionValidator}. 284 */ 285 public OAuthIntrospectionValidator build() { 286 if (introspectionEndpoint == null) 287 throw new IllegalStateException("OAuthIntrospectionValidator requires introspectionEndpoint(...)"); 288 if (clientId == null) 289 throw new IllegalStateException("OAuthIntrospectionValidator requires clientId(...)"); 290 if (clientSecretSupplier == null) 291 throw new IllegalStateException("OAuthIntrospectionValidator requires clientSecret(...) or clientSecretSupplier(...)"); 292 if (tokenCache == null) 293 tokenCache = BoundedLruTokenCache.create(); 294 return new OAuthIntrospectionValidator(this); 295 } 296 } 297 298 private final URI introspectionEndpoint; 299 private final String clientId; 300 private final Supplier<String> clientSecretSupplier; 301 private final TokenCache tokenCache; 302 private final Duration cacheTtl; 303 private final Set<String> requiredScopes; 304 private final Set<String> expectedAudiences; 305 private final Clock clock; 306 private final Consumer<HTTPRequest> httpRequestConfigurator; 307 308 /** 309 * Constructor. 310 * 311 * @param b The builder. 312 */ 313 protected OAuthIntrospectionValidator(Builder b) { 314 this.introspectionEndpoint = b.introspectionEndpoint; 315 this.clientId = b.clientId; 316 this.clientSecretSupplier = b.clientSecretSupplier; 317 this.tokenCache = b.tokenCache; 318 this.cacheTtl = b.cacheTtl; 319 this.requiredScopes = u(cp(b.requiredScopes)); 320 this.expectedAudiences = u(cp(b.expectedAudiences)); 321 this.clock = b.clock; 322 this.httpRequestConfigurator = b.httpRequestConfigurator; 323 } 324 325 /** 326 * Returns the configured introspection endpoint URL. 327 * 328 * @return The endpoint. 329 */ 330 public URI getIntrospectionEndpoint() { 331 return introspectionEndpoint; 332 } 333 334 /** 335 * Returns the configured OAuth client ID. 336 * 337 * @return The client ID. 338 */ 339 public String getClientId() { 340 return clientId; 341 } 342 343 /** 344 * Returns the cache TTL. 345 * 346 * @return The TTL. 347 */ 348 public Duration getCacheTtl() { 349 return cacheTtl; 350 } 351 352 /** 353 * Returns the required-scope set. 354 * 355 * @return An unmodifiable view. 356 */ 357 public Set<String> getRequiredScopes() { 358 return requiredScopes; 359 } 360 361 /** 362 * Returns the configured expected audience / resource-indicator set. 363 * 364 * @return An unmodifiable view. Empty when no audience/resource binding is configured (default). 365 */ 366 public Set<String> getExpectedAudiences() { 367 return expectedAudiences; 368 } 369 370 /** 371 * Returns the underlying token cache. 372 * 373 * @return The token cache. 374 */ 375 public TokenCache getTokenCache() { 376 return tokenCache; 377 } 378 379 @Override /* Overridden from TokenValidator */ 380 public Principal validate(String token) throws AuthenticationException { 381 reqnb("token", token); 382 var cached = tokenCache.getPrincipal(token); 383 if (cached.isPresent()) 384 return cached.get(); 385 return introspect(token); 386 } 387 388 private Principal introspect(String token) throws AuthenticationException { 389 var clientAuth = new ClientSecretBasic(new ClientID(clientId), 390 new com.nimbusds.oauth2.sdk.auth.Secret(clientSecretSupplier.get())); 391 AccessToken accessToken = new BearerAccessToken(token); 392 var req = new TokenIntrospectionRequest(introspectionEndpoint, clientAuth, accessToken); 393 var http = req.toHTTPRequest(); 394 if (httpRequestConfigurator != null) 395 httpRequestConfigurator.accept(http); 396 397 com.nimbusds.oauth2.sdk.http.HTTPResponse httpResp; 398 try { 399 httpResp = http.send(); 400 } catch (IOException e) { 401 throw new AuthenticationException(e, "OAuth introspection HTTP call failed") 402 .wwwAuthenticate(bearerError("invalid_token", "introspection unreachable")); 403 } 404 TokenIntrospectionResponse resp; 405 try { 406 resp = TokenIntrospectionResponse.parse(httpResp); 407 } catch (ParseException e) { 408 throw new AuthenticationException(e, "OAuth introspection response could not be parsed") 409 .wwwAuthenticate(bearerError("invalid_token", "introspection parse failed")); 410 } 411 if (!resp.indicatesSuccess()) 412 throw new AuthenticationException("OAuth introspection error: " + resp.toErrorResponse().getErrorObject().getCode()) 413 .wwwAuthenticate(bearerError("invalid_token", resp.toErrorResponse().getErrorObject().getCode())); 414 var success = resp.toSuccessResponse(); 415 if (!success.isActive()) 416 throw new AuthenticationException("OAuth token inactive") 417 .wwwAuthenticate(bearerError("invalid_token", "token inactive")); 418 enforceAudience(success); 419 var scopes = extractScopes(success); 420 enforceRequiredScopes(scopes); 421 var claims = buildClaims(success, scopes); 422 var subject = success.getSubject() != null ? success.getSubject().getValue() : "<no-sub>"; 423 var principal = new ClaimsPrincipal(subject, claims); 424 var ttl = resolveTtl(success); 425 tokenCache.putPrincipal(token, principal, ttl); 426 return principal; 427 } 428 429 private Set<String> extractScopes(TokenIntrospectionSuccessResponse success) { 430 var s = success.getScope(); 431 if (s == null) 432 return Collections.emptySet(); 433 return s.toStringList().stream() 434 .filter(v -> v != null && !v.isBlank()) // HTT: v==null branch unreachable; Nimbus Scope.toStringList() never returns null entries 435 .collect(Collectors.toCollection(LinkedHashSet::new)); 436 } 437 438 /** 439 * Rejects the token when an expected audience/resource set is configured and the introspection 440 * response's {@code aud} claim contains none of the expected values. No-op when 441 * {@link #expectedAudiences} is empty (no bind configured). 442 */ 443 private void enforceAudience(TokenIntrospectionSuccessResponse success) throws AuthenticationException { 444 if (expectedAudiences.isEmpty()) 445 return; 446 var tokenAudiences = success.getAudience(); 447 var matched = tokenAudiences != null && tokenAudiences.stream() 448 .map(Audience::getValue) 449 .anyMatch(expectedAudiences::contains); 450 if (!matched) 451 throw new AuthenticationException("OAuth token audience/resource mismatch") 452 .wwwAuthenticate(bearerError("invalid_token", "audience mismatch")); 453 } 454 455 private void enforceRequiredScopes(Set<String> tokenScopes) throws AuthenticationException { 456 for (var req : requiredScopes) { 457 if (!tokenScopes.contains(req)) 458 throw new AuthenticationException("Required scope missing: " + req) 459 .wwwAuthenticate(bearerError("insufficient_scope", "scope " + req + " missing")); 460 } 461 } 462 463 private Map<String,Object> buildClaims(TokenIntrospectionSuccessResponse success, Set<String> scopes) { 464 var claims = new LinkedHashMap<String,Object>(success.toJSONObject()); 465 if (!scopes.isEmpty()) 466 claims.put("scope", new ArrayList<>(scopes)); 467 return claims; 468 } 469 470 private Duration resolveTtl(TokenIntrospectionSuccessResponse success) { 471 var ttl = cacheTtl; 472 var exp = success.getExpirationTime(); 473 if (exp != null) { 474 var until = Duration.between(clock.instant(), exp.toInstant()); 475 if (until.isNegative() || until.isZero()) 476 until = Duration.ofSeconds(1); 477 if (until.compareTo(ttl) < 0) 478 ttl = until; 479 } 480 return ttl; 481 } 482 483 private static String bearerError(String code, String description) { 484 return "Bearer error=\"" + code + "\", error_description=\"" + description.replace('"', ' ') + "\""; 485 } 486}