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("
"); - writer.println(""); - writer.println(""); - writer.println(""); - writer.println(""); - 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