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.*; 020import static org.apache.juneau.commons.utils.UriUtils.*; 021 022import java.io.*; 023import java.net.*; 024import java.time.*; 025import java.util.*; 026import java.util.function.*; 027 028import org.apache.juneau.rest.auth.oauth.flow.*; 029 030import com.nimbusds.oauth2.sdk.*; 031import com.nimbusds.oauth2.sdk.http.*; 032import com.nimbusds.oauth2.sdk.id.*; 033import com.nimbusds.oauth2.sdk.pkce.*; 034 035/** 036 * Interactive authorization-code + PKCE (S256) token acquisition for headless / CLI use. 037 * 038 * <p> 039 * Builds on the relocated {@link OAuthAuthorizationCodeFlow}: opens a {@link LoopbackRedirectReceiver}, generates the 040 * {@code state} and PKCE {@code code_verifier}/{@code code_challenge}, hands the authorization URL to a caller-supplied 041 * browser launcher, waits for the loopback redirect, and exchanges the returned code for a token. 042 * 043 * <p> 044 * Security checks on the callback: 045 * <ul> 046 * <li><b>CSRF</b> — the returned {@code state} must match the one that was issued (single-use via 047 * {@link EphemeralStore}). 048 * <li><b>SEP-2468 ({@code iss}, RFC 9207)</b> — the callback {@code iss} parameter must equal the 049 * discovered/expected issuer; a mismatch is rejected. The check gates BOTH the success and error paths (a client 050 * MUST NOT act on an error response whose issuer doesn't match). {@link Builder#expectedIssuer(URI)} is required 051 * by default (opt out with {@link Builder#skipIssuerValidation(boolean)}); a missing {@code iss} is accepted 052 * unless {@link Builder#requireIssuerResponseParameter(boolean)} is set. 053 * </ul> 054 * 055 * <p> 056 * {@link Builder#resource(URI)} (the RFC 8707 resource indicator) is required so the acquired token's audience is bound 057 * to the target MCP server. 058 * 059 * <p> 060 * The optional {@link Builder#offlineAccess(boolean)} adds the {@code offline_access} scope so the IdP issues a refresh 061 * token that can subsequently drive an {@link McpTokenProvider} refresh-token provider (SEP-2207). 062 * 063 * @since 10.0.0 064 */ 065@SuppressWarnings({ 066 "java:S1192" // Duplicated literals (argument/property names) read more clearly inline than as constants 067}) 068public class McpAuthorizationCodeAcquirer { 069 070 /** 071 * Static creator. 072 * 073 * @return A new builder. 074 */ 075 public static Builder create() { 076 return new Builder(); 077 } 078 079 /** 080 * Builder. 081 */ 082 public static class Builder { 083 private URI authorizationEndpoint; 084 private URI tokenEndpoint; 085 private String clientId; 086 private Supplier<String> clientSecretSupplier; 087 private final Set<String> scopes = st(); 088 private boolean offlineAccess; 089 private URI resource; 090 private URI expectedIssuer; 091 private boolean skipIssuerValidation; 092 private boolean requireIssuerResponseParameter; 093 private String redirectPath = DEFAULT_REDIRECT_PATH; 094 private int redirectPort; // 0 == ephemeral / port-agnostic (the default). 095 private Consumer<URI> browserLauncher = McpAuthorizationCodeAcquirer::printAuthorizationUrl; 096 private EphemeralStore store; 097 private Duration httpTimeout = DEFAULT_HTTP_TIMEOUT; 098 private Consumer<HTTPRequest> httpRequestConfigurator; 099 100 /** Constructor. */ 101 protected Builder() {} 102 103 /** 104 * Sets the authorization endpoint URL. Required. 105 * 106 * @param value The endpoint URL. 107 * @return This object. 108 */ 109 public Builder authorizationEndpoint(URI value) { 110 authorizationEndpoint = assertSecureOrLoopback(reqnn("value", value)); 111 return this; 112 } 113 114 /** 115 * Sets the token endpoint URL. Required. 116 * 117 * @param value The endpoint URL. 118 * @return This object. 119 */ 120 public Builder tokenEndpoint(URI value) { 121 tokenEndpoint = assertSecureOrLoopback(reqnn("value", value)); 122 return this; 123 } 124 125 /** 126 * Sets the OAuth client ID. Required. 127 * 128 * @param value The client ID. 129 * @return This object. 130 */ 131 public Builder clientId(String value) { 132 clientId = reqnb("value", value); 133 return this; 134 } 135 136 /** 137 * Sets the client secret (omit for a public/native PKCE-only client). 138 * 139 * @param value The client secret. 140 * @return This object. 141 */ 142 public Builder clientSecret(String value) { 143 reqnb("value", value); 144 clientSecretSupplier = () -> value; 145 return this; 146 } 147 148 /** 149 * Adds requested scopes. 150 * 151 * @param values The scopes. 152 * @return This object. 153 */ 154 public Builder scope(String... values) { 155 reqnn("values", values); 156 for (var v : values) { 157 reqnb("scope", v); 158 scopes.add(v); 159 } 160 return this; 161 } 162 163 /** 164 * Requests the {@code offline_access} scope so the IdP issues a refresh token (SEP-2207). 165 * 166 * @param value <jk>true</jk> to add {@code offline_access}. 167 * @return This object. 168 */ 169 public Builder offlineAccess(boolean value) { 170 offlineAccess = value; 171 return this; 172 } 173 174 /** 175 * Sets the RFC 8707 {@code resource} indicator (the canonical MCP server URI). 176 * 177 * @param value The canonical resource URI. Must not be <jk>null</jk>. 178 * @return This object. 179 */ 180 public Builder resource(URI value) { 181 resource = reqnn("value", value); 182 return this; 183 } 184 185 /** 186 * Sets the expected issuer the callback {@code iss} parameter is validated against (SEP-2468). 187 * 188 * <p> 189 * Typically the {@code issuer} returned by {@code OidcDiscoveryClient} for the discovered authorization 190 * server. 191 * 192 * @param value The expected issuer. Must not be <jk>null</jk>. 193 * @return This object. 194 */ 195 public Builder expectedIssuer(URI value) { 196 expectedIssuer = reqnn("value", value); 197 return this; 198 } 199 200 /** 201 * Explicitly opts out of the SEP-2468 {@code iss} validation. 202 * 203 * <p> 204 * {@code iss} validation is on by default; {@link #build()} requires {@link #expectedIssuer(URI)} unless this 205 * is set to <jk>true</jk>. Only disable this against an authorization server known not to emit {@code iss}. 206 * 207 * @param value <jk>true</jk> to skip the {@code iss} check. 208 * @return This object. 209 */ 210 public Builder skipIssuerValidation(boolean value) { 211 skipIssuerValidation = value; 212 return this; 213 } 214 215 /** 216 * Requires the authorization response to carry an {@code iss} parameter, rejecting a response that omits it. 217 * 218 * <p> 219 * Set this when the discovered authorization-server metadata advertises 220 * {@code authorization_response_iss_parameter_supported} (RFC 9207); see 221 * {@link org.apache.juneau.rest.auth.oauth.oidc.OidcMetadata#authorizationResponseIssParameterSupported()}. 222 * 223 * @param value <jk>true</jk> to reject a response with no {@code iss} parameter. 224 * @return This object. 225 */ 226 public Builder requireIssuerResponseParameter(boolean value) { 227 requireIssuerResponseParameter = value; 228 return this; 229 } 230 231 /** 232 * Sets the loopback redirect path. Default {@code /callback}. 233 * 234 * @param value The path (must start with {@code /}). Must not be <jk>null</jk> or blank. 235 * @return This object. 236 */ 237 public Builder redirectPath(String value) { 238 redirectPath = reqnb("value", value); 239 req(redirectPath.startsWith("/"), "redirectPath must start with '/' (was '%s')", redirectPath); 240 return this; 241 } 242 243 /** 244 * Sets a fixed loopback redirect port so the receiver binds exactly that port (the "bind-first" strategy for 245 * strict exact-match authorization servers). 246 * 247 * <p> 248 * Defaults to {@code 0}, meaning an ephemeral port (the RFC 8252 §7.3 port-agnostic strategy) — the 249 * simplest interoperable choice. Set a non-zero port only when the authorization server registration used a 250 * bind-first {@link LoopbackRedirectUris#forPort(int, String) forPort} redirect; {@link McpClientRegistrations#configure} 251 * wires this automatically from such a registration. 252 * 253 * @param value The fixed loopback port in {@code 1..65535}, or {@code 0} for an ephemeral port. 254 * @return This object. 255 */ 256 public Builder redirectPort(int value) { 257 req(value == 0 || (value >= 1 && value <= 65535), "redirectPort must be 0 (ephemeral) or in 1..65535 (was %s)", value); 258 redirectPort = value; 259 return this; 260 } 261 262 /** 263 * Sets the callback invoked with the authorization URL so the caller can open it in a browser (or print it). 264 * 265 * @param value The launcher. Must not be <jk>null</jk>. 266 * @return This object. 267 */ 268 public Builder browserLauncher(Consumer<URI> value) { 269 browserLauncher = reqnn("value", value); 270 return this; 271 } 272 273 /** 274 * Sets the ephemeral state/verifier store. Defaults to a fresh {@link EphemeralStore}. 275 * 276 * @param value The store. Must not be <jk>null</jk>. 277 * @return This object. 278 */ 279 public Builder store(EphemeralStore value) { 280 store = reqnn("value", value); 281 return this; 282 } 283 284 /** 285 * Sets the connect/read timeout applied to the token-exchange request. Default 10 seconds. 286 * 287 * @param value The timeout. Must not be <jk>null</jk> and must be positive. 288 * @return This object. 289 */ 290 public Builder httpTimeout(Duration value) { 291 reqnn("value", value); 292 req(!value.isZero() && !value.isNegative(), "httpTimeout must be positive (was %s)", value); 293 httpTimeout = value; 294 return this; 295 } 296 297 /** 298 * Sets a callback that customizes the Nimbus {@link HTTPRequest} for the token exchange. 299 * 300 * @param value The callback. Must not be <jk>null</jk>. 301 * @return This object. 302 */ 303 public Builder httpRequestConfigurator(Consumer<HTTPRequest> value) { 304 httpRequestConfigurator = reqnn("value", value); 305 return this; 306 } 307 308 /** 309 * Builds the acquirer. 310 * 311 * @return A new {@link McpAuthorizationCodeAcquirer}. 312 */ 313 public McpAuthorizationCodeAcquirer build() { 314 if (authorizationEndpoint == null) 315 throw isex("McpAuthorizationCodeAcquirer requires authorizationEndpoint(...)"); 316 if (tokenEndpoint == null) 317 throw isex("McpAuthorizationCodeAcquirer requires tokenEndpoint(...)"); 318 if (clientId == null) 319 throw isex("McpAuthorizationCodeAcquirer requires clientId(...)"); 320 if (resource == null) 321 throw isex("McpAuthorizationCodeAcquirer requires resource(...) (RFC 8707 resource indicator)"); 322 if (expectedIssuer == null && ! skipIssuerValidation) 323 throw isex("McpAuthorizationCodeAcquirer requires expectedIssuer(...) (SEP-2468); call skipIssuerValidation(true) to opt out"); 324 if (store == null) 325 store = EphemeralStore.create(); 326 return new McpAuthorizationCodeAcquirer(this); 327 } 328 } 329 330 /** Default connect/read timeout applied to the token-exchange request when the caller sets none. */ 331 static final Duration DEFAULT_HTTP_TIMEOUT = Duration.ofSeconds(10); 332 333 /** 334 * Default loopback callback path. Not RFC 8252-mandated (only the loopback address itself is spec-mandated) and 335 * already fully caller-overridable via {@link Builder#redirectPath(String)}; this is merely the conventional 336 * default. 337 */ 338 @SuppressWarnings({ 339 "java:S1075" // Default value for an already caller-configurable Builder#redirectPath(String); not a hardcoded endpoint. 340 }) 341 static final String DEFAULT_REDIRECT_PATH = "/callback"; 342 343 /** Default HTML body served on the loopback callback so the browser tab shows a friendly confirmation. */ 344 private static final String DEFAULT_CALLBACK_HTML = 345 "<html><body><h3>Authorization received</h3><p>You may close this window and return to the application.</p></body></html>"; 346 347 private final URI authorizationEndpoint; 348 private final URI tokenEndpoint; 349 private final String clientId; 350 private final Supplier<String> clientSecretSupplier; 351 private final Set<String> scopes; 352 private final URI resource; 353 private final URI expectedIssuer; 354 private final boolean skipIssuerValidation; 355 private final boolean requireIssuerResponseParameter; 356 private final String redirectPath; 357 private final int redirectPort; 358 private final Consumer<URI> browserLauncher; 359 private final EphemeralStore store; 360 private final Duration httpTimeout; 361 private final Consumer<HTTPRequest> httpRequestConfigurator; 362 363 /** 364 * Constructor. 365 * 366 * @param b The builder. 367 */ 368 protected McpAuthorizationCodeAcquirer(Builder b) { 369 this.authorizationEndpoint = b.authorizationEndpoint; 370 this.tokenEndpoint = b.tokenEndpoint; 371 this.clientId = b.clientId; 372 this.clientSecretSupplier = b.clientSecretSupplier; 373 var s = new LinkedHashSet<>(b.scopes); 374 if (b.offlineAccess) 375 s.add("offline_access"); 376 this.scopes = u(s); 377 this.resource = b.resource; 378 this.expectedIssuer = b.expectedIssuer; 379 this.skipIssuerValidation = b.skipIssuerValidation; 380 this.requireIssuerResponseParameter = b.requireIssuerResponseParameter; 381 this.redirectPath = b.redirectPath; 382 this.redirectPort = b.redirectPort; 383 this.browserLauncher = b.browserLauncher; 384 this.store = b.store; 385 this.httpTimeout = b.httpTimeout; 386 this.httpRequestConfigurator = b.httpRequestConfigurator; 387 } 388 389 /** 390 * Default {@link Builder#browserLauncher(Consumer)}: prints the authorization URL to standard output so a headless 391 * user can open it manually (rather than silently doing nothing). 392 * 393 * @param authUrl The authorization URL. 394 */ 395 @SuppressWarnings({ 396 "java:S106" // Intentional user-facing console prompt for headless CLI authorization; not diagnostic logging. 397 }) 398 private static void printAuthorizationUrl(URI authUrl) { 399 System.out.println("Open the following URL in a browser to authorize:\n " + authUrl); 400 } 401 402 /** 403 * Runs the full interactive acquisition: starts the loopback listener, opens the authorization URL, waits for the 404 * redirect, validates it, and exchanges the code for a token. 405 * 406 * @param timeout The maximum time to wait for the user to complete authorization at the IdP. Must not be 407 * <jk>null</jk>. 408 * @return The acquired token. 409 * @throws McpAuthException If the listener cannot start, the wait times out, or the callback fails validation. 410 * @throws OAuthFlowException If the token-exchange HTTP round-trip fails or the IdP returns an error. 411 */ 412 public OAuthToken acquire(Duration timeout) { 413 reqnn("timeout", timeout); 414 LoopbackRedirectReceiver receiver; 415 try { 416 receiver = new LoopbackRedirectReceiver(redirectPath, redirectPort, DEFAULT_CALLBACK_HTML); 417 } catch (IOException e) { 418 throw new McpAuthException("Failed to start the loopback redirect listener", e); 419 } 420 try (receiver) { 421 var flow = buildFlow(receiver.redirectUri()); 422 var verifier = new CodeVerifier(); 423 var state = new State().getValue(); 424 var authUrl = buildAuthorizationUrl(flow, state, verifier); 425 store.store(state, verifier.getValue(), expectedIssuer); 426 browserLauncher.accept(authUrl); 427 var callbackUri = receiver.awaitCallback(timeout); 428 return handleCallback(flow, callbackUri); 429 } 430 } 431 432 /** 433 * Builds the authorization URL for a given state and PKCE verifier (exposed for callers driving their own 434 * user-agent interaction). 435 * 436 * @param flow The configured flow. Must not be <jk>null</jk>. 437 * @param state The opaque state. Must not be <jk>null</jk> or blank. 438 * @param verifier The PKCE verifier. Must not be <jk>null</jk>. 439 * @return The authorization URL. 440 */ 441 public URI buildAuthorizationUrl(OAuthAuthorizationCodeFlow flow, String state, CodeVerifier verifier) { 442 reqnn("flow", flow); 443 reqnn("verifier", verifier); 444 var challenge = CodeChallenge.compute(CodeChallengeMethod.S256, verifier); 445 return flow.buildAuthorizationUrl(state, challenge); 446 } 447 448 /** 449 * Validates a loopback callback and exchanges the authorization code for a token. 450 * 451 * <p> 452 * Consumes the pending {@code state} entry (single-use), performs the SEP-2468 {@code iss} check against the issuer 453 * recorded when the request was started, then exchanges the code. 454 * 455 * @param flow The configured flow. Must not be <jk>null</jk>. 456 * @param callbackUri The full loopback callback URI. Must not be <jk>null</jk>. 457 * @return The acquired token. 458 * @throws McpAuthException If the callback is an error response, the state is unknown/replayed, or the {@code iss} 459 * check fails. 460 */ 461 public OAuthToken handleCallback(OAuthAuthorizationCodeFlow flow, URI callbackUri) { 462 reqnn("flow", flow); 463 reqnn("callbackUri", callbackUri); 464 var resp = parse(callbackUri); 465 var state = resp.getState() == null ? null : resp.getState().getValue(); 466 if (state == null) 467 throw new McpAuthException("Authorization callback is missing the state parameter"); 468 var pending = store.consume(state) 469 .orElseThrow(() -> new McpAuthException("Authorization callback state is unknown, expired, or replayed")); 470 var code = extractCode(resp, pending.expectedIssuer(), skipIssuerValidation, requireIssuerResponseParameter); 471 return flow.exchange(code, new CodeVerifier(pending.codeVerifier())); 472 } 473 474 /** 475 * Validates an authorization-code callback and returns the authorization code, without any token exchange or 476 * state-store interaction. 477 * 478 * <p> 479 * This is the pure SEP-2468 / CSRF validation step. The {@code iss} comparison gates BOTH success and error 480 * responses (per the MCP spec a client MUST NOT act on an {@code error}/{@code error_description}/{@code error_uri} 481 * from a response whose {@code iss} does not match), so an error response with a mismatched issuer is reported as 482 * an issuer mismatch rather than a denial. A missing/mismatched {@code state} is rejected as CSRF. When 483 * {@code iss} is absent it is accepted (future-proofed for the eventual "reject if absent" MUST); use 484 * {@link #validateAuthorizationResponse(URI, String, URI, boolean)} to require it. 485 * 486 * @param callbackUri The full callback URI. Must not be <jk>null</jk>. 487 * @param expectedState The state issued when the authorization request was built. Must not be <jk>null</jk>. 488 * @param expectedIssuer The discovered issuer to validate {@code iss} against. Must not be <jk>null</jk>. 489 * @return The authorization code. 490 * @throws McpAuthException If validation fails. 491 */ 492 public static String validateAuthorizationResponse(URI callbackUri, String expectedState, URI expectedIssuer) { 493 return validateAuthorizationResponse(callbackUri, expectedState, expectedIssuer, false); 494 } 495 496 /** 497 * Validates an authorization-code callback and returns the authorization code, without any token exchange or 498 * state-store interaction. 499 * 500 * @param callbackUri The full callback URI. Must not be <jk>null</jk>. 501 * @param expectedState The state issued when the authorization request was built. Must not be <jk>null</jk>. 502 * @param expectedIssuer The discovered issuer to validate {@code iss} against. Must not be <jk>null</jk>. 503 * @param requireIssuerParameter <jk>true</jk> to reject a response that omits the {@code iss} parameter (RFC 9207 504 * {@code authorization_response_iss_parameter_supported}). 505 * @return The authorization code. 506 * @throws McpAuthException If validation fails. 507 */ 508 public static String validateAuthorizationResponse(URI callbackUri, String expectedState, URI expectedIssuer, boolean requireIssuerParameter) { 509 reqnn("callbackUri", callbackUri); 510 reqnb("expectedState", expectedState); 511 reqnn("expectedIssuer", expectedIssuer); 512 var resp = parse(callbackUri); 513 var state = resp.getState() == null ? null : resp.getState().getValue(); 514 if (neq(expectedState, state)) 515 throw new McpAuthException("Authorization callback state mismatch (possible CSRF)"); 516 return extractCode(resp, expectedIssuer, false, requireIssuerParameter); 517 } 518 519 private static AuthorizationResponse parse(URI callbackUri) { 520 try { 521 return AuthorizationResponse.parse(callbackUri); 522 } catch (ParseException e) { 523 throw new McpAuthException("Authorization callback could not be parsed", e); 524 } 525 } 526 527 private static String extractCode(AuthorizationResponse resp, URI expectedIssuer, boolean skipIssuerValidation, boolean requireIssuerParameter) { 528 // SEP-2468: the iss check gates BOTH the success and error paths — a client MUST NOT act on an error response 529 // whose issuer doesn't match, so this runs before the indicatesSuccess() branch. 530 if (! skipIssuerValidation) { 531 var iss = resp.getIssuer(); 532 if (iss == null) { 533 if (requireIssuerParameter) 534 throw new McpAuthException("Authorization response is missing the required iss parameter (SEP-2468 / RFC 9207)"); 535 } else if (expectedIssuer == null || ! iss.getValue().equals(expectedIssuer.toString())) { 536 throw new McpAuthException("Authorization response issuer mismatch (SEP-2468): expected '" 537 + expectedIssuer + "' but got '" + iss.getValue() + "'"); 538 } 539 } 540 if (! resp.indicatesSuccess()) { 541 throw new McpAuthException("Authorization request was denied: " + resp.toErrorResponse().getErrorObject().getCode()); 542 } 543 return resp.toSuccessResponse().getAuthorizationCode().getValue(); 544 } 545 546 private OAuthAuthorizationCodeFlow buildFlow(URI redirectUri) { 547 var b = OAuthAuthorizationCodeFlow.create() 548 .authorizationEndpoint(authorizationEndpoint) 549 .tokenEndpoint(tokenEndpoint) 550 .clientId(clientId) 551 .redirectUri(redirectUri) 552 .httpTimeout(httpTimeout); 553 if (clientSecretSupplier != null) 554 b.clientSecretSupplier(clientSecretSupplier); 555 scopes.forEach(b::scope); 556 b.resource(resource); 557 if (httpRequestConfigurator != null) 558 b.httpRequestConfigurator(httpRequestConfigurator); 559 return b.build(); 560 } 561 562 /** 563 * The configured fixed loopback redirect port ({@code 0} means ephemeral). Package-private for test verification of 564 * the bind-first port carry-through. 565 * 566 * @return The redirect port. 567 */ 568 int redirectPort() { 569 return redirectPort; 570 } 571}