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 static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.io.*; 022import java.net.*; 023import java.nio.charset.*; 024import java.security.*; 025import java.time.*; 026import java.util.*; 027import java.util.concurrent.atomic.*; 028 029import org.apache.juneau.marshall.marshaller.Json; 030 031import com.nimbusds.jose.*; 032import com.nimbusds.jose.crypto.*; 033import com.nimbusds.jose.jwk.*; 034import com.nimbusds.jose.jwk.gen.*; 035import com.nimbusds.jose.jwk.source.*; 036import com.nimbusds.jose.proc.*; 037import com.nimbusds.jwt.*; 038import com.sun.net.httpserver.*; 039 040/** 041 * A minimal, self-contained, in-process stand-in for a real OAuth 2.1 authorization server (AS), so the 042 * {@code secured} MCP example (and its end-to-end test) run entirely offline — no external IdP, no 043 * network access, no pre-shared secrets checked into source control. 044 * 045 * <p><b>DEMO ONLY — NOT SUITABLE FOR PRODUCTION USE.</b></p> 046 * 047 * <p> 048 * This is deliberately NOT a general-purpose or spec-complete authorization server. It exists purely to make 049 * {@link SecuredExampleMcpServer}'s {@code JwtTokenValidator}-backed resource-server gate and 050 * {@link SecuredExampleClient}'s {@code McpTokenProvider}-backed client-credentials flow both have something 051 * real to talk to. Concretely, on {@link #start()} it: 052 * 053 * <ol> 054 * <li>Generates a fresh RSA-2048 signing key (a new key every run — nothing is persisted, so restarting 055 * the example invalidates any previously-issued token, which is exactly the offline-demo property we want). 056 * <li>Publishes only the <b>public</b> half of that key as a {@link JWKSource}, which 057 * {@link SecuredExampleMcpServer} feeds directly to {@code JwtTokenValidator.jwkSource(...)} — no JWKS 058 * HTTP endpoint is needed because the server and "AS" share this JVM. 059 * <li>Starts a tiny {@link HttpServer} exposing two routes: 060 * <ul> 061 * <li>{@code POST /token} — the RFC 6749 §4.4 client-credentials grant against one fixed, 062 * randomly-generated demo client id/secret pair (see {@link #clientId()} / {@link #clientSecret()}). 063 * This is a REAL HTTP round trip: the client-side {@code OAuthClientCredentialsFlow} used by 064 * {@code McpTokenProvider} talks to it exactly as it would talk to a production IdP's token 065 * endpoint, just on {@code localhost}. 066 * <li>{@code GET /.well-known/oauth-authorization-server} — a minimal RFC 8414 Authorization 067 * Server Metadata document ({@code issuer} + {@code token_endpoint} only), so a real client can 068 * perform genuine discovery against {@link #issuerUri()} instead of being handed the token 069 * endpoint out of band. See {@link SecuredExampleClient} for the client half of that handshake. 070 * </ul> 071 * </ol> 072 * 073 * <p> 074 * <b>What this class intentionally does NOT implement</b> (called out here and in the README so nobody mistakes 075 * this demo for a template for a real deployment): 076 * <ul> 077 * <li>No real scope-authorization decision — {@link #handleToken} only checks the requested scope 078 * against a fixed allowlist ({@code mcp.read}/{@code mcp.write}); it never asks a resource owner or policy 079 * engine whether the client is ALLOWED that scope, which is what a real AS's consent/policy step does. 080 * <li>No refresh tokens, no consent screen, no user authentication of any kind — only the 081 * machine-to-machine client-credentials grant, which is all an MCP server-to-server OAuth demo needs. 082 * <li>No persistence, replay protection beyond the JWT {@code exp}/{@code nbf} window, or key rotation. The 083 * RFC 8414 document above is likewise minimal: no {@code jwks_uri}, no {@code scopes_supported}, no 084 * dynamic client registration — this AS's public key is handed directly to the validator in-process 085 * (see {@link #jwkSource()}) rather than published for a real JWKS fetch. 086 * </ul> 087 * 088 * <p> 089 * <b>TLS caveat:</b> this class and {@link SecuredExampleServer} talk plain {@code http} only because both 090 * ends are loopback-only ({@code 127.0.0.1}/{@code localhost}). OAuth 2.1 / RFC 9728 require {@code https} for 091 * any non-loopback resource identifier or token endpoint; a bearer token sent over plaintext to a non-loopback 092 * host is exposed to anyone on the network path. 093 * 094 * <p> 095 * A real deployment would point {@code JwtTokenValidator} at an actual IdP's JWKS URL and issuer, and would 096 * never generate or hand out client secrets like {@link #start()} does here. 097 * 098 * @since 10.0.0 099 */ 100public final class OfflineAuthorizationServer implements AutoCloseable { 101 102 /** The scope minted onto a token when the client's request omits one. */ 103 public static final String DEFAULT_SCOPE = "mcp.read"; 104 105 /** 106 * The fixed allowlist of scopes this offline AS will grant (H2): a client requesting anything outside this 107 * set is rejected with RFC 6749 §5.2 {@code invalid_scope}, rather than being handed back whatever it 108 * asked for. 109 */ 110 public static final Set<String> GRANTABLE_SCOPES = Set.of(DEFAULT_SCOPE, "mcp.write"); 111 112 /** The RFC 6749 {@code scope} form parameter / JWT claim name, minted access tokens carry as {@code scope}. */ 113 private static final String SCOPE_PARAM = "scope"; 114 115 /** How long a minted access token is valid for. */ 116 private static final Duration TOKEN_TTL = Duration.ofMinutes(5); 117 118 /** RFC 8414 Authorization Server Metadata well-known path. */ 119 private static final String WELL_KNOWN_AUTHORIZATION_SERVER = "/.well-known/oauth-authorization-server"; 120 121 // S2119: a single reused instance, not one per randomSecret() call - repeatedly constructing SecureRandom 122 // re-seeds it from the OS entropy source every time, which is wasteful and provides no security benefit 123 // over reusing one properly-seeded instance. 124 private static final SecureRandom SECURE_RANDOM = new SecureRandom(); 125 126 private final RSAKey signingKey; 127 private final String clientId; 128 private final String clientSecret; 129 private final HttpServer httpServer; 130 private final URI issuer; 131 private final AtomicInteger tokenRequestCount = new AtomicInteger(); 132 133 private OfflineAuthorizationServer(RSAKey signingKey, String clientId, String clientSecret, HttpServer httpServer, URI issuer) { 134 this.signingKey = signingKey; 135 this.clientId = clientId; 136 this.clientSecret = clientSecret; 137 this.httpServer = httpServer; 138 this.issuer = issuer; 139 } 140 141 /** 142 * Generates a fresh signing key and demo client credentials, and starts the {@code /token} and RFC 8414 143 * discovery endpoints on an OS-assigned loopback-only port. 144 * 145 * @return A running instance. Close it (or call {@link #close()}) to stop the HTTP endpoint. 146 * @throws Exception If key generation or the HTTP endpoint fails to start. 147 */ 148 @SuppressWarnings({ 149 "java:S112", // throws Exception intentional - example lifecycle method kept simple for demo readability 150 "resource" // returned instance is owned and closed by the caller (see SecuredExampleServer.start()). 151 }) 152 public static OfflineAuthorizationServer start() throws Exception { 153 var signingKey = new RSAKeyGenerator(2048).keyID("demo-key-1").algorithm(JWSAlgorithm.RS256).generate(); 154 var clientId = "demo-client"; 155 var clientSecret = randomSecret(); 156 157 var httpServer = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); 158 // H4/M10: the issuer is THIS instance's own loopback base URL, known as soon as the (possibly 159 // OS-assigned) listen socket is bound - a constructor-supplied instance field, not a shared constant, 160 // so two independently-started instances naturally have two different issuers (see 161 // SecuredExampleMcpEndToEnd_Test's wrong-issuer test). 162 var issuer = URI.create("http://127.0.0.1:" + httpServer.getAddress().getPort()); 163 var self = new OfflineAuthorizationServer(signingKey, clientId, clientSecret, httpServer, issuer); 164 httpServer.createContext("/token", self::handleToken); 165 httpServer.createContext(WELL_KNOWN_AUTHORIZATION_SERVER, self::handleAuthorizationServerMetadata); 166 httpServer.start(); 167 return self; 168 } 169 170 /** Generates a random, never-checked-in demo client secret (32 bytes, URL-safe base64). */ 171 private static String randomSecret() { 172 var bytes = new byte[32]; 173 SECURE_RANDOM.nextBytes(bytes); 174 return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); 175 } 176 177 /** 178 * Returns a {@link JWKSource} publishing only the public half of this server's signing key, ready to hand 179 * straight to {@code JwtTokenValidator.jwkSource(...)}. 180 * 181 * @return The public-key JWK source. Never <jk>null</jk>. 182 */ 183 public JWKSource<SecurityContext> jwkSource() { 184 return new ImmutableJWKSet<>(new JWKSet(signingKey.toPublicJWK())); 185 } 186 187 /** 188 * Returns this authorization server's issuer identity (its own loopback base URL) — used as the JWT 189 * {@code iss} claim, the PRM document's {@code authorization_servers} entry, and the RFC 8414 discovery 190 * document's {@code issuer} field. 191 * 192 * @return The issuer URI. Never <jk>null</jk>. 193 */ 194 public URI issuerUri() { 195 return issuer; 196 } 197 198 /** 199 * Returns the loopback URL of the {@code /token} client-credentials endpoint. 200 * 201 * @return The token endpoint URL. Never <jk>null</jk>. 202 */ 203 public URI tokenEndpoint() { 204 return URI.create(issuer + "/token"); 205 } 206 207 /** 208 * Returns the fixed demo client id a caller must present (via HTTP Basic) to acquire a token. 209 * 210 * @return The client id. Never <jk>null</jk>. 211 */ 212 public String clientId() { 213 return clientId; 214 } 215 216 /** 217 * Returns the randomly-generated demo client secret a caller must present (via HTTP Basic) to acquire a 218 * token. Generated fresh on every {@link #start()}; never persisted anywhere — {@link SecuredExampleServer#main} 219 * prints it to the console purely so a reader running the demo by hand has something to copy/paste. 220 * 221 * @return The client secret. Never <jk>null</jk>. 222 */ 223 public String clientSecret() { 224 return clientSecret; 225 } 226 227 /** 228 * Returns how many HTTP requests have hit the {@code /token} endpoint so far (M7): a test seam proving a 229 * caching {@code McpTokenProvider} genuinely reuses an acquired token across multiple dispatches instead of 230 * silently re-requesting one per call. 231 * 232 * @return The number of {@code /token} requests handled since {@link #start()}. 233 */ 234 public int tokenRequestCount() { 235 return tokenRequestCount.get(); 236 } 237 238 @Override 239 public void close() { 240 httpServer.stop(0); 241 } 242 243 /** 244 * Implements RFC 6749 §4.4 (client-credentials grant) against the fixed demo client id/secret, 245 * minting a signed JWT whose {@code aud} claim is exactly the RFC 8707 {@code resource} indicator the 246 * caller supplied — this is what lets {@code JwtTokenValidator} enforce the audience check on the 247 * receiving end. 248 */ 249 private void handleToken(HttpExchange exchange) throws IOException { 250 // DEMO ONLY: see class javadoc - not a production AS. 251 tokenRequestCount.incrementAndGet(); 252 try { 253 if (neq(exchange.getRequestMethod(), "POST")) { 254 exchange.getResponseHeaders().add("Allow", "POST"); 255 sendJson(exchange, 405, error("invalid_request", "must POST to /token")); 256 return; 257 } 258 if (! authenticateClient(exchange)) { 259 exchange.getResponseHeaders().add("WWW-Authenticate", "Basic realm=\"offline-authorization-server\""); 260 sendJson(exchange, 401, error("invalid_client", "unknown client id or secret")); 261 return; 262 } 263 Map<String,String> form; 264 try (var body = exchange.getRequestBody()) { 265 form = parseForm(body); 266 } 267 if (neq(form.get("grant_type"), "client_credentials")) { 268 sendJson(exchange, 400, error("unsupported_grant_type", "only client_credentials is supported")); 269 return; 270 } 271 var resource = form.get("resource"); 272 if (ib(resource)) { 273 sendJson(exchange, 400, error("invalid_target", "a resource indicator (RFC 8707) is required")); 274 return; 275 } 276 var scope = form.getOrDefault(SCOPE_PARAM, DEFAULT_SCOPE); 277 if (! isGrantable(scope)) { 278 sendJson(exchange, 400, error("invalid_scope", "requested scope must be a subset of " + GRANTABLE_SCOPES)); 279 return; 280 } 281 var token = mintAccessToken(resource, scope, Instant.now(), TOKEN_TTL); 282 exchange.getResponseHeaders().add("Cache-Control", "no-store"); 283 exchange.getResponseHeaders().add("Pragma", "no-cache"); 284 sendJson(exchange, 200, Json.of(Map.<String,Object>of( 285 "access_token", token, 286 "token_type", "Bearer", 287 "expires_in", TOKEN_TTL.toSeconds(), 288 SCOPE_PARAM, scope))); 289 } catch (RuntimeException | JOSEException e) { 290 sendJson(exchange, 500, error("server_error", e.getMessage() == null ? "internal error" : e.getMessage())); 291 } 292 } 293 294 /** Serves a minimal RFC 8414 Authorization Server Metadata document (M10): {@code issuer} + {@code token_endpoint} only. */ 295 private void handleAuthorizationServerMetadata(HttpExchange exchange) throws IOException { 296 if (neq(exchange.getRequestMethod(), "GET")) { 297 exchange.getResponseHeaders().add("Allow", "GET"); 298 exchange.sendResponseHeaders(405, -1); 299 return; 300 } 301 sendJson(exchange, 200, Json.of(Map.of( 302 "issuer", issuer.toString(), 303 "token_endpoint", tokenEndpoint().toString()))); 304 } 305 306 /** Returns whether every space-delimited token in {@code scope} is in the {@link #GRANTABLE_SCOPES} allowlist (H2). */ 307 private static boolean isGrantable(String scope) { 308 for (var s : scope.split("\\s+")) 309 if (! s.isBlank() && ! GRANTABLE_SCOPES.contains(s)) 310 return false; 311 return true; 312 } 313 314 /** Validates the RFC 6749 HTTP Basic client-authentication header against the fixed demo credentials. */ 315 private boolean authenticateClient(HttpExchange exchange) { 316 // DEMO ONLY: see class javadoc - not a production AS. 317 var header = exchange.getRequestHeaders().getFirst("Authorization"); 318 if (header == null || ! header.startsWith("Basic ")) 319 return false; 320 byte[] decodedBytes; 321 try { 322 decodedBytes = Base64.getDecoder().decode(header.substring("Basic ".length())); 323 } catch (IllegalArgumentException e) { // L1: malformed base64 -> a clean 401, not a 500 324 return false; 325 } 326 var decoded = new String(decodedBytes, StandardCharsets.UTF_8); 327 var sep = decoded.indexOf(':'); 328 if (sep < 0) 329 return false; 330 var presentedId = URLDecoder.decode(decoded.substring(0, sep), StandardCharsets.UTF_8); 331 var presentedSecret = URLDecoder.decode(decoded.substring(sep + 1), StandardCharsets.UTF_8); 332 // M6: constant-time comparison - both MessageDigest.isEqual() calls below are unconditionally evaluated 333 // (regardless of the '&&' below) so neither the id nor the secret check can leak timing information 334 // about how many leading bytes matched. The boolean combinator itself has no side effects either way. 335 var idMatches = MessageDigest.isEqual(clientId.getBytes(StandardCharsets.UTF_8), presentedId.getBytes(StandardCharsets.UTF_8)); 336 var secretMatches = MessageDigest.isEqual(clientSecret.getBytes(StandardCharsets.UTF_8), presentedSecret.getBytes(StandardCharsets.UTF_8)); 337 return idMatches && secretMatches; 338 } 339 340 /** 341 * Signs a fresh RS256 access token carrying the mandatory claims {@code JwtTokenValidator} requires. 342 * 343 * <p> 344 * Package-private test seam (H4): exposing {@code issuedAt}/{@code ttl} lets 345 * {@link SecuredExampleMcpEndToEnd_Test} mint an already-expired token directly, without needing a real 346 * clock to actually elapse. 347 * 348 * @param audience The RFC 8707 {@code resource} indicator to mint the token's {@code aud} claim for. 349 * @param scope The (space-delimited) {@code scope} claim. 350 * @param issuedAt The token's {@code iat}/{@code nbf} instant. 351 * @param ttl How long after {@code issuedAt} the token expires. 352 * @return The signed, serialized JWT. 353 * @throws JOSEException If signing fails. 354 */ 355 String mintAccessToken(String audience, String scope, Instant issuedAt, Duration ttl) throws JOSEException { 356 var claims = new JWTClaimsSet.Builder() 357 // M4: a client-credentials grant has no resource owner - the subject IS the client, not a 358 // placeholder end user. 359 .subject(clientId) 360 .issuer(issuer.toString()) 361 .audience(audience) 362 .claim(SCOPE_PARAM, scope) 363 .issueTime(Date.from(issuedAt)) 364 .notBeforeTime(Date.from(issuedAt)) 365 .expirationTime(Date.from(issuedAt.plus(ttl))) 366 .build(); 367 var header = new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(signingKey.getKeyID()).build(); 368 var jwt = new SignedJWT(header, claims); 369 jwt.sign(new RSASSASigner(signingKey)); 370 return jwt.serialize(); 371 } 372 373 /** Parses an {@code application/x-www-form-urlencoded} request body into a key/value map. */ 374 private static Map<String,String> parseForm(InputStream body) throws IOException { 375 var raw = new String(body.readAllBytes(), StandardCharsets.UTF_8); 376 var out = new LinkedHashMap<String,String>(); 377 for (var pair : raw.split("&")) { 378 if (pair.isEmpty()) 379 continue; 380 var eq = pair.indexOf('='); 381 var key = eq < 0 ? pair : pair.substring(0, eq); 382 var value = eq < 0 ? "" : pair.substring(eq + 1); 383 out.put(URLDecoder.decode(key, StandardCharsets.UTF_8), URLDecoder.decode(value, StandardCharsets.UTF_8)); 384 } 385 return out; 386 } 387 388 /** 389 * Builds an RFC 6749 §5.2 JSON error body. 390 * 391 * <p> 392 * M2/M3: built as a {@link Map} and serialized via {@link Json#of(Object)} (the same marshaller 393 * {@link SecuredExampleClient} already uses) instead of hand-concatenating strings, so a client-controlled 394 * {@code error_description} containing a quote or backslash can no longer reshape the JSON structure or 395 * produce invalid JSON. 396 */ 397 private static String error(String code, String description) { 398 return Json.of(Map.of("error", code, "error_description", description)); 399 } 400 401 private static void sendJson(HttpExchange exchange, int status, String json) throws IOException { 402 var bytes = json.getBytes(StandardCharsets.UTF_8); 403 exchange.getResponseHeaders().add("Content-Type", "application/json"); 404 exchange.sendResponseHeaders(status, bytes.length); 405 try (var os = exchange.getResponseBody()) { 406 os.write(bytes); 407 } 408 } 409}