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.client.mcp.auth;
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.client.mcp.*;
027import org.apache.juneau.rest.auth.oauth.flow.*;
028
029import com.nimbusds.oauth2.sdk.http.*;
030
031/**
032 * A headless bearer-token provider that feeds the {@link McpAuthInterceptor} token seam ({@code Supplier<String>}).
033 *
034 * <p>
035 * This is the client-side token-manager: it acquires, caches, and refreshes an access token and returns the current
036 * valid bearer credential from {@link #get()}.  Three modes are supported:
037 * <ul>
038 *    <li><b>Pre-provisioned / static</b> ({@link #ofStaticToken(String)}) &mdash; always returns a fixed token.
039 *    <li><b>Client-credentials</b> ({@link Builder#clientCredentials()}) &mdash; RFC 6749 &sect;4.4 grant, re-acquired
040 *       on expiry.
041 *    <li><b>Refresh-token</b> ({@link Builder#refreshToken(String)}, SEP-2207) &mdash; RFC 6749 &sect;6 grant; rotated
042 *       refresh tokens returned by the IdP are captured and used for the next refresh without re-consent, and the
043 *       refreshed access token is fed back to the supplier.
044 * </ul>
045 *
046 * <p>
047 * The dynamic modes (client-credentials, refresh-token) require {@link Builder#resource(URI)} and carry that RFC 8707
048 * {@code resource} indicator on every token request, binding the token's audience to the target MCP server.  The static
049 * mode issues no token request, so it takes no resource indicator.
050 *
051 * <p>
052 * {@link #get()} never returns a blank token: dynamic modes return the IdP-issued (non-blank) access token or throw
053 * {@link McpAuthException}/{@link OAuthFlowException} on failure (which aborts the intercepted request), and the static
054 * mode rejects a blank token at construction.  This satisfies the {@code McpAuthInterceptor} "supply a non-blank token
055 * or suppress the header" contract.
056 *
057 * <p>
058 * Thread-safe.  <b>Never logged:</b> {@link #toString()} discloses only non-secret configuration.
059 *
060 * @since 10.0.0
061 */
062@SuppressWarnings({
063   "java:S1192" // Duplicated literals (argument/property names) read more clearly inline than as constants
064})
065public class McpTokenProvider implements Supplier<String> {
066
067   private enum Mode { STATIC, CLIENT_CREDENTIALS, REFRESH }
068
069   /**
070    * Creates a provider that always returns the supplied pre-provisioned bearer token.
071    *
072    * @param token The static bearer token.  Must not be <jk>null</jk> or blank.
073    * @return A new provider.
074    */
075   public static McpTokenProvider ofStaticToken(String token) {
076      reqnb("token", token);
077      return new Builder(Mode.STATIC).staticToken(token).build();
078   }
079
080   /**
081    * Creates a builder for the client-credentials grant.
082    *
083    * @return A new builder.
084    */
085   public static Builder clientCredentials() {
086      return new Builder(Mode.CLIENT_CREDENTIALS);
087   }
088
089   /**
090    * Creates a builder for the refresh-token grant, seeded with the initial refresh token.
091    *
092    * @param initialRefreshToken The initial refresh token.  Must not be <jk>null</jk> or blank.
093    * @return A new builder.
094    */
095   public static Builder refreshToken(String initialRefreshToken) {
096      reqnb("initialRefreshToken", initialRefreshToken);
097      return new Builder(Mode.REFRESH).initialRefreshToken(initialRefreshToken);
098   }
099
100   /**
101    * Builder.
102    */
103   public static class Builder {
104      private final Mode mode;
105      private String staticToken;
106      private URI tokenEndpoint;
107      private String clientId;
108      private Supplier<String> clientSecretSupplier;
109      private String initialRefreshToken;
110      private final Set<String> scopes = st();
111      private URI resource;
112      private Duration expirySkew = Duration.ofSeconds(30);
113      private Clock clock = Clock.systemUTC();
114      private Duration httpTimeout = Duration.ofSeconds(10);
115      private Consumer<HTTPRequest> httpRequestConfigurator;
116
117      /**
118       * Constructor.
119       *
120       * @param mode The grant mode.
121       */
122      protected Builder(Mode mode) {
123         this.mode = mode;
124      }
125
126      Builder staticToken(String value) {
127         staticToken = value;
128         return this;
129      }
130
131      Builder initialRefreshToken(String value) {
132         initialRefreshToken = value;
133         return this;
134      }
135
136      /**
137       * Sets the token endpoint URL.  Required for the client-credentials and refresh-token modes.
138       *
139       * @param value The endpoint URL.  Must not be <jk>null</jk>.
140       * @return This object.
141       */
142      public Builder tokenEndpoint(URI value) {
143         tokenEndpoint = reqnn("value", value);
144         return this;
145      }
146
147      /**
148       * Sets the OAuth client ID.  Required for the client-credentials and refresh-token modes.
149       *
150       * @param value The client ID.  Must not be <jk>null</jk> or blank.
151       * @return This object.
152       */
153      public Builder clientId(String value) {
154         clientId = reqnb("value", value);
155         return this;
156      }
157
158      /**
159       * Sets the OAuth client secret as a literal string.
160       *
161       * @param value The client secret.  Must not be <jk>null</jk> or blank.
162       * @return This object.
163       */
164      public Builder clientSecret(String value) {
165         reqnb("value", value);
166         clientSecretSupplier = () -> value;
167         return this;
168      }
169
170      /**
171       * Sets the OAuth client secret via a supplier.
172       *
173       * @param value The supplier.  Must not be <jk>null</jk>.
174       * @return This object.
175       */
176      public Builder clientSecretSupplier(Supplier<String> value) {
177         clientSecretSupplier = reqnn("value", value);
178         return this;
179      }
180
181      /**
182       * Adds requested OAuth scopes.
183       *
184       * @param values The scopes.
185       * @return This object.
186       */
187      public Builder scope(String... values) {
188         reqnn("values", values);
189         for (var v : values) {
190            reqnb("scope", v);
191            scopes.add(v);
192         }
193         return this;
194      }
195
196      /**
197       * Sets the RFC 8707 {@code resource} indicator (the canonical MCP server URI) sent on every token request.
198       *
199       * @param value The canonical resource URI.  Must not be <jk>null</jk>.
200       * @return This object.
201       */
202      public Builder resource(URI value) {
203         resource = reqnn("value", value);
204         return this;
205      }
206
207      /**
208       * Sets the skew subtracted from a cached token's expiry before it is treated as expired.  Default 30s.
209       *
210       * @param value The skew.  Must be non-negative.
211       * @return This object.
212       */
213      public Builder expirySkew(Duration value) {
214         reqnn("value", value);
215         req(!value.isNegative(), "expirySkew must be non-negative (was %s)", value);
216         expirySkew = value;
217         return this;
218      }
219
220      /**
221       * Sets the clock used for token-expiry comparisons.  Primarily for tests.
222       *
223       * @param value The clock.  Must not be <jk>null</jk>.
224       * @return This object.
225       */
226      public Builder clock(Clock value) {
227         clock = reqnn("value", value);
228         return this;
229      }
230
231      /**
232       * Sets the connect/read timeout applied to each token request.  Default 10 seconds.
233       *
234       * @param value The timeout.  Must not be <jk>null</jk> and must be positive.
235       * @return This object.
236       */
237      public Builder httpTimeout(Duration value) {
238         reqnn("value", value);
239         req(!value.isZero() && !value.isNegative(), "httpTimeout must be positive (was %s)", value);
240         httpTimeout = value;
241         return this;
242      }
243
244      /**
245       * Sets a callback that customizes the Nimbus {@link HTTPRequest} for each token request.
246       *
247       * @param value The callback.  Must not be <jk>null</jk>.
248       * @return This object.
249       */
250      public Builder httpRequestConfigurator(Consumer<HTTPRequest> value) {
251         httpRequestConfigurator = reqnn("value", value);
252         return this;
253      }
254
255      /**
256       * Builds the provider.
257       *
258       * @return A new {@link McpTokenProvider}.
259       */
260      public McpTokenProvider build() {
261         if (mode != Mode.STATIC) {
262            if (tokenEndpoint == null)
263               throw isex("McpTokenProvider requires tokenEndpoint(...)");
264            if (clientId == null)
265               throw isex("McpTokenProvider requires clientId(...)");
266            if (resource == null)
267               throw isex("McpTokenProvider requires resource(...) (RFC 8707 resource indicator)");
268         }
269         if (mode == Mode.CLIENT_CREDENTIALS && clientSecretSupplier == null)
270            throw isex("McpTokenProvider (client-credentials) requires clientSecret(...) or clientSecretSupplier(...)");
271         return new McpTokenProvider(this);
272      }
273   }
274
275   private final Mode mode;
276   private final String staticToken;
277   private final URI tokenEndpoint;
278   private final String clientId;
279   private final Supplier<String> clientSecretSupplier;
280   private final Set<String> scopes;
281   private final URI resource;
282   private final Duration expirySkew;
283   private final Clock clock;
284   private final Duration httpTimeout;
285   private final Consumer<HTTPRequest> httpRequestConfigurator;
286
287   private final Object lock = new Object();
288   private OAuthToken currentToken;
289   @SuppressWarnings({
290      "java:S9438" // Reassigned inside a lambda in the token-fetch path; Sonar misses the lambda assignment.
291   })
292   private String currentRefreshToken;
293   private boolean refreshTerminallyRejected;
294
295   /**
296    * Constructor.
297    *
298    * @param b The builder.
299    */
300   protected McpTokenProvider(Builder b) {
301      this.mode = b.mode;
302      this.staticToken = b.staticToken;
303      this.tokenEndpoint = b.tokenEndpoint;
304      this.clientId = b.clientId;
305      this.clientSecretSupplier = b.clientSecretSupplier;
306      this.scopes = u(cp(b.scopes));
307      this.resource = b.resource;
308      this.expirySkew = b.expirySkew;
309      this.clock = b.clock;
310      this.httpTimeout = b.httpTimeout;
311      this.httpRequestConfigurator = b.httpRequestConfigurator;
312      this.currentRefreshToken = b.initialRefreshToken;
313   }
314
315   /**
316    * Returns the current valid bearer token, acquiring or refreshing one on demand.
317    *
318    * <p>
319    * Dynamic modes acquire under a single-flight monitor so concurrent callers don't stampede the IdP; the per-request
320    * HTTP timeout ({@link Builder#httpTimeout(Duration)}, default 10s) is the backstop that prevents one hung IdP from
321    * wedging the monitor indefinitely.  In refresh mode a terminal {@code invalid_grant} latches the provider: every
322    * subsequent call throws a distinguishable {@link McpAuthException} rather than re-hammering the IdP with a dead
323    * refresh token (SEP-2207).
324    *
325    * @return A non-blank bearer token.  Never {@code null} for a successfully configured provider.
326    * @throws OAuthFlowException If a token acquisition/refresh HTTP round-trip fails or the IdP returns an error.
327    * @throws McpAuthException If a prior refresh was terminally rejected ({@code invalid_grant}); re-authorization is
328    *    required.
329    */
330   @Override
331   public String get() {
332      if (mode == Mode.STATIC)
333         return staticToken;
334      synchronized (lock) {
335         if (mode == Mode.REFRESH && refreshTerminallyRejected)
336            throw new McpAuthException("Refresh token was rejected by the authorization server (invalid_grant); re-authorization required");
337         if (currentToken != null && !currentToken.isExpired(clock.instant(), expirySkew))
338            return currentToken.accessToken();
339         OAuthToken t;
340         try {
341            t = acquire();
342         } catch (OAuthFlowException e) {
343            if (mode == Mode.REFRESH && e.errorCode().filter("invalid_grant"::equals).isPresent()) {
344               refreshTerminallyRejected = true;
345               throw new McpAuthException("Refresh token was rejected by the authorization server (invalid_grant); re-authorization required", e);
346            }
347            throw e;
348         }
349         currentToken = t;
350         if (mode == Mode.REFRESH)
351            t.refreshToken().ifPresent(rt -> currentRefreshToken = rt);
352         return t.accessToken();
353      }
354   }
355
356   private OAuthToken acquire() {
357      if (mode == Mode.CLIENT_CREDENTIALS) {
358         var f = OAuthClientCredentialsFlow.create()
359            .tokenEndpoint(tokenEndpoint)
360            .clientId(clientId)
361            .clientSecretSupplier(clientSecretSupplier);
362         f.httpTimeout(httpTimeout);
363         scopes.forEach(f::scope);
364         f.resource(resource);
365         if (httpRequestConfigurator != null)
366            f.httpRequestConfigurator(httpRequestConfigurator);
367         return f.build().acquire();
368      }
369      // REFRESH
370      var f = OAuthRefreshTokenFlow.create()
371         .tokenEndpoint(tokenEndpoint)
372         .clientId(clientId)
373         .refreshToken(currentRefreshToken);
374      if (clientSecretSupplier != null)
375         f.clientSecretSupplier(clientSecretSupplier);
376      f.httpTimeout(httpTimeout);
377      scopes.forEach(f::scope);
378      f.resource(resource);
379      if (httpRequestConfigurator != null)
380         f.httpRequestConfigurator(httpRequestConfigurator);
381      return f.build().acquire();
382   }
383
384   /**
385    * Returns the refresh token currently held (the most recently rotated value in refresh mode).
386    *
387    * <p>
388    * Callers persisting refresh tokens across process restarts read this after each {@link #get()} to capture a
389    * rotated token (SEP-2207).
390    *
391    * @return The current refresh token, or {@link Optional#empty()} if none is held.
392    */
393   public Optional<String> currentRefreshToken() {
394      synchronized (lock) {
395         return o(currentRefreshToken);
396      }
397   }
398
399   /**
400    * Creates an {@link McpAuthInterceptor} backed by this provider, ready to attach to an MCP client builder.
401    *
402    * @return A new interceptor.
403    */
404   public McpAuthInterceptor interceptor() {
405      return new McpAuthInterceptor(this);
406   }
407
408   /**
409    * Redacts token/secret material so nothing sensitive reaches logs via this provider's {@code toString()}.
410    *
411    * @return A redacted string form disclosing only non-secret configuration.
412    */
413   @Override
414   public String toString() {
415      return "McpTokenProvider[mode=" + mode
416         + ", tokenEndpoint=" + tokenEndpoint
417         + ", clientId=" + clientId
418         + ", scopes=" + scopes
419         + ", resource=" + resource
420         + ", staticToken=" + (staticToken != null ? "<redacted>" : "<none>")
421         + ", refreshToken=" + (currentRefreshToken != null ? "<redacted>" : "<none>")
422         + "]";
423   }
424}