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 &mdash; 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} &mdash; 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}