diff --git a/documentation/jetty/modules/code/examples/pom.xml b/documentation/jetty/modules/code/examples/pom.xml
index 6945490abf94..fcd25ea99663 100644
--- a/documentation/jetty/modules/code/examples/pom.xml
+++ b/documentation/jetty/modules/code/examples/pom.xml
@@ -42,6 +42,10 @@
org.eclipse.jetty
jetty-nosql
+
+ org.eclipse.jetty
+ jetty-openid
+
org.eclipse.jetty
jetty-rewrite
@@ -54,6 +58,10 @@
org.eclipse.jetty
jetty-session
+
+ org.eclipse.jetty
+ jetty-slf4j-impl
+
org.eclipse.jetty
jetty-unixdomain-server
@@ -105,6 +113,10 @@
org.eclipse.jetty.memcached
jetty-memcached-sessions
+
+ org.eclipse.jetty.tests
+ jetty-test-common
+
org.eclipse.jetty.websocket
jetty-websocket-jetty-client
diff --git a/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/security/OpenIdDocs.java b/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/security/OpenIdDocs.java
new file mode 100644
index 000000000000..fb33bf6298cd
--- /dev/null
+++ b/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/security/OpenIdDocs.java
@@ -0,0 +1,135 @@
+//
+// ========================================================================
+// 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.docs.programming.security;
+
+import java.io.PrintStream;
+import java.security.Principal;
+import java.util.Map;
+
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.io.Content;
+import org.eclipse.jetty.security.AuthenticationState;
+import org.eclipse.jetty.security.Constraint;
+import org.eclipse.jetty.security.HashLoginService;
+import org.eclipse.jetty.security.LoginService;
+import org.eclipse.jetty.security.SecurityHandler;
+import org.eclipse.jetty.security.UserStore;
+import org.eclipse.jetty.security.openid.OpenIdAuthenticator;
+import org.eclipse.jetty.security.openid.OpenIdConfiguration;
+import org.eclipse.jetty.security.openid.OpenIdLoginService;
+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.Session;
+import org.eclipse.jetty.session.SessionHandler;
+import org.eclipse.jetty.util.Callback;
+import org.eclipse.jetty.util.Fields;
+
+public class OpenIdDocs
+{
+ public void combinedExample() throws Exception
+ {
+ Server server = new Server(8080);
+ // tag::openIdUsageExample[]
+ server.setHandler(new Handler.Abstract()
+ {
+ @Override
+ public boolean handle(Request request, Response response, Callback callback) throws Exception
+ {
+ PrintStream writer = new PrintStream(Content.Sink.asOutputStream(response));
+
+ String pathInContext = Request.getPathInContext(request);
+ if (pathInContext.startsWith("/error"))
+ {
+ // Handle requests to the error page which may have an error description parameter.
+ Fields parameters = Request.getParameters(request);
+ writer.println("error_description: " + parameters.get("error_description_jetty") + "
");
+ }
+ else
+ {
+ Principal userPrincipal = AuthenticationState.getUserPrincipal(request);
+ writer.println("userPrincipal: " + userPrincipal);
+ if (userPrincipal != null)
+ {
+ // You can access the full openid claims for an authenticated session.
+ Session session = request.getSession(false);
+ @SuppressWarnings("unchecked")
+ Map claims = (Map)session.getAttribute("org.eclipse.jetty.security.openid.claims");
+ writer.println("claims: " + claims);
+ writer.println("name: " + claims.get("name"));
+ writer.println("sub: " + claims.get("sub"));
+ }
+ }
+
+ writer.close();
+ callback.succeeded();
+ return true;
+ }
+ });
+ // end::openIdUsageExample[]
+
+ // tag::openIdConfigExample[]
+ // To create an OpenIdConfiguration you can rely on discovery of the OIDC metadata.
+ OpenIdConfiguration openIdConfig = new OpenIdConfiguration(
+ "https://example.com/issuer", // ISSUER
+ "my-client-id", // CLIENT_ID
+ "my-client-secret" // CLIENT_SECRET
+ );
+
+ // Or you can specify the full OpenID configuration manually.
+ openIdConfig = new OpenIdConfiguration(
+ "https://example.com/issuer", // ISSUER
+ "https://example.com/token", // TOKEN_ENDPOINT
+ "https://example.com/auth", // AUTH_ENDPOINT
+ "https://example.com/logout", // END_SESSION_ENDPOINT
+ "my-client-id", // CLIENT_ID
+ "my-client-secret", // CLIENT_SECRET
+ "client_secret_post", // AUTH_METHOD (e.g., client_secret_post, client_secret_basic)
+ new HttpClient() // HttpClient instance
+ );
+
+ // The specific security handler implementation will change depending on whether you are using EE8/EE9/EE10/EE11 or Jetty Core API.
+ SecurityHandler.PathMapped securityHandler = new SecurityHandler.PathMapped();
+ server.insertHandler(securityHandler);
+ securityHandler.put("/auth/*", Constraint.ANY_USER);
+
+ // A nested LoginService is optional and used to specify known users with defined roles.
+ // This can be any instance of LoginService and is not restricted to be a HashLoginService.
+ HashLoginService nestedLoginService = new HashLoginService();
+ UserStore userStore = new UserStore();
+ userStore.addUser("", null, new String[]{"admin"});
+ nestedLoginService.setUserStore(userStore);
+
+ // Optional configuration to allow new users not listed in the nested LoginService to be authenticated.
+ openIdConfig.setAuthenticateNewUsers(true);
+
+ // An OpenIdLoginService should be used which can optionally wrap the nestedLoginService to support roles.
+ LoginService loginService = new OpenIdLoginService(openIdConfig, nestedLoginService);
+ securityHandler.setLoginService(loginService);
+
+ // Configure an OpenIdAuthenticator.
+ securityHandler.setAuthenticator(new OpenIdAuthenticator(openIdConfig,
+ "/j_security_check", // The path where the OIDC provider redirects back to Jetty.
+ "/error", // Optional page where authentication errors are redirected.
+ "/logoutRedirect" // Optional page where the user is redirected to this page after logout.
+ ));
+
+ // Session handler is required for OpenID authentication.
+ server.insertHandler(new SessionHandler());
+ // end::openIdConfigExample[]
+ server.start();
+ server.join();
+ }
+}
\ No newline at end of file
diff --git a/documentation/jetty/modules/operations-guide/nav.adoc b/documentation/jetty/modules/operations-guide/nav.adoc
index 85a995bb712f..dc7964e5366c 100644
--- a/documentation/jetty/modules/operations-guide/nav.adoc
+++ b/documentation/jetty/modules/operations-guide/nav.adoc
@@ -34,8 +34,9 @@
* xref:jndi/index.adoc[]
* Jetty Security
** xref:security/configuring-form-size.adoc[]
-* xref:jaas/index.adoc[]
-* xref:jaspi/index.adoc[]
+** xref:security/jaas-support.adoc[]
+** xref:security/jaspi-support.adoc[]
+** xref:security/openid-support.adoc[]
* xref:jmx/index.adoc[]
* xref:tools/index.adoc[]
* xref:troubleshooting/index.adoc[]
diff --git a/documentation/jetty/modules/operations-guide/pages/features/index.adoc b/documentation/jetty/modules/operations-guide/pages/features/index.adoc
index 083fe540aa3a..532db21ab833 100644
--- a/documentation/jetty/modules/operations-guide/pages/features/index.adoc
+++ b/documentation/jetty/modules/operations-guide/pages/features/index.adoc
@@ -23,7 +23,7 @@ Protocols::
Technologies::
* xref:annotations/index.adoc[Servlet Annotations]
-* xref:jaas/index.adoc[JAAS]
+* xref:security/jaas-support.adoc[JAAS]
* xref:jndi/index.adoc[JNDI]
* xref:jsp/index.adoc[JSP]
* xref:jmx/index.adoc[JMX Monitoring & Management]
diff --git a/documentation/jetty/modules/operations-guide/pages/modules/standard.adoc b/documentation/jetty/modules/operations-guide/pages/modules/standard.adoc
index 7228f57d9fa9..0195b9644c70 100644
--- a/documentation/jetty/modules/operations-guide/pages/modules/standard.adoc
+++ b/documentation/jetty/modules/operations-guide/pages/modules/standard.adoc
@@ -341,6 +341,30 @@ However, we the RMI server is configured to bind to `localhost`, i.e. `127.0.0.1
If the system property `java.rmi.server.hostname` is not specified, the RMI client will try to connect to `127.0.1.1` (because that's what in the RMI stub) and fail because nothing is listening on that address.
+[[openid]]
+== Module `openid`
+
+The `openid` module enables support for OpenID Connect (OIDC) authentication in Jetty, as detailed in xref:security/openid-support.adoc#openid-support[this section].
+
+This module allows Jetty to authenticate users via an OpenID Connect identity provider, making possible to integrate features like "Sign in with Google" or "Sign in with Microsoft", among others.
+
+This simplifies user authentication while leveraging the security and convenience of external identity providers.
+
+The module properties are:
+
+----
+include::{jetty-home}/modules/openid.mod[tags=documentation]
+----
+
+Among the configurable properties, the only required properties are:
+
+`jetty.openid.provider`::
+The issuer identifier of the OpenID Provider, which is the base URL before .well-known/openid-configuration. It must match the issuer field in the provider's configuration and is case-sensitive.
+`jetty.openid.clientId`::
+The Client Identifier assigned to your application by the OpenID Provider, used to uniquely identify your app during authentication requests.
+`jetty.openid.clientSecret`::
+The Client Secret issued by the OpenID Provider, used to verify your application's identity during the authentication process. This must be kept confidential.
+
[[qos]]
== Module `qos`
diff --git a/documentation/jetty/modules/operations-guide/pages/jaas/index.adoc b/documentation/jetty/modules/operations-guide/pages/security/jaas-support.adoc
similarity index 100%
rename from documentation/jetty/modules/operations-guide/pages/jaas/index.adoc
rename to documentation/jetty/modules/operations-guide/pages/security/jaas-support.adoc
diff --git a/documentation/jetty/modules/operations-guide/pages/jaspi/index.adoc b/documentation/jetty/modules/operations-guide/pages/security/jaspi-support.adoc
similarity index 100%
rename from documentation/jetty/modules/operations-guide/pages/jaspi/index.adoc
rename to documentation/jetty/modules/operations-guide/pages/security/jaspi-support.adoc
diff --git a/documentation/jetty/modules/operations-guide/pages/security/openid-support.adoc b/documentation/jetty/modules/operations-guide/pages/security/openid-support.adoc
new file mode 100644
index 000000000000..3f8fc1ff1c72
--- /dev/null
+++ b/documentation/jetty/modules/operations-guide/pages/security/openid-support.adoc
@@ -0,0 +1,90 @@
+//
+// ========================================================================
+// 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
+// ========================================================================
+//
+
+[[openid-support]]
+= OpenID Support
+
+A more general discussion about OpenID and its support in Jetty is available in the xref:programming-guide:security/openid-support.adoc[programming guide section].
+
+Also see xref:operations-guide:modules/standard.adoc#openid[OpenID Module] for more information about the module configuration.
+
+== OpenID Provider Configuration
+
+To enable OpenID support, you need to enable the `openid` module:
+
+----
+$ java -jar $JETTY_HOME/start.jar --add-modules=openid
+----
+
+To configure OpenID Authentication with Jetty you will need to specify the OpenID Provider's issuer identifier (case-sensitive URL) and the OAuth 2.0 Client ID and Client Secret.
+
+If the OpenID Provider does not allow metadata discovery you will also need to specify the token endpoint and authorization endpoint of the OpenID Provider.
+These values can be set as properties in `$JETTY_BASE/start.d/openid.ini` file.
+
+This is an example of an `openid.ini` file which uses discovery of the OpenID endpoints:
+[source]
+----
+## The OpenID Identity Provider's issuer ID (the entire URL *before* ".well-known/openid-configuration")
+jetty.openid.provider=https://id.example.com/
+
+## The Client Identifier
+jetty.openid.clientId=test1234
+
+## The Client Secret
+jetty.openid.clientSecret=XT_Mafv_aUCGheuCaKY8P
+----
+
+== Web Application Specific Configuration in `web.xml`
+
+The `web.xml` file needs some specific configuration to use OpenID.
+
+There must be a `` element with an `` value of `OPENID`, and a `` value of the exact URL string used to set the OpenID Provider.
+
+To set the error page, you must set an `context-param` named `org.eclipse.jetty.security.openid.error_page` whose value should be a path relative to the web application where authentication errors should be redirected.
+
+For example:
+
+[,xml,subs=attributes+]
+----
+
+
+ ...
+
+ OPENID
+ https://accounts.google.com
+
+
+
+ org.eclipse.jetty.security.openid.error_page
+ /error
+
+ ...
+
+----
+
+== Authorization
+
+A nested `LoginService` can be added in order to specify known users by their subject identifier with an empty credential.
+This can be used to assign security roles to users, and you can configure `openid.ini` to only authenticate users known to the nested `LoginService`.
+
+This nested `LoginService` can be configured in XML through the `$JETTY_BASE/etc/openid-baseloginservice.xml` file.
+
+== Supporting Multiple OpenID Providers
+
+It is possible to support multiple OpenID Providers on the same server, where the web application selects the provider to use through the `` in its `` element of `web.xml`.
+
+You may create a custom module to add additional `OpenIdConfiguration` instances as beans on the server.
+See the xref:operations-guide:modules/custom.adoc[Custom Module] section as well as `$JETTY_HOME/etc/jetty-openid.xml` for information on how to do this.
diff --git a/documentation/jetty/modules/programming-guide/nav.adoc b/documentation/jetty/modules/programming-guide/nav.adoc
index a7094cf4c958..62b3c05a9c97 100644
--- a/documentation/jetty/modules/programming-guide/nav.adoc
+++ b/documentation/jetty/modules/programming-guide/nav.adoc
@@ -45,6 +45,7 @@
** xref:troubleshooting/debugging.adoc[]
* Jetty Security
** xref:security/configuring-form-size.adoc[]
+** xref:security/openid-support.adoc[]
* Migration Guides
** xref:migration/94-to-10.adoc[]
** xref:migration/11-to-12.adoc[]
diff --git a/documentation/jetty/modules/programming-guide/pages/security/openid-support.adoc b/documentation/jetty/modules/programming-guide/pages/security/openid-support.adoc
new file mode 100644
index 000000000000..bccae48a83b1
--- /dev/null
+++ b/documentation/jetty/modules/programming-guide/pages/security/openid-support.adoc
@@ -0,0 +1,68 @@
+//
+// ========================================================================
+// 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
+// ========================================================================
+//
+
+[[openid-support]]
+= OpenID Support
+
+Jetty supports authentication using the OpenID Connect protocol (see link:https://openid.net/specs/openid-connect-core-1_0-final.html[OpenID Connect Core 1.0]).
+This allows users to authenticate with third party OpenID Providers, making possible to integrate features like "Sign in with Google" or "Sign in with Microsoft", among others.
+With OpenID Connect, Jetty applications can offload the responsibility of managing user credentials while enabling features like single sign-on (SSO).
+
+== External Configuration
+
+To use OpenID Connect authentication with Jetty you are required to set up an external OpenID Provider, some examples of this are link:https://developers.google.com/identity/protocols/OpenIDConnect[Google] and link:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html[Amazon].
+Once you have set up your OpenID Provider you will have access to a Client ID and Client Secret which you can use to configure Jetty.
+
+You must also configure your OpenID Provider to recognize the redirect URIs that your application will use to handle authentication responses.
+By default, the redirect path is `/j_security_check`, but this can be customized through the `OpenIdAuthenticator` configuration if needed.
+
+For example, you may wish to register the following URIs:
+
+ * For a deployed application, `+https://example.com/j_security_check+`.
+ * For local development, `+http://localhost:8080/j_security_check+`.
+
+Ensure that all relevant environments where your application is deployed have their corresponding URIs registered with the OpenID Provider to avoid authentication errors.
+
+== Jetty Configuration
+
+=== Code Example
+
+This is an example of how you can configure OpenID Connect authentication with an embedded Jetty environment with the Jetty Core API.
+
+[,java,indent=0]
+----
+include::code:example$src/main/java/org/eclipse/jetty/docs/programming/security/OpenIdDocs.java[tags=openIdConfigExample]
+----
+
+=== Application Usage
+
+Here is an example of a Jetty Core `Handler` which handles authenticated requests by accessing the OpenID claims, and also handles authentication errors at `/error`.
+
+[,java,indent=0]
+----
+include::code:example$src/main/java/org/eclipse/jetty/docs/programming/security/OpenIdDocs.java[tags=openIdUsageExample]
+----
+
+==== Claims and Access Token
+Claims about the user can be found using attributes in the HTTP session attribute `org.eclipse.jetty.security.openid.claims`, and the full response containing the OAuth 2.0 Access Token can be found in the HTTP session attribute `org.eclipse.jetty.security.openid.response`.
+
+=== Authorization
+
+If security roles are required they can be configured through a nested `LoginService` which is deferred to for user roles.
+
+The nested `LoginService` be configured through the constructor arguments of `OpenIdLoginService`, or left `null` if no user roles are required.
+
+When using authorization roles, the `authenticateNewUsers` setting becomes significant, which can be configured through the `OpenIdConfiguration`, or directly on the `OpenIdLoginService`.
+
+ * If set to `true`, users not known by the nested `LoginService` will still be authenticated but will have no roles.
+ * If set to `false`, users not known by the nested `LoginService` will be not be allowed to authenticate and are redirected to the error page.
diff --git a/jetty-core/jetty-openid/pom.xml b/jetty-core/jetty-openid/pom.xml
index ccd5c30dd2e3..3b48e9e6dc94 100644
--- a/jetty-core/jetty-openid/pom.xml
+++ b/jetty-core/jetty-openid/pom.xml
@@ -47,6 +47,11 @@
jetty-slf4j-impl
test
+
+ org.eclipse.jetty.tests
+ jetty-test-common
+ test
+
org.eclipse.jetty.toolchain
jetty-test-helper
diff --git a/jetty-core/jetty-openid/src/main/config/modules/openid.mod b/jetty-core/jetty-openid/src/main/config/modules/openid.mod
index 1f75f0478a23..612d39b814d8 100644
--- a/jetty-core/jetty-openid/src/main/config/modules/openid.mod
+++ b/jetty-core/jetty-openid/src/main/config/modules/openid.mod
@@ -19,6 +19,7 @@ etc/jetty-openid-baseloginservice.xml
etc/jetty-openid.xml
[ini-template]
+# tag::documentation[]
## The OpenID Identity Provider's issuer ID (the entire URL *before* ".well-known/openid-configuration")
# jetty.openid.provider=https://id.example.com/
@@ -48,3 +49,4 @@ etc/jetty-openid.xml
## Whether the user should be logged out after the idToken expires.
# jetty.openid.logoutWhenIdTokenIsExpired=false
+# end::documentation[]
diff --git a/jetty-core/jetty-openid/src/test/java/org/eclipse/jetty/security/openid/JwtDecoderTest.java b/jetty-core/jetty-openid/src/test/java/org/eclipse/jetty/security/openid/JwtDecoderTest.java
index b343b222e52e..32a377d313cd 100644
--- a/jetty-core/jetty-openid/src/test/java/org/eclipse/jetty/security/openid/JwtDecoderTest.java
+++ b/jetty-core/jetty-openid/src/test/java/org/eclipse/jetty/security/openid/JwtDecoderTest.java
@@ -16,6 +16,7 @@
import java.util.Map;
import java.util.stream.Stream;
+import org.eclipse.jetty.tests.JwtEncoder;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
diff --git a/jetty-core/jetty-openid/src/test/java/org/eclipse/jetty/security/openid/OpenIdAuthenticationTest.java b/jetty-core/jetty-openid/src/test/java/org/eclipse/jetty/security/openid/OpenIdAuthenticationTest.java
index 79baf390aea4..e122d9b8e5d6 100644
--- a/jetty-core/jetty-openid/src/test/java/org/eclipse/jetty/security/openid/OpenIdAuthenticationTest.java
+++ b/jetty-core/jetty-openid/src/test/java/org/eclipse/jetty/security/openid/OpenIdAuthenticationTest.java
@@ -43,6 +43,7 @@
import org.eclipse.jetty.server.handler.ContextHandlerCollection;
import org.eclipse.jetty.session.FileSessionDataStoreFactory;
import org.eclipse.jetty.session.SessionHandler;
+import org.eclipse.jetty.tests.OpenIdProvider;
import org.eclipse.jetty.toolchain.test.MavenTestingUtils;
import org.eclipse.jetty.util.Callback;
import org.eclipse.jetty.util.IO;
diff --git a/jetty-core/jetty-openid/src/test/java/org/eclipse/jetty/security/openid/OpenIdProvider.java b/jetty-core/jetty-openid/src/test/java/org/eclipse/jetty/security/openid/OpenIdProvider.java
deleted file mode 100644
index 512237b8c6ec..000000000000
--- a/jetty-core/jetty-openid/src/test/java/org/eclipse/jetty/security/openid/OpenIdProvider.java
+++ /dev/null
@@ -1,445 +0,0 @@
-//
-// ========================================================================
-// 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.security.openid;
-
-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.HttpStatus;
-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.server.handler.ContextHandler;
-import org.eclipse.jetty.server.handler.ContextHandlerCollection;
-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(String clientId, String clientSecret)
- {
- this.clientId = clientId;
- this.clientSecret = clientSecret;
-
- server = new Server();
- connector = new ServerConnector(server);
- server.addConnector(connector);
-
- ContextHandlerCollection contexts = new ContextHandlerCollection();
- contexts.addHandler(new ConfigServlet(CONFIG_PATH));
- contexts.addHandler(new AuthEndpoint(AUTH_PATH));
- contexts.addHandler(new TokenEndpoint(TOKEN_PATH));
- contexts.addHandler(new EndSessionEndpoint(END_SESSION_PATH));
- server.setHandler(contexts);
-
- addBean(server);
- }
-
- public void setIdTokenDuration(long duration)
- {
- _idTokenDuration = duration;
- }
-
- public long getIdTokenDuration()
- {
- return _idTokenDuration;
- }
-
- public void join() throws InterruptedException
- {
- server.join();
- }
-
- public OpenIdConfiguration getOpenIdConfiguration()
- {
- String provider = getProvider();
- String authEndpoint = provider + AUTH_PATH;
- String tokenEndpoint = provider + TOKEN_PATH;
- return new OpenIdConfiguration(provider, authEndpoint, tokenEndpoint, clientId, clientSecret, null);
- }
-
- 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 AuthEndpoint extends ContextHandler
- {
- public AuthEndpoint(String contextPath)
- {
- super(contextPath);
- }
-
- @Override
- public boolean handle(Request request, Response response, Callback callback) throws Exception
- {
- switch (request.getMethod())
- {
- case "GET":
- doGet(request, response, callback);
- break;
- case "POST":
- doPost(request, response, callback);
- break;
- default:
- throw new BadMessageException("Unsupported HTTP Method");
- }
-
- return true;
- }
-
- protected void doGet(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))
- {
- 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(StringUtil.csvSplit(scopeString));
- 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)
- {
- response.getHeaders().add(HttpHeader.CONTENT_TYPE, "text/html");
-
- String content =
- "Login to OpenID Connect Provider
" +
- "";
- response.write(true, BufferUtil.toBuffer(content), callback);
- }
- else
- {
- redirectUser(request, response, callback, preAuthedUser, redirectUri, state);
- }
- }
-
- protected void doPost(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;
- }
- }
- }
-
- private class TokenEndpoint extends ContextHandler
- {
- public TokenEndpoint(String contextPath)
- {
- super(contextPath);
- }
-
- @Override
- public boolean handle(Request request, Response response, Callback callback) throws Exception
- {
- 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 true;
- }
-
- User user = issuedAuthCodes.remove(code);
- if (user == null)
- {
- Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "invalid auth code");
- return true;
- }
-
- String accessToken = "ABCDEFG";
- long accessTokenDuration = Duration.ofMinutes(10).toSeconds();
- String content = "{" +
- "\"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(content), callback);
- return true;
- }
- }
-
- private class EndSessionEndpoint extends ContextHandler
- {
- public EndSessionEndpoint(String contextPath)
- {
- super(contextPath);
- }
-
- @Override
- public boolean handle(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 true;
- }
-
- 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 true;
- }
-
- loggedInUsers.decrement();
- response.getHeaders().put(HttpHeader.CONTENT_TYPE, "text/plain");
- Response.sendRedirect(request, response, callback, logoutRedirect);
- return true;
- }
- }
-
- private class ConfigServlet extends ContextHandler
- {
- public ConfigServlet(String contextPath)
- {
- super(contextPath);
- }
-
- @Override
- public boolean handle(Request request, Response response, Callback callback) throws Exception
- {
- 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);
- return true;
- }
- }
-
- 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/jetty-ee8/jetty-ee8-openid/pom.xml b/jetty-ee8/jetty-ee8-openid/pom.xml
index e6d9e2b753db..30bf1aa9c3f0 100644
--- a/jetty-ee8/jetty-ee8-openid/pom.xml
+++ b/jetty-ee8/jetty-ee8-openid/pom.xml
@@ -52,6 +52,11 @@
jetty-ee8-servlet
test
+
+ org.eclipse.jetty.tests
+ jetty-test-common
+ test
+
org.eclipse.jetty.toolchain
jetty-test-helper
diff --git a/jetty-ee9/jetty-ee9-openid/pom.xml b/jetty-ee9/jetty-ee9-openid/pom.xml
index bf3dd4584ce4..4a9dbeeb5a5b 100644
--- a/jetty-ee9/jetty-ee9-openid/pom.xml
+++ b/jetty-ee9/jetty-ee9-openid/pom.xml
@@ -55,6 +55,11 @@
jetty-ee9-servlet
test
+
+ org.eclipse.jetty.tests
+ jetty-test-common
+ test
+
org.eclipse.jetty.toolchain
jetty-test-helper
diff --git a/jetty-ee9/jetty-ee9-openid/src/test/java/org/eclipse/jetty/ee9/security/openid/JwtEncoder.java b/jetty-ee9/jetty-ee9-openid/src/test/java/org/eclipse/jetty/ee9/security/openid/JwtEncoder.java
deleted file mode 100644
index 6de75b9e4ac1..000000000000
--- a/jetty-ee9/jetty-ee9-openid/src/test/java/org/eclipse/jetty/ee9/security/openid/JwtEncoder.java
+++ /dev/null
@@ -1,53 +0,0 @@
-//
-// ========================================================================
-// 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.ee9.security.openid;
-
-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/jetty-ee9/jetty-ee9-openid/src/test/java/org/eclipse/jetty/ee9/security/openid/OpenIdAuthenticationTest.java b/jetty-ee9/jetty-ee9-openid/src/test/java/org/eclipse/jetty/ee9/security/openid/OpenIdAuthenticationTest.java
index dfa5d06537b4..b98937811847 100644
--- a/jetty-ee9/jetty-ee9-openid/src/test/java/org/eclipse/jetty/ee9/security/openid/OpenIdAuthenticationTest.java
+++ b/jetty-ee9/jetty-ee9-openid/src/test/java/org/eclipse/jetty/ee9/security/openid/OpenIdAuthenticationTest.java
@@ -43,6 +43,7 @@
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.session.FileSessionDataStoreFactory;
+import org.eclipse.jetty.tests.OpenIdProvider;
import org.eclipse.jetty.toolchain.test.MavenTestingUtils;
import org.eclipse.jetty.util.IO;
import org.eclipse.jetty.util.security.Password;
diff --git a/jetty-ee9/jetty-ee9-openid/src/test/java/org/eclipse/jetty/ee9/security/openid/OpenIdProvider.java b/jetty-ee9/jetty-ee9-openid/src/test/java/org/eclipse/jetty/ee9/security/openid/OpenIdProvider.java
deleted file mode 100644
index 21f1ff6b7cb8..000000000000
--- a/jetty-ee9/jetty-ee9-openid/src/test/java/org/eclipse/jetty/ee9/security/openid/OpenIdProvider.java
+++ /dev/null
@@ -1,402 +0,0 @@
-//
-// ========================================================================
-// 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.ee9.security.openid;
-
-import java.io.IOException;
-import java.io.PrintWriter;
-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 jakarta.servlet.ServletException;
-import jakarta.servlet.http.HttpServlet;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-import org.eclipse.jetty.ee9.servlet.ServletContextHandler;
-import org.eclipse.jetty.ee9.servlet.ServletHolder;
-import org.eclipse.jetty.security.openid.OpenIdConfiguration;
-import org.eclipse.jetty.server.Server;
-import org.eclipse.jetty.server.ServerConnector;
-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(String clientId, String clientSecret)
- {
- this.clientId = clientId;
- this.clientSecret = clientSecret;
-
- server = new Server();
- connector = new ServerConnector(server);
- server.addConnector(connector);
-
- ServletContextHandler contextHandler = new ServletContextHandler();
- contextHandler.setContextPath("/");
- contextHandler.addServlet(new ServletHolder(new ConfigServlet()), CONFIG_PATH);
- contextHandler.addServlet(new ServletHolder(new AuthEndpoint()), AUTH_PATH);
- contextHandler.addServlet(new ServletHolder(new TokenEndpoint()), TOKEN_PATH);
- contextHandler.addServlet(new ServletHolder(new EndSessionEndpoint()), END_SESSION_PATH);
- server.setHandler(contextHandler);
-
- addBean(server);
- }
-
- public void setIdTokenDuration(long duration)
- {
- _idTokenDuration = duration;
- }
-
- public long getIdTokenDuration()
- {
- return _idTokenDuration;
- }
-
- public void join() throws InterruptedException
- {
- server.join();
- }
-
- public OpenIdConfiguration getOpenIdConfiguration()
- {
- String provider = getProvider();
- String authEndpoint = provider + AUTH_PATH;
- String tokenEndpoint = provider + TOKEN_PATH;
- return new OpenIdConfiguration(provider, authEndpoint, tokenEndpoint, clientId, clientSecret, null);
- }
-
- 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 AuthEndpoint extends HttpServlet
- {
- @Override
- protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException
- {
- if (!clientId.equals(req.getParameter("client_id")))
- {
- resp.sendError(HttpServletResponse.SC_FORBIDDEN, "invalid client_id");
- return;
- }
-
- String redirectUri = req.getParameter("redirect_uri");
- if (!redirectUris.contains(redirectUri))
- {
- LOG.warn("invalid redirectUri {}", redirectUri);
- resp.sendError(HttpServletResponse.SC_FORBIDDEN, "invalid redirect_uri");
- return;
- }
-
- String scopeString = req.getParameter("scope");
- List scopes = (scopeString == null) ? Collections.emptyList() : Arrays.asList(scopeString.split(" "));
- if (!scopes.contains("openid"))
- {
- resp.sendError(HttpServletResponse.SC_FORBIDDEN, "no openid scope");
- return;
- }
-
- if (!"code".equals(req.getParameter("response_type")))
- {
- resp.sendError(HttpServletResponse.SC_FORBIDDEN, "response_type must be code");
- return;
- }
-
- String state = req.getParameter("state");
- if (state == null)
- {
- resp.sendError(HttpServletResponse.SC_FORBIDDEN, "no state param");
- return;
- }
-
- if (preAuthedUser == null)
- {
- PrintWriter writer = resp.getWriter();
- resp.setContentType("text/html");
- writer.println("Login to OpenID Connect Provider
");
- writer.println("");
- }
- else
- {
- redirectUser(resp, preAuthedUser, redirectUri, state);
- }
- }
-
- @Override
- protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException
- {
- String redirectUri = req.getParameter("redirectUri");
- if (!redirectUris.contains(redirectUri))
- {
- resp.sendError(HttpServletResponse.SC_FORBIDDEN, "invalid redirect_uri");
- return;
- }
-
- String state = req.getParameter("state");
- if (state == null)
- {
- resp.sendError(HttpServletResponse.SC_FORBIDDEN, "no state param");
- return;
- }
-
- String username = req.getParameter("username");
- if (username == null)
- {
- resp.sendError(HttpServletResponse.SC_FORBIDDEN, "no username");
- return;
- }
-
- User user = new User(username);
- redirectUser(resp, user, redirectUri, state);
- }
-
- public void redirectUser(HttpServletResponse response, 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(response.encodeRedirectURL(redirectUri));
- }
- catch (Throwable t)
- {
- issuedAuthCodes.remove(authCode);
- throw t;
- }
- }
- }
-
- private class TokenEndpoint extends HttpServlet
- {
- @Override
- protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException
- {
- String code = req.getParameter("code");
-
- if (!clientId.equals(req.getParameter("client_id")) ||
- !clientSecret.equals(req.getParameter("client_secret")) ||
- !redirectUris.contains(req.getParameter("redirect_uri")) ||
- !"authorization_code".equals(req.getParameter("grant_type")) ||
- code == null)
- {
- resp.sendError(HttpServletResponse.SC_FORBIDDEN, "bad auth request");
- return;
- }
-
- User user = issuedAuthCodes.remove(code);
- if (user == null)
- {
- resp.sendError(HttpServletResponse.SC_FORBIDDEN, "invalid auth code");
- return;
- }
-
- String accessToken = "ABCDEFG";
- long accessTokenDuration = Duration.ofMinutes(10).toSeconds();
- String response = "{" +
- "\"access_token\": \"" + accessToken + "\"," +
- "\"id_token\": \"" + JwtEncoder.encode(user.getIdToken(provider, clientId, _idTokenDuration)) + "\"," +
- "\"expires_in\": " + accessTokenDuration + "," +
- "\"token_type\": \"Bearer\"" +
- "}";
-
- loggedInUsers.increment();
- resp.setContentType("text/plain");
- resp.getWriter().print(response);
- }
- }
-
- private class EndSessionEndpoint extends HttpServlet
- {
- @Override
- protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException
- {
- doPost(req, resp);
- }
-
- @Override
- protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException
- {
- String idToken = req.getParameter("id_token_hint");
- if (idToken == null)
- {
- resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "no id_token_hint");
- return;
- }
-
- String logoutRedirect = req.getParameter("post_logout_redirect_uri");
- if (logoutRedirect == null)
- {
- resp.setStatus(HttpServletResponse.SC_OK);
- resp.getWriter().println("logout success on end_session_endpoint");
- return;
- }
-
- loggedInUsers.decrement();
- resp.setContentType("text/plain");
- resp.sendRedirect(logoutRedirect);
- }
- }
-
- private class ConfigServlet extends HttpServlet
- {
- @Override
- protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException
- {
- String discoveryDocument = "{" +
- "\"issuer\": \"" + provider + "\"," +
- "\"authorization_endpoint\": \"" + provider + AUTH_PATH + "\"," +
- "\"token_endpoint\": \"" + provider + TOKEN_PATH + "\"," +
- "\"end_session_endpoint\": \"" + provider + END_SESSION_PATH + "\"," +
- "}";
-
- resp.getWriter().write(discoveryDocument);
- }
- }
-
- 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/pom.xml b/pom.xml
index 1e8bef85ea48..c12b61a24c28 100644
--- a/pom.xml
+++ b/pom.xml
@@ -942,6 +942,11 @@
jetty-quic-server
${project.version}
+
+ org.eclipse.jetty.tests
+ jetty-test-common
+ ${project.version}
+
org.eclipse.jetty.tests
jetty-test-session-common
diff --git a/tests/jetty-test-common/pom.xml b/tests/jetty-test-common/pom.xml
new file mode 100644
index 000000000000..3f26bcd929c5
--- /dev/null
+++ b/tests/jetty-test-common/pom.xml
@@ -0,0 +1,29 @@
+
+
+
+ 4.0.0
+
+ org.eclipse.jetty.tests
+ tests
+ 12.0.17-SNAPSHOT
+
+ jetty-test-common
+ jar
+ Tests :: Test Utilities
+
+
+ ${project.groupId}.testers
+
+
+
+
+ org.eclipse.jetty
+ jetty-server
+
+
+ org.eclipse.jetty
+ jetty-util
+
+
+
+
diff --git a/jetty-core/jetty-openid/src/test/java/org/eclipse/jetty/security/openid/JwtEncoder.java b/tests/jetty-test-common/src/main/java/org/eclipse/jetty/tests/JwtEncoder.java
similarity index 97%
rename from jetty-core/jetty-openid/src/test/java/org/eclipse/jetty/security/openid/JwtEncoder.java
rename to tests/jetty-test-common/src/main/java/org/eclipse/jetty/tests/JwtEncoder.java
index 928a51fd8224..d4611f07a774 100644
--- a/jetty-core/jetty-openid/src/test/java/org/eclipse/jetty/security/openid/JwtEncoder.java
+++ b/tests/jetty-test-common/src/main/java/org/eclipse/jetty/tests/JwtEncoder.java
@@ -11,7 +11,7 @@
// ========================================================================
//
-package org.eclipse.jetty.security.openid;
+package org.eclipse.jetty.tests;
import java.util.Base64;
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 000000000000..aba79c2a0c39
--- /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 cb3a643eb5bc..b97509ad3494 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