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.examples.mcp.secured; 018 019import java.net.*; 020 021import org.apache.juneau.examples.mcp.*; 022import org.apache.juneau.rest.server.auth.jwt.*; 023import org.apache.juneau.rest.server.mcp.v20260728.*; 024 025/** 026 * The OAuth 2.1-secured variant of {@link ExampleMcpServer}: identical notes-service surface (tools, prompt, 027 * resources, resource template, subscriptions), but every {@code POST /} JSON-RPC call now requires a valid 028 * bearer token. 029 * 030 * <p> 031 * This class changes nothing about <i>what</i> the server exposes — it inherits 032 * {@link ExampleMcpServer#createMcpConfig()} unmodified, so the same {@code publishNote}/{@code deleteNote} 033 * tools, {@code summarize} prompt, and {@code note:///...} resources are still there. The only override is 034 * {@link #createMcpOptions()}, which layers an {@code McpResourceServerConfig} on top of the parent's 035 * capabilities. That single override is the entire security story: 036 * 037 * <ul> 038 * <li><b>{@code setEnabled(true)}</b> — turns on the RS (resource-server) gate at all. Off by default, 039 * so an ordinary {@link ExampleMcpServer} is completely unaffected by this class existing. 040 * <li><b>{@code setResource(...)}</b> — this server's own canonical URL (RFC 9728 {@code resource} / 041 * RFC 8707 default audience). A bearer token's {@code aud} claim must include this exact URL or it is 042 * rejected — the confused-deputy defense: a token minted for some OTHER resource cannot be replayed 043 * here even if it is otherwise perfectly valid and signed by a trusted issuer. 044 * <li><b>{@code setTokenValidator(...)}</b> — a {@link JwtTokenValidator} that checks the token's 045 * signature (against {@link OfflineAuthorizationServer#jwkSource()}, offline — no JWKS HTTP fetch), 046 * {@code iss} (must equal {@link OfflineAuthorizationServer#issuerUri()}), and {@code aud}/{@code exp}/ 047 * {@code nbf} (validated against the resource URL above and wall-clock time). 048 * <li><b>{@code addAuthorizationServer(...)}</b> — advertised in the RFC 9728 Protected Resource 049 * Metadata (PRM) document a client fetches from the well-known {@code .well-known/oauth-protected-resource} 050 * path this framework serves automatically once RS auth is enabled. This is how a compliant client 051 * discovers WHICH authorization server to go get a token from, without that URL being hardcoded into the 052 * client at all. 053 * <li><b>{@code addRequiredScope(...)}</b> — the coarse, endpoint-wide baseline scope ({@link #READ_SCOPE}) 054 * every request must carry (on top of a merely valid, correctly-audienced token) before any JSON-RPC 055 * method dispatches. A token missing it gets a {@code 403} with a {@code WWW-Authenticate} challenge 056 * naming the missing scope, not a silent failure. 057 * <li><b>{@code addOperationScope(...)}</b> — SEP-2350 per-operation step-up (H3): {@code publishNote} 058 * and {@code deleteNote} additionally require {@link #WRITE_SCOPE} on top of the baseline. A token 059 * carrying only {@link #READ_SCOPE} can discover the server and read resources, but a mutating tool call 060 * gets its own scoped {@code 403 insufficient_scope} naming {@link #WRITE_SCOPE} specifically — the 061 * baseline scope alone is never enough to invoke either write tool. 062 * </ul> 063 * 064 * <p> 065 * The net effect on the wire: an unauthenticated (or wrongly-audienced, or expired, or insufficiently-scoped) 066 * {@code POST /} now gets {@code 401}/{@code 403} plus a {@code WWW-Authenticate: Bearer ...} challenge 067 * instead of ever reaching {@link ExampleMcpServer}'s tool/prompt/resource handlers; a request bearing a valid 068 * bearer token dispatches exactly as it always did. 069 * 070 * <h5 class='section'>The one wrinkle this class works around:</h5> 071 * <p> 072 * {@link #getResource() The resource URL} is this server's OWN address, which is a problem when 073 * {@link SecuredExampleServer} boots on an OS-assigned ephemeral port (as the end-to-end test does): naively, 074 * that address is not knowable until AFTER Jetty has bound its socket. It is tempting to assume 075 * {@link #createMcpOptions()} (like {@code createMcpConfig()}) is called lazily on the first incoming MCP 076 * request, late enough to bind the resource URL once the port is known — but it is NOT: the REST 077 * framework eagerly walks every {@code @Bean}-annotated method declared on the resource (including this 078 * inherited {@code getMcpOptions()}) while building the servlet's {@code RestContext}, i.e. during Jetty's 079 * OWN servlet initialization, strictly before {@code Server.start()} returns. A constructor-supplied resource 080 * URL is therefore required; see {@link SecuredExampleServer#start(int)} for how it opens the Jetty connector 081 * (and thus learns the real ephemeral port) before this servlet is even constructed. 082 * 083 * @serial exclude 084 */ 085@SuppressWarnings({ 086 "java:S110", // Inheritance depth is inherent to extending the Juneau REST server hierarchy. 087 "java:S2065" // authServer and resource are transient because the servlet is Serializable but these fields are not 088}) 089public class SecuredExampleMcpServer extends ExampleMcpServer { 090 091 private static final long serialVersionUID = 1L; 092 093 /** 094 * The baseline OAuth scope every request to this server must carry, on top of a valid, correctly-audienced 095 * token. Advertised in the PRM document's {@code scopes_supported} and required via 096 * {@code McpResourceServerConfig.addRequiredScope(...)}. 097 */ 098 public static final String READ_SCOPE = "mcp.read"; 099 100 /** 101 * The additional step-up scope {@code publishNote}/{@code deleteNote} require on top of {@link #READ_SCOPE} 102 * (H3, SEP-2350 per-operation scoping), via {@code McpResourceServerConfig.addOperationScope(...)}. 103 */ 104 public static final String WRITE_SCOPE = "mcp.write"; 105 106 @SuppressWarnings({ 107 "resource" // not owned here; lifecycle is managed by the caller (SecuredExampleServer). 108 }) 109 private final transient OfflineAuthorizationServer authServer; 110 private final transient URI resource; 111 112 /** 113 * Constructor. 114 * 115 * @param authServer The offline authorization server this instance validates bearer tokens against 116 * (its {@link OfflineAuthorizationServer#jwkSource() public key} and 117 * {@link OfflineAuthorizationServer#issuerUri() issuer}). Must not be <jk>null</jk>. 118 * @param resource This server's own canonical root URL (e.g. {@code http://localhost:10001/}), known and 119 * fixed before construction — see the class javadoc's "one wrinkle" section for why this cannot be 120 * supplied later. Must not be <jk>null</jk>. 121 */ 122 public SecuredExampleMcpServer(OfflineAuthorizationServer authServer, URI resource) { 123 if (authServer == null) 124 throw new IllegalArgumentException("authServer must not be null"); 125 if (resource == null) 126 throw new IllegalArgumentException("resource must not be null"); 127 this.authServer = authServer; 128 this.resource = resource; 129 } 130 131 /** 132 * Returns this server's own canonical resource URL, as supplied to the constructor. 133 * 134 * @return The resource URL. Never <jk>null</jk>. 135 */ 136 public URI getResource() { 137 return resource; 138 } 139 140 @Override 141 protected McpOptions createMcpOptions() { 142 var validator = JwtTokenValidator.create() 143 .jwkSource(authServer.jwkSource()) 144 .issuer(authServer.issuerUri().toString()) 145 .audience(resource.toString()) 146 .build(); 147 return super.createMcpOptions().resourceServer(rs -> rs 148 .setEnabled(true) 149 .setResource(resource) 150 .setTokenValidator(validator) 151 .addAuthorizationServer(authServer.issuerUri()) 152 .addRequiredScope(READ_SCOPE) 153 // H3: publishNote/deleteNote step up to WRITE_SCOPE on top of the READ_SCOPE baseline above. 154 .addOperationScope("tools/call", "publishNote", WRITE_SCOPE) 155 .addOperationScope("tools/call", "deleteNote", WRITE_SCOPE)); 156 } 157}