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.server.mcp.v20260728;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.net.*;
022import java.util.*;
023
024import org.apache.juneau.rest.server.auth.TokenValidator;
025import org.apache.juneau.rest.server.auth.oauth.OAuthFilter;
026
027/**
028 * Binding-owned OAuth 2.1 resource-server (RS) configuration for a {@code 2026-07-28} MCP endpoint — the
029 * authorization-hardening baseline.
030 *
031 * <p>
032 * <b>Off by default.</b> {@link #isEnabled()} is <jk>false</jk> unless {@link #setEnabled(boolean) enabled}, so an
033 * existing MCP endpoint that does not opt in behaves exactly as before (no bearer requirement, no well-known route).
034 * When enabled, the endpoint:
035 * <ul>
036 *    <li>Requires a valid RFC 6750 bearer token on the MCP POST endpoint, establishing an authenticated principal.
037 *    <li>Serves an RFC 9728 Protected Resource Metadata document from {@code .well-known/oauth-protected-resource}.
038 *    <li>Answers a missing/invalid token with {@code 401} + a {@code WWW-Authenticate: Bearer ...} challenge carrying the
039 *       {@code resource_metadata} pointer.
040 *    <li>Enforces RFC 8707 audience matching against {@link #getResource() the resource identifier}.
041 * </ul>
042 *
043 * <p>
044 * <b>Mutable during setup, effectively immutable once published</b> &mdash; the same builder-less mutable-setup idiom as
045 * {@link McpMrtrConfig} / {@link McpCacheConfig}: configure it fully via the {@code createMcpOptions()} /
046 * {@code getMcpOptions()} override, then never mutate it afterward (a request-time reader sees it with no
047 * synchronization).
048 *
049 * <h5 class='section'>Token-validation mode:</h5>
050 * <p>
051 * The validation mode (RFC 7662 introspection vs JWKS/issuer JWT verification) is selected by which
052 * {@link TokenValidator} is supplied to {@link #setTokenValidator(TokenValidator)}:
053 * {@code OAuthIntrospectionValidator} (from {@code juneau-rest-server-auth-oauth}) for opaque tokens, or
054 * {@code JwtTokenValidator} (from {@code juneau-rest-server-auth-jwt}) for JWT access tokens.
055 *
056 * @since 10.0.0
057 */
058@SuppressWarnings({
059   "java:S115", // Constants use UPPER_snakeCase convention (e.g., MSG_scopeMustNotBeNullOrBlank)
060   "java:S1192" // Duplicated validation-message literals read more clearly inline than as constants.
061})
062public class McpResourceServerConfig {
063
064   /** Default challenge realm. */
065   public static final String DEFAULT_REALM = "mcp";
066
067   /** Default advertised/accepted bearer method. */
068   public static final String DEFAULT_BEARER_METHOD = "header";
069
070   private boolean enabled;
071   private URI resource;
072   private String audience;
073   private String realm = DEFAULT_REALM;
074   private boolean requireAudienceClaim = true;
075   private TokenValidator tokenValidator;
076   private final List<URI> authorizationServers = new ArrayList<>();
077   private final Set<String> scopesSupported = new LinkedHashSet<>();
078   private final Set<String> requiredScopes = new LinkedHashSet<>();
079   private final Set<String> bearerMethodsSupported = new LinkedHashSet<>(List.of(DEFAULT_BEARER_METHOD));
080
081   /** Wildcard operation-name key: an entry keyed on this applies to every operation of a given JSON-RPC method. */
082   static final String OPERATION_WILDCARD = "*";
083
084   private final Map<String,Set<String>> operationScopes = new LinkedHashMap<>();
085   private McpOperationScopeResolver operationScopeResolver;
086
087   @SuppressWarnings({
088      "java:S3077" // OAuthFilter is an effectively-immutable, thread-safe holder; volatile publication of the memoized instance is sufficient.
089   })
090   private volatile OAuthFilter oauthFilter;
091
092   /**
093    * Whether RS authentication is enabled for this binding.
094    *
095    * @return <jk>true</jk> if enabled.  Defaults to <jk>false</jk>.
096    */
097   public boolean isEnabled() {
098      return enabled;
099   }
100
101   /**
102    * Enables or disables RS authentication.
103    *
104    * @param value The new value.
105    * @return This object (for method chaining).
106    */
107   public McpResourceServerConfig setEnabled(boolean value) {
108      enabled = value;
109      return this;
110   }
111
112   /**
113    * This server's canonical resource identifier (RFC 9728 {@code resource} / RFC 8707 audience default).
114    *
115    * @return The resource identifier, or <jk>null</jk> if not set.
116    */
117   public URI getResource() {
118      return resource;
119   }
120
121   /**
122    * Sets the canonical resource identifier.
123    *
124    * @param value The new value.  Can be <jk>null</jk> to unset (but is required when {@link #isEnabled() enabled}).
125    * @return This object (for method chaining).
126    */
127   public McpResourceServerConfig setResource(URI value) {
128      resource = value;
129      return this;
130   }
131
132   /**
133    * The RFC 8707 audience this server accepts.
134    *
135    * @return The explicit audience, or (when unset) {@link #getResource() the resource identifier} as a string, or
136    *    <jk>null</jk> if neither is set.
137    */
138   public String getAudience() {
139      if (audience != null)
140         return audience;
141      return resource == null ? null : resource.toString();
142   }
143
144   /**
145    * Sets an explicit RFC 8707 audience, overriding the {@link #getResource() resource-identifier} default.
146    *
147    * @param value The new value.  Can be <jk>null</jk> to fall back to the resource identifier.
148    * @return This object (for method chaining).
149    */
150   public McpResourceServerConfig setAudience(String value) {
151      audience = value;
152      return this;
153   }
154
155   /**
156    * The {@code WWW-Authenticate: Bearer realm="..."} challenge realm.
157    *
158    * @return The realm.  Never <jk>null</jk>; defaults to {@value #DEFAULT_REALM}.
159    */
160   public String getRealm() {
161      return realm;
162   }
163
164   /**
165    * Sets the challenge realm.
166    *
167    * @param value The new value.  Must not be <jk>null</jk> or blank.
168    * @return This object (for method chaining).
169    * @throws IllegalArgumentException If {@code value} is <jk>null</jk> or blank.
170    */
171   public McpResourceServerConfig setRealm(String value) {
172      if (ib(value))
173         throw iaex("realm must not be null or blank");
174      realm = value;
175      return this;
176   }
177
178   /**
179    * Whether a validated token must expose audience claims for RFC 8707 audience enforcement to apply.
180    *
181    * <p>
182    * When <jk>true</jk> (the default), a validator that returns a bare {@link java.security.Principal} carrying no
183    * claims is <b>rejected</b> ({@code 401 invalid_token}) rather than silently passing the confused-deputy defense.
184    * Set to <jk>false</jk> only when the {@link #getTokenValidator() token validator} itself enforces the audience
185    * internally (e.g. a {@code JwtTokenValidator} configured to verify {@code aud}) so a claims-less principal is a
186    * deliberate, trusted signal &mdash; pairs with introspection-mode validators that legitimately omit {@code aud}.
187    *
188    * @return <jk>true</jk> if a claims-less principal is rejected (the default).
189    */
190   public boolean isRequireAudienceClaim() {
191      return requireAudienceClaim;
192   }
193
194   /**
195    * Sets whether a validated token must expose audience claims for audience enforcement to apply.
196    *
197    * @param value The new value.  Defaults to <jk>true</jk> (fail-closed).
198    * @return This object (for method chaining).
199    */
200   public McpResourceServerConfig setRequireAudienceClaim(boolean value) {
201      requireAudienceClaim = value;
202      return this;
203   }
204
205   /**
206    * The token validator that verifies presented bearer tokens.
207    *
208    * @return The validator, or <jk>null</jk> if not set (required when {@link #isEnabled() enabled}).
209    */
210   public TokenValidator getTokenValidator() {
211      return tokenValidator;
212   }
213
214   /**
215    * Sets the token validator (selects introspection vs JWT validation mode).
216    *
217    * @param value The new value.  Can be <jk>null</jk> to unset (but is required when {@link #isEnabled() enabled}).
218    * @return This object (for method chaining).
219    */
220   public McpResourceServerConfig setTokenValidator(TokenValidator value) {
221      tokenValidator = value;
222      return this;
223   }
224
225   /**
226    * The advertised {@code authorization_servers} (RFC 9728).
227    *
228    * @return A live, mutable list.  Never <jk>null</jk>.
229    */
230   public List<URI> getAuthorizationServers() {
231      return authorizationServers;
232   }
233
234   /**
235    * Adds an authorization-server issuer URI.
236    *
237    * @param value The issuer URI.  Must not be <jk>null</jk>.
238    * @return This object (for method chaining).
239    * @throws IllegalArgumentException If {@code value} is <jk>null</jk>.
240    */
241   public McpResourceServerConfig addAuthorizationServer(URI value) {
242      if (value == null)
243         throw iaex("authorizationServer must not be null");
244      authorizationServers.add(value);
245      return this;
246   }
247
248   /**
249    * The advertised {@code scopes_supported} (RFC 9728).
250    *
251    * @return A live, mutable set.  Never <jk>null</jk>.
252    */
253   public Set<String> getScopesSupported() {
254      return scopesSupported;
255   }
256
257   /**
258    * Adds an advertised supported scope.
259    *
260    * @param value The scope.  Must not be <jk>null</jk> or blank.
261    * @return This object (for method chaining).
262    * @throws IllegalArgumentException If {@code value} is <jk>null</jk> or blank.
263    */
264   public McpResourceServerConfig addScopeSupported(String value) {
265      if (ib(value))
266         throw iaex("scope must not be null or blank");
267      scopesSupported.add(value);
268      return this;
269   }
270
271   /**
272    * The scopes a token must carry to access the MCP endpoint (baseline insufficient-scope enforcement).
273    *
274    * @return A live, mutable set.  Never <jk>null</jk>; empty means no scope is required beyond a valid token.
275    */
276   public Set<String> getRequiredScopes() {
277      return requiredScopes;
278   }
279
280   /**
281    * Adds a required scope (also advertised as supported).
282    *
283    * @param value The scope.  Must not be <jk>null</jk> or blank.
284    * @return This object (for method chaining).
285    * @throws IllegalArgumentException If {@code value} is <jk>null</jk> or blank.
286    */
287   public McpResourceServerConfig addRequiredScope(String value) {
288      if (ib(value))
289         throw iaex("scope must not be null or blank");
290      requiredScopes.add(value);
291      scopesSupported.add(value);
292      return this;
293   }
294
295   /**
296    * The advertised {@code bearer_methods_supported} (RFC 9728 / RFC 6750).
297    *
298    * @return A live, mutable set.  Never <jk>null</jk>; defaults to {@code [header]}.
299    */
300   public Set<String> getBearerMethodsSupported() {
301      return bearerMethodsSupported;
302   }
303
304   /**
305    * Registers the scopes required to invoke a specific MCP operation (SEP-2350 per-operation step-up).
306    *
307    * <p>
308    * Populates the default static per-operation scope map consulted by {@link #requiredScopesFor(McpOperationContext)}
309    * when no explicit {@link #setOperationScopeResolver(McpOperationScopeResolver) resolver} is set.  A {@code null} or
310    * blank {@code name} registers a method-wide entry matching every operation of that JSON-RPC {@code method} (e.g.
311    * every {@code tools/call} regardless of tool name); a specific {@code name} takes precedence over the method-wide
312    * entry.  Each supplied scope is also advertised as {@link #addScopeSupported(String) supported}.
313    *
314    * @param method The JSON-RPC method (e.g. {@code tools/call}).  Must not be <jk>null</jk> or blank.
315    * @param name The operation name (tool/prompt name or resource URI), or <jk>null</jk>/blank for a method-wide entry.
316    * @param scopes The scopes required to invoke the operation.  Must not be <jk>null</jk>, empty, or contain a
317    *    <jk>null</jk>/blank element.
318    * @return This object (for method chaining).
319    * @throws IllegalArgumentException If {@code method} is <jk>null</jk>/blank or {@code scopes} is invalid.
320    */
321   public McpResourceServerConfig addOperationScope(String method, String name, String... scopes) {
322      if (ib(method))
323         throw iaex("method must not be null or blank");
324      if (scopes == null || scopes.length == 0)
325         throw iaex("scopes must not be null or empty");
326      // Validate the whole scopes array BEFORE mutating scopesSupported, so a throw on a later blank element never
327      // leaves earlier scopes half-advertised.
328      var set = new LinkedHashSet<String>();
329      for (var s : scopes) {
330         if (ib(s))
331            throw iaex("scope must not be null or blank");
332         set.add(s);
333      }
334      scopesSupported.addAll(set);
335      operationScopes.put(operationKey(method, name == null || name.isBlank() ? OPERATION_WILDCARD : name), set);
336      return this;
337   }
338
339   /**
340    * The dynamic per-operation scope resolver, if one was installed.
341    *
342    * @return The resolver, or <jk>null</jk> to use the default static {@link #addOperationScope map}.
343    */
344   public McpOperationScopeResolver getOperationScopeResolver() {
345      return operationScopeResolver;
346   }
347
348   /**
349    * Installs a dynamic per-operation scope resolver, overriding the default static {@link #addOperationScope map}.
350    *
351    * <p>
352    * The seam for a future dynamic resolver; not built for 10.0.  When set, it fully replaces the
353    * static map for {@link #requiredScopesFor(McpOperationContext)}.
354    *
355    * @param value The resolver, or <jk>null</jk> to revert to the static map.
356    * @return This object (for method chaining).
357    */
358   public McpResourceServerConfig setOperationScopeResolver(McpOperationScopeResolver value) {
359      operationScopeResolver = value;
360      return this;
361   }
362
363   /**
364    * Resolves the scopes required to invoke the given operation.
365    *
366    * <p>
367    * Consults the {@link #setOperationScopeResolver(McpOperationScopeResolver) resolver} when installed; otherwise the
368    * static {@link #addOperationScope map}, preferring a {@link McpOperationContext#name() name}-specific entry over the
369    * method-wide {@value #OPERATION_WILDCARD} entry.  An empty return means "no per-operation requirement beyond the
370    * endpoint-wide {@link #getRequiredScopes() baseline}".
371    *
372    * @param ctx The resolved operation.  Must not be <jk>null</jk>.
373    * @return The required scopes (never <jk>null</jk>; possibly empty).
374    */
375   public Set<String> requiredScopesFor(McpOperationContext ctx) {
376      if (ctx == null)
377         throw iaex("ctx must not be null");
378      if (operationScopeResolver != null) {
379         var s = operationScopeResolver.requiredScopesFor(ctx);
380         return s == null ? Set.of() : s;
381      }
382      var name = ctx.name();
383      if (name != null) {
384         var s = operationScopes.get(operationKey(ctx.method(), name));
385         if (s != null)
386            return Collections.unmodifiableSet(s);
387      }
388      var s = operationScopes.get(operationKey(ctx.method(), OPERATION_WILDCARD));
389      return s == null ? Set.of() : Collections.unmodifiableSet(s);
390   }
391
392   private static String operationKey(String method, String name) {
393      return method + '\u0000' + name;
394   }
395
396   /**
397    * Validates that this config is internally consistent for the {@link #isEnabled() enabled} state.
398    *
399    * <p>
400    * A no-op when disabled.  When enabled, requires a resource identifier (absolute, with scheme + authority) and a
401    * token validator.
402    *
403    * @throws IllegalStateException If enabled but misconfigured.
404    */
405   public void validateEnabled() {
406      if (!enabled)
407         return;
408      if (resource == null)
409         throw isex("McpResourceServerConfig is enabled but no resource identifier was configured");
410      if (resource.getScheme() == null || resource.getRawAuthority() == null)
411         throw isex("McpResourceServerConfig resource %s must be an absolute URI with a scheme and authority", resource);
412      if (tokenValidator == null)
413         throw isex("McpResourceServerConfig is enabled but no TokenValidator was configured");
414   }
415
416   /**
417    * Returns this config's memoized {@link OAuthFilter}.
418    *
419    * <p>
420    * The filter (RFC 6750 bearer extraction + delegated token validation) is built once from the
421    * {@link #getTokenValidator() token validator} and {@link #getRealm() realm} and reused across requests rather than
422    * rebuilt per request.  First use runs {@link #validateEnabled()}; under the never-mutate-after-setup contract the
423    * validator/realm are stable by the time a request reads this, so the memoized filter is authoritative.
424    *
425    * @return The bearer filter.  Never <jk>null</jk>.
426    * @throws IllegalStateException If this config is enabled but misconfigured.
427    */
428   OAuthFilter oauthFilter() {
429      var f = oauthFilter;
430      if (f == null) {
431         synchronized (this) {
432            f = oauthFilter;
433            if (f == null) {
434               validateEnabled();
435               f = OAuthFilter.create().validator(tokenValidator).realm(realm).build();
436               oauthFilter = f;
437            }
438         }
439      }
440      return f;
441   }
442}