diff --git a/tests/jetty-test-common/pom.xml b/tests/jetty-test-common/pom.xml
new file mode 100644
index 00000000000..d820ada4c30
--- /dev/null
+++ b/tests/jetty-test-common/pom.xml
@@ -0,0 +1,29 @@
+
+
+
+ 4.0.0
+
+ org.eclipse.jetty.tests
+ tests
+ 12.1.0-SNAPSHOT
+
+ jetty-test-common
+ jar
+ Tests :: Test Utilities
+
+
+ ${project.groupId}.testers
+
+
+
+
+ org.eclipse.jetty
+ jetty-server
+
+
+ org.eclipse.jetty
+ jetty-util
+
+
+
+
diff --git a/tests/jetty-test-common/src/main/java/org/eclipse/jetty/tests/JwtEncoder.java b/tests/jetty-test-common/src/main/java/org/eclipse/jetty/tests/JwtEncoder.java
new file mode 100644
index 00000000000..d4611f07a77
--- /dev/null
+++ b/tests/jetty-test-common/src/main/java/org/eclipse/jetty/tests/JwtEncoder.java
@@ -0,0 +1,53 @@
+//
+// ========================================================================
+// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
+// which is available at https://www.apache.org/licenses/LICENSE-2.0.
+//
+// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
+// ========================================================================
+//
+
+package org.eclipse.jetty.tests;
+
+import java.util.Base64;
+
+/**
+ * A basic JWT encoder for testing purposes.
+ */
+public class JwtEncoder
+{
+ private static final Base64.Encoder ENCODER = Base64.getUrlEncoder();
+ private static final String DEFAULT_HEADER = "{\"INFO\": \"this is not used or checked in our implementation\"}";
+ private static final String DEFAULT_SIGNATURE = "we do not validate signature as we use the authorization code flow";
+
+ public static String encode(String idToken)
+ {
+ return stripPadding(ENCODER.encodeToString(DEFAULT_HEADER.getBytes())) + "." +
+ stripPadding(ENCODER.encodeToString(idToken.getBytes())) + "." +
+ stripPadding(ENCODER.encodeToString(DEFAULT_SIGNATURE.getBytes()));
+ }
+
+ private static String stripPadding(String paddedBase64)
+ {
+ return paddedBase64.split("=")[0];
+ }
+
+ /**
+ * Create a basic JWT for testing using argument supplied attributes.
+ */
+ public static String createIdToken(String provider, String clientId, String subject, String name, long expiry)
+ {
+ return "{" +
+ "\"iss\": \"" + provider + "\"," +
+ "\"sub\": \"" + subject + "\"," +
+ "\"aud\": \"" + clientId + "\"," +
+ "\"exp\": " + expiry + "," +
+ "\"name\": \"" + name + "\"," +
+ "\"email\": \"" + name + "@example.com" + "\"" +
+ "}";
+ }
+}
diff --git a/tests/jetty-test-common/src/main/java/org/eclipse/jetty/tests/OpenIdProvider.java b/tests/jetty-test-common/src/main/java/org/eclipse/jetty/tests/OpenIdProvider.java
new file mode 100644
index 00000000000..aba79c2a0c3
--- /dev/null
+++ b/tests/jetty-test-common/src/main/java/org/eclipse/jetty/tests/OpenIdProvider.java
@@ -0,0 +1,421 @@
+//
+// ========================================================================
+// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
+// which is available at https://www.apache.org/licenses/LICENSE-2.0.
+//
+// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
+// ========================================================================
+//
+
+package org.eclipse.jetty.tests;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+
+import org.eclipse.jetty.http.BadMessageException;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.http.HttpStatus;
+import org.eclipse.jetty.server.Handler;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Response;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.util.BufferUtil;
+import org.eclipse.jetty.util.Callback;
+import org.eclipse.jetty.util.Fields;
+import org.eclipse.jetty.util.StringUtil;
+import org.eclipse.jetty.util.component.ContainerLifeCycle;
+import org.eclipse.jetty.util.statistic.CounterStatistic;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class OpenIdProvider extends ContainerLifeCycle
+{
+ private static final Logger LOG = LoggerFactory.getLogger(OpenIdProvider.class);
+
+ private static final String CONFIG_PATH = "/.well-known/openid-configuration";
+ private static final String AUTH_PATH = "/auth";
+ private static final String TOKEN_PATH = "/token";
+ private static final String END_SESSION_PATH = "/end_session";
+ private final Map issuedAuthCodes = new HashMap<>();
+
+ protected final String clientId;
+ protected final String clientSecret;
+ protected final List redirectUris = new ArrayList<>();
+ private final ServerConnector connector;
+ private final Server server;
+ private int port = 0;
+ private String provider;
+ private User preAuthedUser;
+ private final CounterStatistic loggedInUsers = new CounterStatistic();
+ private long _idTokenDuration = Duration.ofSeconds(10).toMillis();
+
+ public static void main(String[] args) throws Exception
+ {
+ String clientId = "CLIENT_ID123";
+ String clientSecret = "PASSWORD123";
+ int port = 5771;
+ String redirectUri = "http://localhost:8080/j_security_check";
+
+ OpenIdProvider openIdProvider = new OpenIdProvider(clientId, clientSecret);
+ openIdProvider.addRedirectUri(redirectUri);
+ openIdProvider.setPort(port);
+ openIdProvider.start();
+ try
+ {
+ openIdProvider.join();
+ }
+ finally
+ {
+ openIdProvider.stop();
+ }
+ }
+
+ public OpenIdProvider()
+ {
+ this("clientId" + StringUtil.randomAlphaNumeric(4), StringUtil.randomAlphaNumeric(10));
+ }
+
+ public OpenIdProvider(String clientId, String clientSecret)
+ {
+ this.clientId = clientId;
+ this.clientSecret = clientSecret;
+
+ server = new Server();
+ connector = new ServerConnector(server);
+ server.addConnector(connector);
+
+ server.setHandler(new OpenIdProviderHandler());
+ addBean(server);
+ }
+
+ public String getClientId()
+ {
+ return clientId;
+ }
+
+ public String getClientSecret()
+ {
+ return clientSecret;
+ }
+
+ public void setIdTokenDuration(long duration)
+ {
+ _idTokenDuration = duration;
+ }
+
+ public long getIdTokenDuration()
+ {
+ return _idTokenDuration;
+ }
+
+ public void join() throws InterruptedException
+ {
+ server.join();
+ }
+
+ public CounterStatistic getLoggedInUsers()
+ {
+ return loggedInUsers;
+ }
+
+ @Override
+ protected void doStart() throws Exception
+ {
+ connector.setPort(port);
+ super.doStart();
+ provider = "http://localhost:" + connector.getLocalPort();
+ }
+
+ public void setPort(int port)
+ {
+ if (isStarted())
+ throw new IllegalStateException();
+ this.port = port;
+ }
+
+ public void setUser(User user)
+ {
+ this.preAuthedUser = user;
+ }
+
+ public String getProvider()
+ {
+ if (!isStarted() && port == 0)
+ throw new IllegalStateException("Port of OpenIdProvider not configured");
+ return provider;
+ }
+
+ public void addRedirectUri(String uri)
+ {
+ redirectUris.add(uri);
+ }
+
+ public class OpenIdProviderHandler extends Handler.Abstract
+ {
+ @Override
+ public boolean handle(Request request, Response response, Callback callback) throws Exception
+ {
+ String pathInContext = Request.getPathInContext(request);
+ switch (pathInContext)
+ {
+ case CONFIG_PATH -> doGetConfigServlet(request, response, callback);
+ case AUTH_PATH -> doAuthEndpoint(request, response, callback);
+ case TOKEN_PATH -> doTokenEndpoint(request, response, callback);
+ case END_SESSION_PATH -> doEndSessionEndpoint(request, response, callback);
+ default -> Response.writeError(request, response, callback, HttpStatus.NOT_FOUND_404);
+ }
+
+ return true;
+ }
+ }
+
+ protected void doAuthEndpoint(Request request, Response response, Callback callback) throws Exception
+ {
+ String method = request.getMethod();
+ switch (method)
+ {
+ case "GET" -> doGetAuthEndpoint(request, response, callback);
+ case "POST" -> doPostAuthEndpoint(request, response, callback);
+ default -> throw new BadMessageException("Unsupported HTTP method: " + method);
+ }
+ }
+
+ protected void doGetAuthEndpoint(Request request, Response response, Callback callback) throws Exception
+ {
+ Fields parameters = Request.getParameters(request);
+
+ if (!clientId.equals(parameters.getValue("client_id")))
+ {
+ Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "invalid client_id");
+ return;
+ }
+
+ String redirectUri = parameters.getValue("redirect_uri");
+ if (!redirectUris.contains(redirectUri))
+ {
+ LOG.warn("invalid redirectUri {}", redirectUri);
+ Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "invalid redirect_uri");
+ return;
+ }
+
+ String scopeString = parameters.getValue("scope");
+ List scopes = (scopeString == null) ? Collections.emptyList() : Arrays.asList(scopeString.split(" "));
+ if (!scopes.contains("openid"))
+ {
+ Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "no openid scope");
+ return;
+ }
+
+ if (!"code".equals(parameters.getValue("response_type")))
+ {
+ Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "response_type must be code");
+ return;
+ }
+
+ String state = parameters.getValue("state");
+ if (state == null)
+ {
+ Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "no state param");
+ return;
+ }
+
+ if (preAuthedUser == null)
+ {
+ String responseContent = String.format("""
+ Login to OpenID Connect Provider
+
+ """, AUTH_PATH, redirectUri, state);
+ response.getHeaders().put(HttpHeader.CONTENT_TYPE, "text/html");
+ response.write(true, BufferUtil.toBuffer(responseContent), callback);
+ }
+ else
+ {
+ redirectUser(request, response, callback, preAuthedUser, redirectUri, state);
+ }
+ }
+
+ protected void doPostAuthEndpoint(Request request, Response response, Callback callback) throws Exception
+ {
+ Fields parameters = Request.getParameters(request);
+ String redirectUri = parameters.getValue("redirectUri");
+ if (!redirectUris.contains(redirectUri))
+ {
+ Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "invalid redirect_uri");
+ return;
+ }
+
+ String state = parameters.getValue("state");
+ if (state == null)
+ {
+ Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "no state param");
+ return;
+ }
+
+ String username = parameters.getValue("username");
+ if (username == null)
+ {
+ Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "no username");
+ return;
+ }
+
+ User user = new User(username);
+ redirectUser(request, response, callback, user, redirectUri, state);
+ }
+
+ public void redirectUser(Request request, Response response, Callback callback, User user, String redirectUri, String state) throws IOException
+ {
+ String authCode = UUID.randomUUID().toString().replace("-", "");
+ issuedAuthCodes.put(authCode, user);
+
+ try
+ {
+ redirectUri += "?code=" + authCode + "&state=" + state;
+ Response.sendRedirect(request, response, callback, redirectUri);
+ }
+ catch (Throwable t)
+ {
+ issuedAuthCodes.remove(authCode);
+ throw t;
+ }
+ }
+
+ protected void doTokenEndpoint(Request request, Response response, Callback callback) throws Exception
+ {
+ if (!HttpMethod.POST.is(request.getMethod()))
+ throw new BadMessageException("Unsupported HTTP method for token Endpoint: " + request.getMethod());
+
+ Fields parameters = Request.getParameters(request);
+ String code = parameters.getValue("code");
+
+ if (!clientId.equals(parameters.getValue("client_id")) ||
+ !clientSecret.equals(parameters.getValue("client_secret")) ||
+ !redirectUris.contains(parameters.getValue("redirect_uri")) ||
+ !"authorization_code".equals(parameters.getValue("grant_type")) ||
+ code == null)
+ {
+ Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "bad auth request");
+ return;
+ }
+
+ User user = issuedAuthCodes.remove(code);
+ if (user == null)
+ {
+ Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "invalid auth code");
+ return;
+ }
+
+ String accessToken = "ABCDEFG";
+ long accessTokenDuration = Duration.ofMinutes(10).toSeconds();
+ String responseContent = "{" +
+ "\"access_token\": \"" + accessToken + "\"," +
+ "\"id_token\": \"" + JwtEncoder.encode(user.getIdToken(provider, clientId, _idTokenDuration)) + "\"," +
+ "\"expires_in\": " + accessTokenDuration + "," +
+ "\"token_type\": \"Bearer\"" +
+ "}";
+
+ loggedInUsers.increment();
+ response.getHeaders().put(HttpHeader.CONTENT_TYPE, "text/plain");
+ response.write(true, BufferUtil.toBuffer(responseContent), callback);
+ }
+
+ protected void doEndSessionEndpoint(Request request, Response response, Callback callback) throws Exception
+ {
+ Fields parameters = Request.getParameters(request);
+ String idToken = parameters.getValue("id_token_hint");
+ if (idToken == null)
+ {
+ Response.writeError(request, response, callback, HttpStatus.BAD_REQUEST_400, "no id_token_hint");
+ return;
+ }
+
+ String logoutRedirect = parameters.getValue("post_logout_redirect_uri");
+ if (logoutRedirect == null)
+ {
+ response.setStatus(HttpStatus.OK_200);
+ response.write(true, BufferUtil.toBuffer("logout success on end_session_endpoint"), callback);
+ return;
+ }
+
+ loggedInUsers.decrement();
+ Response.sendRedirect(request, response, callback, logoutRedirect);
+ }
+
+ protected void doGetConfigServlet(Request request, Response response, Callback callback) throws IOException
+ {
+ String discoveryDocument = "{" +
+ "\"issuer\": \"" + provider + "\"," +
+ "\"authorization_endpoint\": \"" + provider + AUTH_PATH + "\"," +
+ "\"token_endpoint\": \"" + provider + TOKEN_PATH + "\"," +
+ "\"end_session_endpoint\": \"" + provider + END_SESSION_PATH + "\"," +
+ "}";
+
+ response.write(true, BufferUtil.toBuffer(discoveryDocument), callback);
+ }
+
+ public static class User
+ {
+ private final String subject;
+ private final String name;
+
+ public User(String name)
+ {
+ this(UUID.nameUUIDFromBytes(name.getBytes()).toString(), name);
+ }
+
+ public User(String subject, String name)
+ {
+ this.subject = subject;
+ this.name = name;
+ }
+
+ public String getName()
+ {
+ return name;
+ }
+
+ public String getSubject()
+ {
+ return subject;
+ }
+
+ public String getIdToken(String provider, String clientId, long duration)
+ {
+ long expiryTime = Instant.now().plusMillis(duration).getEpochSecond();
+ return JwtEncoder.createIdToken(provider, clientId, subject, name, expiryTime);
+ }
+
+ @Override
+ public boolean equals(Object obj)
+ {
+ if (!(obj instanceof User))
+ return false;
+ return Objects.equals(subject, ((User)obj).subject) && Objects.equals(name, ((User)obj).name);
+ }
+
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(subject, name);
+ }
+ }
+}
diff --git a/tests/pom.xml b/tests/pom.xml
index 0ec5773e2db..73a94d115ce 100644
--- a/tests/pom.xml
+++ b/tests/pom.xml
@@ -13,6 +13,7 @@
jetty-testers
jetty-jmh
+ jetty-test-common
jetty-test-multipart
jetty-test-session-common
test-cross-context-dispatch
diff --git a/tests/test-distribution/test-distribution-common/pom.xml b/tests/test-distribution/test-distribution-common/pom.xml
index 65c35e81de5..4fc3b4b3450 100644
--- a/tests/test-distribution/test-distribution-common/pom.xml
+++ b/tests/test-distribution/test-distribution-common/pom.xml
@@ -187,6 +187,20 @@
war
test
+
+ org.eclipse.jetty.ee10
+ jetty-ee10-test-openid-webapp
+ ${project.version}
+ war
+ test
+
+
+ org.eclipse.jetty.ee11
+ jetty-ee11-test-openid-webapp
+ ${project.version}
+ war
+ test
+
org.eclipse.jetty.http2
jetty-http2-client