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
019
020import java.io.*;
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.*;
029import com.nimbusds.oauth2.sdk.http.*;
030import com.nimbusds.oauth2.sdk.util.*;
031
032import net.minidev.json.*;
033import static org.apache.juneau.commons.utils.Shorts.*;
034
035/**
036 * Fetches and parses an RFC 9728 Protected Resource Metadata (PRM) document, and resolves the authorization server a
037 * protected MCP resource delegates to.
038 *
039 * <p>
040 * This is the client half of the OAuth 2.1 baseline PRM discovery required by the MCP {@code 2026-07-28} revision: on a
041 * {@code 401 WWW-Authenticate} challenge the client recovers the {@code resource_metadata} pointer
042 * ({@link WwwAuthenticateChallenge#resourceMetadata()}), fetches the PRM document here, selects an authorization server
043 * ({@link McpProtectedResourceMetadata#firstAuthorizationServer()}), and validates that server's issuer identity via
044 * {@link OidcDiscoveryClient} (RFC 8414 / OIDC Discovery).
045 *
046 * <p>
047 * When an expected resource identifier is supplied ({@link Builder#expectedResource(URI)} or the {@code expectedResource}
048 * argument of {@link #fetch(URI, URI)} / {@link #parse(String, URI, URI)}), the PRM document's {@code resource} field is
049 * validated against it per RFC 9728 &sect;3.3 (exact string match, mirroring the issuer-identity posture) and a mismatch
050 * is rejected &mdash; this defends against a malicious server pointing the client at a PRM document minted for a
051 * different resource.  When no expected resource is supplied the identity check is skipped.
052 *
053 * <p>
054 * By default the PRM URL and any discovered authorization-server URI must use {@code https} (loopback hosts are always
055 * permitted for local/testing use); {@link Builder#allowInsecureHttp(boolean)} disables the scheme check entirely.
056 *
057 * @since 10.0.0
058 */
059@SuppressWarnings({
060   "java:S1192" // Duplicated literals (argument/property names) read more clearly inline than as constants
061})
062public class McpProtectedResourceMetadataClient {
063
064   /** Default connect/read timeout applied to a PRM fetch / AS discovery when the caller sets none. */
065   static final Duration DEFAULT_HTTP_TIMEOUT = Duration.ofSeconds(10);
066
067   /**
068    * Static creator.
069    *
070    * @return A new builder.
071    */
072   public static Builder create() {
073      return new Builder();
074   }
075
076   /**
077    * Builder.
078    */
079   public static class Builder {
080      private URI expectedResource;
081      private Duration httpTimeout = DEFAULT_HTTP_TIMEOUT;
082      private boolean allowInsecureHttp;
083      private Consumer<HTTPRequest> httpRequestConfigurator;
084
085      /** Constructor. */
086      protected Builder() {}
087
088      /**
089       * Sets the expected {@code resource} identifier the PRM document's {@code resource} field is validated against
090       * (RFC 9728 &sect;3.3).
091       *
092       * <p>
093       * This is the canonical URI of the MCP server the client was actually talking to.  A per-call value passed to
094       * {@link #fetch(URI, URI)} / {@link #parse(String, URI, URI)} overrides this.
095       *
096       * @param value The expected resource identifier.  Must not be <jk>null</jk>.
097       * @return This object.
098       */
099      public Builder expectedResource(URI value) {
100         expectedResource = reqnn("value", value);
101         return this;
102      }
103
104      /**
105       * Sets the connect/read timeout applied to the PRM fetch and AS discovery.  Default 10 seconds.
106       *
107       * @param value The timeout.  Must not be <jk>null</jk> and must be positive.
108       * @return This object.
109       */
110      public Builder httpTimeout(Duration value) {
111         reqnn("value", value);
112         req(!value.isZero() && !value.isNegative(), "httpTimeout must be positive (was %s)", value);
113         httpTimeout = value;
114         return this;
115      }
116
117      /**
118       * Allows plain {@code http} PRM / authorization-server URLs (loopback hosts are always allowed regardless).
119       *
120       * @param value <jk>true</jk> to skip the {@code https} scheme check.
121       * @return This object.
122       */
123      public Builder allowInsecureHttp(boolean value) {
124         allowInsecureHttp = value;
125         return this;
126      }
127
128      /**
129       * Sets a callback that customizes the Nimbus {@link HTTPRequest} used for the PRM fetch and AS discovery.
130       *
131       * @param value The callback.  Must not be <jk>null</jk>.
132       * @return This object.
133       */
134      public Builder httpRequestConfigurator(Consumer<HTTPRequest> value) {
135         httpRequestConfigurator = reqnn("value", value);
136         return this;
137      }
138
139      /**
140       * Builds the client.
141       *
142       * @return A new {@link McpProtectedResourceMetadataClient}.
143       */
144      public McpProtectedResourceMetadataClient build() {
145         return new McpProtectedResourceMetadataClient(this);
146      }
147   }
148
149   private final URI expectedResource;
150   private final Duration httpTimeout;
151   private final boolean allowInsecureHttp;
152   private final Consumer<HTTPRequest> httpRequestConfigurator;
153
154   /**
155    * Constructor.
156    *
157    * @param b The builder.
158    */
159   protected McpProtectedResourceMetadataClient(Builder b) {
160      this.expectedResource = b.expectedResource;
161      this.httpTimeout = b.httpTimeout;
162      this.allowInsecureHttp = b.allowInsecureHttp;
163      this.httpRequestConfigurator = b.httpRequestConfigurator;
164   }
165
166   /**
167    * Fetches and parses the PRM document at the supplied URL, validating its {@code resource} field against the
168    * builder-configured {@link Builder#expectedResource(URI)} (if any).
169    *
170    * @param prmUrl The PRM document URL (typically the {@code resource_metadata} pointer from a {@code 401}
171    *    challenge, or the well-known URL for the resource).  Must not be <jk>null</jk>.
172    * @return The parsed metadata.
173    * @throws McpAuthException If the fetch fails, the document cannot be parsed, or the resource identity check fails.
174    */
175   public McpProtectedResourceMetadata fetch(URI prmUrl) {
176      return fetch(prmUrl, expectedResource);
177   }
178
179   /**
180    * Fetches and parses the PRM document at the supplied URL, validating its {@code resource} field against the
181    * supplied expected resource identifier (RFC 9728 &sect;3.3).
182    *
183    * @param prmUrl The PRM document URL.  Must not be <jk>null</jk>.
184    * @param expectedResource The resource identifier the client was actually talking to.  May be <jk>null</jk> to
185    *    skip the identity check.
186    * @return The parsed metadata.
187    * @throws McpAuthException If the fetch fails, the document cannot be parsed, or the resource identity check fails.
188    */
189   public McpProtectedResourceMetadata fetch(URI prmUrl, URI expectedResource) {
190      reqnn("prmUrl", prmUrl);
191      requireSecure(prmUrl, "Protected-resource-metadata URL");
192      HTTPResponse resp;
193      try {
194         var req = new HTTPRequest(HTTPRequest.Method.GET, prmUrl.toURL());
195         req.setConnectTimeout((int) httpTimeout.toMillis());
196         req.setReadTimeout((int) httpTimeout.toMillis());
197         req.setAccept("application/json");
198         if (httpRequestConfigurator != null)
199            httpRequestConfigurator.accept(req);
200         resp = req.send();
201      } catch (IOException e) {
202         throw new McpAuthException("Protected-resource-metadata fetch failed for " + prmUrl, e);
203      }
204      if (!resp.indicatesSuccess())
205         throw new McpAuthException("Protected-resource-metadata fetch returned HTTP " + resp.getStatusCode() + " for " + prmUrl);
206      return parse(resp.getBody(), prmUrl, expectedResource);
207   }
208
209   /**
210    * Parses a PRM JSON document body, validating its {@code resource} field against the builder-configured
211    * {@link Builder#expectedResource(URI)} (if any).
212    *
213    * @param json The JSON document body.  Must not be <jk>null</jk>.
214    * @param source The source URI, used only for error messages.  May be <jk>null</jk>.
215    * @return The parsed metadata.
216    * @throws McpAuthException If the document cannot be parsed, is missing the required {@code resource} field, or
217    *    fails the resource identity check.
218    */
219   public McpProtectedResourceMetadata parse(String json, URI source) {
220      return parse(json, source, expectedResource);
221   }
222
223   /**
224    * Parses a PRM JSON document body, validating its {@code resource} field against the supplied expected resource
225    * identifier (RFC 9728 &sect;3.3).
226    *
227    * @param json The JSON document body.  Must not be <jk>null</jk>.
228    * @param source The source URI, used only for error messages.  May be <jk>null</jk>.
229    * @param expectedResource The resource identifier the client was actually talking to.  May be <jk>null</jk> to
230    *    skip the identity check.
231    * @return The parsed metadata.
232    * @throws McpAuthException If the document cannot be parsed, is missing the required {@code resource} field, or
233    *    fails the resource identity check.
234    */
235   public McpProtectedResourceMetadata parse(String json, URI source, URI expectedResource) {
236      reqnn("json", json);
237      JSONObject o;
238      try {
239         o = JSONObjectUtils.parse(json);
240      } catch (ParseException e) {
241         throw new McpAuthException("Protected-resource-metadata document could not be parsed for " + source, e);
242      }
243      McpProtectedResourceMetadata prm;
244      try {
245         var resource = JSONObjectUtils.getURI(o, "resource");
246         var authServers = new ArrayList<URI>();
247         if (o.containsKey("authorization_servers"))
248            for (var v : JSONObjectUtils.getStringList(o, "authorization_servers"))
249               authServers.add(URI.create(v));
250         var scopes = new LinkedHashSet<String>();
251         if (o.containsKey("scopes_supported"))
252            scopes.addAll(JSONObjectUtils.getStringList(o, "scopes_supported"));
253         var extras = new LinkedHashMap<String,Object>();
254         for (var e : o.entrySet())
255            if (!STANDARD_FIELDS.contains(e.getKey()))
256               extras.put(e.getKey(), e.getValue());
257         prm = new McpProtectedResourceMetadata(resource, authServers, scopes, extras);
258      } catch (ParseException | IllegalArgumentException e) {
259         throw new McpAuthException("Protected-resource-metadata document was malformed for " + source, e);
260      }
261      validateResourceIdentity(prm, expectedResource);
262      return prm;
263   }
264
265   /**
266    * Selects the first authorization server from the PRM document and validates its issuer identity via RFC 8414 /
267    * OIDC discovery.
268    *
269    * @param prm The parsed PRM document.  Must not be <jk>null</jk>.
270    * @return The discovered authorization-server metadata (issuer validated).
271    * @throws McpAuthException If no authorization server is advertised or discovery/issuer validation fails.
272    */
273   public OidcMetadata discoverAuthorizationServer(McpProtectedResourceMetadata prm) {
274      reqnn("prm", prm);
275      var as = prm.firstAuthorizationServer()
276         .orElseThrow(() -> new McpAuthException("Protected-resource-metadata for " + prm.resource() + " advertised no authorization_servers"));
277      requireSecure(as, "Authorization-server URL");
278      try {
279         return OidcDiscoveryClient.create()
280            .issuer(as)
281            .httpTimeout(httpTimeout)
282            .httpRequestConfigurator(httpRequestConfigurator == null ? h -> {} : httpRequestConfigurator)
283            .build()
284            .discover();
285      } catch (IOException | OidcDiscoveryException e) {
286         throw new McpAuthException("Authorization-server discovery/issuer validation failed for " + as, e);
287      }
288   }
289
290   /**
291    * Validates the PRM {@code resource} field against the expected resource identifier (RFC 9728 &sect;3.3), using a
292    * normalization-free exact string comparison to match the issuer-identity posture.
293    */
294   private static void validateResourceIdentity(McpProtectedResourceMetadata prm, URI expectedResource) {
295      if (expectedResource == null)
296         return;
297      if (!expectedResource.toString().equals(prm.resource().toString()))
298         throw new McpAuthException("Protected-resource-metadata resource identity mismatch (RFC 9728 3.3): expected '"
299            + expectedResource + "' but document declared '" + prm.resource() + "'");
300   }
301
302   private void requireSecure(URI uri, String what) {
303      if (allowInsecureHttp || eqic("https", uri.getScheme()) || isLoopback(uri))
304         return;
305      throw new McpAuthException(what + " must use https (was '" + uri + "'); enable allowInsecureHttp for non-loopback http");
306   }
307
308   private static boolean isLoopback(URI uri) {
309      var host = uri.getHost();
310      if (host == null)
311         return false;
312      return eqic("localhost", host) || eqa(host, "127.0.0.1", "[::1]", "::1");
313   }
314
315   private static final Set<String> STANDARD_FIELDS = Set.of(
316      "resource", "authorization_servers", "scopes_supported"
317   );
318}