diff --git a/jetty-ee10/jetty-ee10-openid/src/main/java/org/eclipse/jetty/ee10/security/openid/OpenIdAuthenticator.java b/jetty-ee10/jetty-ee10-openid/src/main/java/org/eclipse/jetty/ee10/security/openid/OpenIdAuthenticator.java index a12ee1b2120..18a79d2ab4f 100644 --- a/jetty-ee10/jetty-ee10-openid/src/main/java/org/eclipse/jetty/ee10/security/openid/OpenIdAuthenticator.java +++ b/jetty-ee10/jetty-ee10-openid/src/main/java/org/eclipse/jetty/ee10/security/openid/OpenIdAuthenticator.java @@ -25,8 +25,8 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import org.eclipse.jetty.ee10.servlet.ServletContextRequest; +import org.eclipse.jetty.ee10.servlet.ServletContextResponse; import org.eclipse.jetty.ee10.servlet.security.Authentication; -import org.eclipse.jetty.ee10.servlet.security.Authenticator; import org.eclipse.jetty.ee10.servlet.security.LoginService; import org.eclipse.jetty.ee10.servlet.security.ServerAuthException; import org.eclipse.jetty.ee10.servlet.security.UserAuthentication; @@ -69,6 +69,7 @@ public class OpenIdAuthenticator extends LoginAuthenticator public static final String RESPONSE = "org.eclipse.jetty.security.openid.response"; public static final String ISSUER = "org.eclipse.jetty.security.openid.issuer"; public static final String REDIRECT_PATH = "org.eclipse.jetty.security.openid.redirect_path"; + public static final String LOGOUT_REDIRECT_PATH = "org.eclipse.jetty.security.openid.logout_redirect_path"; public static final String ERROR_PAGE = "org.eclipse.jetty.security.openid.error_page"; public static final String J_URI = "org.eclipse.jetty.security.openid.URI"; public static final String J_POST = "org.eclipse.jetty.security.openid.POST"; @@ -83,6 +84,7 @@ public class OpenIdAuthenticator extends LoginAuthenticator private final SecureRandom _secureRandom = new SecureRandom(); private OpenIdConfiguration _openIdConfiguration; private String _redirectPath; + private String _logoutRedirectPath; private String _errorPage; private String _errorPath; private String _errorQuery; @@ -104,15 +106,22 @@ public class OpenIdAuthenticator extends LoginAuthenticator } public OpenIdAuthenticator(OpenIdConfiguration configuration, String redirectPath, String errorPage) + { + this(configuration, redirectPath, errorPage, null); + } + + public OpenIdAuthenticator(OpenIdConfiguration configuration, String redirectPath, String errorPage, String logoutRedirectPath) { _openIdConfiguration = configuration; setRedirectPath(redirectPath); if (errorPage != null) setErrorPage(errorPage); + if (logoutRedirectPath != null) + setLogoutRedirectPath(logoutRedirectPath); } @Override - public void setConfiguration(Authenticator.AuthConfiguration authConfig) + public void setConfiguration(AuthConfiguration authConfig) { if (_openIdConfiguration == null) { @@ -124,12 +133,16 @@ public class OpenIdAuthenticator extends LoginAuthenticator String redirectPath = authConfig.getInitParameter(REDIRECT_PATH); if (redirectPath != null) - _redirectPath = redirectPath; + setRedirectPath(redirectPath); String error = authConfig.getInitParameter(ERROR_PAGE); if (error != null) setErrorPage(error); + String logout = authConfig.getInitParameter(LOGOUT_REDIRECT_PATH); + if (logout != null) + setLogoutRedirectPath(logout); + super.setConfiguration(new OpenIdAuthConfiguration(_openIdConfiguration, authConfig)); } @@ -167,6 +180,22 @@ public class OpenIdAuthenticator extends LoginAuthenticator _redirectPath = redirectPath; } + public void setLogoutRedirectPath(String logoutRedirectPath) + { + if (logoutRedirectPath == null) + { + LOG.warn("redirect path must not be null, defaulting to /"); + logoutRedirectPath = "/"; + } + else if (!logoutRedirectPath.startsWith("/")) + { + LOG.warn("redirect path must start with /"); + logoutRedirectPath = "/" + logoutRedirectPath; + } + + _logoutRedirectPath = logoutRedirectPath; + } + public void setErrorPage(String path) { if (path == null || path.trim().length() == 0) @@ -220,6 +249,7 @@ public class OpenIdAuthenticator extends LoginAuthenticator @Override public void logout(Request request) { + attemptLogoutRedirect(request); super.logout(request); ServletContextRequest servletContextRequest = Request.as(request, ServletContextRequest.class); HttpServletRequest httpRequest = servletContextRequest.getHttpServletRequest(); @@ -233,6 +263,65 @@ public class OpenIdAuthenticator extends LoginAuthenticator session.removeAttribute(SessionAuthentication.__J_AUTHENTICATED); session.removeAttribute(CLAIMS); session.removeAttribute(RESPONSE); + session.removeAttribute(ISSUER); + } + } + + /** + * This will attempt to redirect the request to the end_session_endpoint, and finally to the {@link #REDIRECT_PATH}. + * + * If end_session_endpoint is defined the request will be redirected to the end_session_endpoint, the optional + * post_logout_redirect_uri parameter will be set if {@link #REDIRECT_PATH} is non-null. + * + * If the end_session_endpoint is not defined then the request will be redirected to {@link #REDIRECT_PATH} if it is a + * non-null value, otherwise no redirection will be done. + * + * @param request the request to redirect. + */ + private void attemptLogoutRedirect(Request request) + { + try + { + ServletContextRequest baseRequest = Request.as(request, ServletContextRequest.class); + ServletContextResponse baseResponse = baseRequest.getResponse(); + HttpServletRequest httpServletRequest = baseRequest.getHttpServletRequest(); + HttpServletResponse httpServletResponse = baseResponse.getHttpServletResponse(); + String endSessionEndpoint = _openIdConfiguration.getEndSessionEndpoint(); + String redirectUri = null; + if (_logoutRedirectPath != null) + { + StringBuilder sb = new StringBuilder(128); + URIUtil.appendSchemeHostPort(sb, httpServletRequest.getScheme(), httpServletRequest.getServerName(), httpServletRequest.getServerPort()); + sb.append(httpServletRequest.getContextPath()); + sb.append(_logoutRedirectPath); + redirectUri = sb.toString(); + } + + HttpSession session = baseRequest.getHttpServletRequest().getSession(false); + if (endSessionEndpoint == null || session == null) + { + if (redirectUri != null) + httpServletResponse.sendRedirect(redirectUri); + return; + } + + Object openIdResponse = session.getAttribute(OpenIdAuthenticator.RESPONSE); + if (!(openIdResponse instanceof Map)) + { + if (redirectUri != null) + httpServletResponse.sendRedirect(redirectUri); + return; + } + + @SuppressWarnings("rawtypes") + String idToken = (String)((Map)openIdResponse).get("id_token"); + httpServletResponse.sendRedirect(endSessionEndpoint + + "?id_token_hint=" + UrlEncoded.encodeString(idToken, StandardCharsets.UTF_8) + + ((redirectUri == null) ? "" : "&post_logout_redirect_uri=" + UrlEncoded.encodeString(redirectUri, StandardCharsets.UTF_8))); + } + catch (Throwable t) + { + LOG.warn("failed to redirect to end_session_endpoint", t); } } diff --git a/jetty-ee10/jetty-ee10-openid/src/main/java/org/eclipse/jetty/ee10/security/openid/OpenIdConfiguration.java b/jetty-ee10/jetty-ee10-openid/src/main/java/org/eclipse/jetty/ee10/security/openid/OpenIdConfiguration.java index 1b0ff83caeb..e452d10b91e 100644 --- a/jetty-ee10/jetty-ee10-openid/src/main/java/org/eclipse/jetty/ee10/security/openid/OpenIdConfiguration.java +++ b/jetty-ee10/jetty-ee10-openid/src/main/java/org/eclipse/jetty/ee10/security/openid/OpenIdConfiguration.java @@ -42,6 +42,7 @@ public class OpenIdConfiguration extends ContainerLifeCycle private static final String CONFIG_PATH = "/.well-known/openid-configuration"; private static final String AUTHORIZATION_ENDPOINT = "authorization_endpoint"; private static final String TOKEN_ENDPOINT = "token_endpoint"; + private static final String END_SESSION_ENDPOINT = "end_session_endpoint"; private static final String ISSUER = "issuer"; private final HttpClient httpClient; @@ -52,6 +53,7 @@ public class OpenIdConfiguration extends ContainerLifeCycle private final String authMethod; private String authEndpoint; private String tokenEndpoint; + private String endSessionEndpoint; private boolean authenticateNewUsers = false; /** @@ -97,14 +99,38 @@ public class OpenIdConfiguration extends ContainerLifeCycle @Name("clientSecret") String clientSecret, @Name("authMethod") String authMethod, @Name("httpClient") HttpClient httpClient) + { + this(issuer, authorizationEndpoint, tokenEndpoint, null, clientId, clientSecret, authMethod, httpClient); + } + + /** + * Create an OpenID configuration for a specific OIDC provider. + * @param issuer The URL of the OpenID provider. + * @param authorizationEndpoint the URL of the OpenID provider's authorization endpoint if configured. + * @param tokenEndpoint the URL of the OpenID provider's token endpoint if configured. + * @param endSessionEndpoint the URL of the OpdnID provider's end session endpoint if configured. + * @param clientId OAuth 2.0 Client Identifier valid at the Authorization Server. + * @param clientSecret The client secret known only by the Client and the Authorization Server. + * @param authMethod Authentication method to use with the Token Endpoint. + * @param httpClient The {@link HttpClient} instance to use. + */ + public OpenIdConfiguration(@Name("issuer") String issuer, + @Name("authorizationEndpoint") String authorizationEndpoint, + @Name("tokenEndpoint") String tokenEndpoint, + @Name("endSessionEndpoint") String endSessionEndpoint, + @Name("clientId") String clientId, + @Name("clientSecret") String clientSecret, + @Name("authMethod") String authMethod, + @Name("httpClient") HttpClient httpClient) { this.issuer = issuer; this.clientId = clientId; this.clientSecret = clientSecret; this.authEndpoint = authorizationEndpoint; + this.endSessionEndpoint = endSessionEndpoint; this.tokenEndpoint = tokenEndpoint; this.httpClient = httpClient != null ? httpClient : newHttpClient(); - this.authMethod = authMethod; + this.authMethod = authMethod == null ? "client_secret_post" : authMethod; if (this.issuer == null) throw new IllegalArgumentException("Issuer was not configured"); @@ -140,6 +166,10 @@ public class OpenIdConfiguration extends ContainerLifeCycle if (tokenEndpoint == null) throw new IllegalStateException(TOKEN_ENDPOINT); + // End session endpoint is optional. + if (endSessionEndpoint == null) + endSessionEndpoint = (String)discoveryDocument.get(END_SESSION_ENDPOINT); + // We are lenient and not throw here as some major OIDC providers do not conform to this. if (!Objects.equals(discoveryDocument.get(ISSUER), issuer)) LOG.warn("The issuer in the metadata is not correct."); @@ -166,8 +196,8 @@ public class OpenIdConfiguration extends ContainerLifeCycle { Map rawResult = (Map)parsedResult; result = rawResult.entrySet().stream() - .filter(entry -> entry.getValue() != null) - .collect(Collectors.toMap(it -> it.getKey().toString(), Map.Entry::getValue)); + .filter(entry -> entry.getValue() != null) + .collect(Collectors.toMap(it -> it.getKey().toString(), Map.Entry::getValue)); if (LOG.isDebugEnabled()) LOG.debug("discovery document {}", result); return result; @@ -214,6 +244,11 @@ public class OpenIdConfiguration extends ContainerLifeCycle return tokenEndpoint; } + public String getEndSessionEndpoint() + { + return endSessionEndpoint; + } + public String getAuthMethod() { return authMethod; diff --git a/jetty-ee10/jetty-ee10-openid/src/test/java/org/eclipse/jetty/ee10/security/openid/OpenIdAuthenticationTest.java b/jetty-ee10/jetty-ee10-openid/src/test/java/org/eclipse/jetty/ee10/security/openid/OpenIdAuthenticationTest.java index d1d023fcd68..d1911717271 100644 --- a/jetty-ee10/jetty-ee10-openid/src/test/java/org/eclipse/jetty/ee10/security/openid/OpenIdAuthenticationTest.java +++ b/jetty-ee10/jetty-ee10-openid/src/test/java/org/eclipse/jetty/ee10/security/openid/OpenIdAuthenticationTest.java @@ -17,6 +17,7 @@ import java.io.IOException; import java.security.Principal; import java.util.Map; +import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -35,6 +36,7 @@ import org.junit.jupiter.api.Test; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.is; @@ -102,6 +104,7 @@ public class OpenIdAuthenticationTest server.addBean(new OpenIdConfiguration(openIdProvider.getProvider(), CLIENT_ID, CLIENT_SECRET)); securityHandler.setInitParameter(OpenIdAuthenticator.REDIRECT_PATH, "/redirect_path"); securityHandler.setInitParameter(OpenIdAuthenticator.ERROR_PAGE, "/error"); + securityHandler.setInitParameter(OpenIdAuthenticator.LOGOUT_REDIRECT_PATH, "/"); context.setSecurityHandler(securityHandler); server.start(); @@ -155,6 +158,11 @@ public class OpenIdAuthenticationTest assertThat(response.getStatus(), is(HttpStatus.OK_200)); content = response.getContentAsString(); assertThat(content, containsString("not authenticated")); + + // Test that the user was logged out successfully on the openid provider. + assertThat(openIdProvider.getLoggedInUsers().getCurrent(), equalTo(0L)); + assertThat(openIdProvider.getLoggedInUsers().getMax(), equalTo(1L)); + assertThat(openIdProvider.getLoggedInUsers().getTotal(), equalTo(1L)); } public static class LoginPage extends HttpServlet @@ -171,10 +179,9 @@ public class OpenIdAuthenticationTest public static class LogoutPage extends HttpServlet { @Override - protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { - request.getSession().invalidate(); - response.sendRedirect("/"); + request.logout(); } } diff --git a/jetty-ee10/jetty-ee10-openid/src/test/java/org/eclipse/jetty/ee10/security/openid/OpenIdProvider.java b/jetty-ee10/jetty-ee10-openid/src/test/java/org/eclipse/jetty/ee10/security/openid/OpenIdProvider.java index e38c2ecabdb..380c792cd22 100644 --- a/jetty-ee10/jetty-ee10-openid/src/test/java/org/eclipse/jetty/ee10/security/openid/OpenIdProvider.java +++ b/jetty-ee10/jetty-ee10-openid/src/test/java/org/eclipse/jetty/ee10/security/openid/OpenIdProvider.java @@ -22,6 +22,7 @@ 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; @@ -34,6 +35,7 @@ import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; 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; @@ -44,6 +46,7 @@ public class OpenIdProvider extends ContainerLifeCycle 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; @@ -54,6 +57,7 @@ public class OpenIdProvider extends ContainerLifeCycle private int port = 0; private String provider; private User preAuthedUser; + private final CounterStatistic loggedInUsers = new CounterStatistic(); public static void main(String[] args) throws Exception { @@ -87,9 +91,10 @@ public class OpenIdProvider extends ContainerLifeCycle ServletContextHandler contextHandler = new ServletContextHandler(); contextHandler.setContextPath("/"); - contextHandler.addServlet(new ServletHolder(new OpenIdConfigServlet()), CONFIG_PATH); - contextHandler.addServlet(new ServletHolder(new OpenIdAuthEndpoint()), AUTH_PATH); - contextHandler.addServlet(new ServletHolder(new OpenIdTokenEndpoint()), TOKEN_PATH); + 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); @@ -108,6 +113,11 @@ public class OpenIdProvider extends ContainerLifeCycle return new OpenIdConfiguration(provider, authEndpoint, tokenEndpoint, clientId, clientSecret, null); } + public CounterStatistic getLoggedInUsers() + { + return loggedInUsers; + } + @Override protected void doStart() throws Exception { @@ -140,7 +150,7 @@ public class OpenIdProvider extends ContainerLifeCycle redirectUris.add(uri); } - public class OpenIdAuthEndpoint extends HttpServlet + public class AuthEndpoint extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException @@ -244,7 +254,7 @@ public class OpenIdProvider extends ContainerLifeCycle } } - public class OpenIdTokenEndpoint extends HttpServlet + private class TokenEndpoint extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException @@ -277,12 +287,45 @@ public class OpenIdProvider extends ContainerLifeCycle "\"token_type\": \"Bearer\"" + "}"; + loggedInUsers.increment(); resp.setContentType("text/plain"); resp.getWriter().print(response); } } - public class OpenIdConfigServlet extends HttpServlet + 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 @@ -291,6 +334,7 @@ public class OpenIdProvider extends ContainerLifeCycle "\"issuer\": \"" + provider + "\"," + "\"authorization_endpoint\": \"" + provider + AUTH_PATH + "\"," + "\"token_endpoint\": \"" + provider + TOKEN_PATH + "\"," + + "\"end_session_endpoint\": \"" + provider + END_SESSION_PATH + "\"," + "}"; resp.getWriter().write(discoveryDocument); @@ -328,5 +372,13 @@ public class OpenIdProvider extends ContainerLifeCycle long expiry = System.currentTimeMillis() + Duration.ofMinutes(1).toMillis(); return JwtEncoder.createIdToken(provider, clientId, subject, name, expiry); } + + @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); + } } }