Skip to content

Commit 8641c40

Browse files
committed
[ELY-2534] OIDC logout support
1 parent 9104231 commit 8641c40

23 files changed

Lines changed: 659 additions & 301 deletions

http/oidc/src/main/java/org/wildfly/security/http/oidc/AuthenticatedActionsHandler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/*
22
* JBoss, Home of Professional Open Source.
3-
* Copyright 2024 Red Hat, Inc., and individual contributors
3+
* Copyright 2021 Red Hat, Inc., and individual contributors
44
* as indicated by the @author tags.
55
*
66
* Licensed under the Apache License, Version 2.0 (the "License");

http/oidc/src/main/java/org/wildfly/security/http/oidc/ElytronMessages.java

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,8 @@ interface ElytronMessages extends BasicLogger {
211211
@Message(id = 23049, value = "Invalid 'auth-server-url' or 'provider-url': '%s'")
212212
void invalidAuthServerUrlOrProviderUrl(String url);
213213

214-
@Message(id = 23050, value = "Invalid bearer token claims")
215-
OidcException invalidBearerTokenClaims();
214+
@Message(id = 23050, value = "Invalid token claims")
215+
OidcException invalidTokenClaims();
216216

217217
@Message(id = 23051, value = "Invalid bearer token")
218218
OidcException invalidBearerToken(@Cause Throwable cause);
@@ -288,11 +288,16 @@ interface ElytronMessages extends BasicLogger {
288288
@Message(id = 23073, value = "Nonce cookie does not exist")
289289
String nonceCookieDoesNotExist();
290290

291-
@Message(id = 23071, value = "%s is not a valid value for %s")
292-
RuntimeException invalidLogoutPath(String pathValue, String pathName);
291+
@Message(id = 23074, value = "Invalid logout path: %s is not a valid value for %s")
292+
IllegalArgumentException invalidLogoutPath(String pathValue, String pathName);
293293

294-
@Message(id = 23072, value = "The end substring of %s: %s can not be identical to %s: %s")
295-
RuntimeException invalidLogoutCallbackPath(String callbackPathTitle, String callbacPathkValue,
296-
String logoutPathTitle, String logoutPathValue);
294+
@Message(id = 23075, value = "Invalid %s: %s the logout callback path value must be an absolute URI")
295+
IllegalArgumentException invalidLogoutCallbackPath(String callbackPathTitle, String callbackPathValue);
296+
297+
@Message(id = 23076, value = "Unable to create end session endpoint request: %s . [%s]")
298+
RuntimeException unableToCreateEndSessionEndpointRequest(String url, String msg);
299+
300+
@Message(id = 23077, value = "Back-channel logout request received but can not infer sid from logout token to mark it for invalidation")
301+
String sidCanNotBeInferFromLogoutToken();
297302
}
298303

http/oidc/src/main/java/org/wildfly/security/http/oidc/IDToken.java

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,7 @@ public class IDToken extends JsonWebToken {
5353
public static final String CLAIMS_LOCALES = "claims_locales";
5454
public static final String ACR = "acr";
5555
public static final String S_HASH = "s_hash";
56-
<<<<<<< HEAD
5756
public static final String NONCE = "nonce";
58-
=======
59-
public static final String SID = "sid";
60-
>>>>>>> 4698b602c6 (OpenID Connect Logout support)
6157

6258
/**
6359
* Construct a new instance.

http/oidc/src/main/java/org/wildfly/security/http/oidc/LogoutHandler.java

Lines changed: 79 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
import static java.util.Collections.synchronizedMap;
2222
import static org.wildfly.security.http.oidc.ElytronMessages.log;
2323

24+
import java.net.MalformedURLException;
2425
import java.net.URISyntaxException;
26+
import java.net.URL;
2527
import java.util.LinkedHashMap;
2628
import java.util.Map;
2729

@@ -38,32 +40,32 @@
3840
*/
3941
final class LogoutHandler {
4042

41-
public static final String POST_LOGOUT_REDIRECT_URI_PARAM = "post_logout_redirect_uri";
42-
public static final String ID_TOKEN_HINT_PARAM = "id_token_hint";
43+
private static final String POST_LOGOUT_REDIRECT_URI_PARAM = "post_logout_redirect_uri";
44+
private static final String ID_TOKEN_HINT_PARAM = "id_token_hint";
4345
private static final String LOGOUT_TOKEN_PARAM = "logout_token";
44-
private static final String LOGOUT_TOKEN_TYPE = "Logout";
46+
private static final String LOGOUT_JWT_TOKEN_TYPE = "logout+jwt";
47+
private static final String KEYCLOCK_LOGOUT_TOKEN_TYPE = "Logout";
4548
private static final String CLIENT_ID_SID_SEPARATOR = "-";
46-
public static final String SID = "sid";
47-
public static final String ISS = "iss";
49+
private static final String SID = "sid";
50+
private static final String ISS = "iss";
4851

4952
/**
5053
* A bounded map to store sessions marked for invalidation after receiving logout requests through the back-channel
5154
*/
5255
private Map<String, OidcClientConfiguration> sessionsMarkedForInvalidation = synchronizedMap(new LinkedHashMap<String, OidcClientConfiguration>(16, 0.75f, true) {
5356
@Override
5457
protected boolean removeEldestEntry(Map.Entry<String, OidcClientConfiguration> eldest) {
55-
boolean remove = sessionsMarkedForInvalidation.size() > eldest.getValue().getLogoutSessionWaitingLimit();
58+
boolean remove = sessionsMarkedForInvalidation.size() > eldest.getValue().getBackChannelLogoutSessionInvalidationLimit();
5659

5760
if (remove) {
58-
log.debugf("Limit [%s] reached for sessions waiting [%s] for logout", eldest.getValue().getLogoutSessionWaitingLimit(), sessionsMarkedForInvalidation.size());
61+
log.debugf("Limit [%s] reached for sessions waiting [%s] for logout", eldest.getValue().getBackChannelLogoutSessionInvalidationLimit(), sessionsMarkedForInvalidation.size());
5962
}
6063

6164
return remove;
6265
}
6366
});
6467

6568
boolean tryLogout(OidcHttpFacade facade) {
66-
log.trace("tryLogout entered");
6769
RefreshableOidcSecurityContext securityContext = getSecurityContext(facade);
6870
if (securityContext == null) {
6971
// no active session
@@ -95,6 +97,7 @@ boolean tryLogout(OidcHttpFacade facade) {
9597
boolean isSessionMarkedForInvalidation(OidcHttpFacade facade) {
9698
HttpScope session = facade.getScope(Scope.SESSION);
9799
if (session == null || ! session.exists()) return false;
100+
98101
RefreshableOidcSecurityContext securityContext = (RefreshableOidcSecurityContext) session.getAttachment(OidcSecurityContext.class.getName());
99102
if (securityContext == null) {
100103
return false;
@@ -104,7 +107,7 @@ boolean isSessionMarkedForInvalidation(OidcHttpFacade facade) {
104107
if (idToken == null) {
105108
return false;
106109
}
107-
return sessionsMarkedForInvalidation.remove(getSessionKey(facade, idToken.getSid())) != null;
110+
return sessionsMarkedForInvalidation.remove(idToken.getNonce()) != null;
108111
}
109112

110113
private void redirectEndSessionEndpoint(OidcHttpFacade facade) {
@@ -116,16 +119,17 @@ private void redirectEndSessionEndpoint(OidcHttpFacade facade) {
116119
try {
117120
URIBuilder redirectUriBuilder = new URIBuilder(clientConfiguration.getEndSessionEndpointUrl())
118121
.addParameter(ID_TOKEN_HINT_PARAM, securityContext.getIDTokenString());
119-
String postLogoutPath = clientConfiguration.getPostLogoutPath();
120-
if (postLogoutPath != null) {
121-
redirectUriBuilder.addParameter(POST_LOGOUT_REDIRECT_URI_PARAM,
122-
getRedirectUri(facade) + postLogoutPath);
122+
String postLogoutRedirectUri = clientConfiguration.getPostLogoutRedirectUri();
123+
if (postLogoutRedirectUri != null) {
124+
log.trace("post_logout_redirect_uri: " + postLogoutRedirectUri);
125+
redirectUriBuilder.addParameter(POST_LOGOUT_REDIRECT_URI_PARAM, postLogoutRedirectUri);
123126
}
124127

125128
logoutUri = redirectUriBuilder.build().toString();
126-
log.trace("redirectEndSessionEndpoint path: " + redirectUriBuilder.toString());
129+
log.trace("redirectEndSessionEndpoint path: " + logoutUri);
127130
} catch (URISyntaxException e) {
128-
throw new RuntimeException(e);
131+
throw log.unableToCreateEndSessionEndpointRequest(
132+
clientConfiguration.getEndSessionEndpointUrl(), e.getMessage());
129133
}
130134

131135
log.debugf("Sending redirect to the end_session_endpoint: %s", logoutUri);
@@ -134,7 +138,6 @@ private void redirectEndSessionEndpoint(OidcHttpFacade facade) {
134138
}
135139

136140
boolean tryBackChannelLogout(OidcHttpFacade facade) {
137-
log.trace("tryBackChannelLogout entered");
138141
if (isLogoutCallbackPath(facade)) {
139142
log.trace("isLogoutCallbackPath");
140143
if (isBackChannel(facade)) {
@@ -147,24 +150,48 @@ boolean tryBackChannelLogout(OidcHttpFacade facade) {
147150
}
148151

149152
private void handleBackChannelLogoutRequest(OidcHttpFacade facade) {
153+
154+
OidcClientConfiguration clientConfiguration = facade.getOidcClientConfiguration();
150155
String logoutToken = facade.getRequest().getFirstParam(LOGOUT_TOKEN_PARAM);
151-
TokenValidator tokenValidator = TokenValidator.builder(facade.getOidcClientConfiguration())
152-
.setSkipExpirationValidator()
153-
.setTokenType(LOGOUT_TOKEN_TYPE)
156+
TokenValidator.Builder tokenBuilder = TokenValidator.builder(clientConfiguration)
157+
.setSkipExpirationValidator();
158+
// Keycloak uses claim type "Logout". Other OP's may be using "logout+jwt"
159+
// or a typ unique to it.
160+
String providerLogoutTokenType = (facade.getOidcClientConfiguration().getProviderJwtClaimsTyp() == null) ?
161+
KEYCLOCK_LOGOUT_TOKEN_TYPE : clientConfiguration.getProviderJwtClaimsTyp();
162+
TokenValidator tokenValidator = tokenBuilder.setTokenType(providerLogoutTokenType)
154163
.build();
155-
JwtClaims claims;
156164

165+
JwtClaims claims = null;
166+
Exception cause = null;
157167
try {
168+
// check keycloak 'typ'
158169
claims = tokenValidator.verify(logoutToken);
159-
} catch (Exception cause) {
160-
log.debug("Unexpected error when verifying logout token", cause);
161-
facade.getResponse().setStatus(HttpStatus.SC_BAD_REQUEST);
162-
facade.authenticationFailed();
163-
return;
170+
} catch (Exception expKeyclockClaims) {
171+
cause = expKeyclockClaims;
172+
if (expKeyclockClaims.getCause().getMessage().contains("ELY23054: Unexpected value for typ claim")) {
173+
log.warn("OpenID Provider claims typ " + providerLogoutTokenType
174+
+ " was not valid. Trying typ "+ LOGOUT_JWT_TOKEN_TYPE);
175+
176+
// check other OP's 'typ'
177+
tokenValidator = tokenBuilder.setTokenType(LOGOUT_JWT_TOKEN_TYPE)
178+
.build();
179+
try {
180+
claims = tokenValidator.verify(logoutToken);
181+
} catch (Exception expOtherProviderCliams) {
182+
cause = expOtherProviderCliams;
183+
}
184+
}
185+
if (claims == null) {
186+
log.debugf("Unexpected error when verifying logout token", cause);
187+
facade.getResponse().setStatus(HttpStatus.SC_BAD_REQUEST);
188+
facade.authenticationFailed();
189+
return;
190+
}
164191
}
165192

166-
if (!isSessionRequiredOnLogout(facade)) {
167-
log.warn("Back-channel logout request received but can not infer sid from logout token to mark it for invalidation");
193+
if (!isLogoutSessionRequired(facade)) {
194+
log.warn(log.sidCanNotBeInferFromLogoutToken());
168195
facade.getResponse().setStatus(HttpStatus.SC_BAD_REQUEST);
169196
facade.authenticationFailed();
170197
return;
@@ -178,7 +205,7 @@ private void handleBackChannelLogoutRequest(OidcHttpFacade facade) {
178205
return;
179206
}
180207

181-
log.debug("Marking session for invalidation during back-channel logout");
208+
log.debugf("Marking session for invalidation during back-channel logout");
182209
sessionsMarkedForInvalidation.put(getSessionKey(facade, sessionId), facade.getOidcClientConfiguration());
183210
}
184211

@@ -187,7 +214,7 @@ private String getSessionKey(OidcHttpFacade facade, String sessionId) {
187214
}
188215

189216
private void handleFrontChannelLogoutRequest(OidcHttpFacade facade) {
190-
if (isSessionRequiredOnLogout(facade)) {
217+
if (isLogoutSessionRequired(facade)) {
191218
Request request = facade.getRequest();
192219
String sessionId = request.getQueryParamValue(SID);
193220

@@ -201,14 +228,14 @@ private void handleFrontChannelLogoutRequest(OidcHttpFacade facade) {
201228
IDToken idToken = context.getIDToken();
202229
String issuer = request.getQueryParamValue(ISS);
203230

204-
if (idToken == null || !sessionId.equals(idToken.getSid()) || !idToken.getIssuer().equals(issuer)) {
231+
if (idToken == null || !sessionId.equals(idToken.getNonce()) || !idToken.getIssuer().equals(issuer)) {
205232
facade.getResponse().setStatus(HttpStatus.SC_BAD_REQUEST);
206233
facade.authenticationFailed();
207234
return;
208235
}
209236
}
210237

211-
log.debug("Invalidating session during front-channel logout");
238+
log.debugf("Invalidating session during front-channel logout");
212239
facade.getTokenStore().logout(false);
213240
}
214241

@@ -228,17 +255,33 @@ private String getRedirectUri(OidcHttpFacade facade) {
228255
}
229256

230257
private boolean isLogoutCallbackPath(OidcHttpFacade facade) {
231-
String path = facade.getRequest().getRelativePath();
232-
return path.endsWith(getLogoutCallbackPath(facade));
258+
String uriStr = facade.getRequest().getURI();
259+
// logoutCallbackPath can be either an URL path component or an absolute path.
260+
// Only the path components are to be compared.
261+
String tmpLogoutCallbackPath = getLogoutCallbackPath(facade);
262+
try {
263+
URL url = new URL(tmpLogoutCallbackPath);
264+
if (uriStr.equals(url.toString())
265+
|| uriStr.endsWith(tmpLogoutCallbackPath)) {
266+
return true;
267+
}
268+
} catch (MalformedURLException e) {
269+
// no-op
270+
}
271+
return false;
233272
}
234273

235274
private boolean isRpInitiatedLogoutPath(OidcHttpFacade facade) {
236275
String path = facade.getRequest().getRelativePath();
237-
return path.endsWith(getLogoutPath(facade));
276+
String logoutPath = getLogoutPath(facade);
277+
if (logoutPath == null) {
278+
return false;
279+
}
280+
return path.endsWith(logoutPath);
238281
}
239282

240-
private boolean isSessionRequiredOnLogout(OidcHttpFacade facade) {
241-
return facade.getOidcClientConfiguration().isSessionRequiredOnLogout();
283+
private boolean isLogoutSessionRequired(OidcHttpFacade facade) {
284+
return facade.getOidcClientConfiguration().isLogoutSessionRequired();
242285
}
243286

244287
private RefreshableOidcSecurityContext getSecurityContext(OidcHttpFacade facade) {

http/oidc/src/main/java/org/wildfly/security/http/oidc/Oidc.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,13 @@ public class Oidc {
185185
public static final String PROVIDER_URL = "provider-url";
186186
public static final String LOGOUT_PATH = "logout-path";
187187
public static final String LOGOUT_CALLBACK_PATH = "logout-callback-path";
188-
public static final String POST_LOGOUT_PATH = "post-logout-path";
188+
public static final String POST_LOGOUT_REDIRECT_URI= "post-logout-redirect-uri";
189189
public static final String LOGOUT_SESSION_REQUIRED = "logout-session-required";
190-
190+
static final String DEFAULT_LOGOUT_PATH = "/logout";
191+
static final String DEFAULT_LOGOUT_CALLBACK_PATH = "/logout/callback";
192+
static final int DEFAULT_BACK_CHANNEL_LOGOUT_SESSION_INVALIDATION_LIMIT = 16;
193+
static final String BACK_CHANNEL_LOGOUT_SESSION_INVALIDATION_LIMIT = "back-channel-logout-session-invalidation-limit";
194+
static final String PROVIDER_JWT_CLAIMS_TYP = "provider-jwt-claims-typ";
191195
/**
192196
* Bearer token pattern.
193197
* The Bearer token authorization header is of the form "Bearer", followed by optional whitespace, followed by

http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcAuthenticationMechanism.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public String getMechanismName() {
6060

6161
@Override
6262
public void evaluateRequest(HttpServerRequest request) throws HttpAuthenticationException {
63-
log.debug("evaluateRequest uri: " + request.getRequestURI().toString());
63+
log.debugf("evaluateRequest uri: " + request.getRequestURI().toString());
6464
OidcClientContext oidcClientContext = getOidcClientContext(request);
6565
if (oidcClientContext == null) {
6666
log.debugf("Ignoring request for path [%s] from mechanism [%s]. No client configuration context found.", request.getRequestURI(), getMechanismName());

http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcClientConfiguration.java

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -145,13 +145,12 @@ public enum RelativeUrlsUsed {
145145
protected JWKEncPublicKeyLocator encryptionPublicKeyLocator;
146146
private boolean logoutSessionRequired = true;
147147

148-
private String postLogoutPath;
149-
private boolean sessionRequiredOnLogout = true;
150-
private String logoutPath = "/logout";
151-
private String logoutCallbackPath = "/logout/callback";
152-
153-
private int logoutSessionWaitingLimit = 100;
148+
private String postLogoutRedirectUri;
149+
private String logoutPath;
150+
private String logoutCallbackPath;
151+
private String providerJwtClaimsTyp;
154152

153+
private int backChannelLogoutSessionInvalidationLimit = Oidc.DEFAULT_BACK_CHANNEL_LOGOUT_SESSION_INVALIDATION_LIMIT;
155154
public OidcClientConfiguration() {
156155
}
157156

@@ -277,7 +276,7 @@ protected void resolveUrls(OidcClientUriBuilder authUrlBuilder) {
277276
issuerUrl = authUrlBuilder.clone().path(ServiceUrlConstants.REALM_INFO_PATH).build(getRealm()).toString();
278277

279278
tokenUrl = authUrlBuilder.clone().path(ServiceUrlConstants.TOKEN_PATH).build(getRealm()).toString();
280-
logoutUrl = OidcClientUriBuilder.fromUri(authUrlBuilder.clone().path(ServiceUrlConstants.TOKEN_SERVICE_LOGOUT_PATH).build(getRealm()).toString()).buildAsString();
279+
endSessionEndpointUrl = OidcClientUriBuilder.fromUri(authUrlBuilder.clone().path(ServiceUrlConstants.TOKEN_SERVICE_LOGOUT_PATH).build(getRealm()).toString()).buildAsString();
281280
accountUrl = authUrlBuilder.clone().path(ServiceUrlConstants.ACCOUNT_SERVICE_PATH).build(getRealm()).toString();
282281
registerNodeUrl = authUrlBuilder.clone().path(ServiceUrlConstants.CLIENTS_MANAGEMENT_REGISTER_NODE_PATH).build(getRealm()).toString();
283282
unregisterNodeUrl = authUrlBuilder.clone().path(ServiceUrlConstants.CLIENTS_MANAGEMENT_UNREGISTER_NODE_PATH).build(getRealm()).toString();
@@ -719,20 +718,12 @@ public String getTokenSignatureAlgorithm() {
719718
return tokenSignatureAlgorithm;
720719
}
721720

722-
public boolean isSessionRequiredOnLogout() {
723-
return sessionRequiredOnLogout;
724-
}
725-
726-
public void setSessionRequiredOnLogout(boolean sessionRequiredOnLogout) {
727-
this.sessionRequiredOnLogout = sessionRequiredOnLogout;
728-
}
729-
730-
public int getLogoutSessionWaitingLimit() {
731-
return logoutSessionWaitingLimit;
721+
public int getBackChannelLogoutSessionInvalidationLimit() {
722+
return backChannelLogoutSessionInvalidationLimit;
732723
}
733724

734-
public void setLogoutSessionWaitingLimit(int logoutSessionWaitingLimit) {
735-
this.logoutSessionWaitingLimit = logoutSessionWaitingLimit;
725+
public void setBackChannelLogoutSessionInvalidationLimit(int backChannelLogoutSessionInvalidationLimit) {
726+
this.backChannelLogoutSessionInvalidationLimit = backChannelLogoutSessionInvalidationLimit;
736727
}
737728

738729
public String getAuthenticationRequestFormat() {
@@ -823,12 +814,12 @@ public JWKEncPublicKeyLocator getEncryptionPublicKeyLocator() {
823814
return this.encryptionPublicKeyLocator;
824815
}
825816

826-
public void setPostLogoutPath(String postLogoutPath) {
827-
this.postLogoutPath = postLogoutPath;
817+
public void setPostLogoutRedirectUri(String postLogoutRedirectUri) {
818+
this.postLogoutRedirectUri = postLogoutRedirectUri;
828819
}
829820

830-
public String getPostLogoutPath() {
831-
return postLogoutPath;
821+
public String getPostLogoutRedirectUri() {
822+
return postLogoutRedirectUri;
832823
}
833824

834825
public boolean isLogoutSessionRequired() {
@@ -850,4 +841,11 @@ public String getLogoutCallbackPath() {
850841
public void setLogoutCallbackPath(String logoutCallbackPath) {
851842
this.logoutCallbackPath = logoutCallbackPath;
852843
}
844+
845+
public String getProviderJwtClaimsTyp() {
846+
return this.providerJwtClaimsTyp;
847+
}
848+
public void setProviderJwtClaimsTyp(String providerJwtClaimsTyp) {
849+
this.providerJwtClaimsTyp = providerJwtClaimsTyp;
850+
}
853851
}

0 commit comments

Comments
 (0)