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 — 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)}) — rather than authenticating — 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 — 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 — 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 — 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 — 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} — 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 — 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 — 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 — 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 — 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} — 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 — 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> — 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 — 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 — and thus the operation — 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} — 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 * §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}