diff --git a/tests/jetty-test-common/pom.xml b/tests/jetty-test-common/pom.xml new file mode 100644 index 00000000000..d820ada4c30 --- /dev/null +++ b/tests/jetty-test-common/pom.xml @@ -0,0 +1,29 @@ + + + + 4.0.0 + + org.eclipse.jetty.tests + tests + 12.1.0-SNAPSHOT + + jetty-test-common + jar + Tests :: Test Utilities + + + ${project.groupId}.testers + + + + + org.eclipse.jetty + jetty-server + + + org.eclipse.jetty + jetty-util + + + + diff --git a/tests/jetty-test-common/src/main/java/org/eclipse/jetty/tests/JwtEncoder.java b/tests/jetty-test-common/src/main/java/org/eclipse/jetty/tests/JwtEncoder.java new file mode 100644 index 00000000000..d4611f07a77 --- /dev/null +++ b/tests/jetty-test-common/src/main/java/org/eclipse/jetty/tests/JwtEncoder.java @@ -0,0 +1,53 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.tests; + +import java.util.Base64; + +/** + * A basic JWT encoder for testing purposes. + */ +public class JwtEncoder +{ + private static final Base64.Encoder ENCODER = Base64.getUrlEncoder(); + private static final String DEFAULT_HEADER = "{\"INFO\": \"this is not used or checked in our implementation\"}"; + private static final String DEFAULT_SIGNATURE = "we do not validate signature as we use the authorization code flow"; + + public static String encode(String idToken) + { + return stripPadding(ENCODER.encodeToString(DEFAULT_HEADER.getBytes())) + "." + + stripPadding(ENCODER.encodeToString(idToken.getBytes())) + "." + + stripPadding(ENCODER.encodeToString(DEFAULT_SIGNATURE.getBytes())); + } + + private static String stripPadding(String paddedBase64) + { + return paddedBase64.split("=")[0]; + } + + /** + * Create a basic JWT for testing using argument supplied attributes. + */ + public static String createIdToken(String provider, String clientId, String subject, String name, long expiry) + { + return "{" + + "\"iss\": \"" + provider + "\"," + + "\"sub\": \"" + subject + "\"," + + "\"aud\": \"" + clientId + "\"," + + "\"exp\": " + expiry + "," + + "\"name\": \"" + name + "\"," + + "\"email\": \"" + name + "@example.com" + "\"" + + "}"; + } +} diff --git a/tests/jetty-test-common/src/main/java/org/eclipse/jetty/tests/OpenIdProvider.java b/tests/jetty-test-common/src/main/java/org/eclipse/jetty/tests/OpenIdProvider.java new file mode 100644 index 00000000000..aba79c2a0c3 --- /dev/null +++ b/tests/jetty-test-common/src/main/java/org/eclipse/jetty/tests/OpenIdProvider.java @@ -0,0 +1,421 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.tests; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +import org.eclipse.jetty.http.BadMessageException; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.util.BufferUtil; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.Fields; +import org.eclipse.jetty.util.StringUtil; +import org.eclipse.jetty.util.component.ContainerLifeCycle; +import org.eclipse.jetty.util.statistic.CounterStatistic; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class OpenIdProvider extends ContainerLifeCycle +{ + private static final Logger LOG = LoggerFactory.getLogger(OpenIdProvider.class); + + private static final String CONFIG_PATH = "/.well-known/openid-configuration"; + private static final String AUTH_PATH = "/auth"; + private static final String TOKEN_PATH = "/token"; + private static final String END_SESSION_PATH = "/end_session"; + private final Map issuedAuthCodes = new HashMap<>(); + + protected final String clientId; + protected final String clientSecret; + protected final List redirectUris = new ArrayList<>(); + private final ServerConnector connector; + private final Server server; + private int port = 0; + private String provider; + private User preAuthedUser; + private final CounterStatistic loggedInUsers = new CounterStatistic(); + private long _idTokenDuration = Duration.ofSeconds(10).toMillis(); + + public static void main(String[] args) throws Exception + { + String clientId = "CLIENT_ID123"; + String clientSecret = "PASSWORD123"; + int port = 5771; + String redirectUri = "http://localhost:8080/j_security_check"; + + OpenIdProvider openIdProvider = new OpenIdProvider(clientId, clientSecret); + openIdProvider.addRedirectUri(redirectUri); + openIdProvider.setPort(port); + openIdProvider.start(); + try + { + openIdProvider.join(); + } + finally + { + openIdProvider.stop(); + } + } + + public OpenIdProvider() + { + this("clientId" + StringUtil.randomAlphaNumeric(4), StringUtil.randomAlphaNumeric(10)); + } + + public OpenIdProvider(String clientId, String clientSecret) + { + this.clientId = clientId; + this.clientSecret = clientSecret; + + server = new Server(); + connector = new ServerConnector(server); + server.addConnector(connector); + + server.setHandler(new OpenIdProviderHandler()); + addBean(server); + } + + public String getClientId() + { + return clientId; + } + + public String getClientSecret() + { + return clientSecret; + } + + public void setIdTokenDuration(long duration) + { + _idTokenDuration = duration; + } + + public long getIdTokenDuration() + { + return _idTokenDuration; + } + + public void join() throws InterruptedException + { + server.join(); + } + + public CounterStatistic getLoggedInUsers() + { + return loggedInUsers; + } + + @Override + protected void doStart() throws Exception + { + connector.setPort(port); + super.doStart(); + provider = "http://localhost:" + connector.getLocalPort(); + } + + public void setPort(int port) + { + if (isStarted()) + throw new IllegalStateException(); + this.port = port; + } + + public void setUser(User user) + { + this.preAuthedUser = user; + } + + public String getProvider() + { + if (!isStarted() && port == 0) + throw new IllegalStateException("Port of OpenIdProvider not configured"); + return provider; + } + + public void addRedirectUri(String uri) + { + redirectUris.add(uri); + } + + public class OpenIdProviderHandler extends Handler.Abstract + { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + String pathInContext = Request.getPathInContext(request); + switch (pathInContext) + { + case CONFIG_PATH -> doGetConfigServlet(request, response, callback); + case AUTH_PATH -> doAuthEndpoint(request, response, callback); + case TOKEN_PATH -> doTokenEndpoint(request, response, callback); + case END_SESSION_PATH -> doEndSessionEndpoint(request, response, callback); + default -> Response.writeError(request, response, callback, HttpStatus.NOT_FOUND_404); + } + + return true; + } + } + + protected void doAuthEndpoint(Request request, Response response, Callback callback) throws Exception + { + String method = request.getMethod(); + switch (method) + { + case "GET" -> doGetAuthEndpoint(request, response, callback); + case "POST" -> doPostAuthEndpoint(request, response, callback); + default -> throw new BadMessageException("Unsupported HTTP method: " + method); + } + } + + protected void doGetAuthEndpoint(Request request, Response response, Callback callback) throws Exception + { + Fields parameters = Request.getParameters(request); + + if (!clientId.equals(parameters.getValue("client_id"))) + { + Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "invalid client_id"); + return; + } + + String redirectUri = parameters.getValue("redirect_uri"); + if (!redirectUris.contains(redirectUri)) + { + LOG.warn("invalid redirectUri {}", redirectUri); + Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "invalid redirect_uri"); + return; + } + + String scopeString = parameters.getValue("scope"); + List scopes = (scopeString == null) ? Collections.emptyList() : Arrays.asList(scopeString.split(" ")); + if (!scopes.contains("openid")) + { + Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "no openid scope"); + return; + } + + if (!"code".equals(parameters.getValue("response_type"))) + { + Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "response_type must be code"); + return; + } + + String state = parameters.getValue("state"); + if (state == null) + { + Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "no state param"); + return; + } + + if (preAuthedUser == null) + { + String responseContent = String.format(""" +

Login to OpenID Connect Provider

+
+ + + + +
+ """, AUTH_PATH, redirectUri, state); + response.getHeaders().put(HttpHeader.CONTENT_TYPE, "text/html"); + response.write(true, BufferUtil.toBuffer(responseContent), callback); + } + else + { + redirectUser(request, response, callback, preAuthedUser, redirectUri, state); + } + } + + protected void doPostAuthEndpoint(Request request, Response response, Callback callback) throws Exception + { + Fields parameters = Request.getParameters(request); + String redirectUri = parameters.getValue("redirectUri"); + if (!redirectUris.contains(redirectUri)) + { + Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "invalid redirect_uri"); + return; + } + + String state = parameters.getValue("state"); + if (state == null) + { + Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "no state param"); + return; + } + + String username = parameters.getValue("username"); + if (username == null) + { + Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "no username"); + return; + } + + User user = new User(username); + redirectUser(request, response, callback, user, redirectUri, state); + } + + public void redirectUser(Request request, Response response, Callback callback, User user, String redirectUri, String state) throws IOException + { + String authCode = UUID.randomUUID().toString().replace("-", ""); + issuedAuthCodes.put(authCode, user); + + try + { + redirectUri += "?code=" + authCode + "&state=" + state; + Response.sendRedirect(request, response, callback, redirectUri); + } + catch (Throwable t) + { + issuedAuthCodes.remove(authCode); + throw t; + } + } + + protected void doTokenEndpoint(Request request, Response response, Callback callback) throws Exception + { + if (!HttpMethod.POST.is(request.getMethod())) + throw new BadMessageException("Unsupported HTTP method for token Endpoint: " + request.getMethod()); + + Fields parameters = Request.getParameters(request); + String code = parameters.getValue("code"); + + if (!clientId.equals(parameters.getValue("client_id")) || + !clientSecret.equals(parameters.getValue("client_secret")) || + !redirectUris.contains(parameters.getValue("redirect_uri")) || + !"authorization_code".equals(parameters.getValue("grant_type")) || + code == null) + { + Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "bad auth request"); + return; + } + + User user = issuedAuthCodes.remove(code); + if (user == null) + { + Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, "invalid auth code"); + return; + } + + String accessToken = "ABCDEFG"; + long accessTokenDuration = Duration.ofMinutes(10).toSeconds(); + String responseContent = "{" + + "\"access_token\": \"" + accessToken + "\"," + + "\"id_token\": \"" + JwtEncoder.encode(user.getIdToken(provider, clientId, _idTokenDuration)) + "\"," + + "\"expires_in\": " + accessTokenDuration + "," + + "\"token_type\": \"Bearer\"" + + "}"; + + loggedInUsers.increment(); + response.getHeaders().put(HttpHeader.CONTENT_TYPE, "text/plain"); + response.write(true, BufferUtil.toBuffer(responseContent), callback); + } + + protected void doEndSessionEndpoint(Request request, Response response, Callback callback) throws Exception + { + Fields parameters = Request.getParameters(request); + String idToken = parameters.getValue("id_token_hint"); + if (idToken == null) + { + Response.writeError(request, response, callback, HttpStatus.BAD_REQUEST_400, "no id_token_hint"); + return; + } + + String logoutRedirect = parameters.getValue("post_logout_redirect_uri"); + if (logoutRedirect == null) + { + response.setStatus(HttpStatus.OK_200); + response.write(true, BufferUtil.toBuffer("logout success on end_session_endpoint"), callback); + return; + } + + loggedInUsers.decrement(); + Response.sendRedirect(request, response, callback, logoutRedirect); + } + + protected void doGetConfigServlet(Request request, Response response, Callback callback) throws IOException + { + String discoveryDocument = "{" + + "\"issuer\": \"" + provider + "\"," + + "\"authorization_endpoint\": \"" + provider + AUTH_PATH + "\"," + + "\"token_endpoint\": \"" + provider + TOKEN_PATH + "\"," + + "\"end_session_endpoint\": \"" + provider + END_SESSION_PATH + "\"," + + "}"; + + response.write(true, BufferUtil.toBuffer(discoveryDocument), callback); + } + + public static class User + { + private final String subject; + private final String name; + + public User(String name) + { + this(UUID.nameUUIDFromBytes(name.getBytes()).toString(), name); + } + + public User(String subject, String name) + { + this.subject = subject; + this.name = name; + } + + public String getName() + { + return name; + } + + public String getSubject() + { + return subject; + } + + public String getIdToken(String provider, String clientId, long duration) + { + long expiryTime = Instant.now().plusMillis(duration).getEpochSecond(); + return JwtEncoder.createIdToken(provider, clientId, subject, name, expiryTime); + } + + @Override + public boolean equals(Object obj) + { + if (!(obj instanceof User)) + return false; + return Objects.equals(subject, ((User)obj).subject) && Objects.equals(name, ((User)obj).name); + } + + @Override + public int hashCode() + { + return Objects.hash(subject, name); + } + } +} diff --git a/tests/pom.xml b/tests/pom.xml index 0ec5773e2db..73a94d115ce 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -13,6 +13,7 @@ jetty-testers jetty-jmh + jetty-test-common jetty-test-multipart jetty-test-session-common test-cross-context-dispatch diff --git a/tests/test-distribution/test-distribution-common/pom.xml b/tests/test-distribution/test-distribution-common/pom.xml index 65c35e81de5..4fc3b4b3450 100644 --- a/tests/test-distribution/test-distribution-common/pom.xml +++ b/tests/test-distribution/test-distribution-common/pom.xml @@ -187,6 +187,20 @@ war test + + org.eclipse.jetty.ee10 + jetty-ee10-test-openid-webapp + ${project.version} + war + test + + + org.eclipse.jetty.ee11 + jetty-ee11-test-openid-webapp + ${project.version} + war + test + org.eclipse.jetty.http2 jetty-http2-client