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.auth.oauth.oidc.*;
027
028import com.nimbusds.oauth2.sdk.http.*;
029
030/**
031 * Orchestrates SEP-2352 registration-mechanism selection, issuer-keying, and re-registration on authorization-server
032 * migration, returning a usable {@link McpClientRegistration} for a discovered AS.
033 *
034 * <p>
035 * Given a discovered {@link OidcMetadata} (whose {@code issuer} identifies the AS and whose {@code extras} carry the
036 * RFC 7591 {@code registration_endpoint}), {@link #resolve(OidcMetadata)} selects credentials per the SEP-2352 rules:
037 * <ul>
038 *    <li><b>Pre-registered credentials</b> ({@link Builder#preRegistered(McpClientRegistration)}) are used only when
039 *       their bound {@code issuer} matches the discovered AS; a mismatch (the AS migrated) surfaces an
040 *       {@link McpAuthException} rather than silently reusing cross-AS credentials (SEP-2352 SHOULD).
041 *    <li><b>With a store</b> ({@link Builder#store(McpClientRegistrationStore)}) the issuer-keyed entry is reused without
042 *       a second DCR round-trip; on migration to a different issuer the store has no entry for the new AS, so the manager
043 *       re-registers via DCR and stores under the new issuer &mdash; the old AS's entry is never reused (SEP-2352
044 *       "MUST NOT reuse client credentials from a different authorization server").
045 *    <li><b>On-demand mode</b> (no store) performs a fresh DCR on every {@link #resolve(OidcMetadata)} call &mdash; fully
046 *       compliant (persisting a registration is optional).
047 *    <li>If the AS advertises no {@code registration_endpoint} and no matching pre-registered credentials exist, an
048 *       {@link McpAuthException} is thrown (no way to obtain a {@code client_id}; CIMD is a tracked fast-follow).
049 * </ul>
050 *
051 * @since 10.0.0
052 */
053@SuppressWarnings({
054   "java:S1192" // Duplicated literals (argument/property names) read more clearly inline than as constants
055})
056public class McpClientRegistrationManager {
057
058   /**
059    * Static creator.
060    *
061    * @return A new builder.
062    */
063   public static Builder create() {
064      return new Builder();
065   }
066
067   /**
068    * Builder.
069    */
070   public static class Builder {
071      McpClientRegistrationStore store;
072      McpClientRegistration preRegistered;
073      McpApplicationType applicationType = McpApplicationType.NATIVE;
074      Supplier<List<URI>> redirectUrisSupplier;
075      final Set<String> scopes = st();
076      boolean confidential;
077      String clientName;
078      Supplier<String> initialAccessTokenSupplier;
079      Duration httpTimeout = McpDynamicClientRegistrar.DEFAULT_HTTP_TIMEOUT;
080      Consumer<HTTPRequest> httpRequestConfigurator;
081      Function<OidcMetadata,McpClientRegistration> dcrOverride;
082
083      /** Constructor. */
084      protected Builder() {}
085
086      /**
087       * Sets the optional issuer-keyed credential store.  When omitted, the manager runs in on-demand mode and
088       * performs a fresh DCR on every {@code resolve(...)} call (compliant).
089       *
090       * @param value The store.  Must not be <jk>null</jk>.
091       * @return This object.
092       */
093      public Builder store(McpClientRegistrationStore value) {
094         store = reqnn("value", value);
095         return this;
096      }
097
098      /**
099       * Sets pre-registered (manually provisioned) credentials bound to a specific AS {@code issuer}.
100       *
101       * @param value The pre-registered credentials.  Must not be <jk>null</jk>.
102       * @return This object.
103       */
104      public Builder preRegistered(McpClientRegistration value) {
105         preRegistered = reqnn("value", value);
106         return this;
107      }
108
109      /**
110       * Sets the {@code application_type} declared when the manager performs DCR.  Default {@link McpApplicationType#NATIVE}.
111       *
112       * @param value The application type.  Must not be <jk>null</jk>.
113       * @return This object.
114       */
115      public Builder applicationType(McpApplicationType value) {
116         applicationType = reqnn("value", value);
117         return this;
118      }
119
120      /**
121       * Sets the redirect URIs to register when the manager performs DCR.
122       *
123       * @param values The redirect URIs (e.g. {@link LoopbackRedirectUris#portAgnostic(String)}).  Must not be
124       *    <jk>null</jk> or empty.
125       * @return This object.
126       */
127      public Builder redirectUris(List<URI> values) {
128         reqnn("values", values);
129         req(! values.isEmpty(), "redirectUris must not be empty");
130         var copy = u(cp(values));
131         redirectUrisSupplier = () -> copy;
132         return this;
133      }
134
135      /**
136       * Sets a supplier of redirect URIs to register when the manager performs DCR (for the bind-first strategy where
137       * the loopback port is learned per-connection).
138       *
139       * @param value The supplier.  Must not be <jk>null</jk>.
140       * @return This object.
141       */
142      public Builder redirectUrisSupplier(Supplier<List<URI>> value) {
143         redirectUrisSupplier = reqnn("value", value);
144         return this;
145      }
146
147      /**
148       * Adds scopes requested during DCR.
149       *
150       * @param values The scopes.
151       * @return This object.
152       */
153      public Builder scope(String... values) {
154         reqnn("values", values);
155         for (var v : values) {
156            reqnb("scope", v);
157            scopes.add(v);
158         }
159         return this;
160      }
161
162      /**
163       * Registers a confidential client during DCR (default public/native).
164       *
165       * @param value <jk>true</jk> to register a confidential client.
166       * @return This object.
167       */
168      public Builder confidential(boolean value) {
169         confidential = value;
170         return this;
171      }
172
173      /**
174       * Sets the human-readable client name declared during DCR.
175       *
176       * @param value The client name.
177       * @return This object.
178       */
179      public Builder clientName(String value) {
180         clientName = reqnb("value", value);
181         return this;
182      }
183
184      /**
185       * Sets the RFC 7591 &sect;3 initial access token for a protected registration endpoint.
186       *
187       * @param value The initial access token.  Must not be <jk>null</jk> or blank.
188       * @return This object.
189       */
190      public Builder initialAccessToken(String value) {
191         reqnb("value", value);
192         initialAccessTokenSupplier = () -> value;
193         return this;
194      }
195
196      /**
197       * Sets the connect/read timeout applied to DCR requests.  Default 10 seconds.
198       *
199       * @param value The timeout.  Must not be <jk>null</jk> and must be positive.
200       * @return This object.
201       */
202      public Builder httpTimeout(Duration value) {
203         reqnn("value", value);
204         req(!value.isZero() && !value.isNegative(), "httpTimeout must be positive (was %s)", value);
205         httpTimeout = value;
206         return this;
207      }
208
209      /**
210       * Sets a callback that customizes the Nimbus {@link HTTPRequest} for each DCR request.
211       *
212       * @param value The callback.  Must not be <jk>null</jk>.
213       * @return This object.
214       */
215      public Builder httpRequestConfigurator(Consumer<HTTPRequest> value) {
216         httpRequestConfigurator = reqnn("value", value);
217         return this;
218      }
219
220      /**
221       * Overrides the registration function the manager invokes to perform DCR (advanced / test seam).
222       *
223       * <p>
224       * The default builds an {@link McpDynamicClientRegistrar} from the discovered {@link OidcMetadata} and performs
225       * a live RFC 7591 round-trip; supply this to inject an alternate registration strategy or a stub.
226       *
227       * @param value The registration function mapping a discovered AS to an issued {@link McpClientRegistration}.
228       *    Must not be <jk>null</jk>.
229       * @return This object.
230       */
231      public Builder registrarFunction(Function<OidcMetadata,McpClientRegistration> value) {
232         dcrOverride = reqnn("value", value);
233         return this;
234      }
235
236      /**
237       * Builds the manager.
238       *
239       * @return A new {@link McpClientRegistrationManager}.
240       */
241      public McpClientRegistrationManager build() {
242         return new McpClientRegistrationManager(this);
243      }
244   }
245
246   private final McpClientRegistrationStore store;
247   private final McpClientRegistration preRegistered;
248   private final McpApplicationType applicationType;
249   private final Supplier<List<URI>> redirectUrisSupplier;
250   private final Set<String> scopes;
251   private final boolean confidential;
252   private final String clientName;
253   private final Supplier<String> initialAccessTokenSupplier;
254   private final Duration httpTimeout;
255   private final Consumer<HTTPRequest> httpRequestConfigurator;
256   private final Function<OidcMetadata,McpClientRegistration> dcrOverride;
257
258   /**
259    * Constructor.
260    *
261    * @param b The builder.
262    */
263   protected McpClientRegistrationManager(Builder b) {
264      this.store = b.store;
265      this.preRegistered = b.preRegistered;
266      this.applicationType = b.applicationType;
267      this.redirectUrisSupplier = b.redirectUrisSupplier;
268      this.scopes = u(new LinkedHashSet<>(b.scopes));
269      this.confidential = b.confidential;
270      this.clientName = b.clientName;
271      this.initialAccessTokenSupplier = b.initialAccessTokenSupplier;
272      this.httpTimeout = b.httpTimeout;
273      this.httpRequestConfigurator = b.httpRequestConfigurator;
274      this.dcrOverride = b.dcrOverride;
275   }
276
277   /**
278    * Resolves a usable client registration for the discovered authorization server, applying the SEP-2352 selection,
279    * issuer-keying, and migration rules (see the class javadoc).
280    *
281    * @param as The discovered authorization-server metadata.  Must not be <jk>null</jk>.
282    * @return A usable registration bound to {@code as.issuer()}.  Never <jk>null</jk>.
283    * @throws McpAuthException If credentials cannot be obtained (AS migrated away from pre-registered creds, or the AS
284    *    advertises no {@code registration_endpoint} and no matching pre-registered creds exist).
285    */
286   public McpClientRegistration resolve(OidcMetadata as) {
287      reqnn("as", as);
288      var issuer = as.issuer();
289
290      // Pre-registered credentials: use only if bound to this exact AS; a mismatch means the AS migrated (SEP-2352
291      // MUST NOT reuse cross-AS creds — SHOULD surface an error rather than silently reuse).
292      if (preRegistered != null) {
293         if (! preRegistered.issuer().equals(issuer))
294            throw new McpAuthException("Pre-registered client credentials are bound to issuer '" + preRegistered.issuer()
295               + "' but the resource now indicates a different authorization server '" + issuer
296               + "' (SEP-2352): refusing to reuse credentials across authorization servers");
297         if (store != null)
298            store.put(issuer, preRegistered);
299         return preRegistered;
300      }
301
302      // Store-hit: reuse the issuer-keyed entry without a second DCR round-trip.  On migration to a new issuer the
303      // store has no entry for it, so we fall through to DCR and store under the new issuer, never touching the old.
304      if (store != null) {
305         var hit = store.find(issuer);
306         if (hit.isPresent()) {
307            // Defensive: a buggy durable store that returns an entry keyed under the wrong issuer would otherwise
308            // cause exactly the cross-AS credential reuse SEP-2352 forbids.  Never trust the returned entry's key.
309            if (! hit.get().issuer().equals(issuer))
310               throw new McpAuthException("Client-registration store returned an entry bound to issuer '" + hit.get().issuer()
311                  + "' for a lookup of issuer '" + issuer + "' (SEP-2352): refusing to reuse credentials across authorization servers");
312            return hit.get();
313         }
314      }
315
316      var reg = register(as);
317      if (store != null)
318         store.put(issuer, reg);
319      return reg;
320   }
321
322   private McpClientRegistration register(OidcMetadata as) {
323      if (dcrOverride != null)
324         return dcrOverride.apply(as);
325      var regEndpoint = registrationEndpoint(as)
326         .orElseThrow(() -> new McpAuthException("Authorization server '" + as.issuer()
327            + "' advertises no registration_endpoint and no matching pre-registered credentials are configured"
328            + " (Dynamic Client Registration not possible; CIMD is out of scope)"));
329      if (redirectUrisSupplier == null)
330         throw new McpAuthException("McpClientRegistrationManager requires redirectUris(...) to perform Dynamic Client Registration");
331      var b = McpDynamicClientRegistrar.create()
332         .registrationEndpoint(regEndpoint)
333         .issuer(as.issuer())
334         .applicationType(applicationType)
335         .addRedirectUris(redirectUrisSupplier.get())
336         .confidential(confidential)
337         .httpTimeout(httpTimeout);
338      scopes.forEach(b::scope);
339      if (clientName != null)
340         b.clientName(clientName);
341      if (initialAccessTokenSupplier != null)
342         b.initialAccessToken(initialAccessTokenSupplier.get());
343      if (httpRequestConfigurator != null)
344         b.httpRequestConfigurator(httpRequestConfigurator);
345      return b.build().register();
346   }
347
348   /**
349    * Extracts the RFC 7591 {@code registration_endpoint} from the discovered AS metadata's extras, if present.
350    *
351    * <p>
352    * The value is AS-supplied, so a hostile/misconfigured AS document is defended against here: a malformed URI or a
353    * plaintext {@code http://} (non-loopback) endpoint yields the documented {@link McpAuthException} rather than a raw
354    * {@link IllegalArgumentException} (from {@code URI.create}) or a downstream {@link IllegalStateException} (from the
355    * registrar's https gate).
356    */
357   static Optional<URI> registrationEndpoint(OidcMetadata as) {
358      var v = as.extras().get("registration_endpoint");
359      if (v == null)
360         return oe();
361      URI uri;
362      try {
363         uri = URI.create(v.toString());
364      } catch (IllegalArgumentException e) {
365         throw new McpAuthException("Authorization server advertised a malformed registration_endpoint: '" + v + "'", e);
366      }
367      if (! McpDynamicClientRegistrar.isSecureOrLoopback(uri))
368         throw new McpAuthException("Authorization server advertised a non-https registration_endpoint (loopback exempt): '" + uri + "'");
369      return o(uri);
370   }
371}