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.net.*;
022import java.time.*;
023import java.util.*;
024import java.util.function.*;
025
026import org.apache.juneau.rest.auth.oauth.flow.*;
027
028import com.nimbusds.oauth2.sdk.http.*;
029
030/**
031 * Server-side caching decorator layering an optional {@link TokenCache} over the role-neutral
032 * {@link OAuthClientCredentialsFlow}.
033 *
034 * <p>
035 * The neutral flow performs the RFC 6749 &sect;4.4 client-credentials token acquisition; this decorator adds the
036 * server-side {@code (clientId, scope)}-keyed cache so repeat callers within the cache window reuse the token rather
037 * than hitting the IdP on each call.  When no {@link Builder#tokenCache(TokenCache) cache} is configured the decorator
038 * simply delegates to the neutral flow.
039 *
040 * <h5 class='topic'>Usage</h5>
041 *
042 * <p class='bjava'>
043 *    <jk>var</jk> token = CachingClientCredentialsFlow.<jsm>create</jsm>()
044 *       .tokenEndpoint(URI.<jsm>create</jsm>(<js>"https://idp.example.com/oauth2/token"</js>))
045 *       .clientId(<js>"worker-service"</js>)
046 *       .clientSecret(<js>"..."</js>)
047 *       .scope(<js>"read:orders"</js>)
048 *       .tokenCache(BoundedLruTokenCache.<jsm>create</jsm>())
049 *       .build()
050 *       .acquire();
051 * </p>
052 *
053 * @since 10.0.0
054 */
055@SuppressWarnings({
056   "java:S1192" // Repeated "value" is the argument-name literal passed to null checks; constants would obscure each call site.
057})
058public class CachingClientCredentialsFlow {
059
060   /**
061    * Static creator.
062    *
063    * @return A new builder.
064    */
065   public static Builder create() {
066      return new Builder();
067   }
068
069   /**
070    * Builder.
071    */
072   public static class Builder {
073      private URI tokenEndpoint;
074      private String clientId;
075      private Supplier<String> clientSecretSupplier;
076      private final Set<String> scopes = st();
077      private TokenCache tokenCache;
078      private Duration cacheSkew = Duration.ofSeconds(30);
079      private Consumer<HTTPRequest> httpRequestConfigurator;
080
081      /** Constructor. */
082      protected Builder() {}
083
084      /**
085       * Sets the token endpoint URL.  Required.
086       *
087       * @param value The endpoint URL.  Must not be <jk>null</jk>.
088       * @return This object.
089       */
090      public Builder tokenEndpoint(URI value) {
091         tokenEndpoint = reqnn("value", value);
092         return this;
093      }
094
095      /**
096       * Sets the OAuth client ID.  Required.
097       *
098       * @param value The client ID.  Must not be <jk>null</jk> or blank.
099       * @return This object.
100       */
101      public Builder clientId(String value) {
102         clientId = reqnb("value", value);
103         return this;
104      }
105
106      /**
107       * Sets the OAuth client secret as a literal string.
108       *
109       * @param value The client secret.  Must not be <jk>null</jk> or blank.
110       * @return This object.
111       */
112      public Builder clientSecret(String value) {
113         reqnb("value", value);
114         clientSecretSupplier = () -> value;
115         return this;
116      }
117
118      /**
119       * Sets the OAuth client secret via a supplier.
120       *
121       * @param value The supplier.  Must not be <jk>null</jk>.
122       * @return This object.
123       */
124      public Builder clientSecretSupplier(Supplier<String> value) {
125         clientSecretSupplier = reqnn("value", value);
126         return this;
127      }
128
129      /**
130       * Adds requested OAuth scopes.
131       *
132       * @param values The scopes.
133       * @return This object.
134       */
135      public Builder scope(String... values) {
136         reqnn("values", values);
137         for (var v : values) {
138            reqnb("scope", v);
139            scopes.add(v);
140         }
141         return this;
142      }
143
144      /**
145       * Configures the cache for the acquired token.
146       *
147       * @param value The cache.  Must not be <jk>null</jk>.
148       * @return This object.
149       */
150      public Builder tokenCache(TokenCache value) {
151         tokenCache = reqnn("value", value);
152         return this;
153      }
154
155      /**
156       * Sets the skew tolerance subtracted from the cached token's expiry.  Default 30s.
157       *
158       * @param value The skew.  Must be non-negative.
159       * @return This object.
160       */
161      public Builder cacheSkew(Duration value) {
162         reqnn("value", value);
163         if (value.isNegative())
164            throw new IllegalArgumentException("cacheSkew must be non-negative");
165         cacheSkew = value;
166         return this;
167      }
168
169      /**
170       * Sets a callback that customizes the Nimbus {@link HTTPRequest}.
171       *
172       * @param value The callback.  Must not be <jk>null</jk>.
173       * @return This object.
174       */
175      public Builder httpRequestConfigurator(Consumer<HTTPRequest> value) {
176         httpRequestConfigurator = reqnn("value", value);
177         return this;
178      }
179
180      /**
181       * Builds the flow.
182       *
183       * @return A new {@link CachingClientCredentialsFlow}.
184       */
185      public CachingClientCredentialsFlow build() {
186         if (tokenEndpoint == null)
187            throw new IllegalStateException("CachingClientCredentialsFlow requires tokenEndpoint(...)");
188         if (clientId == null)
189            throw new IllegalStateException("CachingClientCredentialsFlow requires clientId(...)");
190         if (clientSecretSupplier == null)
191            throw new IllegalStateException("CachingClientCredentialsFlow requires clientSecret(...) or clientSecretSupplier(...)");
192         return new CachingClientCredentialsFlow(this);
193      }
194   }
195
196   private final OAuthClientCredentialsFlow delegate;
197   private final TokenCache tokenCache;
198   private final Duration cacheSkew;
199   private final String cacheKey;
200
201   /**
202    * Constructor.
203    *
204    * @param b The builder.
205    */
206   protected CachingClientCredentialsFlow(Builder b) {
207      var scopes = u(cp(b.scopes));
208      var db = OAuthClientCredentialsFlow.create()
209         .tokenEndpoint(b.tokenEndpoint)
210         .clientId(b.clientId)
211         .clientSecretSupplier(b.clientSecretSupplier);
212      if (!scopes.isEmpty())
213         db.scope(scopes.toArray(new String[0]));
214      if (b.httpRequestConfigurator != null)
215         db.httpRequestConfigurator(b.httpRequestConfigurator);
216      this.delegate = db.build();
217      this.tokenCache = b.tokenCache;
218      this.cacheSkew = b.cacheSkew;
219      this.cacheKey = "cc|" + b.clientId + "|" + String.join(" ", scopes);
220   }
221
222   /**
223    * Acquires a token, returning the cached token when a fresh one is present.
224    *
225    * @return The acquired token.
226    */
227   public OAuthToken acquire() {
228      if (tokenCache != null) {
229         var hit = tokenCache.getToken(cacheKey, Instant.now(), cacheSkew);
230         if (hit.isPresent())
231            return hit.get();
232      }
233      var token = delegate.acquire();
234      if (tokenCache != null)
235         tokenCache.putToken(cacheKey, token);
236      return token;
237   }
238}