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>) &mdash; {@link McpApplicationType#NATIVE}
043 * for CLI/desktop/loopback clients, {@link McpApplicationType#WEB} for remote browser apps &mdash; 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 &sect;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 &mdash; 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) &mdash; 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 &mdash;
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 &sect;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 &mdash; 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 &mdash; 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}