This is an automated email from the ASF dual-hosted git repository. jamesbognar pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/juneau.git
commit 3ff2f9d274517f8d412511ffa492d15f4852a761 Author: James Bognar <[email protected]> AuthorDate: Thu Aug 6 11:15:34 2026 -0700 OAuth 2.1-secured variant of the MCP example (juneau-examples-mcp) Adds an org.apache.juneau.examples.mcp.secured package: a fully offline-runnable OAuth 2.1 demo layered on the existing notes-service example. - SecuredExampleMcpServer extends ExampleMcpServer, overriding only createMcpOptions() to enable McpResourceServerConfig: RS gate, RFC 8707 audience enforcement, JwtTokenValidator (offline JWK source; iss/aud/exp/nbf), a baseline mcp.read required scope, and SEP-2350 per-operation step-up (publishNote/deleteNote require mcp.write). - OfflineAuthorizationServer: a clearly-marked DEMO-ONLY stand-in AS (self-generated RSA key, offline JWK source, RFC 6749 client-credentials /token endpoint with a fixed scope allowlist, and an RFC 8414 .well-known/oauth-authorization-server metadata endpoint) so the whole flow runs with zero network. - SecuredExampleClient drives the full discovery chain: 401 + WWW-Authenticate -> RFC 9728 PRM -> RFC 8414 AS metadata -> token acquisition via juneau-rest-client-mcp-auth's McpTokenProvider -> mcp.read read succeeds -> read-only token 403'd on a write -> step up to mcp.read mcp.write. - SecuredExampleServer launcher + SecuredExampleMcpEndToEnd_Test (missing/garbage/wrong-audience/ expired/wrong-issuer/insufficient-scope negative cases, PRM contents, and valid-token round-trips). Additive only; the module's distrib packaging is unchanged. Co-authored-by: Cursor <[email protected]> --- juneau-examples/juneau-examples-mcp/README.md | 61 ++++ .../juneau-examples-mcp/build-overlay/README.md | 59 ++++ .../juneau-examples-mcp/build-overlay/pom.xml | 41 +++ juneau-examples/juneau-examples-mcp/pom.xml | 46 +++ .../mcp/secured/OfflineAuthorizationServer.java | 391 +++++++++++++++++++++ .../examples/mcp/secured/SecuredExampleClient.java | 277 +++++++++++++++ .../mcp/secured/SecuredExampleMcpServer.java | 150 ++++++++ .../examples/mcp/secured/SecuredExampleServer.java | 171 +++++++++ .../secured/SecuredExampleMcpEndToEnd_Test.java | 380 ++++++++++++++++++++ 9 files changed, 1576 insertions(+) diff --git a/juneau-examples/juneau-examples-mcp/README.md b/juneau-examples/juneau-examples-mcp/README.md index 31bfd91cc3..9031239293 100644 --- a/juneau-examples/juneau-examples-mcp/README.md +++ b/juneau-examples/juneau-examples-mcp/README.md @@ -46,6 +46,7 @@ A single domain — an in-memory `title → body` note store (`NoteStore`) — i | [`spring/SpringExampleMcpServer.java`](src/main/java/org/apache/juneau/examples/mcp/spring/SpringExampleMcpServer.java) | Spring Boot variant using `SpringMcpRestServlet` | | [`spring/SpringExampleApplication.java`](src/main/java/org/apache/juneau/examples/mcp/spring/SpringExampleApplication.java) | `@SpringBootApplication` launcher | | [`ExampleMcpEndToEnd_Test.java`](src/test/java/org/apache/juneau/examples/mcp/ExampleMcpEndToEnd_Test.java) | In-process end-to-end proof | +| [`secured/`](src/main/java/org/apache/juneau/examples/mcp/secured/) | The OAuth 2.1-secured variant — see [below](#4-the-oauth-21-secured-variant) | ## Run it @@ -111,6 +112,60 @@ mvn -f juneau-examples/juneau-examples-mcp/pom.xml test `ExampleMcpEndToEnd_Test` boots the server in-process on an ephemeral port and drives the real client through every surface, asserting each outcome. +### 4. The OAuth 2.1-secured variant + +`org.apache.juneau.examples.mcp.secured` wraps the exact same notes service in an OAuth 2.1 +resource-server gate — every JSON-RPC call now requires a valid bearer token, and the two +mutating tools (`publishNote`/`deleteNote`) additionally require a step-up `mcp.write` scope on +top of the baseline `mcp.read` (see below). It is entirely **self-contained and +offline-runnable**: alongside the secured server, an in-process +[`OfflineAuthorizationServer.java`](src/main/java/org/apache/juneau/examples/mcp/secured/OfflineAuthorizationServer.java) +stands in for a real authorization server (AS) — it generates its own RSA signing key, publishes +the public half of it directly to the validator (no JWKS HTTP fetch needed), and answers a real +RFC 6749 §4.4 client-credentials token request, as well as a real RFC 8414 authorization-server +metadata discovery request. Nothing here talks to the network or requires any setup; see that +class's javadoc for the full design rationale, including exactly what this offline stand-in does +**not** implement (no real scope-authorization decision beyond a fixed allowlist, no refresh +tokens, no user auth). + +> **Client secrets don't belong on a command line.** `SecuredExampleServer.main` prints the demo +> client secret to the console purely so this walkthrough has something to copy/paste. A real +> client should read its secret from an environment variable or a file it controls, never accept +> it as a CLI argument (`argv` is visible to every other process on the host via `/proc` or `ps`). + +Start the secured server (defaults to port `5001`; pass a port to override). It prints a startup +banner with everything the client needs — copy those three values: + +```bash +mvn -f juneau-examples/juneau-examples-mcp/pom.xml exec:java \ + -Dexec.mainClass=org.apache.juneau.examples.mcp.secured.SecuredExampleServer +``` + +In another terminal, paste the three printed values into the secured client walkthrough: + +```bash +mvn -f juneau-examples/juneau-examples-mcp/pom.xml exec:java \ + -Dexec.mainClass=org.apache.juneau.examples.mcp.secured.SecuredExampleClient \ + -Dexec.args="<endpoint> <clientId> <clientSecret>" +``` + +The client walks through seven beats: (1) a raw, header-level HTTP call showing the exact `401` + +`WWW-Authenticate: Bearer ...` challenge (including the RFC 9728 `resource_metadata` pointer) an +unauthenticated request gets; (2) the same call again, this time through the real `McpClient` SDK, +showing the ergonomic failure mode (a bare `IOException`, since the gate's rejection body is +plain text, not a JSON-RPC envelope `McpClient` can parse into a typed result); (3) fetching the +RFC 9728 Protected Resource Metadata document the challenge pointed at; +(4) RFC 8414 discovery against the authorization server the PRM document named, resolving its +`token_endpoint` (the token endpoint is never hardcoded or passed on the command line); (5) +acquiring a real `mcp.read`-scoped bearer token and successfully reading a resource with it; (6) +the SAME read-only token attempting `publishNote` — a scoped `403 insufficient_scope` step-up +challenge naming `mcp.write`; (7) acquiring a second token carrying both `mcp.read` and +`mcp.write` and successfully publishing a note with it. + +[`SecuredExampleMcpEndToEnd_Test.java`](src/test/java/org/apache/juneau/examples/mcp/secured/SecuredExampleMcpEndToEnd_Test.java) +boots both servers in-process on ephemeral ports and asserts the same rejected/accepted paths +without needing two terminals — run it the same way as `ExampleMcpEndToEnd_Test` (part 3 above). + ## How the wiring works - **Server:** `ExampleMcpServer extends McpRestServlet` (the v2 base). `createMcpConfig()` lists @@ -120,3 +175,9 @@ client through every surface, asserting each outcome. a `Microservice` with `JettyConfiguration`, which auto-mounts the `@Rest` servlet at `/`. - **Client:** `McpClient` (v2) — `connect()` does the mandatory `server/discover` handshake; `callTool`/`readResource`/`getPrompt`/`complete`/`listen`/`callToolWithElicitation` do the rest. +- **Security (part 4):** `SecuredExampleMcpServer extends ExampleMcpServer`, overriding only + `createMcpOptions()` to add `.resourceServer(rs -> rs.setEnabled(true)...)` — a `JwtTokenValidator` + (from `juneau-rest-server-auth-jwt`) validates the bearer, and the RFC 9728 well-known metadata + route is served automatically once RS auth is enabled. The client side uses + `juneau-rest-client-mcp-auth`'s `McpTokenProvider.clientCredentials()` to acquire a token and + `McpAuthInterceptor` (via `.interceptor(tokens.interceptor())`) to attach it to every request. diff --git a/juneau-examples/juneau-examples-mcp/build-overlay/README.md b/juneau-examples/juneau-examples-mcp/build-overlay/README.md index f358b783e9..f98b8cd0cb 100644 --- a/juneau-examples/juneau-examples-mcp/build-overlay/README.md +++ b/juneau-examples/juneau-examples-mcp/build-overlay/README.md @@ -46,6 +46,7 @@ A single domain — an in-memory `title → body` note store (`NoteStore`) — i | [`spring/SpringExampleMcpServer.java`](src/main/java/org/apache/juneau/examples/mcp/spring/SpringExampleMcpServer.java) | Spring Boot variant using `SpringMcpRestServlet` | | [`spring/SpringExampleApplication.java`](src/main/java/org/apache/juneau/examples/mcp/spring/SpringExampleApplication.java) | `@SpringBootApplication` launcher | | [`ExampleMcpEndToEnd_Test.java`](src/test/java/org/apache/juneau/examples/mcp/ExampleMcpEndToEnd_Test.java) | In-process end-to-end proof | +| [`secured/`](src/main/java/org/apache/juneau/examples/mcp/secured/) | The OAuth 2.1-secured variant — see [below](#4-the-oauth-21-secured-variant) | ## Run it @@ -105,6 +106,58 @@ mvn test `ExampleMcpEndToEnd_Test` boots the server in-process on an ephemeral port and drives the real client through every surface, asserting each outcome. +### 4. The OAuth 2.1-secured variant + +`org.apache.juneau.examples.mcp.secured` wraps the exact same notes service in an OAuth 2.1 +resource-server gate — every JSON-RPC call now requires a valid bearer token, and the two +mutating tools (`publishNote`/`deleteNote`) additionally require a step-up `mcp.write` scope on +top of the baseline `mcp.read` (see below). It is entirely **self-contained and +offline-runnable**: alongside the secured server, an in-process +[`OfflineAuthorizationServer.java`](src/main/java/org/apache/juneau/examples/mcp/secured/OfflineAuthorizationServer.java) +stands in for a real authorization server (AS) — it generates its own RSA signing key, publishes +the public half of it directly to the validator (no JWKS HTTP fetch needed), and answers a real +RFC 6749 §4.4 client-credentials token request, as well as a real RFC 8414 authorization-server +metadata discovery request. Nothing here talks to the network or requires any setup; see that +class's javadoc for the full design rationale, including exactly what this offline stand-in does +**not** implement (no real scope-authorization decision beyond a fixed allowlist, no refresh +tokens, no user auth). + +> **Client secrets don't belong on a command line.** `SecuredExampleServer.main` prints the demo +> client secret to the console purely so this walkthrough has something to copy/paste. A real +> client should read its secret from an environment variable or a file it controls, never accept +> it as a CLI argument (`argv` is visible to every other process on the host via `/proc` or `ps`). + +Start the secured server (defaults to port `5001`; pass a port to override). It prints a startup +banner with everything the client needs — copy those three values: + +```bash +mvn exec:java -Dexec.mainClass=org.apache.juneau.examples.mcp.secured.SecuredExampleServer +``` + +In another terminal, paste the three printed values into the secured client walkthrough: + +```bash +mvn exec:java -Dexec.mainClass=org.apache.juneau.examples.mcp.secured.SecuredExampleClient \ + -Dexec.args="<endpoint> <clientId> <clientSecret>" +``` + +The client walks through seven beats: (1) a raw, header-level HTTP call showing the exact `401` + +`WWW-Authenticate: Bearer ...` challenge (including the RFC 9728 `resource_metadata` pointer) an +unauthenticated request gets; (2) the same call again, this time through the real `McpClient` SDK, +showing the ergonomic failure mode (a bare `IOException`, since the gate's rejection body is +plain text, not a JSON-RPC envelope `McpClient` can parse into a typed result); (3) fetching the +RFC 9728 Protected Resource Metadata document the challenge pointed at; +(4) RFC 8414 discovery against the authorization server the PRM document named, resolving its +`token_endpoint` (the token endpoint is never hardcoded or passed on the command line); (5) +acquiring a real `mcp.read`-scoped bearer token and successfully reading a resource with it; (6) +the SAME read-only token attempting `publishNote` — a scoped `403 insufficient_scope` step-up +challenge naming `mcp.write`; (7) acquiring a second token carrying both `mcp.read` and +`mcp.write` and successfully publishing a note with it. + +[`SecuredExampleMcpEndToEnd_Test.java`](src/test/java/org/apache/juneau/examples/mcp/secured/SecuredExampleMcpEndToEnd_Test.java) +boots both servers in-process on ephemeral ports and asserts the same rejected/accepted paths +without needing two terminals — run it the same way as `ExampleMcpEndToEnd_Test` (part 3 above). + ## How the wiring works - **Server:** `ExampleMcpServer extends McpRestServlet` (the v2 base). `createMcpConfig()` lists @@ -114,3 +167,9 @@ client through every surface, asserting each outcome. a `Microservice` with `JettyConfiguration`, which auto-mounts the `@Rest` servlet at `/`. - **Client:** `McpClient` (v2) — `connect()` does the mandatory `server/discover` handshake; `callTool`/`readResource`/`getPrompt`/`complete`/`listen`/`callToolWithElicitation` do the rest. +- **Security (part 4):** `SecuredExampleMcpServer extends ExampleMcpServer`, overriding only + `createMcpOptions()` to add `.resourceServer(rs -> rs.setEnabled(true)...)` — a `JwtTokenValidator` + (from `juneau-rest-server-auth-jwt`) validates the bearer, and the RFC 9728 well-known metadata + route is served automatically once RS auth is enabled. The client side uses + `juneau-rest-client-mcp-auth`'s `McpTokenProvider.clientCredentials()` to acquire a token and + `McpAuthInterceptor` (via `.interceptor(tokens.interceptor())`) to attach it to every request. diff --git a/juneau-examples/juneau-examples-mcp/build-overlay/pom.xml b/juneau-examples/juneau-examples-mcp/build-overlay/pom.xml index 242e19c0d7..00b2f36702 100644 --- a/juneau-examples/juneau-examples-mcp/build-overlay/pom.xml +++ b/juneau-examples/juneau-examples-mcp/build-overlay/pom.xml @@ -87,6 +87,47 @@ </exclusions> </dependency> + <!-- + Backs the OAuth 2.1-secured variant (org.apache.juneau.examples.mcp.secured): JwtTokenValidator, + the JWKS-backed TokenValidator the RS config wires in. Declared explicitly because it is marked + <optional> (provided) in juneau-rest-server-mcp-v20260728, so it does not transit to consumers. + --> + <dependency> + <groupId>org.apache.juneau</groupId> + <artifactId>juneau-rest-server-auth-jwt</artifactId> + <version>${project.version}</version> + </dependency> + + <!-- + juneau-rest-server-auth-jwt's sole runtime dependency, deliberately declared <provided> (non-transitive) + upstream so JWT support stays opt-in. The secured example DOES use JWT validation, so it must pull + this in itself (pinned to the same version juneau-rest-server-auth-jwt itself uses). + --> + <dependency> + <groupId>com.nimbusds</groupId> + <artifactId>nimbus-jose-jwt</artifactId> + <version>${nimbus-jose-jwt.version}</version> + </dependency> + + <!-- Client-side OAuth 2.1 token acquisition (McpTokenProvider, McpProtectedResourceMetadataClient, ...) + backing the secured client demo. --> + <dependency> + <groupId>org.apache.juneau</groupId> + <artifactId>juneau-rest-client-mcp-auth</artifactId> + <version>${project.version}</version> + </dependency> + + <!-- + juneau-rest-client-mcp-auth's sole runtime dependency, also <provided> (non-transitive) upstream for + the same opt-in reason as nimbus-jose-jwt above; the secured client demo exercises the real + client-credentials flow, so this must be declared explicitly too. + --> + <dependency> + <groupId>com.nimbusds</groupId> + <artifactId>oauth2-oidc-sdk</artifactId> + <version>${nimbus-oauth2-oidc-sdk.version}</version> + </dependency> + <!-- Test: JUnit Jupiter, for the in-process end-to-end test. --> <dependency> <groupId>org.junit.jupiter</groupId> diff --git a/juneau-examples/juneau-examples-mcp/pom.xml b/juneau-examples/juneau-examples-mcp/pom.xml index ec222c9e24..fdbe2836a9 100644 --- a/juneau-examples/juneau-examples-mcp/pom.xml +++ b/juneau-examples/juneau-examples-mcp/pom.xml @@ -34,6 +34,11 @@ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <!-- Default exec:java target; override with -Dexec.mainClass=... for ExampleClient or the Spring launcher. --> <exec.mainClass>org.apache.juneau.examples.mcp.ExampleServer</exec.mainClass> + <!-- Pins mirror juneau-rest-server-auth-jwt / juneau-rest-client-mcp-auth's own <properties> exactly - + both declare their Nimbus dependency 'provided' (non-transitive), so a consumer that actually runs + JWT validation or the OAuth client flows (as the secured example does) must re-declare them itself. --> + <nimbus-jose-jwt.version>10.3</nimbus-jose-jwt.version> + <nimbus-oauth2-oidc-sdk.version>11.37.2</nimbus-oauth2-oidc-sdk.version> </properties> <dependencies> @@ -87,6 +92,47 @@ </exclusions> </dependency> + <!-- + Backs the OAuth 2.1-secured variant (org.apache.juneau.examples.mcp.secured): JwtTokenValidator, + the JWKS-backed TokenValidator the RS config wires in. Declared explicitly because it is marked + <optional> (provided) in juneau-rest-server-mcp-v20260728, so it does not transit to consumers. + --> + <dependency> + <groupId>org.apache.juneau</groupId> + <artifactId>juneau-rest-server-auth-jwt</artifactId> + <version>${project.version}</version> + </dependency> + + <!-- + juneau-rest-server-auth-jwt's sole runtime dependency, deliberately declared <provided> (non-transitive) + upstream so JWT support stays opt-in. The secured example DOES use JWT validation, so it must pull + this in itself (pinned to the same version juneau-rest-server-auth-jwt itself uses). + --> + <dependency> + <groupId>com.nimbusds</groupId> + <artifactId>nimbus-jose-jwt</artifactId> + <version>${nimbus-jose-jwt.version}</version> + </dependency> + + <!-- Client-side OAuth 2.1 token acquisition (McpTokenProvider, McpProtectedResourceMetadataClient, ...) + backing the secured client demo. --> + <dependency> + <groupId>org.apache.juneau</groupId> + <artifactId>juneau-rest-client-mcp-auth</artifactId> + <version>${project.version}</version> + </dependency> + + <!-- + juneau-rest-client-mcp-auth's sole runtime dependency, also <provided> (non-transitive) upstream for + the same opt-in reason as nimbus-jose-jwt above; the secured client demo exercises the real + client-credentials flow, so this must be declared explicitly too. + --> + <dependency> + <groupId>com.nimbusds</groupId> + <artifactId>oauth2-oidc-sdk</artifactId> + <version>${nimbus-oauth2-oidc-sdk.version}</version> + </dependency> + <!-- Test: JUnit Jupiter, for the in-process end-to-end test. --> <dependency> <groupId>org.junit.jupiter</groupId> diff --git a/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/OfflineAuthorizationServer.java b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/OfflineAuthorizationServer.java new file mode 100644 index 0000000000..644927e28b --- /dev/null +++ b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/OfflineAuthorizationServer.java @@ -0,0 +1,391 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.juneau.examples.mcp.secured; + +import java.io.*; +import java.net.*; +import java.nio.charset.*; +import java.security.*; +import java.time.*; +import java.util.*; +import java.util.concurrent.atomic.*; + +import org.apache.juneau.marshall.marshaller.Json; + +import com.nimbusds.jose.*; +import com.nimbusds.jose.crypto.*; +import com.nimbusds.jose.jwk.*; +import com.nimbusds.jose.jwk.gen.*; +import com.nimbusds.jose.jwk.source.*; +import com.nimbusds.jose.proc.*; +import com.nimbusds.jwt.*; +import com.sun.net.httpserver.*; + +/** + * A minimal, self-contained, in-process stand-in for a real OAuth 2.1 authorization server (AS), so the + * {@code secured} MCP example (and its end-to-end test) run entirely offline — no external IdP, no + * network access, no pre-shared secrets checked into source control. + * + * <p><b>DEMO ONLY — NOT SUITABLE FOR PRODUCTION USE.</b></p> + * + * <p> + * This is deliberately NOT a general-purpose or spec-complete authorization server. It exists purely to make + * {@link SecuredExampleMcpServer}'s {@code JwtTokenValidator}-backed resource-server gate and + * {@link SecuredExampleClient}'s {@code McpTokenProvider}-backed client-credentials flow both have something + * real to talk to. Concretely, on {@link #start()} it: + * + * <ol> + * <li>Generates a fresh RSA-2048 signing key (a new key every run — nothing is persisted, so restarting + * the example invalidates any previously-issued token, which is exactly the offline-demo property we want). + * <li>Publishes only the <b>public</b> half of that key as a {@link JWKSource}, which + * {@link SecuredExampleMcpServer} feeds directly to {@code JwtTokenValidator.jwkSource(...)} — no JWKS + * HTTP endpoint is needed because the server and "AS" share this JVM. + * <li>Starts a tiny {@link HttpServer} exposing two routes: + * <ul> + * <li>{@code POST /token} — the RFC 6749 §4.4 client-credentials grant against one fixed, + * randomly-generated demo client id/secret pair (see {@link #clientId()} / {@link #clientSecret()}). + * This is a REAL HTTP round trip: the client-side {@code OAuthClientCredentialsFlow} used by + * {@code McpTokenProvider} talks to it exactly as it would talk to a production IdP's token + * endpoint, just on {@code localhost}. + * <li>{@code GET /.well-known/oauth-authorization-server} — a minimal RFC 8414 Authorization + * Server Metadata document ({@code issuer} + {@code token_endpoint} only), so a real client can + * perform genuine discovery against {@link #issuerUri()} instead of being handed the token + * endpoint out of band. See {@link SecuredExampleClient} for the client half of that handshake. + * </ul> + * </ol> + * + * <p> + * <b>What this class intentionally does NOT implement</b> (called out here and in the README so nobody mistakes + * this demo for a template for a real deployment): + * <ul> + * <li>No real scope-authorization decision — {@link #handleToken} only checks the requested scope + * against a fixed allowlist ({@code mcp.read}/{@code mcp.write}); it never asks a resource owner or policy + * engine whether the client is ALLOWED that scope, which is what a real AS's consent/policy step does. + * <li>No refresh tokens, no consent screen, no user authentication of any kind — only the + * machine-to-machine client-credentials grant, which is all an MCP server-to-server OAuth demo needs. + * <li>No persistence, replay protection beyond the JWT {@code exp}/{@code nbf} window, or key rotation. The + * RFC 8414 document above is likewise minimal: no {@code jwks_uri}, no {@code scopes_supported}, no + * dynamic client registration — this AS's public key is handed directly to the validator in-process + * (see {@link #jwkSource()}) rather than published for a real JWKS fetch. + * </ul> + * + * <p> + * <b>TLS caveat:</b> this class and {@link SecuredExampleServer} talk plain {@code http} only because both + * ends are loopback-only ({@code 127.0.0.1}/{@code localhost}). OAuth 2.1 / RFC 9728 require {@code https} for + * any non-loopback resource identifier or token endpoint; a bearer token sent over plaintext to a non-loopback + * host is exposed to anyone on the network path. + * + * <p> + * A real deployment would point {@code JwtTokenValidator} at an actual IdP's JWKS URL and issuer, and would + * never generate or hand out client secrets like {@link #start()} does here. + * + * @since 10.0.0 + */ +public final class OfflineAuthorizationServer implements AutoCloseable { + + /** The scope minted onto a token when the client's request omits one. */ + public static final String DEFAULT_SCOPE = "mcp.read"; + + /** + * The fixed allowlist of scopes this offline AS will grant (H2): a client requesting anything outside this + * set is rejected with RFC 6749 §5.2 {@code invalid_scope}, rather than being handed back whatever it + * asked for. + */ + public static final Set<String> GRANTABLE_SCOPES = Set.of("mcp.read", "mcp.write"); + + /** How long a minted access token is valid for. */ + private static final Duration TOKEN_TTL = Duration.ofMinutes(5); + + /** RFC 8414 Authorization Server Metadata well-known path. */ + private static final String WELL_KNOWN_AUTHORIZATION_SERVER = "/.well-known/oauth-authorization-server"; + + private final RSAKey signingKey; + private final String clientId; + private final String clientSecret; + private final HttpServer httpServer; + private final URI issuer; + private final AtomicInteger tokenRequestCount = new AtomicInteger(); + + private OfflineAuthorizationServer(RSAKey signingKey, String clientId, String clientSecret, HttpServer httpServer, URI issuer) { + this.signingKey = signingKey; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.httpServer = httpServer; + this.issuer = issuer; + } + + /** + * Generates a fresh signing key and demo client credentials, and starts the {@code /token} and RFC 8414 + * discovery endpoints on an OS-assigned loopback-only port. + * + * @return A running instance. Close it (or call {@link #close()}) to stop the HTTP endpoint. + * @throws Exception If key generation or the HTTP endpoint fails to start. + */ + public static OfflineAuthorizationServer start() throws Exception { + var signingKey = new RSAKeyGenerator(2048).keyID("demo-key-1").algorithm(JWSAlgorithm.RS256).generate(); + var clientId = "demo-client"; + var clientSecret = randomSecret(); + + var httpServer = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + // H4/M10: the issuer is THIS instance's own loopback base URL, known as soon as the (possibly + // OS-assigned) listen socket is bound - a constructor-supplied instance field, not a shared constant, + // so two independently-started instances naturally have two different issuers (see + // SecuredExampleMcpEndToEnd_Test's wrong-issuer test). + var issuer = URI.create("http://127.0.0.1:" + httpServer.getAddress().getPort()); + var self = new OfflineAuthorizationServer(signingKey, clientId, clientSecret, httpServer, issuer); + httpServer.createContext("/token", self::handleToken); + httpServer.createContext(WELL_KNOWN_AUTHORIZATION_SERVER, self::handleAuthorizationServerMetadata); + httpServer.start(); + return self; + } + + /** Generates a random, never-checked-in demo client secret (32 bytes, URL-safe base64). */ + private static String randomSecret() { + var bytes = new byte[32]; + new SecureRandom().nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + /** + * Returns a {@link JWKSource} publishing only the public half of this server's signing key, ready to hand + * straight to {@code JwtTokenValidator.jwkSource(...)}. + * + * @return The public-key JWK source. Never <jk>null</jk>. + */ + public JWKSource<SecurityContext> jwkSource() { + return new ImmutableJWKSet<>(new JWKSet(signingKey.toPublicJWK())); + } + + /** + * Returns this authorization server's issuer identity (its own loopback base URL) — used as the JWT + * {@code iss} claim, the PRM document's {@code authorization_servers} entry, and the RFC 8414 discovery + * document's {@code issuer} field. + * + * @return The issuer URI. Never <jk>null</jk>. + */ + public URI issuerUri() { + return issuer; + } + + /** + * Returns the loopback URL of the {@code /token} client-credentials endpoint. + * + * @return The token endpoint URL. Never <jk>null</jk>. + */ + public URI tokenEndpoint() { + return URI.create(issuer + "/token"); + } + + /** + * Returns the fixed demo client id a caller must present (via HTTP Basic) to acquire a token. + * + * @return The client id. Never <jk>null</jk>. + */ + public String clientId() { + return clientId; + } + + /** + * Returns the randomly-generated demo client secret a caller must present (via HTTP Basic) to acquire a + * token. Generated fresh on every {@link #start()}; never persisted anywhere — {@link SecuredExampleServer#main} + * prints it to the console purely so a reader running the demo by hand has something to copy/paste. + * + * @return The client secret. Never <jk>null</jk>. + */ + public String clientSecret() { + return clientSecret; + } + + /** + * Returns how many HTTP requests have hit the {@code /token} endpoint so far (M7): a test seam proving a + * caching {@code McpTokenProvider} genuinely reuses an acquired token across multiple dispatches instead of + * silently re-requesting one per call. + * + * @return The number of {@code /token} requests handled since {@link #start()}. + */ + public int tokenRequestCount() { + return tokenRequestCount.get(); + } + + @Override + public void close() { + httpServer.stop(0); + } + + /** + * Implements RFC 6749 §4.4 (client-credentials grant) against the fixed demo client id/secret, + * minting a signed JWT whose {@code aud} claim is exactly the RFC 8707 {@code resource} indicator the + * caller supplied — this is what lets {@code JwtTokenValidator} enforce the audience check on the + * receiving end. + */ + private void handleToken(HttpExchange exchange) throws IOException { + // DEMO ONLY: see class javadoc - not a production AS. + tokenRequestCount.incrementAndGet(); + try { + if (! "POST".equals(exchange.getRequestMethod())) { + exchange.getResponseHeaders().add("Allow", "POST"); + sendJson(exchange, 405, error("invalid_request", "must POST to /token")); + return; + } + if (! authenticateClient(exchange)) { + exchange.getResponseHeaders().add("WWW-Authenticate", "Basic realm=\"offline-authorization-server\""); + sendJson(exchange, 401, error("invalid_client", "unknown client id or secret")); + return; + } + var form = parseForm(exchange.getRequestBody()); + if (! "client_credentials".equals(form.get("grant_type"))) { + sendJson(exchange, 400, error("unsupported_grant_type", "only client_credentials is supported")); + return; + } + var resource = form.get("resource"); + if (resource == null || resource.isBlank()) { + sendJson(exchange, 400, error("invalid_target", "a resource indicator (RFC 8707) is required")); + return; + } + var scope = form.getOrDefault("scope", DEFAULT_SCOPE); + if (! isGrantable(scope)) { + sendJson(exchange, 400, error("invalid_scope", "requested scope must be a subset of " + GRANTABLE_SCOPES)); + return; + } + var token = mintAccessToken(resource, scope, Instant.now(), TOKEN_TTL); + exchange.getResponseHeaders().add("Cache-Control", "no-store"); + exchange.getResponseHeaders().add("Pragma", "no-cache"); + sendJson(exchange, 200, Json.of(Map.<String,Object>of( + "access_token", token, + "token_type", "Bearer", + "expires_in", TOKEN_TTL.toSeconds(), + "scope", scope))); + } catch (RuntimeException | JOSEException e) { + sendJson(exchange, 500, error("server_error", e.getMessage() == null ? "internal error" : e.getMessage())); + } + } + + /** Serves a minimal RFC 8414 Authorization Server Metadata document (M10): {@code issuer} + {@code token_endpoint} only. */ + private void handleAuthorizationServerMetadata(HttpExchange exchange) throws IOException { + if (! "GET".equals(exchange.getRequestMethod())) { + exchange.getResponseHeaders().add("Allow", "GET"); + exchange.sendResponseHeaders(405, -1); + return; + } + sendJson(exchange, 200, Json.of(Map.of( + "issuer", issuer.toString(), + "token_endpoint", tokenEndpoint().toString()))); + } + + /** Returns whether every space-delimited token in {@code scope} is in the {@link #GRANTABLE_SCOPES} allowlist (H2). */ + private static boolean isGrantable(String scope) { + for (var s : scope.split("\\s+")) + if (! s.isBlank() && ! GRANTABLE_SCOPES.contains(s)) + return false; + return true; + } + + /** Validates the RFC 6749 HTTP Basic client-authentication header against the fixed demo credentials. */ + private boolean authenticateClient(HttpExchange exchange) { + // DEMO ONLY: see class javadoc - not a production AS. + var header = exchange.getRequestHeaders().getFirst("Authorization"); + if (header == null || ! header.startsWith("Basic ")) + return false; + byte[] decodedBytes; + try { + decodedBytes = Base64.getDecoder().decode(header.substring("Basic ".length())); + } catch (IllegalArgumentException e) { // L1: malformed base64 -> a clean 401, not a 500 + return false; + } + var decoded = new String(decodedBytes, StandardCharsets.UTF_8); + var sep = decoded.indexOf(':'); + if (sep < 0) + return false; + var presentedId = URLDecoder.decode(decoded.substring(0, sep), StandardCharsets.UTF_8); + var presentedSecret = URLDecoder.decode(decoded.substring(sep + 1), StandardCharsets.UTF_8); + // M6: constant-time comparison (both operands, combined with '&' rather than '&&') so neither the + // id nor the secret check can leak timing information about how many leading bytes matched. + var idMatches = MessageDigest.isEqual(clientId.getBytes(StandardCharsets.UTF_8), presentedId.getBytes(StandardCharsets.UTF_8)); + var secretMatches = MessageDigest.isEqual(clientSecret.getBytes(StandardCharsets.UTF_8), presentedSecret.getBytes(StandardCharsets.UTF_8)); + return idMatches & secretMatches; + } + + /** + * Signs a fresh RS256 access token carrying the mandatory claims {@code JwtTokenValidator} requires. + * + * <p> + * Package-private test seam (H4): exposing {@code issuedAt}/{@code ttl} lets + * {@link SecuredExampleMcpEndToEnd_Test} mint an already-expired token directly, without needing a real + * clock to actually elapse. + * + * @param audience The RFC 8707 {@code resource} indicator to mint the token's {@code aud} claim for. + * @param scope The (space-delimited) {@code scope} claim. + * @param issuedAt The token's {@code iat}/{@code nbf} instant. + * @param ttl How long after {@code issuedAt} the token expires. + * @return The signed, serialized JWT. + * @throws JOSEException If signing fails. + */ + String mintAccessToken(String audience, String scope, Instant issuedAt, Duration ttl) throws JOSEException { + var claims = new JWTClaimsSet.Builder() + // M4: a client-credentials grant has no resource owner - the subject IS the client, not a + // placeholder end user. + .subject(clientId) + .issuer(issuer.toString()) + .audience(audience) + .claim("scope", scope) + .issueTime(Date.from(issuedAt)) + .notBeforeTime(Date.from(issuedAt)) + .expirationTime(Date.from(issuedAt.plus(ttl))) + .build(); + var header = new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(signingKey.getKeyID()).build(); + var jwt = new SignedJWT(header, claims); + jwt.sign(new RSASSASigner(signingKey)); + return jwt.serialize(); + } + + /** Parses an {@code application/x-www-form-urlencoded} request body into a key/value map. */ + private static Map<String,String> parseForm(InputStream body) throws IOException { + var raw = new String(body.readAllBytes(), StandardCharsets.UTF_8); + var out = new LinkedHashMap<String,String>(); + for (var pair : raw.split("&")) { + if (pair.isEmpty()) + continue; + var eq = pair.indexOf('='); + var key = eq < 0 ? pair : pair.substring(0, eq); + var value = eq < 0 ? "" : pair.substring(eq + 1); + out.put(URLDecoder.decode(key, StandardCharsets.UTF_8), URLDecoder.decode(value, StandardCharsets.UTF_8)); + } + return out; + } + + /** + * Builds an RFC 6749 §5.2 JSON error body. + * + * <p> + * M2/M3: built as a {@link Map} and serialized via {@link Json#of(Object)} (the same marshaller + * {@link SecuredExampleClient} already uses) instead of hand-concatenating strings, so a client-controlled + * {@code error_description} containing a quote or backslash can no longer reshape the JSON structure or + * produce invalid JSON. + */ + private static String error(String code, String description) { + return Json.of(Map.of("error", code, "error_description", description)); + } + + private static void sendJson(HttpExchange exchange, int status, String json) throws IOException { + var bytes = json.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length); + try (var os = exchange.getResponseBody()) { + os.write(bytes); + } + } +} diff --git a/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/SecuredExampleClient.java b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/SecuredExampleClient.java new file mode 100644 index 0000000000..ad94d50023 --- /dev/null +++ b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/SecuredExampleClient.java @@ -0,0 +1,277 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.juneau.examples.mcp.secured; + +import java.io.*; +import java.net.*; +import java.net.http.*; +import java.util.*; + +import org.apache.juneau.bean.jsonrpc.*; +import org.apache.juneau.bean.mcp.v20260728.*; +import org.apache.juneau.examples.mcp.*; +import org.apache.juneau.marshall.marshaller.Json; +import org.apache.juneau.rest.client.mcp.auth.*; +import org.apache.juneau.rest.client.mcp.v20260728.*; + +/** + * A guided walkthrough of calling {@link SecuredExampleMcpServer}, showing the secured request/response + * cycle a newcomer to MCP OAuth 2.1 needs to see: a rejected unauthenticated call (twice — once at the + * raw wire level, once through the real {@link McpClient} SDK), the full RFC 9728 → RFC 8414 discovery + * chain, a successful read-only call, a scoped step-up rejection, and finally a successful write once a token + * carrying the write scope has been acquired. + * + * <p> + * Run {@link #main(String[]) main} with three arguments — the secured server's endpoint and the demo + * client id/secret — all three of which {@link SecuredExampleServer#main(String[])} prints on startup + * (they cannot be hardcoded here: the demo client secret is freshly randomly generated on every server run, by + * design; see {@link OfflineAuthorizationServer}). Unlike an earlier version of this walkthrough, the token + * endpoint is no longer a fourth argument (M10): this client discovers it itself, the same way a real client + * would. + * + * @serial exclude + */ +public final class SecuredExampleClient { + + private SecuredExampleClient() {} + + /** + * Runs the walkthrough against a running {@link SecuredExampleServer}. + * + * @param args Three required arguments: endpoint, client id, client secret — copy these from + * {@link SecuredExampleServer#main(String[])}'s startup banner. + * @throws Exception If any step fails unexpectedly (a REJECTED call is expected and handled, not thrown). + */ + public static void main(String[] args) throws Exception { + if (args.length < 3) { + System.out.println("Usage: SecuredExampleClient <endpoint> <clientId> <clientSecret>"); + System.out.println("Copy these three values from the SecuredExampleServer startup banner."); + return; + } + run(args[0], args[1], args[2]); + } + + /** + * Executes each numbered step of the walkthrough. + * + * @param endpoint The secured MCP server's endpoint URL. + * @param clientId The demo OAuth client id. + * @param clientSecret The demo OAuth client secret. + * @throws Exception If an unexpected (non-auth-related) failure occurs. + */ + public static void run(String endpoint, String clientId, String clientSecret) throws Exception { + + section("1. Unauthenticated call, at the raw wire level — a 401 challenge"); + var challenge = rawUnauthenticatedCall(endpoint); + + section("2. The same unauthenticated call through the real McpClient SDK"); + mcpClientUnauthenticatedCall(endpoint); + + section("3. RFC 9728 discovery — fetch the Protected Resource Metadata the 401 pointed at"); + McpProtectedResourceMetadata prm = null; + if (challenge != null && challenge.resourceMetadata().isPresent()) + prm = discoverProtectedResourceMetadata(endpoint, challenge.resourceMetadata().get()); + else + System.out.println(" (no resource_metadata pointer found on the challenge; skipping)"); + + section("4. RFC 8414 discovery — resolve the authorization server's token endpoint"); + var tokenEndpoint = discoverTokenEndpoint(prm); + + section("5. Acquire a read-only (mcp.read) bearer token and call again — success"); + readOnlyCall(endpoint, tokenEndpoint, clientId, clientSecret); + + section("6. The SAME read-only token attempting a write — a scoped 403 step-up challenge"); + insufficientScopeCall(endpoint, tokenEndpoint, clientId, clientSecret); + + section("7. Step up to mcp.read + mcp.write and publish a note — success"); + writeCall(endpoint, tokenEndpoint, clientId, clientSecret); + + System.out.println("\nWalkthrough complete."); + } + + /** + * Step 1: a raw HTTP POST with no {@code Authorization} header at all, bypassing the {@link McpClient} SDK + * entirely so the actual {@code 401} status and {@code WWW-Authenticate} response header are directly + * visible. This is necessary because the resource-server gate's {@code 401}/{@code 403} rejections are + * plain-text bodies, not JSON-RPC envelopes — {@code AbstractMcpClient.send(...)} cannot parse one into a + * typed result and instead surfaces the failure as a bare {@link IOException} whose message embeds the + * status code as text. There is no way to read the status code, let alone the challenge header, through + * the SDK itself; only at the wire level. + */ + private static WwwAuthenticateChallenge rawUnauthenticatedCall(String endpoint) throws IOException, InterruptedException { + var request = HttpRequest.newBuilder(URI.create(endpoint)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("Mcp-Method", "server/discover") + .header("Mcp-Name", "") + .POST(HttpRequest.BodyPublishers.ofString(discoverRequestBody())) + .build(); + var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + System.out.println(" HTTP status: " + response.statusCode()); + var header = response.headers().firstValue("WWW-Authenticate").orElse(null); + System.out.println(" WWW-Authenticate: " + header); + var challenge = WwwAuthenticateChallenge.parse(header).orElse(null); + if (challenge != null) { + System.out.println(" parsed scope hint: " + challenge.scopes()); + System.out.println(" parsed resource_metadata: " + challenge.resourceMetadata().orElse(null)); + } + return challenge; + } + + /** + * Step 2: the same unauthenticated call, but through {@link McpClient#connect(String)} — the ergonomic + * failure mode a real caller actually sees: a thrown {@link IOException} (see {@link #rawUnauthenticatedCall} + * above for why it is a bare {@link IOException} and not a typed exception) out of the mandatory + * {@code server/discover} handshake. + */ + private static void mcpClientUnauthenticatedCall(String endpoint) { + try (var ignored = McpClient.connect(endpoint)) { + throw new IllegalStateException("expected the unauthenticated connect() to fail, but it succeeded"); + } catch (IOException e) { + System.out.println(" rejected as expected: " + e.getMessage()); + } + } + + /** + * Step 3: fetches the RFC 9728 Protected Resource Metadata document the challenge pointed at, showing what + * a client learns from it — notably WHICH authorization server to acquire a token from, discovered from + * the response rather than hardcoded into this client. + */ + private static McpProtectedResourceMetadata discoverProtectedResourceMetadata(String endpoint, URI resourceMetadataUrl) { + var prm = McpProtectedResourceMetadataClient.create() + .expectedResource(URI.create(endpoint)) + .build() + .fetch(resourceMetadataUrl); + System.out.println(" resource: " + prm.resource()); + System.out.println(" authorization_servers: " + prm.authorizationServers()); + System.out.println(" scopes_supported: " + prm.scopesSupported()); + return prm; + } + + /** + * Step 4 (M10): performs real RFC 8414 discovery against the authorization server the PRM document + * advertised, via {@link McpProtectedResourceMetadataClient#discoverAuthorizationServer}, and returns the + * discovered {@code token_endpoint} — nothing here is hardcoded or passed in on the command line. This + * completes the full 401 → {@code resource_metadata} → PRM → {@code authorization_servers} + * → RFC 8414 → {@code token_endpoint} chain a compliant client walks end-to-end. + */ + private static URI discoverTokenEndpoint(McpProtectedResourceMetadata prm) { + if (prm == null) + throw new IllegalStateException("no Protected Resource Metadata was discovered in step 3"); + var as = McpProtectedResourceMetadataClient.create().build().discoverAuthorizationServer(prm); + System.out.println(" issuer: " + as.issuer()); + System.out.println(" token_endpoint: " + as.tokenEndpoint()); + return as.tokenEndpoint(); + } + + /** + * Step 5: acquires a real bearer token scoped to {@code mcp.read} only via + * {@link McpTokenProvider#clientCredentials()} (a genuine RFC 6749 §4.4 HTTP round trip to the + * discovered token endpoint), wires it into a fresh {@link McpClient} via + * {@link McpTokenProvider#interceptor()}, and shows a read succeeding transparently. + */ + private static void readOnlyCall(String endpoint, URI tokenEndpoint, String clientId, String clientSecret) throws IOException { + var tokens = readOnlyTokenProvider(endpoint, tokenEndpoint, clientId, clientSecret); + try (var client = McpClient.connect(McpClient.builder() + .endpoint(endpoint) + .clientInfo(new Implementation().setName("juneau-secured-notes-example-client").setVersion("1.0.0")) + .interceptor(tokens.interceptor()))) { + System.out.println(" server/discover succeeded: " + Json.of(client.discoveredServer().getServerInfo())); + var read = client.readResource(NoteStore.SCHEME + "index"); + System.out.println(" " + Json.of(read.getContents())); + } + } + + /** + * Step 6 (H3): the SAME read-only-scoped token from step 5's flavor, now attempting {@code publishNote} — + * a mutating tool that {@link SecuredExampleMcpServer} step-up-gates behind {@code mcp.write}. Demonstrates + * that holding a merely-valid, correctly-scoped-for-reads token is deliberately NOT enough: the call is + * rejected with a {@code 403} naming the missing {@code mcp.write} scope, not silently allowed. As with + * step 1/2 above, the 403's plain-text body surfaces as a bare {@link IOException}, not a typed exception. + */ + private static void insufficientScopeCall(String endpoint, URI tokenEndpoint, String clientId, String clientSecret) throws IOException { + var tokens = readOnlyTokenProvider(endpoint, tokenEndpoint, clientId, clientSecret); + try (var client = McpClient.connect(McpClient.builder() + .endpoint(endpoint) + .clientInfo(new Implementation().setName("juneau-secured-notes-example-client").setVersion("1.0.0")) + .interceptor(tokens.interceptor()))) { + client.callToolText("publishNote", Map.of("title", "should-fail", "body", "should never be stored")); + throw new IllegalStateException("expected the write with a read-only token to fail, but it succeeded"); + } catch (IOException e) { + System.out.println(" rejected as expected: " + e.getMessage()); + } + } + + /** + * Step 7: acquires a SECOND token, this time carrying both {@code mcp.read} and {@code mcp.write}, and + * shows the previously-rejected {@code publishNote} call (and a confirming read) now succeeding. + */ + private static void writeCall(String endpoint, URI tokenEndpoint, String clientId, String clientSecret) throws IOException { + var tokens = McpTokenProvider.clientCredentials() + .tokenEndpoint(tokenEndpoint) + .clientId(clientId) + .clientSecret(clientSecret) + .resource(URI.create(endpoint)) + .scope(SecuredExampleMcpServer.READ_SCOPE, SecuredExampleMcpServer.WRITE_SCOPE) + .build(); + + try (var client = McpClient.connect(McpClient.builder() + .endpoint(endpoint) + .clientInfo(new Implementation().setName("juneau-secured-notes-example-client").setVersion("1.0.0")) + .interceptor(tokens.interceptor()))) { + System.out.println(" -> " + client.callToolText("publishNote", + Map.of("title", "secured", "body", "Hello from behind OAuth 2.1"))); + + var read = client.readResource(NoteStore.uriFor("secured")); + System.out.println(" " + Json.of(read.getContents())); + } + } + + /** Builds a token provider scoped to {@link SecuredExampleMcpServer#READ_SCOPE} only. */ + private static McpTokenProvider readOnlyTokenProvider(String endpoint, URI tokenEndpoint, String clientId, String clientSecret) { + return McpTokenProvider.clientCredentials() + .tokenEndpoint(tokenEndpoint) + .clientId(clientId) + .clientSecret(clientSecret) + .resource(URI.create(endpoint)) + .scope(SecuredExampleMcpServer.READ_SCOPE) + .build(); + } + + /** + * Builds a plausible (but not necessarily complete) {@code server/discover} JSON-RPC request body using + * the real wire beans, purely so step 1's raw HTTP call looks like a genuine MCP request on the wire. The + * resource-server bearer gate rejects it before the body is ever parsed, so its exact contents do not + * affect the outcome — but a reader should see real MCP traffic here, not an arbitrary placeholder. + */ + private static String discoverRequestBody() { + var params = new RequestParamsOnly().setMeta(new RequestMeta() + .setProtocolVersion(McpProtocol.VERSION_2026_07_28) + .setClientInfo(new Implementation().setName("juneau-secured-notes-example-client").setVersion("1.0.0")) + .setClientCapabilities(new ClientCapabilities())); + var req = new JsonRpcRequest() + .setJsonrpc(McpProtocol.JSON_RPC_2_0) + .setId(1) + .setMethod("server/discover") + .setParams(params); + return Json.of(req); + } + + private static void section(String title) { + System.out.println("\n=== " + title + " ==="); + } +} diff --git a/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/SecuredExampleMcpServer.java b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/SecuredExampleMcpServer.java new file mode 100644 index 0000000000..a46fb49b97 --- /dev/null +++ b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/SecuredExampleMcpServer.java @@ -0,0 +1,150 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.juneau.examples.mcp.secured; + +import java.net.*; + +import org.apache.juneau.examples.mcp.*; +import org.apache.juneau.rest.server.auth.jwt.*; +import org.apache.juneau.rest.server.mcp.v20260728.*; + +/** + * The OAuth 2.1-secured variant of {@link ExampleMcpServer}: identical notes-service surface (tools, prompt, + * resources, resource template, subscriptions), but every {@code POST /} JSON-RPC call now requires a valid + * bearer token. + * + * <p> + * This class changes nothing about <i>what</i> the server exposes — it inherits + * {@link ExampleMcpServer#createMcpConfig()} unmodified, so the same {@code publishNote}/{@code deleteNote} + * tools, {@code summarize} prompt, and {@code note:///...} resources are still there. The only override is + * {@link #createMcpOptions()}, which layers an {@code McpResourceServerConfig} on top of the parent's + * capabilities. That single override is the entire security story: + * + * <ul> + * <li><b>{@code setEnabled(true)}</b> — turns on the RS (resource-server) gate at all. Off by default, + * so an ordinary {@link ExampleMcpServer} is completely unaffected by this class existing. + * <li><b>{@code setResource(...)}</b> — this server's own canonical URL (RFC 9728 {@code resource} / + * RFC 8707 default audience). A bearer token's {@code aud} claim must include this exact URL or it is + * rejected — the confused-deputy defense: a token minted for some OTHER resource cannot be replayed + * here even if it is otherwise perfectly valid and signed by a trusted issuer. + * <li><b>{@code setTokenValidator(...)}</b> — a {@link JwtTokenValidator} that checks the token's + * signature (against {@link OfflineAuthorizationServer#jwkSource()}, offline — no JWKS HTTP fetch), + * {@code iss} (must equal {@link OfflineAuthorizationServer#issuerUri()}), and {@code aud}/{@code exp}/ + * {@code nbf} (validated against the resource URL above and wall-clock time). + * <li><b>{@code addAuthorizationServer(...)}</b> — advertised in the RFC 9728 Protected Resource + * Metadata (PRM) document a client fetches from the well-known {@code .well-known/oauth-protected-resource} + * path this framework serves automatically once RS auth is enabled. This is how a compliant client + * discovers WHICH authorization server to go get a token from, without that URL being hardcoded into the + * client at all. + * <li><b>{@code addRequiredScope(...)}</b> — the coarse, endpoint-wide baseline scope ({@link #READ_SCOPE}) + * every request must carry (on top of a merely valid, correctly-audienced token) before any JSON-RPC + * method dispatches. A token missing it gets a {@code 403} with a {@code WWW-Authenticate} challenge + * naming the missing scope, not a silent failure. + * <li><b>{@code addOperationScope(...)}</b> — SEP-2350 per-operation step-up (H3): {@code publishNote} + * and {@code deleteNote} additionally require {@link #WRITE_SCOPE} on top of the baseline. A token + * carrying only {@link #READ_SCOPE} can discover the server and read resources, but a mutating tool call + * gets its own scoped {@code 403 insufficient_scope} naming {@link #WRITE_SCOPE} specifically — the + * baseline scope alone is never enough to invoke either write tool. + * </ul> + * + * <p> + * The net effect on the wire: an unauthenticated (or wrongly-audienced, or expired, or insufficiently-scoped) + * {@code POST /} now gets {@code 401}/{@code 403} plus a {@code WWW-Authenticate: Bearer ...} challenge + * instead of ever reaching {@link ExampleMcpServer}'s tool/prompt/resource handlers; a request bearing a valid + * bearer token dispatches exactly as it always did. + * + * <h5 class='section'>The one wrinkle this class works around:</h5> + * <p> + * {@link #getResource() The resource URL} is this server's OWN address, which is a problem when + * {@link SecuredExampleServer} boots on an OS-assigned ephemeral port (as the end-to-end test does): naively, + * that address is not knowable until AFTER Jetty has bound its socket. It is tempting to assume + * {@link #createMcpOptions()} (like {@code createMcpConfig()}) is called lazily on the first incoming MCP + * request, late enough to bind the resource URL once the port is known — but it is NOT: the REST + * framework eagerly walks every {@code @Bean}-annotated method declared on the resource (including this + * inherited {@code getMcpOptions()}) while building the servlet's {@code RestContext}, i.e. during Jetty's + * OWN servlet initialization, strictly before {@code Server.start()} returns. A constructor-supplied resource + * URL is therefore required; see {@link SecuredExampleServer#start(int)} for how it opens the Jetty connector + * (and thus learns the real ephemeral port) before this servlet is even constructed. + * + * @serial exclude + */ +public class SecuredExampleMcpServer extends ExampleMcpServer { + + private static final long serialVersionUID = 1L; + + /** + * The baseline OAuth scope every request to this server must carry, on top of a valid, correctly-audienced + * token. Advertised in the PRM document's {@code scopes_supported} and required via + * {@code McpResourceServerConfig.addRequiredScope(...)}. + */ + public static final String READ_SCOPE = "mcp.read"; + + /** + * The additional step-up scope {@code publishNote}/{@code deleteNote} require on top of {@link #READ_SCOPE} + * (H3, SEP-2350 per-operation scoping), via {@code McpResourceServerConfig.addOperationScope(...)}. + */ + public static final String WRITE_SCOPE = "mcp.write"; + + private final transient OfflineAuthorizationServer authServer; + private final transient URI resource; + + /** + * Constructor. + * + * @param authServer The offline authorization server this instance validates bearer tokens against + * (its {@link OfflineAuthorizationServer#jwkSource() public key} and + * {@link OfflineAuthorizationServer#issuerUri() issuer}). Must not be <jk>null</jk>. + * @param resource This server's own canonical root URL (e.g. {@code http://localhost:5001/}), known and + * fixed before construction — see the class javadoc's "one wrinkle" section for why this cannot be + * supplied later. Must not be <jk>null</jk>. + */ + public SecuredExampleMcpServer(OfflineAuthorizationServer authServer, URI resource) { + if (authServer == null) + throw new IllegalArgumentException("authServer must not be null"); + if (resource == null) + throw new IllegalArgumentException("resource must not be null"); + this.authServer = authServer; + this.resource = resource; + } + + /** + * Returns this server's own canonical resource URL, as supplied to the constructor. + * + * @return The resource URL. Never <jk>null</jk>. + */ + public URI getResource() { + return resource; + } + + @Override + protected McpOptions createMcpOptions() { + var validator = JwtTokenValidator.create() + .jwkSource(authServer.jwkSource()) + .issuer(authServer.issuerUri().toString()) + .audience(resource.toString()) + .build(); + return super.createMcpOptions().resourceServer(rs -> rs + .setEnabled(true) + .setResource(resource) + .setTokenValidator(validator) + .addAuthorizationServer(authServer.issuerUri()) + .addRequiredScope(READ_SCOPE) + // H3: publishNote/deleteNote step up to WRITE_SCOPE on top of the READ_SCOPE baseline above. + .addOperationScope("tools/call", "publishNote", WRITE_SCOPE) + .addOperationScope("tools/call", "deleteNote", WRITE_SCOPE)); + } +} diff --git a/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/SecuredExampleServer.java b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/SecuredExampleServer.java new file mode 100644 index 0000000000..113ebecb4f --- /dev/null +++ b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/SecuredExampleServer.java @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.juneau.examples.mcp.secured; + +import java.net.*; + +import org.apache.juneau.commons.inject.*; +import org.apache.juneau.microservice.*; +import org.apache.juneau.microservice.jetty.*; +import org.eclipse.jetty.ee11.servlet.*; +import org.eclipse.jetty.server.*; + +import jakarta.servlet.*; + +/** + * Boots {@link SecuredExampleMcpServer} on an embedded Jetty server, alongside an in-process + * {@link OfflineAuthorizationServer} that stands in for a real OAuth 2.1 authorization server. + * + * <p> + * Mirrors {@link org.apache.juneau.examples.mcp.ExampleServer} (read that class first — this one only + * documents what is different). Run {@link #main(String[]) main} to start both servers on fixed ports and + * print everything a {@link SecuredExampleClient} run needs to copy: the endpoint, the offline authorization + * server's token endpoint, and the randomly-generated demo client id/secret. The in-process end-to-end test + * uses {@link #start(int) start(0)} to boot on an OS-assigned ephemeral port instead. + * + * <p> + * <b>The resource URL is resolved before the secured servlet is even constructed.</b> {@link SecuredExampleMcpServer} + * needs its own address up front (see its class javadoc), which is awkward on an OS-assigned ephemeral port + * because normally nothing knows that port until Jetty finishes starting. The fix: {@link ServerConnector#open()} + * can be, and by Jetty's own convention normally is (see {@code Server.doStart()}'s "open network connector to + * ensure ports are available" step), called to bind the listening socket BEFORE the rest of the server starts. + * Calling it ourselves, one step earlier than usual, lets us read {@link ServerConnector#getLocalPort()} + * immediately — before building {@link SecuredExampleMcpServer} or starting the {@link Microservice} at + * all. Jetty's own {@code Server.start()} then finds the connector already open and skips rebinding it. + */ +public final class SecuredExampleServer implements AutoCloseable { + + /** Default listen port used by {@link #main(String[])} when none is supplied. */ + public static final int DEFAULT_PORT = 5001; + + private final Microservice microservice; + private final OfflineAuthorizationServer authServer; + private final URI rootUrl; + + private SecuredExampleServer(Microservice microservice, OfflineAuthorizationServer authServer, URI rootUrl) { + this.microservice = microservice; + this.authServer = authServer; + this.rootUrl = rootUrl; + } + + /** + * Starts the secured example MCP server and its offline authorization server. + * + * @param port The TCP port the MCP server listens on, or {@code 0} to let the OS assign an ephemeral port. + * (The offline authorization server always uses its own separate OS-assigned ephemeral port.) + * @return A running server handle. Close it (or call {@link #close()}) to stop both servers. + * @throws Exception If either server fails to start. + */ + @SuppressWarnings("resource") // The bean store is handed to (and closed by) the Microservice lifecycle. + public static SecuredExampleServer start(int port) throws Exception { + var authServer = OfflineAuthorizationServer.start(); + // M9: hoisted above the try so a failure AFTER connector.open() (which has already bound a real OS + // socket) can still close it in the catch below - Server.stop() alone will not close a connector that + // was opened but never handed to a started Server. + ServerConnector connector = null; + try { + var jetty = new Server(); + connector = new ServerConnector(jetty); + connector.setPort(port); + jetty.addConnector(connector); + // Bind the (possibly ephemeral) listen socket now, before the servlet context (and therefore + // SecuredExampleMcpServer's RestContext) is built, so the resource URL below is real and final + // by the time anything asks the servlet for it. See the class javadoc for why that ordering + // matters here specifically. + connector.open(); + var rootUrl = URI.create("http://localhost:" + connector.getLocalPort() + "/"); + + var handler = new ServletContextHandler(); + handler.setContextPath("/"); + jetty.setAttribute("ServletContextHandler", handler); + jetty.setHandler(handler); + + var securedServlet = new SecuredExampleMcpServer(authServer, rootUrl); + + var beanStore = new BasicBeanStore(); + beanStore.addBean(Server.class, jetty); + beanStore.addBean(Servlet.class, securedServlet); + + var microservice = Microservice.create() + .beanStore(beanStore) + .configurations(JettyConfiguration.class) + .consoleEnabled(false) + .build(); + microservice.start(); + + return new SecuredExampleServer(microservice, authServer, rootUrl); + } catch (Exception e) { + if (connector != null) + connector.close(); + authServer.close(); + throw e; + } + } + + /** + * Returns the root URL the secured MCP server is listening on (e.g. {@code http://localhost:5001/}). + * + * @return The root URL. Never <jk>null</jk>. + */ + public URI getRootUrl() { + return rootUrl; + } + + /** + * Returns the offline authorization server backing this instance, so a caller (e.g. the end-to-end test, + * or {@link #main(String[])} printing a startup banner) can read its token endpoint and demo credentials. + * + * @return The offline authorization server. Never <jk>null</jk>. + */ + public OfflineAuthorizationServer getAuthServer() { + return authServer; + } + + @Override + public void close() throws Exception { + try { + microservice.stop(); + } finally { + authServer.close(); + } + } + + /** + * Runs both servers until the process is killed, printing everything {@link SecuredExampleClient} needs. + * + * <p> + * M10: the token endpoint is no longer printed here — {@link SecuredExampleClient} now discovers it + * itself via RFC 9728 PRM + RFC 8414 discovery, exactly as a real client would, so only the endpoint and + * demo client credentials need to be copied. + * + * @param args Optional single argument: the port the MCP server listens on (defaults to {@link #DEFAULT_PORT}). + * @throws Exception If either server fails to start. + */ + public static void main(String[] args) throws Exception { + var port = args.length > 0 ? Integer.parseInt(args[0]) : DEFAULT_PORT; + var server = start(port); + var auth = server.getAuthServer(); + System.out.println("Juneau SECURED MCP example server is listening at " + server.getRootUrl()); + System.out.println("Demo client id: " + auth.clientId()); + System.out.println("Demo client secret: " + auth.clientSecret()); + System.out.println(); + System.out.println("Drive it with: SecuredExampleClient " + server.getRootUrl() + " " + + auth.clientId() + " " + auth.clientSecret()); + System.out.println("Press Ctrl-C to stop."); + Thread.currentThread().join(); + } +} diff --git a/juneau-examples/juneau-examples-mcp/src/test/java/org/apache/juneau/examples/mcp/secured/SecuredExampleMcpEndToEnd_Test.java b/juneau-examples/juneau-examples-mcp/src/test/java/org/apache/juneau/examples/mcp/secured/SecuredExampleMcpEndToEnd_Test.java new file mode 100644 index 0000000000..e0311ba874 --- /dev/null +++ b/juneau-examples/juneau-examples-mcp/src/test/java/org/apache/juneau/examples/mcp/secured/SecuredExampleMcpEndToEnd_Test.java @@ -0,0 +1,380 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.juneau.examples.mcp.secured; + +import static org.apache.juneau.test.bct.BctAssertions.*; +import static org.junit.jupiter.api.Assertions.*; + +import java.net.*; +import java.net.http.*; +import java.time.*; +import java.util.*; + +import org.apache.juneau.TestBase; +import org.apache.juneau.bean.jsonrpc.*; +import org.apache.juneau.bean.mcp.v20260728.*; +import org.apache.juneau.examples.mcp.*; +import org.apache.juneau.marshall.marshaller.Json; +import org.apache.juneau.rest.client.mcp.auth.*; +import org.apache.juneau.rest.client.mcp.v20260728.*; +import org.junit.jupiter.api.*; + +import com.nimbusds.jwt.*; + +/** + * Proves the OAuth 2.1-secured variant actually works: boots {@link SecuredExampleServer} (which brings up + * its own in-process {@link OfflineAuthorizationServer}) on an ephemeral port and drives both a raw + * {@link HttpClient} (to inspect the exact {@code 401}/{@code 403}/{@code WWW-Authenticate} wire behavior) and + * the real {@link McpClient} SDK (to prove a genuinely-acquired bearer token round-trips a tool call + * end-to-end) against it. + * + * <p> + * Follows the same fixture shape as {@code ExampleMcpEndToEnd_Test} (share one server/AS pair across the + * class; each test that mutates note state uses its own distinctly-titled note) and the same assertion style + * as {@code McpResourceServerBinding_Test} (challenge header parsing via {@link WwwAuthenticateChallenge}). + * + * <p> + * Covers, in order: rejected calls (missing/garbage/wrong-audience/expired/wrong-issuer/{@code alg=none} + * tokens, section {@code a}), RFC 9728 discovery (section {@code b}), H3's per-operation + * {@code mcp.write} step-up (section {@code c}), a successfully-dispatched call plus the M7 token-caching + * proof (section {@code d}), and a full {@link SecuredExampleClient#run} walkthrough (section {@code e}). + */ +class SecuredExampleMcpEndToEnd_Test extends TestBase { + + private static SecuredExampleServer server; + private static HttpClient http; + + @BeforeAll + static void setUp() throws Exception { + server = SecuredExampleServer.start(0); + http = HttpClient.newHttpClient(); + } + + @AfterAll + static void tearDown() throws Exception { + if (server != null) + server.close(); + } + + /** A request builder pre-populated with the headers a real v2 MCP JSON-RPC POST requires. */ + private static HttpRequest.Builder discoverRequest() { + return HttpRequest.newBuilder(server.getRootUrl()) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("Mcp-Method", "server/discover") + .header("Mcp-Name", ""); + } + + /** A request builder for a {@code tools/call} POST invoking {@code toolName}. */ + private static HttpRequest.Builder toolsCallRequest(String toolName) { + return HttpRequest.newBuilder(server.getRootUrl()) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("Mcp-Method", "tools/call") + .header("Mcp-Name", toolName); + } + + /** A real {@code tools/call} JSON-RPC request body for {@code toolName}, using the actual wire beans. */ + private static String toolsCallBody(String toolName, Map<String,Object> arguments) { + var params = new CallToolRequest() + .setName(toolName) + .setArguments(arguments) + .setMeta(new RequestMeta() + .setProtocolVersion(McpProtocol.VERSION_2026_07_28) + .setClientInfo(new Implementation().setName("test-client").setVersion("1.0.0")) + .setClientCapabilities(new ClientCapabilities())); + var req = new JsonRpcRequest() + .setJsonrpc(McpProtocol.JSON_RPC_2_0) + .setId(1) + .setMethod("tools/call") + .setParams(params); + return Json.of(req); + } + + private static WwwAuthenticateChallenge challenge(HttpResponse<?> response) { + return WwwAuthenticateChallenge.parse(response.headers().firstValue("WWW-Authenticate").orElse(null)) + .orElseThrow(() -> new AssertionError("no WWW-Authenticate header on response: " + response)); + } + + // -------- a: rejected - no token / garbage / wrong-audience / expired / wrong-issuer / alg=none -------- + + @Test + void a01_missingToken_401WithBearerChallengeAndScopeHint() throws Exception { + var response = http.send(discoverRequest().POST(HttpRequest.BodyPublishers.ofString("{}")).build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(401, response.statusCode()); + var c = challenge(response); + assertTrue(c.isBearer()); + assertEquals(Set.of(SecuredExampleMcpServer.READ_SCOPE), c.scopes()); + // N3 (RFC 6750 §3): no credentials were presented at all, so the challenge must carry no error code - + // that is reserved for a credential that WAS presented but rejected (a02/a03 below). + assertTrue(c.error().isEmpty(), "a credential-less challenge must not carry an error code"); + // N2: assert the actual resource_metadata value, not merely that one is present. + assertEquals(Optional.of(server.getRootUrl().resolve(".well-known/oauth-protected-resource")), c.resourceMetadata()); + } + + @Test + void a02_garbageToken_401WithInvalidTokenError() throws Exception { + var response = http.send(discoverRequest().header("Authorization", "Bearer not-a-real-jwt") + .POST(HttpRequest.BodyPublishers.ofString("{}")).build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(401, response.statusCode()); + assertEquals(Optional.of("invalid_token"), challenge(response).error()); + } + + @Test + void a03_wrongAudienceToken_401WithInvalidTokenError() throws Exception { + // A token minted for a DIFFERENT resource must be rejected even though it is otherwise perfectly + // valid and correctly signed by the trusted offline authorization server - RFC 8707 audience + // enforcement (the confused-deputy defense). + var auth = server.getAuthServer(); + var wrongAudienceToken = McpTokenProvider.clientCredentials() + .tokenEndpoint(auth.tokenEndpoint()) + .clientId(auth.clientId()) + .clientSecret(auth.clientSecret()) + .resource(URI.create("http://localhost:1/a-different-resource")) + .build() + .get(); + var response = http.send(discoverRequest().header("Authorization", "Bearer " + wrongAudienceToken) + .POST(HttpRequest.BodyPublishers.ofString("{}")).build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(401, response.statusCode()); + assertEquals(Optional.of("invalid_token"), challenge(response).error()); + } + + @Test + void a04_expiredToken_401WithInvalidTokenError() throws Exception { + // H4 test seam: mintAccessToken(...) lets us mint an already-expired token directly, without a real + // clock needing to actually elapse five minutes. + var auth = server.getAuthServer(); + var expiredToken = auth.mintAccessToken(server.getRootUrl().toString(), SecuredExampleMcpServer.READ_SCOPE, + Instant.now().minus(Duration.ofHours(1)), Duration.ofMinutes(5)); + var response = http.send(discoverRequest().header("Authorization", "Bearer " + expiredToken) + .POST(HttpRequest.BodyPublishers.ofString("{}")).build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(401, response.statusCode()); + assertEquals(Optional.of("invalid_token"), challenge(response).error()); + } + + @Test + void a05_wrongIssuerToken_401WithInvalidTokenError() throws Exception { + // H4: the issuer is now a constructor-supplied instance field (not a shared static constant), so a + // second, independently-started offline AS naturally has both a different issuer AND a different + // signing key from the one this server's JwtTokenValidator trusts - a token it mints must be rejected + // exactly like any other untrusted issuer's token. + try (var otherAuth = OfflineAuthorizationServer.start()) { + var wrongIssuerToken = McpTokenProvider.clientCredentials() + .tokenEndpoint(otherAuth.tokenEndpoint()) + .clientId(otherAuth.clientId()) + .clientSecret(otherAuth.clientSecret()) + .resource(server.getRootUrl()) + .scope(SecuredExampleMcpServer.READ_SCOPE) + .build() + .get(); + var response = http.send(discoverRequest().header("Authorization", "Bearer " + wrongIssuerToken) + .POST(HttpRequest.BodyPublishers.ofString("{}")).build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(401, response.statusCode()); + assertEquals(Optional.of("invalid_token"), challenge(response).error()); + } + } + + @Test + void a06_algNoneToken_401WithInvalidTokenError() throws Exception { + // N5 (teaching artifact): JwtTokenValidator explicitly rejects unsigned/alg=none JWTs outright (it + // never reaches signature verification), regardless of how plausible the claims otherwise look. + var auth = server.getAuthServer(); + var claims = new JWTClaimsSet.Builder() + .subject(auth.clientId()) + .issuer(auth.issuerUri().toString()) + .audience(server.getRootUrl().toString()) + .claim("scope", SecuredExampleMcpServer.READ_SCOPE) + .issueTime(Date.from(Instant.now())) + .expirationTime(Date.from(Instant.now().plus(Duration.ofMinutes(5)))) + .build(); + var algNoneToken = new PlainJWT(claims).serialize(); + var response = http.send(discoverRequest().header("Authorization", "Bearer " + algNoneToken) + .POST(HttpRequest.BodyPublishers.ofString("{}")).build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(401, response.statusCode()); + assertEquals(Optional.of("invalid_token"), challenge(response).error()); + } + + // -------- b: RFC 9728 Protected Resource Metadata -------- + + @Test + void b01_wellKnownPrm_advertisesResourceAndOfflineAuthorizationServer() throws Exception { + var prmUrl = server.getRootUrl().resolve(".well-known/oauth-protected-resource"); + var response = http.send(HttpRequest.newBuilder(prmUrl).GET().build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + var prm = McpProtectedResourceMetadataClient.create().build().parse(response.body(), prmUrl); + assertEquals(server.getRootUrl(), prm.resource()); + assertEquals(List.of(server.getAuthServer().issuerUri()), prm.authorizationServers()); + // H3: addOperationScope(...) also advertises mcp.write as supported, on top of the mcp.read baseline. + assertEquals(Set.of(SecuredExampleMcpServer.READ_SCOPE, SecuredExampleMcpServer.WRITE_SCOPE), prm.scopesSupported()); + } + + // -------- c: H3/H4 - baseline scope and per-operation mcp.write step-up enforcement -------- + + @Test + void c01_insufficientBaselineScope_403WithInsufficientScopeError() throws Exception { + // A token that is otherwise perfectly valid but was never granted the endpoint-wide mcp.read + // baseline is 403'd before any JSON-RPC method (even server/discover) dispatches. + var auth = server.getAuthServer(); + var noBaselineToken = McpTokenProvider.clientCredentials() + .tokenEndpoint(auth.tokenEndpoint()) + .clientId(auth.clientId()) + .clientSecret(auth.clientSecret()) + .resource(server.getRootUrl()) + .scope(SecuredExampleMcpServer.WRITE_SCOPE) + .build() + .get(); + var response = http.send(discoverRequest().header("Authorization", "Bearer " + noBaselineToken) + .POST(HttpRequest.BodyPublishers.ofString("{}")).build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(403, response.statusCode()); + var c = challenge(response); + assertEquals(Optional.of("insufficient_scope"), c.error()); + assertEquals(Set.of(SecuredExampleMcpServer.READ_SCOPE), c.scopes()); + } + + @Test + void c02_readOnlyToken_canReadButCannotWrite() throws Exception { + var auth = server.getAuthServer(); + + // The mcp.read baseline alone is enough to discover the server and read a resource. + var readTokens = McpTokenProvider.clientCredentials() + .tokenEndpoint(auth.tokenEndpoint()) + .clientId(auth.clientId()) + .clientSecret(auth.clientSecret()) + .resource(server.getRootUrl()) + .scope(SecuredExampleMcpServer.READ_SCOPE) + .build(); + try (var client = McpClient.connect(McpClient.builder() + .endpoint(server.getRootUrl().toString()) + .interceptor(readTokens.interceptor()))) { + assertNotNull(client.readResource(NoteStore.SCHEME + "index")); + } + + // H3/H4: but that SAME mcp.read-only token is 403'd, naming mcp.write specifically, on EITHER + // step-up-gated mutating tool. + var readOnlyToken = readTokens.get(); + for (var tool : List.of("publishNote", "deleteNote")) { + var response = http.send(toolsCallRequest(tool) + .header("Authorization", "Bearer " + readOnlyToken) + .POST(HttpRequest.BodyPublishers.ofString(toolsCallBody(tool, Map.of("title", "step-up-probe", "body", "x")))) + .build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(403, response.statusCode(), tool + " must require mcp.write on top of the mcp.read baseline"); + var c = challenge(response); + assertEquals(Optional.of("insufficient_scope"), c.error()); + assertEquals(Set.of(SecuredExampleMcpServer.WRITE_SCOPE), c.scopes()); + } + } + + @Test + void c03_readWriteToken_dispatchesWrites() throws Exception { + // A token carrying BOTH the baseline and the step-up scope dispatches publishNote/deleteNote normally. + // deleteNote advertises an elicitation (confirm) round trip, so the client must both advertise the + // elicitation capability and answer it - unrelated to the OAuth scoping this test targets, but + // required for the call to reach a successful outcome at all. + var auth = server.getAuthServer(); + var tokens = McpTokenProvider.clientCredentials() + .tokenEndpoint(auth.tokenEndpoint()) + .clientId(auth.clientId()) + .clientSecret(auth.clientSecret()) + .resource(server.getRootUrl()) + .scope(SecuredExampleMcpServer.READ_SCOPE, SecuredExampleMcpServer.WRITE_SCOPE) + .build(); + try (var client = McpClient.connect(McpClient.builder() + .endpoint(server.getRootUrl().toString()) + .clientCapabilities(new ClientCapabilities().setElicitation(new ElicitationCapability())) + .interceptor(tokens.interceptor()))) { + var stored = client.callTool("publishNote", Map.of("title", "step-up-note", "body", "hello")); + assertEquals("Stored note 'step-up-note' (5 chars).", stored.firstText()); + var deleted = client.callToolWithElicitation("deleteNote", Map.of("title", "step-up-note"), + requests -> { + var answers = new LinkedHashMap<String,ElicitResult>(); + requests.keySet().forEach(id -> answers.put(id, + new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("confirm", true))); + return answers; + }); + assertNotNull(deleted); + } + } + + // -------- d: accepted - a real token acquired from the offline authorization server -------- + + @Test + void d01_validToken_dispatchesAndRoundTripsANote() throws Exception { + var auth = server.getAuthServer(); + var tokens = McpTokenProvider.clientCredentials() + .tokenEndpoint(auth.tokenEndpoint()) + .clientId(auth.clientId()) + .clientSecret(auth.clientSecret()) + .resource(server.getRootUrl()) + .scope(SecuredExampleMcpServer.READ_SCOPE, SecuredExampleMcpServer.WRITE_SCOPE) + .build(); + + try (var client = McpClient.connect(McpClient.builder() + .endpoint(server.getRootUrl().toString()) + .interceptor(tokens.interceptor()))) { + assertBean(client.discoveredServer(), "serverInfo{name}", "{juneau-notes-example}"); + + var stored = client.callTool("publishNote", Map.of("title", "secured-note", "body", "hello")); + assertEquals("Stored note 'secured-note' (5 chars).", stored.firstText()); + + var read = client.readResource(NoteStore.uriFor("secured-note")); + assertEquals("hello", ((TextResourceContents) read.getContents().get(0)).getText()); + } + } + + @Test + void d02_validToken_singleTokenRequestReusedAcrossCalls() throws Exception { + // M7: proves the caching McpTokenProvider genuinely reuses ONE acquired token across multiple + // dispatches on the same connection, by counting actual /token HTTP round trips on the offline AS - + // not merely asserting the calls happen to succeed (which would also be true of a provider that + // re-requested a token on every single call). + var auth = server.getAuthServer(); + var before = auth.tokenRequestCount(); + var tokens = McpTokenProvider.clientCredentials() + .tokenEndpoint(auth.tokenEndpoint()) + .clientId(auth.clientId()) + .clientSecret(auth.clientSecret()) + .resource(server.getRootUrl()) + .scope(SecuredExampleMcpServer.READ_SCOPE, SecuredExampleMcpServer.WRITE_SCOPE) + .build(); + + try (var client = McpClient.connect(McpClient.builder() + .endpoint(server.getRootUrl().toString()) + .interceptor(tokens.interceptor()))) { + client.callTool("publishNote", Map.of("title", "cached-note", "body", "hi")); + var contents = client.readResource(NoteStore.SCHEME + "index").getContents(); + assertTrue(((TextResourceContents) contents.get(0)).getText().contains("cached-note")); + client.readResource(NoteStore.uriFor("cached-note")); + } + + assertEquals(before + 1, auth.tokenRequestCount(), + "a single cached token must be reused across all three dispatches above, not re-requested per call"); + } + + // -------- e: M8 - SecuredExampleClient's own end-to-end walkthrough -------- + + @Test + void e01_run_completesWithoutThrowing() throws Exception { + // A dedicated, independently-started server/AS pair (own port, own notes, own demo credentials) so + // this walkthrough - which publishes/reads its own notes and deliberately triggers a rejected call - + // cannot collide with any test above sharing the class-level fixture. + try (var standalone = SecuredExampleServer.start(0)) { + var auth = standalone.getAuthServer(); + assertDoesNotThrow(() -> SecuredExampleClient.run(standalone.getRootUrl().toString(), auth.clientId(), auth.clientSecret())); + } + } +}
