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.io.*; 022import java.net.*; 023import java.time.*; 024import java.util.*; 025import java.util.function.*; 026 027import org.apache.juneau.commons.utils.UriUtils; 028 029import com.nimbusds.oauth2.sdk.*; 030import com.nimbusds.oauth2.sdk.auth.*; 031import com.nimbusds.oauth2.sdk.client.*; 032import com.nimbusds.oauth2.sdk.http.*; 033import com.nimbusds.oauth2.sdk.token.*; 034import com.nimbusds.openid.connect.sdk.rp.*; 035 036/** 037 * Performs an RFC 7591 / OpenID Connect Dynamic Client Registration (SEP-837) round-trip against an authorization 038 * server's {@code registration_endpoint}, returning the issued credentials as a redaction-safe 039 * {@link McpClientRegistration} keyed by the AS {@code issuer} (the SEP-2352 binding key). 040 * 041 * <p> 042 * The registration declares an {@code application_type} (SEP-837 <b>MUST</b>) — {@link McpApplicationType#NATIVE} 043 * for CLI/desktop/loopback clients, {@link McpApplicationType#WEB} for remote browser apps — along with the 044 * caller-supplied redirect URIs (see {@link LoopbackRedirectUris}), grant/response types, scope, and 045 * {@code token_endpoint_auth_method} ({@code none} for a public/native client by default; 046 * {@link Builder#confidential(boolean)} switches to {@code client_secret_basic}). A protected registration endpoint's 047 * RFC 7591 §3 initial access token is attached when {@link Builder#initialAccessToken(String)} is set. 048 * 049 * <p> 050 * The DCR-issued {@code client_id}/secret then feed the existing flows unchanged — DCR changes only how the 051 * {@code client_id} was obtained, not the {@code resource=} (RFC 8707) or {@code iss} (RFC 9207 / SEP-2468) 052 * enforcement; see {@link McpClientRegistrations} for the bridge to {@link McpAuthorizationCodeAcquirer} / 053 * {@link McpTokenProvider}. 054 * 055 * <p> 056 * {@link #register()} is the single public round-trip entry point. Internally it is split into a request-build step and 057 * a response-parse step (both package-private, so the module's own tests can drive them without a network) — those 058 * helpers deliberately traffic in Nimbus {@code provided}-scope types and are <b>not</b> part of the public API. A 059 * registration failure (e.g. {@code invalid_redirect_uri}) surfaces as an {@link McpAuthException} carrying the AS error 060 * code (SEP-837 "clients MUST be prepared to handle registration failures"). 061 * 062 * <h5 class='topic'>Security</h5> 063 * <p> 064 * The registration endpoint MUST originate from validated AS metadata ({@link org.apache.juneau.rest.auth.oauth.oidc.OidcDiscoveryClient} 065 * enforces the RFC 8414 / OIDC issuer-identity check) and MUST be {@code https} (loopback exempt for local testing, 066 * matching {@code McpProtectedResourceMetadataClient.requireSecure}). Client secrets are never logged — 067 * {@link #toString()} discloses only non-secret configuration and {@link McpClientRegistration} redacts its 068 * secret-bearing fields. 069 * 070 * @since 10.0.0 071 */ 072@SuppressWarnings({ 073 "java:S1192" // Duplicated literals (argument/property names) read more clearly inline than as constants 074}) 075public class McpDynamicClientRegistrar { 076 077 /** 078 * Static creator. 079 * 080 * @return A new builder. 081 */ 082 public static Builder create() { 083 return new Builder(); 084 } 085 086 /** 087 * Builder. 088 */ 089 public static class Builder { 090 URI registrationEndpoint; 091 URI issuer; 092 McpApplicationType applicationType; 093 final List<URI> redirectUris = new ArrayList<>(); 094 final Set<String> scopes = st(); 095 final Set<String> grantTypes = st(); 096 final Set<String> responseTypes = st(); 097 boolean confidential; 098 String clientName; 099 Supplier<String> initialAccessTokenSupplier; 100 Duration httpTimeout = DEFAULT_HTTP_TIMEOUT; 101 Consumer<HTTPRequest> httpRequestConfigurator; 102 103 /** Constructor. */ 104 protected Builder() {} 105 106 /** 107 * Sets the AS {@code registration_endpoint} URL. Required. 108 * 109 * @param value The registration endpoint (typically from validated AS metadata's {@code registration_endpoint}). 110 * @return This object. 111 */ 112 public Builder registrationEndpoint(URI value) { 113 registrationEndpoint = reqnn("value", value); 114 return this; 115 } 116 117 /** 118 * Sets the AS {@code issuer} these credentials are being registered with. Required. 119 * 120 * <p> 121 * Stamped onto the resulting {@link McpClientRegistration} as the SEP-2352 credential-binding key. 122 * 123 * @param value The issuer URI. 124 * @return This object. 125 */ 126 public Builder issuer(URI value) { 127 issuer = reqnn("value", value); 128 return this; 129 } 130 131 /** 132 * Sets the {@code application_type} (SEP-837 <b>MUST</b>). Required. 133 * 134 * @param value The application type. 135 * @return This object. 136 */ 137 public Builder applicationType(McpApplicationType value) { 138 applicationType = reqnn("value", value); 139 return this; 140 } 141 142 /** 143 * Adds one or more redirect URIs to register (accumulates across calls). At least one is required. 144 * 145 * @param values The redirect URIs (e.g. from {@link LoopbackRedirectUris#forPort(int, String)}). 146 * @return This object. 147 */ 148 public Builder addRedirectUri(URI... values) { 149 reqnn("values", values); 150 for (var v : values) 151 redirectUris.add(reqnn("redirectUri", v)); 152 return this; 153 } 154 155 /** 156 * Adds a list of redirect URIs to register (accumulates across calls). At least one is required. 157 * 158 * @param values The redirect URIs (e.g. from {@link LoopbackRedirectUris#forPort(int, String)}). 159 * @return This object. 160 */ 161 public Builder addRedirectUris(List<URI> values) { 162 reqnn("values", values); 163 for (var v : values) 164 redirectUris.add(reqnn("redirectUri", v)); 165 return this; 166 } 167 168 /** 169 * Adds requested scopes. 170 * 171 * @param values The scopes. 172 * @return This object. 173 */ 174 public Builder scope(String... values) { 175 reqnn("values", values); 176 for (var v : values) { 177 reqnb("scope", v); 178 scopes.add(v); 179 } 180 return this; 181 } 182 183 /** 184 * Adds requested {@code grant_types} (accumulates across calls). When none are added, defaults to 185 * {@code authorization_code} + {@code refresh_token}. 186 * 187 * @param values The grant type identifiers. 188 * @return This object. 189 */ 190 public Builder addGrantType(String... values) { 191 reqnn("values", values); 192 for (var v : values) { 193 reqnb("grantType", v); 194 grantTypes.add(v); 195 } 196 return this; 197 } 198 199 /** 200 * Adds requested {@code response_types} (accumulates across calls). When none are added, defaults to 201 * {@code code}. 202 * 203 * @param values The response type identifiers. 204 * @return This object. 205 */ 206 public Builder addResponseType(String... values) { 207 reqnn("values", values); 208 for (var v : values) { 209 reqnb("responseType", v); 210 responseTypes.add(v); 211 } 212 return this; 213 } 214 215 /** 216 * Registers a confidential client ({@code token_endpoint_auth_method=client_secret_basic}) instead of the 217 * default public/native client ({@code token_endpoint_auth_method=none}). 218 * 219 * @param value <jk>true</jk> to register a confidential client. 220 * @return This object. 221 */ 222 public Builder confidential(boolean value) { 223 confidential = value; 224 return this; 225 } 226 227 /** 228 * Sets the human-readable {@code client_name}. 229 * 230 * @param value The client name. 231 * @return This object. 232 */ 233 public Builder clientName(String value) { 234 clientName = reqnb("value", value); 235 return this; 236 } 237 238 /** 239 * Sets the RFC 7591 §3 initial access token used to authorize the call to a protected registration 240 * endpoint. 241 * 242 * @param value The initial access token. Must not be <jk>null</jk> or blank. 243 * @return This object. 244 */ 245 public Builder initialAccessToken(String value) { 246 reqnb("value", value); 247 initialAccessTokenSupplier = () -> value; 248 return this; 249 } 250 251 /** 252 * Sets the connect/read timeout applied to the registration request. Default 10 seconds. 253 * 254 * @param value The timeout. Must not be <jk>null</jk> and must be positive. 255 * @return This object. 256 */ 257 public Builder httpTimeout(Duration value) { 258 reqnn("value", value); 259 req(!value.isZero() && !value.isNegative(), "httpTimeout must be positive (was %s)", value); 260 httpTimeout = value; 261 return this; 262 } 263 264 /** 265 * Sets a callback that customizes the Nimbus {@link HTTPRequest} for the registration call. 266 * 267 * @param value The callback. Must not be <jk>null</jk>. 268 * @return This object. 269 */ 270 public Builder httpRequestConfigurator(Consumer<HTTPRequest> value) { 271 httpRequestConfigurator = reqnn("value", value); 272 return this; 273 } 274 275 /** 276 * Builds the registrar. 277 * 278 * @return A new {@link McpDynamicClientRegistrar}. 279 */ 280 public McpDynamicClientRegistrar build() { 281 if (registrationEndpoint == null) 282 throw isex("McpDynamicClientRegistrar requires registrationEndpoint(...)"); 283 if (issuer == null) 284 throw isex("McpDynamicClientRegistrar requires issuer(...)"); 285 if (applicationType == null) 286 throw isex("McpDynamicClientRegistrar requires applicationType(...) (SEP-837)"); 287 if (redirectUris.isEmpty()) 288 throw isex("McpDynamicClientRegistrar requires at least one redirectUri(...)"); 289 if (! isSecureOrLoopback(registrationEndpoint)) 290 throw isex("McpDynamicClientRegistrar registration endpoint must use https (loopback exempt): " + registrationEndpoint); 291 return new McpDynamicClientRegistrar(this); 292 } 293 } 294 295 /** Default connect/read timeout applied to the registration request when the caller sets none. */ 296 static final Duration DEFAULT_HTTP_TIMEOUT = Duration.ofSeconds(10); 297 298 private final URI registrationEndpoint; 299 private final URI issuer; 300 private final McpApplicationType applicationType; 301 private final List<URI> redirectUris; 302 private final Set<String> scopes; 303 private final Set<String> grantTypes; 304 private final Set<String> responseTypes; 305 private final boolean confidential; 306 private final String clientName; 307 private final Supplier<String> initialAccessTokenSupplier; 308 private final Duration httpTimeout; 309 private final Consumer<HTTPRequest> httpRequestConfigurator; 310 311 /** 312 * Constructor. 313 * 314 * @param b The builder. 315 */ 316 protected McpDynamicClientRegistrar(Builder b) { 317 this.registrationEndpoint = b.registrationEndpoint; 318 this.issuer = b.issuer; 319 this.applicationType = b.applicationType; 320 this.redirectUris = u(cp(b.redirectUris)); 321 this.scopes = u(new LinkedHashSet<>(b.scopes)); 322 this.grantTypes = u(new LinkedHashSet<>(b.grantTypes.isEmpty() ? List.of("authorization_code", "refresh_token") : b.grantTypes)); 323 this.responseTypes = u(new LinkedHashSet<>(b.responseTypes.isEmpty() ? List.of("code") : b.responseTypes)); 324 this.confidential = b.confidential; 325 this.clientName = b.clientName; 326 this.initialAccessTokenSupplier = b.initialAccessTokenSupplier; 327 this.httpTimeout = b.httpTimeout; 328 this.httpRequestConfigurator = b.httpRequestConfigurator; 329 } 330 331 /** 332 * Builds the Nimbus {@link OIDCClientRegistrationRequest} that {@link #register()} would send, declaring the 333 * {@code application_type}, redirect URIs, grant/response types, {@code token_endpoint_auth_method}, scope, and 334 * (if configured) the initial access token. 335 * 336 * <p> 337 * Package-private: returns a Nimbus {@code provided}-scope type, so it is an internal seam for the module's tests to 338 * inspect the exact request without a network round-trip — not part of the public API. 339 * 340 * @return The registration request. Never <jk>null</jk>. 341 */ 342 OIDCClientRegistrationRequest buildRegistrationRequest() { 343 var md = new OIDCClientMetadata(); 344 md.setApplicationType(applicationType.toNimbus()); 345 md.setRedirectionURIs(new LinkedHashSet<>(redirectUris)); 346 md.setGrantTypes(mapGrantTypes(grantTypes)); 347 md.setResponseTypes(mapResponseTypes(responseTypes)); 348 md.setTokenEndpointAuthMethod(confidential ? ClientAuthenticationMethod.CLIENT_SECRET_BASIC : ClientAuthenticationMethod.NONE); 349 if (! scopes.isEmpty()) 350 md.setScope(Scope.parse(scopes)); 351 if (clientName != null) 352 md.setName(clientName); 353 var iat = initialAccessTokenSupplier == null ? null : new BearerAccessToken(initialAccessTokenSupplier.get()); 354 return new OIDCClientRegistrationRequest(registrationEndpoint, md, iat); 355 } 356 357 /** 358 * Parses a (possibly canned) registration {@link HTTPResponse} into an {@link McpClientRegistration}, stamping the 359 * configured {@code issuer}. 360 * 361 * <p> 362 * On a registration error the AS error object's code is surfaced as an {@link McpAuthException} (SEP-837 363 * registration-failure handling). 364 * 365 * <p> 366 * Package-private: accepts a Nimbus {@code provided}-scope type, so it is an internal seam for the module's tests to 367 * map a canned response — not part of the public API. 368 * 369 * @param httpResponse The registration response. Must not be <jk>null</jk>. 370 * @return The mapped registration result. Never <jk>null</jk>. 371 * @throws McpAuthException If the AS returned an error or the response could not be parsed. 372 */ 373 McpClientRegistration parseRegistrationResponse(HTTPResponse httpResponse) { 374 reqnn("httpResponse", httpResponse); 375 ClientRegistrationResponse resp; 376 try { 377 resp = OIDCClientRegistrationResponseParser.parse(httpResponse); 378 } catch (ParseException e) { 379 throw new McpAuthException("Dynamic client registration response could not be parsed", e); 380 } 381 if (! resp.indicatesSuccess()) { 382 var err = resp.toErrorResponse().getErrorObject(); 383 var code = err.getCode() == null ? "unknown_error" : err.getCode(); 384 String desc = err.getDescription(); 385 throw new McpAuthException("Dynamic client registration failed: " + code + (desc == null ? "" : " (" + desc + ")")); 386 } 387 return toRegistration(resp.toSuccessResponse().getClientInformation()); 388 } 389 390 /** 391 * Performs the live RFC 7591 registration round-trip: builds the request, applies the finite HTTP timeout and the 392 * caller's {@code httpRequestConfigurator}, sends it, and maps the response. 393 * 394 * @return The issued registration. Never <jk>null</jk>. 395 * @throws McpAuthException If the HTTP call fails or the AS returned an error. 396 */ 397 public McpClientRegistration register() { 398 var http = buildRegistrationRequest().toHTTPRequest(); 399 var ms = (int) Math.min(httpTimeout.toMillis(), Integer.MAX_VALUE); 400 http.setConnectTimeout(ms); 401 http.setReadTimeout(ms); 402 if (httpRequestConfigurator != null) 403 httpRequestConfigurator.accept(http); 404 HTTPResponse httpResp; 405 try { 406 httpResp = http.send(); 407 } catch (IOException e) { 408 throw new McpAuthException("Dynamic client registration HTTP call failed", e); 409 } 410 return parseRegistrationResponse(httpResp); 411 } 412 413 private McpClientRegistration toRegistration(ClientInformation info) { 414 var secret = info.getSecret(); 415 Optional<String> clientSecret = secret == null ? oe() : o(secret.getValue()); 416 Optional<Instant> secretExpires = (secret == null || secret.getExpirationDate() == null) ? oe() : o(secret.getExpirationDate().toInstant()); 417 var rat = info.getRegistrationAccessToken(); 418 Optional<String> regAccessToken = rat == null ? oe() : o(rat.getValue()); 419 Optional<URI> regClientUri = info.getRegistrationURI() == null ? oe() : o(info.getRegistrationURI()); 420 return new McpClientRegistration( 421 info.getID().getValue(), 422 clientSecret, 423 secretExpires, 424 regAccessToken, 425 regClientUri, 426 issuer, 427 redirectUris, 428 applicationType, 429 extrasFrom(info)); 430 } 431 432 /** Surfaces any non-registered custom fields the AS returned in the registration response as the registration's extras. */ 433 private static Map<String,Object> extrasFrom(ClientInformation info) { 434 var custom = info.getMetadata().getCustomFields(); 435 return custom.isEmpty() ? Map.of() : new LinkedHashMap<>(custom); 436 } 437 438 private static Set<GrantType> mapGrantTypes(Set<String> v) { 439 var out = new LinkedHashSet<GrantType>(); 440 for (var g : v) { 441 try { 442 out.add(GrantType.parse(g)); 443 } catch (ParseException e) { // HTT: GrantType.parse only throws ParseException for null/empty input, which the builder's reqnb rejects up front 444 throw iaex(e, "Invalid grant_type '%s'", g); // HTT: see above 445 } 446 } 447 return out; 448 } 449 450 private static Set<ResponseType> mapResponseTypes(Set<String> v) { 451 var out = new LinkedHashSet<ResponseType>(); 452 for (var r : v) { 453 try { 454 out.add(ResponseType.parse(r)); 455 } catch (ParseException e) { // HTT: ResponseType.parse only throws ParseException for null/empty input, which the builder's reqnb rejects up front 456 throw iaex(e, "Invalid response_type '%s'", r); // HTT: see above 457 } 458 } 459 return out; 460 } 461 462 /** Whether the URI is {@code https} or targets a loopback host (exempt from the https requirement). */ 463 static boolean isSecureOrLoopback(URI uri) { 464 return UriUtils.isSecureOrLoopback(uri); 465 } 466 467 @Override /* Object */ 468 public String toString() { 469 return "McpDynamicClientRegistrar[registrationEndpoint=" + registrationEndpoint 470 + ", issuer=" + issuer 471 + ", applicationType=" + applicationType 472 + ", redirectUris=" + redirectUris 473 + ", confidential=" + confidential 474 + ", initialAccessToken=" + (initialAccessTokenSupplier != null ? "<redacted>" : "<none>") 475 + "]"; 476 } 477}