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 &mdash; 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 &mdash; 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(...)} &mdash; 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} &mdash; the RFC 6749 &sect;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} &mdash; 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 &mdash; {@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 &mdash; 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 &mdash; 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 &sect;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) &mdash; 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 &mdash; {@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 &sect;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 &mdash; 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 &sect;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}