Skip to content

Commit 3e67b2b

Browse files
committed
Finalizes logout support
Implements RP initiated SLO as described in OpenID Connect Session Management 1.0 Draft 38 https://openid.net/specs/openid-connect-session-1_0.html#RPLogout
1 parent 300f523 commit 3e67b2b

File tree

11 files changed

+473
-100
lines changed

11 files changed

+473
-100
lines changed

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/oidc/OpenIdConnectLogoutRequest.java

-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import org.elasticsearch.common.io.stream.StreamOutput;
1414

1515
import java.io.IOException;
16-
import java.io.InputStream;
1716

1817
import static org.elasticsearch.action.ValidateActions.addValidationError;
1918

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/oidc/OpenIdConnectRealmSettings.java

+12-3
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ private OpenIdConnectRealmSettings() {
4747
throw new IllegalArgumentException("Invalid value [" + v + "] for [" + key + "]. Not a valid URI.", e);
4848
}
4949
}, Setting.Property.NodeScope));
50+
public static final Setting.AffixSetting<String> RP_POST_LOGOUT_REDIRECT_URI
51+
= Setting.affixKeySetting(RealmSettings.realmSettingPrefix(TYPE), "rp.post_logout_redirect_uri",
52+
key -> Setting.simpleString(key, v -> {
53+
try {
54+
new URI(v);
55+
} catch (URISyntaxException e) {
56+
throw new IllegalArgumentException("Invalid value [" + v + "] for [" + key + "]. Not a valid URI.", e);
57+
}
58+
}, Setting.Property.NodeScope));
5059
public static final Setting.AffixSetting<String> RP_RESPONSE_TYPE
5160
= Setting.affixKeySetting(RealmSettings.realmSettingPrefix(TYPE), "rp.response_type",
5261
key -> Setting.simpleString(key, v -> {
@@ -141,9 +150,9 @@ private OpenIdConnectRealmSettings() {
141150
public static Set<Setting.AffixSetting<?>> getSettings() {
142151
final Set<Setting.AffixSetting<?>> set = Sets.newHashSet(
143152
RP_CLIENT_ID, RP_REDIRECT_URI, RP_RESPONSE_TYPE, RP_REQUESTED_SCOPES, RP_CLIENT_SECRET, RP_SIGNATURE_VERIFICATION_ALGORITHM,
144-
OP_NAME, OP_AUTHORIZATION_ENDPOINT, OP_TOKEN_ENDPOINT, OP_USERINFO_ENDPOINT, OP_ENDSESSION_ENDPOINT, OP_ISSUER,
145-
OP_JWKSET_PATH, HTTP_CONNECT_TIMEOUT, HTTP_CONNECTION_READ_TIMEOUT, HTTP_SOCKET_TIMEOUT, HTTP_MAX_CONNECTIONS,
146-
HTTP_MAX_ENDPOINT_CONNECTIONS, ALLOWED_CLOCK_SKEW);
153+
RP_POST_LOGOUT_REDIRECT_URI, OP_NAME, OP_AUTHORIZATION_ENDPOINT, OP_TOKEN_ENDPOINT, OP_USERINFO_ENDPOINT,
154+
OP_ENDSESSION_ENDPOINT, OP_ISSUER, OP_JWKSET_PATH, HTTP_CONNECT_TIMEOUT, HTTP_CONNECTION_READ_TIMEOUT, HTTP_SOCKET_TIMEOUT,
155+
HTTP_MAX_CONNECTIONS, HTTP_MAX_ENDPOINT_CONNECTIONS, ALLOWED_CLOCK_SKEW);
147156
set.addAll(DelegatedAuthorizationSettings.getSettings(TYPE));
148157
set.addAll(RealmSettings.getStandardSettings(TYPE));
149158
set.addAll(SSLConfigurationSettings.getRealmSettings(TYPE));

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/oidc/TransportOpenIdConnectLogoutAction.java

+54-8
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
*/
66
package org.elasticsearch.xpack.security.action.oidc;
77

8+
import com.nimbusds.jwt.JWT;
9+
import com.nimbusds.jwt.JWTParser;
810
import org.apache.logging.log4j.LogManager;
911
import org.apache.logging.log4j.Logger;
1012
import org.elasticsearch.ElasticsearchSecurityException;
@@ -22,11 +24,14 @@
2224
import org.elasticsearch.xpack.core.security.authc.Authentication;
2325
import org.elasticsearch.xpack.core.security.authc.Realm;
2426
import org.elasticsearch.xpack.core.security.authc.support.TokensInvalidationResult;
27+
import org.elasticsearch.xpack.core.security.user.User;
2528
import org.elasticsearch.xpack.security.authc.Realms;
2629
import org.elasticsearch.xpack.security.authc.TokenService;
2730
import org.elasticsearch.xpack.security.authc.oidc.OpenIdConnectRealm;
2831

2932
import java.io.IOException;
33+
import java.text.ParseException;
34+
import java.util.Map;
3035

3136
/**
3237
* Transport action responsible for generating an OpenID connect logout request to be sent to an OpenID Connect Provider
@@ -53,7 +58,8 @@ protected void doExecute(Task task, OpenIdConnectLogoutRequest request, ActionLi
5358
final String token = request.getToken();
5459
tokenService.getAuthenticationAndMetaData(token, ActionListener.wrap(
5560
tuple -> {
56-
Authentication authentication = tuple.v1();
61+
final Authentication authentication = tuple.v1();
62+
final Map<String, Object> tokenMetadata = tuple.v2();
5763
tokenService.invalidateAccessToken(token, ActionListener.wrap(
5864
result -> {
5965
if (logger.isTraceEnabled()) {
@@ -62,7 +68,7 @@ protected void doExecute(Task task, OpenIdConnectLogoutRequest request, ActionLi
6268
token.substring(0, 8),
6369
token.substring(token.length() - 8));
6470
}
65-
OpenIdConnectLogoutResponse response = buildResponse(authentication);
71+
OpenIdConnectLogoutResponse response = buildResponse(authentication, tokenMetadata);
6672
listener.onResponse(response);
6773
}, listener::onFailure)
6874
);
@@ -74,22 +80,62 @@ protected void doExecute(Task task, OpenIdConnectLogoutRequest request, ActionLi
7480
}, listener::onFailure));
7581
}
7682

77-
private OpenIdConnectLogoutResponse buildResponse(Authentication authentication) {
83+
private OpenIdConnectLogoutResponse buildResponse(Authentication authentication, Map<String, Object> tokenMetadata) {
84+
validateAuthenticationAndMetadata(authentication, tokenMetadata);
85+
final String idTokenHint = (String) getFromMetadata(tokenMetadata, "id_token_hint");
86+
final Realm realm = this.realms.realm(authentication.getAuthenticatedBy().getName());
87+
final JWT idToken;
88+
try {
89+
idToken = JWTParser.parse(idTokenHint);
90+
} catch (ParseException e) {
91+
throw new ElasticsearchSecurityException("Token Metadata did not contain a valid IdToken", e);
92+
}
93+
return ((OpenIdConnectRealm) realm).buildLogoutResponse(idToken);
94+
}
95+
96+
private void validateAuthenticationAndMetadata(Authentication authentication, Map<String, Object> tokenMetadata) {
97+
if (tokenMetadata == null) {
98+
throw new ElasticsearchSecurityException("Authentication did not contain metadata");
99+
}
100+
if (authentication == null) {
101+
throw new ElasticsearchSecurityException("No active authentication");
102+
}
103+
final User user = authentication.getUser();
104+
if (user == null) {
105+
throw new ElasticsearchSecurityException("No active user");
106+
}
107+
78108
final Authentication.RealmRef ref = authentication.getAuthenticatedBy();
79109
if (ref == null || Strings.isNullOrEmpty(ref.getName())) {
80-
throw new ElasticsearchSecurityException("Authentication {} has no authenticating realm", authentication);
110+
throw new ElasticsearchSecurityException("Authentication {} has no authenticating realm",
111+
authentication);
81112
}
82113
final Realm realm = this.realms.realm(authentication.getAuthenticatedBy().getName());
83114
if (realm == null) {
84115
throw new ElasticsearchSecurityException("Authenticating realm {} does not exist", ref.getName());
85116
}
86-
if (realm instanceof OpenIdConnectRealm) {
87-
return ((OpenIdConnectRealm) realm).buildLogoutResponse();
88-
} else {
89-
throw new ElasticsearchSecurityException("Authenticating realm {} is not a SAML realm", realm);
117+
if (realm instanceof OpenIdConnectRealm == false) {
118+
throw new ElasticsearchSecurityException("Authenticating realm {} is not an OpenID Connect realm",
119+
realm);
120+
}
121+
final Object tokenRealm = getFromMetadata(tokenMetadata, "oidc_realm");
122+
if (realm.name().equals(tokenRealm) == false) {
123+
throw new ElasticsearchSecurityException("Authenticating realm [{}] does not match token realm [{}]", realm, tokenRealm);
90124
}
91125
}
92126

127+
private Object getFromMetadata(Map<String, Object> metadata, String key) {
128+
if (metadata.containsKey(key) == false) {
129+
throw new ElasticsearchSecurityException("Authentication token does not have OpenID Connect metadata [{}]", key);
130+
}
131+
Object value = metadata.get(key);
132+
if (null != value && value instanceof String == false) {
133+
throw new ElasticsearchSecurityException("In authentication token, OpenID Connect metadata [{}] is [{}] rather than " +
134+
"String", key, value.getClass());
135+
}
136+
return value;
137+
138+
}
93139
private void invalidateRefreshToken(String refreshToken, ActionListener<TokensInvalidationResult> listener) {
94140
if (refreshToken == null) {
95141
listener.onResponse(null);

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectAuthenticator.java

+8-3
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,15 @@ private void getUserClaims(@Nullable AccessToken accessToken, JWT idToken, Nonce
219219
if (logger.isTraceEnabled()) {
220220
logger.trace("Received and validated the Id Token for the user: [{}]", verifiedIdTokenClaims);
221221
}
222+
// Add the Id Token string as a synthetic claim
223+
final JSONObject verifiedIdTokenClaimsObject = verifiedIdTokenClaims.toJSONObject();
224+
final JWTClaimsSet idTokenClaim = new JWTClaimsSet.Builder().claim("id_token_hint", idToken.serialize()).build();
225+
verifiedIdTokenClaimsObject.merge(idTokenClaim.toJSONObject());
226+
final JWTClaimsSet enrichedVerifiedIdTokenClaims = JWTClaimsSet.parse(verifiedIdTokenClaimsObject);
222227
if (accessToken != null && opConfig.getUserinfoEndpoint() != null) {
223-
getAndCombineUserInfoClaims(accessToken, verifiedIdTokenClaims, claimsListener);
228+
getAndCombineUserInfoClaims(accessToken, enrichedVerifiedIdTokenClaims, claimsListener);
224229
} else {
225-
claimsListener.onResponse(verifiedIdTokenClaims);
230+
claimsListener.onResponse(enrichedVerifiedIdTokenClaims);
226231
}
227232
} catch (BadJOSEException e) {
228233
// We only try to update the cached JWK set once if a remote source is used and
@@ -241,7 +246,7 @@ private void getUserClaims(@Nullable AccessToken accessToken, JWT idToken, Nonce
241246
} else {
242247
claimsListener.onFailure(new ElasticsearchSecurityException("Failed to parse or validate the ID Token", e));
243248
}
244-
} catch (com.nimbusds.oauth2.sdk.ParseException | JOSEException e) {
249+
} catch (com.nimbusds.oauth2.sdk.ParseException | ParseException | JOSEException e) {
245250
claimsListener.onFailure(new ElasticsearchSecurityException("Failed to parse or validate the ID Token", e));
246251
}
247252
}

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectProviderConfiguration.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ public class OpenIdConnectProviderConfiguration {
1919
private final URI authorizationEndpoint;
2020
private final URI tokenEndpoint;
2121
private final URI userinfoEndpoint;
22-
private final URI endsessionEndpoint;;
22+
private final URI endsessionEndpoint;
2323
private final Issuer issuer;
2424
private final String jwkSetPath;
2525

2626
public OpenIdConnectProviderConfiguration(String providerName, Issuer issuer, String jwkSetPath, URI authorizationEndpoint,
27-
URI tokenEndpoint, @Nullable URI userinfoEndpoint, @Nullable URI endsessionEndpoint;) {
27+
URI tokenEndpoint, @Nullable URI userinfoEndpoint, @Nullable URI endsessionEndpoint) {
2828
this.providerName = Objects.requireNonNull(providerName, "OP Name must be provided");
2929
this.authorizationEndpoint = Objects.requireNonNull(authorizationEndpoint, "Authorization Endpoint must be provided");
3030
this.tokenEndpoint = Objects.requireNonNull(tokenEndpoint, "Token Endpoint must be provided");

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectRealm.java

+33-6
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
package org.elasticsearch.xpack.security.authc.oidc;
77

88
import com.nimbusds.jose.JWSAlgorithm;
9+
import com.nimbusds.jwt.JWT;
910
import com.nimbusds.jwt.JWTClaimsSet;
1011

1112
import com.nimbusds.oauth2.sdk.ParseException;
@@ -15,6 +16,7 @@
1516
import com.nimbusds.oauth2.sdk.id.Issuer;
1617
import com.nimbusds.oauth2.sdk.id.State;
1718
import com.nimbusds.openid.connect.sdk.AuthenticationRequest;
19+
import com.nimbusds.openid.connect.sdk.LogoutRequest;
1820
import com.nimbusds.openid.connect.sdk.Nonce;
1921
import org.apache.logging.log4j.Logger;
2022
import org.elasticsearch.ElasticsearchException;
@@ -73,6 +75,7 @@
7375
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.PRINCIPAL_CLAIM;
7476
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_CLIENT_ID;
7577
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_CLIENT_SECRET;
78+
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_POST_LOGOUT_REDIRECT_URI;
7679
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_REDIRECT_URI;
7780
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_RESPONSE_TYPE;
7881
import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_REQUESTED_SCOPES;
@@ -180,8 +183,21 @@ private void buildUserFromClaims(JWTClaimsSet claims, ActionListener<Authenticat
180183
return;
181184
}
182185

186+
final Map<String, Object> tokenMetadata = new HashMap<>();
187+
tokenMetadata.put("id_token_hint", claims.getClaim("id_token_hint"));
188+
tokenMetadata.put("oidc_realm", this.name());
189+
ActionListener<AuthenticationResult> wrappedAuthResultListener = ActionListener.wrap(auth -> {
190+
if (auth.isAuthenticated()) {
191+
// Add the ID Token as metadata on the authentication, so that it can be used for logout requests
192+
Map<String, Object> metadata = new HashMap<>(auth.getMetadata());
193+
metadata.put(CONTEXT_TOKEN_DATA, tokenMetadata);
194+
auth = AuthenticationResult.success(auth.getUser(), metadata);
195+
}
196+
authResultListener.onResponse(auth);
197+
}, authResultListener::onFailure);
198+
183199
if (delegatedRealms.hasDelegation()) {
184-
delegatedRealms.resolve(principal, authResultListener);
200+
delegatedRealms.resolve(principal, wrappedAuthResultListener);
185201
return;
186202
}
187203

@@ -206,8 +222,8 @@ private void buildUserFromClaims(JWTClaimsSet claims, ActionListener<Authenticat
206222
UserRoleMapper.UserData userData = new UserRoleMapper.UserData(principal, dn, groups, userMetadata, config);
207223
roleMapper.resolveRoles(userData, ActionListener.wrap(roles -> {
208224
final User user = new User(principal, roles.toArray(Strings.EMPTY_ARRAY), name, mail, userMetadata, true);
209-
authResultListener.onResponse(AuthenticationResult.success(user));
210-
}, authResultListener::onFailure));
225+
wrappedAuthResultListener.onResponse(AuthenticationResult.success(user));
226+
}, wrappedAuthResultListener::onFailure));
211227

212228
}
213229

@@ -220,6 +236,14 @@ private RelyingPartyConfiguration buildRelyingPartyConfiguration(RealmConfig con
220236
// This should never happen as it's already validated in the settings
221237
throw new SettingsException("Invalid URI:" + RP_REDIRECT_URI.getKey(), e);
222238
}
239+
final String postLogoutRedirectUriString = config.getSetting(RP_POST_LOGOUT_REDIRECT_URI);
240+
final URI postLogoutRedirectUri;
241+
try {
242+
postLogoutRedirectUri = new URI(postLogoutRedirectUriString);
243+
} catch (URISyntaxException e) {
244+
// This should never happen as it's already validated in the settings
245+
throw new SettingsException("Invalid URI:" + RP_POST_LOGOUT_REDIRECT_URI.getKey(), e);
246+
}
223247
final ClientID clientId = new ClientID(require(config, RP_CLIENT_ID));
224248
final SecureString clientSecret = config.getSetting(RP_CLIENT_SECRET);
225249
final ResponseType responseType;
@@ -237,7 +261,7 @@ private RelyingPartyConfiguration buildRelyingPartyConfiguration(RealmConfig con
237261
final JWSAlgorithm signatureVerificationAlgorithm = JWSAlgorithm.parse(require(config, RP_SIGNATURE_VERIFICATION_ALGORITHM));
238262

239263
return new RelyingPartyConfiguration(clientId, clientSecret, redirectUri, responseType, requestedScope,
240-
signatureVerificationAlgorithm);
264+
signatureVerificationAlgorithm, postLogoutRedirectUri);
241265
}
242266

243267
private OpenIdConnectProviderConfiguration buildOpenIdConnectProviderConfiguration(RealmConfig config) {
@@ -313,8 +337,11 @@ public OpenIdConnectPrepareAuthenticationResponse buildAuthenticationRequestUri(
313337
state.getValue(), nonce.getValue());
314338
}
315339

316-
public OpenIdConnectLogoutResponse buildLogoutResponse() {
317-
return new OpenIdConnectLogoutResponse(opConfiguration.getEndsessionEndpoint());
340+
public OpenIdConnectLogoutResponse buildLogoutResponse(JWT idTokenHint) {
341+
final State state = new State();
342+
final LogoutRequest logoutRequest = new LogoutRequest(opConfiguration.getEndsessionEndpoint(), idTokenHint,
343+
rpConfiguration.getPostLogoutRedirectUri(), state);
344+
return new OpenIdConnectLogoutResponse(logoutRequest.toURI().toString());
318345
}
319346

320347
@Override

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/RelyingPartyConfiguration.java

+8-2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.nimbusds.oauth2.sdk.ResponseType;
1010
import com.nimbusds.oauth2.sdk.Scope;
1111
import com.nimbusds.oauth2.sdk.id.ClientID;
12+
import org.elasticsearch.common.Nullable;
1213
import org.elasticsearch.common.settings.SecureString;
1314

1415
import java.net.URI;
@@ -24,16 +25,17 @@ public class RelyingPartyConfiguration {
2425
private final ResponseType responseType;
2526
private final Scope requestedScope;
2627
private final JWSAlgorithm signatureAlgorithm;
28+
private final URI postLogoutRedirectUri;
2729

2830
public RelyingPartyConfiguration(ClientID clientId, SecureString clientSecret, URI redirectUri, ResponseType responseType,
29-
Scope requestedScope,
30-
JWSAlgorithm algorithm) {
31+
Scope requestedScope, JWSAlgorithm algorithm, @Nullable URI postLogoutRedirectUri) {
3132
this.clientId = Objects.requireNonNull(clientId, "clientId must be provided");
3233
this.clientSecret = Objects.requireNonNull(clientSecret, "clientSecret must be provided");
3334
this.redirectUri = Objects.requireNonNull(redirectUri, "redirectUri must be provided");
3435
this.responseType = Objects.requireNonNull(responseType, "responseType must be provided");
3536
this.requestedScope = Objects.requireNonNull(requestedScope, "responseType must be provided");
3637
this.signatureAlgorithm = Objects.requireNonNull(algorithm, "algorithm must be provided");
38+
this.postLogoutRedirectUri = postLogoutRedirectUri;
3739
}
3840

3941
public ClientID getClientId() {
@@ -59,4 +61,8 @@ public Scope getRequestedScope() {
5961
public JWSAlgorithm getSignatureAlgorithm() {
6062
return signatureAlgorithm;
6163
}
64+
65+
public URI getPostLogoutRedirectUri() {
66+
return postLogoutRedirectUri;
67+
}
6268
}

0 commit comments

Comments
 (0)