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 §3.3 (exact string match, mirroring the issuer-identity posture) and a mismatch 050 * is rejected — 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 §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 §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 §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 §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}