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 &mdash; 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> &mdash; 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> &mdash; 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 &mdash; 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> &mdash; a {@link JwtTokenValidator} that checks the token's
045 *       signature (against {@link OfflineAuthorizationServer#jwkSource()}, offline &mdash; 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> &mdash; 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> &mdash; 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> &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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}