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.*;
020import static org.apache.juneau.commons.utils.StringUtils.*;
021
022import java.lang.reflect.*;
023import java.net.*;
024import java.security.*;
025import java.util.*;
026
027import org.apache.juneau.http.response.Forbidden;
028import org.apache.juneau.http.response.InternalServerError;
029import org.apache.juneau.http.response.NotFound;
030import org.apache.juneau.rest.server.MethodSafety;
031import org.apache.juneau.rest.server.RestContext;
032import org.apache.juneau.rest.server.RestOpContext;
033import org.apache.juneau.rest.server.RestRequest;
034import org.apache.juneau.rest.server.auth.AuthenticationException;
035import org.apache.juneau.rest.server.auth.AuthResult;
036import org.apache.juneau.rest.server.mcp.McpEndpointMixin;
037import org.apache.juneau.rest.server.util.RestUtils;
038import org.apache.juneau.rest.server.util.UrlPath;
039
040import jakarta.servlet.http.*;
041
042/**
043 * Request-time glue for the OAuth 2.1 resource-server baseline — bearer validation, RFC 8707 audience
044 * enforcement, {@code 401}/{@code 403} challenge emission, and RFC 9728 Protected Resource Metadata construction —
045 * driven by an {@link McpResourceServerConfig}.
046 *
047 * <p>
048 * Bearer extraction and token validation are delegated to the reusable
049 * {@link org.apache.juneau.rest.server.auth.oauth.OAuthFilter} (RFC 6750) from
050 * {@code juneau-rest-server-auth-oauth}; this class layers on the MCP-specific {@code resource_metadata} challenge
051 * parameter, RFC 8707 audience matching ({@link McpAudienceValidator}), and baseline required-scope enforcement.
052 *
053 * @since 10.0.0
054 */
055public final class McpResourceServerSupport {
056
057   private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
058
059   /** Java method name of the MCP JSON-RPC dispatch operation ({@link McpEndpointMixin#handleMcpRequest}). */
060   private static final String MCP_DISPATCH_METHOD = "handleMcpRequest";
061
062   /**
063    * Request attribute under which {@link #authenticate} stashes the caller's granted scopes (the validated token's
064    * roles) so the POST-parse dispatch point can enforce SEP-2350 per-operation step-up scopes.
065    */
066   public static final String GRANTED_SCOPES_ATTR = "org.apache.juneau.rest.server.mcp.v20260728.grantedScopes";
067
068   private McpResourceServerSupport() {}
069
070   /**
071    * Router-identity bearer gate for the {@link McpEndpoint} mixin's MCP JSON-RPC endpoint.
072    *
073    * <p>
074    * A no-op unless RS auth is {@link McpResourceServerConfig#isEnabled() enabled} and this request resolves to the MCP
075    * JSON-RPC dispatch operation.  See {@link #resolvesToMcpDispatch(RestContext, HttpServletRequest)} for how the target
076    * operation is identified without any hand-rolled path reconstruction &mdash; the key change that makes this gate
077    * immune to {@code @Mixin(path/paths)} re-mounts and to trailing-slash / percent-encoding / method-override drift.
078    *
079    * <p>
080    * Fails fast with the same origin-root {@code 500} diagnostic as the well-known route (see
081    * {@link #assertWellKnownMountReachable(RestRequest)}) &mdash; rather than authenticating &mdash; whenever the
082    * resolved MCP dispatch operation is itself mounted under a {@code @Mixin(path/paths)} re-mount prefix:
083    * a {@code 401} on such a mount would advertise a {@code resource_metadata} URL that the re-mount
084    * has already made unreachable, so the two routes must agree rather than leaving a broken half-state.
085    *
086    * @param cfg The RS config.  Must not be <jk>null</jk>.
087    * @param ctx The host {@link RestContext} handling this request (injected into the {@code @RestStartCall} hook).
088    *    Must not be <jk>null</jk>.
089    * @param req The raw HTTP request.  Must not be <jk>null</jk>.
090    * @param res The raw HTTP response (used to emit the challenge header on rejection).  Must not be <jk>null</jk>.
091    */
092   public static void gateMcpEndpoint(McpResourceServerConfig cfg, RestContext ctx, HttpServletRequest req, HttpServletResponse res) {
093      if (!cfg.isEnabled())
094         return;
095      if (!isEffectivePost(req))
096         return;
097      var op = resolveMcpDispatchOp(ctx, req);
098      if (op == null)
099         return;
100      assertOriginRootMount(req);
101      assertNotMixinRemounted(op.getContext().getMixinMountPrefixes());
102      authenticate(cfg, req, res);
103   }
104
105   /**
106    * Router-aligned bearer gate for the {@link McpRestServlet}'s {@code POST /} endpoint.
107    *
108    * <p>
109    * A no-op unless RS auth is {@link McpResourceServerConfig#isEnabled() enabled} and this request resolves to a
110    * {@code POST} (the dedicated MCP servlet's only writable surface).  Gating every effective {@code POST} is correct for
111    * a dedicated MCP servlet: there is no non-MCP writable surface to distinguish, so no per-operation identity check is
112    * needed.
113    *
114    * @param cfg The RS config.  Must not be <jk>null</jk>.
115    * @param req The raw HTTP request.  Must not be <jk>null</jk>.
116    * @param res The raw HTTP response (used to emit the challenge header on rejection).  Must not be <jk>null</jk>.
117    */
118   public static void gateMcpServlet(McpResourceServerConfig cfg, HttpServletRequest req, HttpServletResponse res) {
119      if (!cfg.isEnabled())
120         return;
121      if (!isEffectivePost(req))
122         return;
123      assertOriginRootMount(req);
124      authenticate(cfg, req, res);
125   }
126
127   /**
128    * Returns whether this request resolves to the MCP JSON-RPC dispatch operation ({@link McpEndpointMixin#handleMcpRequest}).
129    *
130    * <p>
131    * <b>Why this cannot drift from the router:</b> instead of reconstructing the MCP endpoint's path from a
132    * hardcoded constant &mdash; which the previous gate did, so a {@code @Mixin(path="/api")} re-mount routed
133    * {@code POST /api/mcp} to the handler while the gate compared {@code ["api","mcp"]} against {@code ["mcp"]} and
134    * silently skipped authentication &mdash; this identifies the <i>operation</i> the router actually selected:
135    * <ul>
136    *    <li><b>Mixin sub-context</b> (the {@code McpEndpoint} mixin's {@code @RestStartCall} fires from
137    *       {@code RestSession.run()} <i>after</i> the operation is resolved): the resolved Java method is available, so we
138    *       gate iff it is {@link McpEndpointMixin#handleMcpRequest} (or an override).  No path logic at all &mdash; zero drift,
139    *       and re-mounts / multi-mounts are handled for free because the router already routed to the op.
140    *    <li><b>Host {@code implements McpEndpoint}</b> (the host's {@code @RestStartCall} fires <i>before</i> routing, so the
141    *       resolved op is not yet known): we look up the dispatch operation's OWN {@link org.apache.juneau.rest.server.util.UrlPathMatcher}
142    *       objects from {@link RestContext#getRestOperations()} and match the request against them &mdash; reusing the router's
143    *       own matchers, so re-mounts, {@code /*}, trailing slashes and percent-encoding are all handled exactly as the router
144    *       handles them.
145    * </ul>
146    *
147    * @param ctx The host {@link RestContext} handling this request.
148    * @param req The raw HTTP request.
149    * @return <jk>true</jk> if the request resolves to the MCP dispatch operation.
150    */
151   static boolean resolvesToMcpDispatch(RestContext ctx, HttpServletRequest req) {
152      return resolveMcpDispatchOp(ctx, req) != null;
153   }
154
155   /**
156    * Returns the resolved MCP JSON-RPC dispatch operation's {@link RestOpContext} for this request, or
157    * <jk>null</jk> if the request does not resolve to it.
158    *
159    * <p>
160    * Same two-case resolution as {@link #resolvesToMcpDispatch(RestContext, HttpServletRequest)}, but returns the
161    * matched operation's own {@link RestOpContext} (rather than a boolean) so callers can also inspect the
162    * operation's owning {@link RestContext#getMixinMountPrefixes() mixin mount prefixes} &mdash; e.g. to detect a
163    * {@code @Mixin(path/paths)} re-mount, the same signal {@link #assertWellKnownMountReachable(RestRequest)} uses
164    * for the well-known route.
165    *
166    * @param ctx The host {@link RestContext} handling this request.
167    * @param req The raw HTTP request.
168    * @return The resolved MCP dispatch operation's {@link RestOpContext}, or <jk>null</jk>.
169    */
170   static RestOpContext resolveMcpDispatchOp(RestContext ctx, HttpServletRequest req) {
171      var opSession = ctx.getLocalSession().getOpSessionOrNull();
172      if (opSession != null) {
173         var oc = opSession.getContext();
174         return isMcpDispatchMethod(oc.getJavaMethod()) ? oc : null;
175      }
176      return matchMcpDispatchOp(ctx, req);
177   }
178
179   /**
180    * Returns whether the specified Java method is the MCP JSON-RPC dispatch operation
181    * ({@link McpEndpointMixin#handleMcpRequest}, or an override of it).
182    *
183    * @param m The resolved operation's Java method.  May be <jk>null</jk>.
184    * @return <jk>true</jk> if it is the MCP dispatch method.
185    */
186   static boolean isMcpDispatchMethod(Method m) {
187      return m != null
188         && eq(m.getName(), MCP_DISPATCH_METHOD)
189         && McpEndpointMixin.class.isAssignableFrom(m.getDeclaringClass());
190   }
191
192   /**
193    * Returns whether the request path matches the MCP dispatch operation's own router matchers (host
194    * {@code implements McpEndpoint} case, where the resolved op is not yet available).
195    *
196    * @param ctx The host {@link RestContext} handling this request.
197    * @param req The raw HTTP request.
198    * @return The MCP dispatch operation's {@link RestOpContext} if the request path matches its matchers, else
199    *    <jk>null</jk>.
200    */
201   static RestOpContext matchMcpDispatchOp(RestContext ctx, HttpServletRequest req) {
202      var ops = ctx.getRestOperations();
203      if (ops == null)
204         return null;
205      var urlPath = UrlPath.of(RestUtils.getPathInfoUndecoded(req));
206      for (var oc : ops.getOpContexts())
207         if (isMcpDispatchMethod(oc.getJavaMethod()))
208            for (var matcher : oc.getPathMatchers())
209               if (matcher.match(urlPath) != null)
210                  return oc;
211      return null;
212   }
213
214   /**
215    * Returns whether this request resolves to a {@code POST} once method-override channels are honored.
216    *
217    * <p>
218    * Treats a {@code POST} claim on any override channel as a {@code POST}: the request-line method, any
219    * {@code ?method=} value, or any value of any of the {@link MethodSafety#overrideHeaders() override headers}, each
220    * compared after trimming and ignoring case.  Because a {@code @RestStartCall} hook cannot see the host's
221    * {@code allowedMethodParams}/{@code allowedMethodHeaders} allow-lists, and must not depend on which of several
222    * conflicting claims dispatch ends up honoring, this is an over-approximation of what {@code RestSession.getMethod()}
223    * can resolve to &mdash; fail-closed: the only cost is that a {@code POST} claim aimed at the MCP path on a
224    * host that does <i>not</i> allow overrides is challenged rather than 404'd, which never weakens the gate.
225    */
226   static boolean isEffectivePost(HttpServletRequest req) {
227      if (eqic("POST", req.getMethod()))
228         return true;
229      var q = req.getQueryString();
230      if (MethodSafety.queryClaims(q).stream().anyMatch(McpResourceServerSupport::isPostClaim))
231         return true;
232      var mp = RestUtils.parseQuery(q).get("method");
233      if (mp != null && mp.stream().anyMatch(McpResourceServerSupport::isPostClaim))
234         return true;
235      for (var h : MethodSafety.overrideHeaders()) {
236         var e = req.getHeaders(h);
237         while (e != null && e.hasMoreElements())
238            if (isPostClaim(e.nextElement()))
239               return true;
240      }
241      return false;
242   }
243
244   private static boolean isPostClaim(String claim) {
245      return claim != null && eqic("POST", claim.trim());
246   }
247
248   /**
249    * Fails fast when this endpoint is not mounted at the origin root.
250    *
251    * <p>
252    * RFC 9728 requires the Protected Resource Metadata document at an origin-root well-known location
253    * ({@code https://host/.well-known/oauth-protected-resource[/path]}), which is exactly the URL advertised in the
254    * {@code resource_metadata} challenge parameter.  Juneau registers the well-known {@code @RestGet} routes
255    * servlet-relative, so on any non-origin-root mount (a non-empty servlet path or servlet-context path) the advertised
256    * URL would resolve to a different location and 404.  Rather than silently advertising an unreachable document, RS
257    * auth refuses to operate on such a mount.
258    *
259    * <p>
260    * A container that reports the root context / root servlet mapping as {@code "/"} (rather than the empty string) is
261    * treated as origin-root &mdash; the tolerance is symmetric across {@code contextPath} and {@code servletPath}.
262    *
263    * @param req The HTTP request.
264    * @throws InternalServerError Always, when a non-root context path or non-root servlet path is present.
265    */
266   static void assertOriginRootMount(HttpServletRequest req) {
267      var ctx = emptyIfNull(req.getContextPath());
268      var svl = emptyIfNull(req.getServletPath());
269      if (!isOriginRootPath(ctx) || !isOriginRootPath(svl))
270         throw new InternalServerError(
271            "MCP resource-server auth requires an origin-root mount so the advertised RFC 9728 well-known PRM URL "
272            + "resolves; found contextPath='%s' servletPath='%s'.  Mount the MCP endpoint at the origin root "
273            + "(no servlet path / no context path).",
274            ctx, svl);
275   }
276
277   /** Returns whether a context/servlet path denotes the origin root ({@code ""} or {@code "/"}). */
278   private static boolean isOriginRootPath(String p) {
279      return p.isEmpty() || eq(p, "/");
280   }
281
282   /**
283    * Fails fast when the well-known PRM route cannot be served from the origin root.
284    *
285    * <p>
286    * Extends {@link #assertOriginRootMount(HttpServletRequest)} with the {@code @Mixin(path/paths)} re-mount case: when the
287    * MCP endpoint is mounted as a mixin under one or more host-chosen prefixes, the well-known {@code @RestGet} routes are
288    * re-mounted along with every other mixin op (to {@code /<prefix>/.well-known/...}), so the origin-root PRM URL
289    * advertised in the {@code resource_metadata} challenge is unreachable.  {@code contextPath}/{@code servletPath} are both
290    * empty in that case (the host is root-mounted), so the servlet-path check alone cannot detect it &mdash; we consult the
291    * resolved operation's {@link RestContext#getMixinMountPrefixes() mixin mount prefixes} instead, and fail fast with the
292    * same origin-root diagnostic so discovery can never silently advertise an unreachable URL.
293    *
294    * @param req The REST request (its resolved operation identifies the possibly re-mounted sub-context).
295    * @throws InternalServerError When a non-root mount or a {@code @Mixin(path/paths)} re-mount is detected.
296    */
297   static void assertWellKnownMountReachable(RestRequest req) {
298      assertOriginRootMount(req.getHttpServletRequest());
299      var opCtx = req.getOpContext();
300      assertNotMixinRemounted(opCtx == null ? List.of() : opCtx.getContext().getMixinMountPrefixes());
301   }
302
303   /**
304    * Fails fast when the resolved MCP operation is mounted under one or more host-chosen {@code @Mixin(path/paths)}
305    * re-mount prefixes &mdash; the SAME diagnostic used by
306    * {@link #assertWellKnownMountReachable(RestRequest)}, shared so the {@code POST} bearer gate
307    * ({@link #gateMcpEndpoint}) and the well-known route never disagree on whether a given mount is supported.
308    *
309    * @param mixinMountPrefixes The resolved operation's owning {@link RestContext#getMixinMountPrefixes() mixin
310    *    mount prefixes}; empty when the operation is not mixin-mounted (or not re-mounted).
311    * @throws InternalServerError When {@code mixinMountPrefixes} is non-empty.
312    */
313   private static void assertNotMixinRemounted(List<String> mixinMountPrefixes) {
314      if (!mixinMountPrefixes.isEmpty())
315         throw new InternalServerError(
316            "MCP resource-server auth requires an origin-root mount so the advertised RFC 9728 well-known PRM URL "
317            + "resolves; the MCP endpoint is re-mounted under host-chosen @Mixin prefix(es) %s, which moves the "
318            + "well-known PRM route off the origin root.  Mount the MCP mixin at the origin root (no @Mixin(path/paths)).",
319            mixinMountPrefixes);
320   }
321
322   /**
323    * Serves this endpoint's PRM from the SEP-2351 root well-known location (fallback), failing fast on a non-root or
324    * re-mounted mount; a no-op-throwing {@code 404} when RS auth is disabled.
325    *
326    * @param cfg The RS config.
327    * @param req The REST request.
328    * @return The PRM document.
329    */
330   public static McpProtectedResourceMetadata metadataForWellKnownRoot(McpResourceServerConfig cfg, RestRequest req) {
331      if (!cfg.isEnabled())
332         throw new NotFound();
333      assertWellKnownMountReachable(req);
334      return buildMetadata(cfg);
335   }
336
337   /**
338    * Serves this endpoint's PRM from the RFC 9728 path-inserted well-known location, but only when the requested suffix
339    * matches this resource's expected well-known path; otherwise {@code 404}.  Fails fast on a non-root or re-mounted
340    * mount; a {@code 404} when RS auth is disabled.
341    *
342    * @param cfg The RS config.
343    * @param req The REST request.
344    * @return The PRM document.
345    */
346   public static McpProtectedResourceMetadata metadataForWellKnownPathInserted(McpResourceServerConfig cfg, RestRequest req) {
347      if (!cfg.isEnabled())
348         throw new NotFound();
349      assertWellKnownMountReachable(req);
350      var expected = McpWellKnownRouting.wellKnownRequestPath(cfg.getResource());
351      if (!expected.equals(trimTrailingSlash(req.getHttpServletRequest().getPathInfo())))
352         throw new NotFound();
353      return buildMetadata(cfg);
354   }
355
356   private static String trimTrailingSlash(String p) {
357      if (p == null)
358         return "";
359      while (p.length() > 1 && p.endsWith("/"))
360         p = p.substring(0, p.length() - 1);
361      return p;
362   }
363
364   /**
365    * Builds the RFC 9728 Protected Resource Metadata document from the config.
366    *
367    * @param cfg The RS config.  Must be {@link McpResourceServerConfig#isEnabled() enabled} and valid.
368    * @return The PRM bean.
369    */
370   public static McpProtectedResourceMetadata buildMetadata(McpResourceServerConfig cfg) {
371      cfg.validateEnabled();
372      var m = new McpProtectedResourceMetadata().setResource(cfg.getResource());
373      if (!cfg.getAuthorizationServers().isEmpty())
374         m.setAuthorizationServers(new ArrayList<>(cfg.getAuthorizationServers()));
375      if (!cfg.getScopesSupported().isEmpty())
376         m.setScopesSupported(new LinkedHashSet<>(cfg.getScopesSupported()));
377      if (!cfg.getBearerMethodsSupported().isEmpty())
378         m.setBearerMethodsSupported(new LinkedHashSet<>(cfg.getBearerMethodsSupported()));
379      return m;
380   }
381
382   /**
383    * Returns the path-inserted well-known PRM URI advertised in the {@code resource_metadata} challenge parameter.
384    *
385    * @param cfg The RS config.
386    * @return The PRM document URI.
387    */
388   public static URI metadataUri(McpResourceServerConfig cfg) {
389      return McpWellKnownRouting.metadataUri(cfg.getResource());
390   }
391
392   /**
393    * Authenticates the request against the RS config, establishing the authenticated principal.
394    *
395    * <p>
396    * On success the principal is stashed under {@code "juneau.principal"} and returned.  On failure the
397    * {@code WWW-Authenticate: Bearer ...} challenge (carrying the {@code resource_metadata} pointer) is written directly
398    * to {@code res} &mdash; because an exception thrown from a {@code @RestStartCall} hook does not route through the
399    * response processors that would otherwise copy an {@link AuthenticationException}'s headers &mdash; and then a
400    * {@code 401 Unauthorized} ({@link AuthenticationException}) or {@code 403 Forbidden} (insufficient scope) is thrown.
401    *
402    * @param cfg The RS config.  Must be {@link McpResourceServerConfig#isEnabled() enabled} and valid.
403    * @param req The HTTP request.  Must not be <jk>null</jk>.
404    * @param res The HTTP response, used to write the challenge header on failure.  Must not be <jk>null</jk>.
405    * @return The authenticated principal.  Never <jk>null</jk>.
406    */
407   public static Principal authenticate(McpResourceServerConfig cfg, HttpServletRequest req, HttpServletResponse res) {
408      cfg.validateEnabled();
409      var filter = cfg.oauthFilter();
410      Optional<AuthResult> result;
411      try {
412         result = filter.authenticate(req);
413      } catch (AuthenticationException e) {
414         res.setHeader(WWW_AUTHENTICATE, invalidTokenChallenge(cfg));
415         throw new AuthenticationException(e, "Invalid bearer token");
416      }
417      if (result.isEmpty()) {
418         res.setHeader(WWW_AUTHENTICATE, missingTokenChallenge(cfg));
419         throw new AuthenticationException("Bearer token required");
420      }
421      var auth = result.get();
422      var principal = auth.getPrincipal();
423      if (!McpAudienceValidator.matches(principal, cfg.getAudience(), cfg.isRequireAudienceClaim())) {
424         res.setHeader(WWW_AUTHENTICATE, invalidTokenChallenge(cfg));
425         throw new AuthenticationException("Token audience mismatch");
426      }
427      if (!satisfies(auth.getRoles(), cfg.getRequiredScopes())) {
428         res.setHeader(WWW_AUTHENTICATE, insufficientScopeChallenge(cfg));
429         throw new Forbidden("Insufficient scope");
430      }
431      // Write the framework-standard principal attribute so @Auth Principal resolves downstream.
432      req.setAttribute("juneau.principal", principal);
433      // SEP-2350: stash the granted scopes so the POST-parse dispatch point can enforce per-operation step-up scopes.
434      req.setAttribute(GRANTED_SCOPES_ATTR, Set.copyOf(auth.getRoles()));
435      return principal;
436   }
437
438   /**
439    * Returns the granted scopes stashed by {@link #authenticate} for this request.
440    *
441    * @param req The HTTP request.  May be <jk>null</jk>.
442    * @return The granted scopes, or an empty set when none were stashed (RS auth disabled / gate not run).
443    */
444   @SuppressWarnings({
445      "unchecked" // The granted-scopes request attribute is cast to Set<String> after an instanceof Set check; only authenticate() stores it
446   })
447   public static Set<String> grantedScopes(HttpServletRequest req) {
448      if (req == null)
449         return Set.of();
450      var v = req.getAttribute(GRANTED_SCOPES_ATTR);
451      return v instanceof Set ? (Set<String>)v : Set.of();
452   }
453
454   /**
455    * Returns the authenticated {@link Principal} {@link #authenticate} stashed for this request.
456    *
457    * <p>
458    * Reads the same framework-standard {@code "juneau.principal"} attribute {@link #authenticate}
459    * writes on success, so this is the single source of truth for "who is calling this MCP request".  It is the seam
460    * the {@code 2026-07-28} dispatcher threads into {@link RequestStateCodec#seal}/{@link RequestStateCodec#unseal}
461    * for principal-bound {@code requestState} AAD.  Mirrors {@link #grantedScopes(HttpServletRequest)}:
462    * a <jk>null</jk> request, an absent attribute, or a non-{@link Principal} value all return <jk>null</jk> &mdash; the
463    * anonymous / RS-auth-disabled path, which every caller must handle without an NPE.  Unlike
464    * {@link #grantedScopes(HttpServletRequest)}, which falls back to an empty {@link Set} in that same situation, this
465    * returns <jk>null</jk> rather than an empty value in that case; the divergence is intentional &mdash; a scalar
466    * identity has no natural "empty" representative the way a collection does.
467    *
468    * @param req The HTTP request.  May be <jk>null</jk>.
469    * @return The authenticated principal, or <jk>null</jk> when none was stashed (RS auth disabled / gate not run /
470    *    anonymous caller).
471    */
472   public static Principal principal(HttpServletRequest req) {
473      if (req == null)
474         return null;
475      var v = req.getAttribute("juneau.principal");
476      return v instanceof Principal p ? p : null;
477   }
478
479   /**
480    * Enforces SEP-2350 per-operation step-up scopes at the POST-parse dispatch point.
481    *
482    * <p>
483    * A no-op unless RS auth is {@link McpResourceServerConfig#isEnabled() enabled} and the resolved operation has a
484    * non-empty {@link McpResourceServerConfig#requiredScopesFor(McpOperationContext) required-scope set}.  When the
485    * caller's {@code grantedScopes} do not {@link #satisfies satisfy} the operation's required scopes, throws a
486    * {@code 403 Forbidden} carrying a {@code WWW-Authenticate: Bearer ..., error="insufficient_scope", scope="..."}
487    * step-up challenge that names exactly the scopes required for this operation.
488    *
489    * <p>
490    * This is the enforcement point the security review mandated: unlike the {@code @RestStartCall} bearer gate (which
491    * fires before the JSON-RPC body is parsed), this runs once the MCP method &mdash; and thus the operation &mdash; is
492    * known, so the challenge can be scoped to the specific operation.  It layers on top of, and never weakens, the
493    * baseline gate (op-identity, origin-root fail-fast, audience, and baseline required-scope enforcement all still run
494    * first in {@link #authenticate}).
495    *
496    * @param cfg The RS config.  Must not be <jk>null</jk>.
497    * @param grantedScopes The caller's granted scopes.  May be <jk>null</jk> (treated as empty).
498    * @param ctx The resolved operation.  Must not be <jk>null</jk>.
499    * @throws Forbidden {@code 403 insufficient_scope} when the granted scopes do not satisfy the operation's required
500    *    scopes.
501    */
502   public static void enforceOperationScopes(McpResourceServerConfig cfg, Collection<String> grantedScopes, McpOperationContext ctx) {
503      if (!cfg.isEnabled())
504         return;
505      var required = cfg.requiredScopesFor(ctx);
506      if (required.isEmpty())
507         return;
508      var granted = grantedScopes == null ? Set.<String>of() : grantedScopes;
509      if (satisfies(granted, required))
510         return;
511      var e = new Forbidden("Insufficient scope for operation '%s'", ctx.method());
512      e.setHeader(WWW_AUTHENTICATE, stepUpChallenge(cfg, required));
513      throw e;
514   }
515
516   /**
517    * Builds the SEP-2350 scoped step-up {@code WWW-Authenticate} challenge naming the scopes required to invoke a
518    * specific operation ({@code error="insufficient_scope"}, with the operation's {@code scope} set).
519    *
520    * @param cfg The RS config.
521    * @param operationScopes The scopes required to invoke the operation.
522    * @return The challenge value.
523    */
524   public static String stepUpChallenge(McpResourceServerConfig cfg, Collection<String> operationScopes) {
525      return McpBearerChallenge.create()
526         .realm(cfg.getRealm())
527         .error("insufficient_scope")
528         .scope(operationScopes)
529         .resourceMetadata(metadataUri(cfg))
530         .build();
531   }
532
533   /**
534    * Returns whether the granted scopes satisfy every required scope, using exact string equality.
535    *
536    * <p>
537    * A required scope {@code r} is satisfied only when some granted scope {@code g} is exactly equal to it.  OAuth
538    * scopes have no universal hierarchy, so a granted {@code repo} does <b>not</b> imply a required {@code repo:write}
539    * or {@code repo.admin} &mdash; a broad-but-low-privilege scope must never authorize a differently named privileged
540    * operation. Applications that want scope implication must expand their granted scopes themselves before calling
541    * this method; this method does not provide a hierarchical-implication SPI.
542    *
543    * @param grantedScopes The caller's granted scopes.  Never <jk>null</jk>.
544    * @param requiredScopes The scopes required to invoke the operation.  Never <jk>null</jk>.
545    * @return <jk>true</jk> if every required scope is satisfied.
546    */
547   public static boolean satisfies(Collection<String> grantedScopes, Collection<String> requiredScopes) {
548      return grantedScopes.containsAll(requiredScopes);
549   }
550
551   /**
552    * Builds the {@code WWW-Authenticate} challenge for a request that presented no bearer credentials (RFC 6750
553    * &sect;3: no {@code error} code).
554    *
555    * <p>
556    * Carries a {@code scope} hint listing the endpoint-wide baseline required scopes when any are
557    * configured, so a client can request them on its first authorization; the hint is omitted (no {@code scope=""})
558    * when no baseline scope is required.
559    *
560    * @param cfg The RS config.
561    * @return The challenge value.
562    */
563   public static String missingTokenChallenge(McpResourceServerConfig cfg) {
564      return McpBearerChallenge.create()
565         .realm(cfg.getRealm())
566         .scope(cfg.getRequiredScopes())  // SEP-2350: hint the baseline scope on the initial 401 (no-op when empty).
567         .resourceMetadata(metadataUri(cfg))
568         .build();
569   }
570
571   /**
572    * Builds the {@code WWW-Authenticate} challenge for an invalid/expired token or an audience mismatch
573    * ({@code error="invalid_token"}).
574    *
575    * @param cfg The RS config.
576    * @return The challenge value.
577    */
578   public static String invalidTokenChallenge(McpResourceServerConfig cfg) {
579      return McpBearerChallenge.create()
580         .realm(cfg.getRealm())
581         .error("invalid_token")
582         .resourceMetadata(metadataUri(cfg))
583         .build();
584   }
585
586   /**
587    * Builds the {@code WWW-Authenticate} challenge for a token missing a required baseline scope
588    * ({@code error="insufficient_scope"}, with the required {@code scope} set).
589    *
590    * @param cfg The RS config.
591    * @return The challenge value.
592    */
593   public static String insufficientScopeChallenge(McpResourceServerConfig cfg) {
594      return McpBearerChallenge.create()
595         .realm(cfg.getRealm())
596         .error("insufficient_scope")
597         .scope(cfg.getRequiredScopes())
598         .resourceMetadata(metadataUri(cfg))
599         .build();
600   }
601}