001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.examples.mcp.secured;
018
019import java.net.*;
020
021import org.apache.juneau.commons.inject.*;
022import org.apache.juneau.microservice.*;
023import org.apache.juneau.microservice.jetty.*;
024import org.eclipse.jetty.ee11.servlet.*;
025import org.eclipse.jetty.server.*;
026
027import jakarta.servlet.*;
028
029/**
030 * Boots {@link SecuredExampleMcpServer} on an embedded Jetty server, alongside an in-process
031 * {@link OfflineAuthorizationServer} that stands in for a real OAuth 2.1 authorization server.
032 *
033 * <p>
034 * Mirrors {@link org.apache.juneau.examples.mcp.ExampleServer} (read that class first &mdash; this one only
035 * documents what is different). Run {@link #main(String[]) main} to start both servers on fixed ports and
036 * print everything a {@link SecuredExampleClient} run needs to copy: the endpoint, the offline authorization
037 * server's token endpoint, and the randomly-generated demo client id/secret. The in-process end-to-end test
038 * uses {@link #start(int) start(0)} to boot on an OS-assigned ephemeral port instead.
039 *
040 * <p>
041 * <b>The resource URL is resolved before the secured servlet is even constructed.</b> {@link SecuredExampleMcpServer}
042 * needs its own address up front (see its class javadoc), which is awkward on an OS-assigned ephemeral port
043 * because normally nothing knows that port until Jetty finishes starting. The fix: {@link ServerConnector#open()}
044 * can be, and by Jetty's own convention normally is (see {@code Server.doStart()}'s "open network connector to
045 * ensure ports are available" step), called to bind the listening socket BEFORE the rest of the server starts.
046 * Calling it ourselves, one step earlier than usual, lets us read {@link ServerConnector#getLocalPort()}
047 * immediately &mdash; before building {@link SecuredExampleMcpServer} or starting the {@link Microservice} at
048 * all. Jetty's own {@code Server.start()} then finds the connector already open and skips rebinding it.
049 */
050@SuppressWarnings({
051   "java:S106", // Example walkthrough intentionally prints to stdout; console output is the demo's deliverable.
052   "resource" // The auth server, Jetty connector and Microservice opened in start() are handed to the returned handle, whose close() stops them.
053})
054public final class SecuredExampleServer implements AutoCloseable {
055
056   /** Default listen port used by {@link #main(String[])} when none is supplied. */
057   public static final int DEFAULT_PORT = 10001;
058
059   private final Microservice microservice;
060   private final OfflineAuthorizationServer authServer;
061   private final URI rootUrl;
062
063   private SecuredExampleServer(Microservice microservice, OfflineAuthorizationServer authServer, URI rootUrl) {
064      this.microservice = microservice;
065      this.authServer = authServer;
066      this.rootUrl = rootUrl;
067   }
068
069   /**
070    * Starts the secured example MCP server and its offline authorization server.
071    *
072    * @param port The TCP port the MCP server listens on, or {@code 0} to let the OS assign an ephemeral port.
073    *    (The offline authorization server always uses its own separate OS-assigned ephemeral port.)
074    * @return A running server handle. Close it (or call {@link #close()}) to stop both servers.
075    * @throws Exception If either server fails to start.
076    */
077   public static SecuredExampleServer start(int port) throws Exception {
078      var authServer = OfflineAuthorizationServer.start();
079      // M9: hoisted above the try so a failure AFTER connector.open() (which has already bound a real OS
080      // socket) can still close it in the catch below - Server.stop() alone will not close a connector that
081      // was opened but never handed to a started Server.
082      ServerConnector connector = null;
083      try {
084         var jetty = new Server();
085         connector = new ServerConnector(jetty);
086         connector.setPort(port);
087         jetty.addConnector(connector);
088         // Bind the (possibly ephemeral) listen socket now, before the servlet context (and therefore
089         // SecuredExampleMcpServer's RestContext) is built, so the resource URL below is real and final
090         // by the time anything asks the servlet for it. See the class javadoc for why that ordering
091         // matters here specifically.
092         connector.open();
093         var rootUrl = URI.create("http://localhost:" + connector.getLocalPort() + "/");
094
095         var handler = new ServletContextHandler();
096         handler.setContextPath("/");
097         jetty.setAttribute("ServletContextHandler", handler);
098         jetty.setHandler(handler);
099
100         var securedServlet = new SecuredExampleMcpServer(authServer, rootUrl);
101
102         var beanStore = new BasicBeanStore();
103         beanStore.addBean(Server.class, jetty);
104         beanStore.addBean(Servlet.class, securedServlet);
105
106         var microservice = Microservice.create()
107            .beanStore(beanStore)
108            .configurations(JettyConfiguration.class)
109            .consoleEnabled(false)
110            .build();
111         microservice.start();
112
113         return new SecuredExampleServer(microservice, authServer, rootUrl);
114      } catch (Exception e) {
115         if (connector != null)
116            connector.close();
117         authServer.close();
118         throw e;
119      }
120   }
121
122   /**
123    * Returns the root URL the secured MCP server is listening on (e.g. {@code http://localhost:10001/}).
124    *
125    * @return The root URL. Never <jk>null</jk>.
126    */
127   public URI getRootUrl() {
128      return rootUrl;
129   }
130
131   /**
132    * Returns the offline authorization server backing this instance, so a caller (e.g. the end-to-end test,
133    * or {@link #main(String[])} printing a startup banner) can read its token endpoint and demo credentials.
134    *
135    * @return The offline authorization server. Never <jk>null</jk>.
136    */
137   public OfflineAuthorizationServer getAuthServer() {
138      return authServer;
139   }
140
141   @Override
142   public void close() throws Exception {
143      try {
144         microservice.stop();
145      } finally {
146         authServer.close();
147      }
148   }
149
150   /**
151    * Runs both servers until the process is killed, printing everything {@link SecuredExampleClient} needs.
152    *
153    * <p>
154    * M10: the token endpoint is no longer printed here &mdash; {@link SecuredExampleClient} now discovers it
155    * itself via RFC 9728 PRM + RFC 8414 discovery, exactly as a real client would, so only the endpoint and
156    * demo client credentials need to be copied.
157    *
158    * @param args Optional single argument: the port the MCP server listens on (defaults to {@link #DEFAULT_PORT}).
159    * @throws Exception If either server fails to start.
160    */
161   public static void main(String[] args) throws Exception {
162      var port = args.length > 0 ? Integer.parseInt(args[0]) : DEFAULT_PORT;
163      var server = start(port);
164      var auth = server.getAuthServer();
165      System.out.println("Juneau SECURED MCP example server is listening at " + server.getRootUrl());
166      System.out.println("Demo client id:     " + auth.clientId());
167      System.out.println("Demo client secret: " + auth.clientSecret());
168      System.out.println();
169      System.out.println("Drive it with:  SecuredExampleClient " + server.getRootUrl() + " "
170         + auth.clientId() + " " + auth.clientSecret());
171      System.out.println("Press Ctrl-C to stop.");
172      Thread.currentThread().join();
173   }
174}