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)}) — always returns a fixed token. 039 * <li><b>Client-credentials</b> ({@link Builder#clientCredentials()}) — RFC 6749 §4.4 grant, re-acquired 040 * on expiry. 041 * <li><b>Refresh-token</b> ({@link Builder#refreshToken(String)}, SEP-2207) — RFC 6749 §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}