-
Notifications
You must be signed in to change notification settings - Fork 280
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Ely 2534 OIDC logout support #2245
base: 2.x
Are you sure you want to change the base?
Changes from 14 commits
4698b60
298d503
4fe600b
d430574
c16a6cb
0ba9eea
ad376b0
3affa81
509a811
77d4911
3aebc41
ebdeafe
9a61d67
d20aa8a
48c15dd
6974f0a
edd3d93
324e71c
8d1e837
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,6 @@ | ||
/* | ||
* JBoss, Home of Professional Open Source. | ||
* Copyright 2021 Red Hat, Inc., and individual contributors | ||
* Copyright 2024 Red Hat, Inc., and individual contributors | ||
* as indicated by the @author tags. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
|
@@ -36,6 +36,7 @@ | |
* @author <a href="mailto:[email protected]">Farah Juma</a> | ||
*/ | ||
public class AuthenticatedActionsHandler { | ||
|
||
private OidcClientConfiguration deployment; | ||
private OidcHttpFacade facade; | ||
|
||
|
@@ -52,6 +53,7 @@ public boolean handledRequest() { | |
queryBearerToken(); | ||
return true; | ||
} | ||
|
||
return false; | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -278,5 +278,12 @@ interface ElytronMessages extends BasicLogger { | |
|
||
@Message(id = 23070, value = "Authentication request format must be one of the following: oauth2, request, request_uri.") | ||
RuntimeException invalidAuthenticationRequestFormat(); | ||
|
||
@Message(id = 23071, value = "%s is not a valid value for %s") | ||
RuntimeException invalidLogoutPath(String pathValue, String pathName); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Might be good to mention the logout path is invalid, e.g., "Invalid logout output: %s is not a valid value for %s" There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done |
||
|
||
@Message(id = 23072, value = "The end substring of %s: %s can not be identical to %s: %s") | ||
RuntimeException invalidLogoutCallbackPath(String callbackPathTitle, String callbacPathkValue, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here, might be good to provide more details in the error message There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. s/callbacPathkValue/callbackPathValue There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe s/logoutPathTitle/logoutPathName? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done |
||
String logoutPathTitle, String logoutPathValue); | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,270 @@ | ||
/* | ||
* JBoss, Home of Professional Open Source. | ||
* Copyright 2024 Red Hat, Inc., and individual contributors | ||
* as indicated by the @author tags. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package org.wildfly.security.http.oidc; | ||
|
||
import static java.util.Collections.synchronizedMap; | ||
import static org.wildfly.security.http.oidc.ElytronMessages.log; | ||
|
||
import java.net.URISyntaxException; | ||
import java.util.LinkedHashMap; | ||
import java.util.Map; | ||
|
||
import org.apache.http.HttpStatus; | ||
import org.apache.http.client.utils.URIBuilder; | ||
import org.jose4j.jwt.JwtClaims; | ||
import org.wildfly.security.http.HttpConstants; | ||
import org.wildfly.security.http.HttpScope; | ||
import org.wildfly.security.http.Scope; | ||
import org.wildfly.security.http.oidc.OidcHttpFacade.Request; | ||
|
||
/** | ||
* @author <a href="mailto:[email protected]">Pedro Igor</a> | ||
*/ | ||
final class LogoutHandler { | ||
|
||
public static final String POST_LOGOUT_REDIRECT_URI_PARAM = "post_logout_redirect_uri"; | ||
public static final String ID_TOKEN_HINT_PARAM = "id_token_hint"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do these need to be public? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done |
||
private static final String LOGOUT_TOKEN_PARAM = "logout_token"; | ||
private static final String LOGOUT_TOKEN_TYPE = "Logout"; | ||
private static final String CLIENT_ID_SID_SEPARATOR = "-"; | ||
public static final String SID = "sid"; | ||
public static final String ISS = "iss"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do these need to be public? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done |
||
|
||
/** | ||
* A bounded map to store sessions marked for invalidation after receiving logout requests through the back-channel | ||
*/ | ||
private Map<String, OidcClientConfiguration> sessionsMarkedForInvalidation = synchronizedMap(new LinkedHashMap<String, OidcClientConfiguration>(16, 0.75f, true) { | ||
@Override | ||
protected boolean removeEldestEntry(Map.Entry<String, OidcClientConfiguration> eldest) { | ||
boolean remove = sessionsMarkedForInvalidation.size() > eldest.getValue().getLogoutSessionWaitingLimit(); | ||
|
||
if (remove) { | ||
log.debugf("Limit [%s] reached for sessions waiting [%s] for logout", eldest.getValue().getLogoutSessionWaitingLimit(), sessionsMarkedForInvalidation.size()); | ||
} | ||
|
||
return remove; | ||
} | ||
}); | ||
|
||
boolean tryLogout(OidcHttpFacade facade) { | ||
log.trace("tryLogout entered"); | ||
RefreshableOidcSecurityContext securityContext = getSecurityContext(facade); | ||
if (securityContext == null) { | ||
// no active session | ||
log.trace("tryLogout securityContext == null"); | ||
return false; | ||
} | ||
|
||
if (isRpInitiatedLogoutPath(facade)) { | ||
log.trace("isRpInitiatedLogoutPath"); | ||
redirectEndSessionEndpoint(facade); | ||
return true; | ||
} | ||
|
||
if (isLogoutCallbackPath(facade)) { | ||
log.trace("isLogoutCallbackPath"); | ||
if (isFrontChannel(facade)) { | ||
log.trace("isFrontChannel"); | ||
handleFrontChannelLogoutRequest(facade); | ||
return true; | ||
} else { | ||
// we have an active session, should have received a GET logout request | ||
facade.getResponse().setStatus(HttpStatus.SC_METHOD_NOT_ALLOWED); | ||
facade.authenticationFailed(); | ||
} | ||
} | ||
return false; | ||
} | ||
|
||
boolean isSessionMarkedForInvalidation(OidcHttpFacade facade) { | ||
HttpScope session = facade.getScope(Scope.SESSION); | ||
if (session == null || ! session.exists()) return false; | ||
RefreshableOidcSecurityContext securityContext = (RefreshableOidcSecurityContext) session.getAttachment(OidcSecurityContext.class.getName()); | ||
if (securityContext == null) { | ||
return false; | ||
} | ||
IDToken idToken = securityContext.getIDToken(); | ||
|
||
if (idToken == null) { | ||
return false; | ||
} | ||
return sessionsMarkedForInvalidation.remove(getSessionKey(facade, idToken.getSid())) != null; | ||
} | ||
|
||
private void redirectEndSessionEndpoint(OidcHttpFacade facade) { | ||
RefreshableOidcSecurityContext securityContext = getSecurityContext(facade); | ||
OidcClientConfiguration clientConfiguration = securityContext.getOidcClientConfiguration(); | ||
|
||
String logoutUri; | ||
|
||
try { | ||
URIBuilder redirectUriBuilder = new URIBuilder(clientConfiguration.getEndSessionEndpointUrl()) | ||
.addParameter(ID_TOKEN_HINT_PARAM, securityContext.getIDTokenString()); | ||
String postLogoutPath = clientConfiguration.getPostLogoutPath(); | ||
if (postLogoutPath != null) { | ||
redirectUriBuilder.addParameter(POST_LOGOUT_REDIRECT_URI_PARAM, | ||
getRedirectUri(facade) + postLogoutPath); | ||
} | ||
|
||
logoutUri = redirectUriBuilder.build().toString(); | ||
log.trace("redirectEndSessionEndpoint path: " + redirectUriBuilder.toString()); | ||
} catch (URISyntaxException e) { | ||
throw new RuntimeException(e); | ||
} | ||
|
||
log.debugf("Sending redirect to the end_session_endpoint: %s", logoutUri); | ||
facade.getResponse().setStatus(HttpStatus.SC_MOVED_TEMPORARILY); | ||
facade.getResponse().setHeader(HttpConstants.LOCATION, logoutUri); | ||
} | ||
|
||
boolean tryBackChannelLogout(OidcHttpFacade facade) { | ||
log.trace("tryBackChannelLogout entered"); | ||
if (isLogoutCallbackPath(facade)) { | ||
log.trace("isLogoutCallbackPath"); | ||
if (isBackChannel(facade)) { | ||
log.trace("isBackChannel"); | ||
handleBackChannelLogoutRequest(facade); | ||
return true; | ||
} | ||
} | ||
return false; | ||
} | ||
|
||
private void handleBackChannelLogoutRequest(OidcHttpFacade facade) { | ||
String logoutToken = facade.getRequest().getFirstParam(LOGOUT_TOKEN_PARAM); | ||
TokenValidator tokenValidator = TokenValidator.builder(facade.getOidcClientConfiguration()) | ||
.setSkipExpirationValidator() | ||
.setTokenType(LOGOUT_TOKEN_TYPE) | ||
.build(); | ||
JwtClaims claims; | ||
|
||
try { | ||
claims = tokenValidator.verify(logoutToken); | ||
} catch (Exception cause) { | ||
log.debug("Unexpected error when verifying logout token", cause); | ||
facade.getResponse().setStatus(HttpStatus.SC_BAD_REQUEST); | ||
facade.authenticationFailed(); | ||
return; | ||
} | ||
|
||
if (!isSessionRequiredOnLogout(facade)) { | ||
log.warn("Back-channel logout request received but can not infer sid from logout token to mark it for invalidation"); | ||
facade.getResponse().setStatus(HttpStatus.SC_BAD_REQUEST); | ||
facade.authenticationFailed(); | ||
return; | ||
} | ||
|
||
String sessionId = claims.getClaimValueAsString(SID); | ||
|
||
if (sessionId == null) { | ||
facade.getResponse().setStatus(HttpStatus.SC_BAD_REQUEST); | ||
facade.authenticationFailed(); | ||
return; | ||
} | ||
|
||
log.debug("Marking session for invalidation during back-channel logout"); | ||
sessionsMarkedForInvalidation.put(getSessionKey(facade, sessionId), facade.getOidcClientConfiguration()); | ||
} | ||
|
||
private String getSessionKey(OidcHttpFacade facade, String sessionId) { | ||
return facade.getOidcClientConfiguration().getClientId() + CLIENT_ID_SID_SEPARATOR + sessionId; | ||
} | ||
|
||
private void handleFrontChannelLogoutRequest(OidcHttpFacade facade) { | ||
if (isSessionRequiredOnLogout(facade)) { | ||
Request request = facade.getRequest(); | ||
String sessionId = request.getQueryParamValue(SID); | ||
|
||
if (sessionId == null) { | ||
facade.getResponse().setStatus(HttpStatus.SC_BAD_REQUEST); | ||
facade.authenticationFailed(); | ||
return; | ||
} | ||
|
||
RefreshableOidcSecurityContext context = getSecurityContext(facade); | ||
IDToken idToken = context.getIDToken(); | ||
String issuer = request.getQueryParamValue(ISS); | ||
|
||
if (idToken == null || !sessionId.equals(idToken.getSid()) || !idToken.getIssuer().equals(issuer)) { | ||
facade.getResponse().setStatus(HttpStatus.SC_BAD_REQUEST); | ||
facade.authenticationFailed(); | ||
return; | ||
} | ||
} | ||
|
||
log.debug("Invalidating session during front-channel logout"); | ||
facade.getTokenStore().logout(false); | ||
} | ||
|
||
private String getRedirectUri(OidcHttpFacade facade) { | ||
String uri = facade.getRequest().getURI(); | ||
|
||
if (uri.indexOf('?') != -1) { | ||
uri = uri.substring(0, uri.indexOf('?')); | ||
} | ||
int logoutPathIndex = uri.indexOf(getLogoutPath(facade)); | ||
|
||
if (logoutPathIndex != -1) { | ||
uri = uri.substring(0, logoutPathIndex); | ||
} | ||
|
||
return uri; | ||
} | ||
|
||
private boolean isLogoutCallbackPath(OidcHttpFacade facade) { | ||
String path = facade.getRequest().getRelativePath(); | ||
return path.endsWith(getLogoutCallbackPath(facade)); | ||
} | ||
|
||
private boolean isRpInitiatedLogoutPath(OidcHttpFacade facade) { | ||
String path = facade.getRequest().getRelativePath(); | ||
return path.endsWith(getLogoutPath(facade)); | ||
} | ||
|
||
private boolean isSessionRequiredOnLogout(OidcHttpFacade facade) { | ||
return facade.getOidcClientConfiguration().isSessionRequiredOnLogout(); | ||
} | ||
|
||
private RefreshableOidcSecurityContext getSecurityContext(OidcHttpFacade facade) { | ||
RefreshableOidcSecurityContext securityContext = (RefreshableOidcSecurityContext) facade.getSecurityContext(); | ||
|
||
if (securityContext == null) { | ||
facade.getResponse().setStatus(HttpStatus.SC_UNAUTHORIZED); | ||
facade.authenticationFailed(); | ||
return null; | ||
} | ||
|
||
return securityContext; | ||
} | ||
|
||
private String getLogoutPath(OidcHttpFacade facade) { | ||
return facade.getOidcClientConfiguration().getLogoutPath(); | ||
} | ||
private String getLogoutCallbackPath(OidcHttpFacade facade) { | ||
return facade.getOidcClientConfiguration().getLogoutCallbackPath(); | ||
} | ||
|
||
private boolean isBackChannel(OidcHttpFacade facade) { | ||
return "post".equalsIgnoreCase(facade.getRequest().getMethod()); | ||
} | ||
|
||
private boolean isFrontChannel(OidcHttpFacade facade) { | ||
return "get".equalsIgnoreCase(facade.getRequest().getMethod()); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think NO_SESSION_ID and METHOD_NOT_ALLOWED can be removed, I don't see them being used.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done