Merge pull request #8286 from eclipse/jetty-10.0.x-8216-openid-logout

Issue #8216 - OpenID Connect RP-Initiated Logout
This commit is contained in:
Lachlan 2022-07-20 14:39:13 +10:00 committed by GitHub
commit b74da7ce3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 192 additions and 11 deletions

View File

@ -68,6 +68,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";
@ -82,6 +83,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;
@ -103,11 +105,18 @@ 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
@ -123,11 +132,15 @@ 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));
}
@ -166,6 +179,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)
@ -218,6 +247,7 @@ public class OpenIdAuthenticator extends LoginAuthenticator
@Override
public void logout(ServletRequest request)
{
attemptLogoutRedirect(request);
super.logout(request);
HttpServletRequest httpRequest = (HttpServletRequest)request;
HttpSession session = httpRequest.getSession(false);
@ -230,6 +260,64 @@ 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(ServletRequest request)
{
try
{
Request baseRequest = Objects.requireNonNull(Request.getBaseRequest(request));
Response baseResponse = baseRequest.getResponse();
String endSessionEndpoint = _openIdConfiguration.getEndSessionEndpoint();
String redirectUri = null;
if (_logoutRedirectPath != null)
{
StringBuilder sb = new StringBuilder(128);
URIUtil.appendSchemeHostPort(sb, request.getScheme(), request.getServerName(), request.getServerPort());
sb.append(baseRequest.getContextPath());
sb.append(_logoutRedirectPath);
redirectUri = sb.toString();
}
HttpSession session = baseRequest.getSession(false);
if (endSessionEndpoint == null || session == null)
{
if (redirectUri != null)
baseResponse.sendRedirect(redirectUri, true);
return;
}
Object openIdResponse = session.getAttribute(OpenIdAuthenticator.RESPONSE);
if (!(openIdResponse instanceof Map))
{
if (redirectUri != null)
baseResponse.sendRedirect(redirectUri, true);
return;
}
@SuppressWarnings("rawtypes")
String idToken = (String)((Map)openIdResponse).get("id_token");
baseResponse.sendRedirect(endSessionEndpoint +
"?id_token_hint=" + UrlEncoded.encodeString(idToken, StandardCharsets.UTF_8) +
((redirectUri == null) ? "" : "&post_logout_redirect_uri=" + UrlEncoded.encodeString(redirectUri, StandardCharsets.UTF_8)),
true);
}
catch (Throwable t)
{
LOG.warn("failed to redirect to end_session_endpoint", t);
}
}

View File

@ -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.");
@ -213,6 +243,11 @@ public class OpenIdConfiguration extends ContainerLifeCycle
{
return tokenEndpoint;
}
public String getEndSessionEndpoint()
{
return endSessionEndpoint;
}
public String getAuthMethod()
{

View File

@ -16,6 +16,7 @@ package org.eclipse.jetty.security.openid;
import java.io.IOException;
import java.security.Principal;
import java.util.Map;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.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();
}
}

View File

@ -38,6 +38,7 @@ import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
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;
@ -48,6 +49,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<String, User> issuedAuthCodes = new HashMap<>();
protected final String clientId;
@ -58,6 +60,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
{
@ -91,9 +94,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);
@ -112,6 +116,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
{
@ -144,7 +153,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
@ -252,7 +261,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
@ -285,12 +294,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
@ -299,6 +341,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);
@ -336,5 +379,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);
}
}
}