diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/oidc/OpenIdConnectLogoutAction.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/oidc/OpenIdConnectLogoutAction.java new file mode 100644 index 0000000000000..482484a7dedee --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/oidc/OpenIdConnectLogoutAction.java @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.core.security.action.oidc; + +import org.elasticsearch.action.Action; +import org.elasticsearch.common.io.stream.Writeable; + +public class OpenIdConnectLogoutAction extends Action { + + public static final OpenIdConnectLogoutAction INSTANCE = new OpenIdConnectLogoutAction(); + public static final String NAME = "cluster:admin/xpack/security/oidc/logout"; + + private OpenIdConnectLogoutAction() { + super(NAME); + } + + @Override + public OpenIdConnectLogoutResponse newResponse() { + throw new UnsupportedOperationException("usage of Streamable is to be replaced by Writeable"); + } + + @Override + public Writeable.Reader getResponseReader() { + return OpenIdConnectLogoutResponse::new; + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/oidc/OpenIdConnectLogoutRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/oidc/OpenIdConnectLogoutRequest.java new file mode 100644 index 0000000000000..777df403ecab3 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/oidc/OpenIdConnectLogoutRequest.java @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.core.security.action.oidc; + +import org.elasticsearch.action.ActionRequest; +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.common.Nullable; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; + +import java.io.IOException; + +import static org.elasticsearch.action.ValidateActions.addValidationError; + +public final class OpenIdConnectLogoutRequest extends ActionRequest { + + private String token; + @Nullable + private String refreshToken; + + public OpenIdConnectLogoutRequest() { + + } + + public OpenIdConnectLogoutRequest(StreamInput in) throws IOException { + super.readFrom(in); + token = in.readString(); + refreshToken = in.readOptionalString(); + } + + @Override + public ActionRequestValidationException validate() { + ActionRequestValidationException validationException = null; + if (Strings.isNullOrEmpty(token)) { + validationException = addValidationError("token is missing", validationException); + } + return validationException; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeString(token); + out.writeOptionalString(refreshToken); + } + + @Override + public void readFrom(StreamInput in) { + throw new UnsupportedOperationException("usage of Streamable is to be replaced by Writeable"); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/oidc/OpenIdConnectLogoutResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/oidc/OpenIdConnectLogoutResponse.java new file mode 100644 index 0000000000000..e725701e01c7e --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/oidc/OpenIdConnectLogoutResponse.java @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.core.security.action.oidc; + +import org.elasticsearch.action.ActionResponse; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; + +import java.io.IOException; + +public final class OpenIdConnectLogoutResponse extends ActionResponse { + + private String endSessionUrl; + + public OpenIdConnectLogoutResponse(StreamInput in) throws IOException { + super.readFrom(in); + this.endSessionUrl = in.readString(); + } + + public OpenIdConnectLogoutResponse(String endSessionUrl) { + this.endSessionUrl = endSessionUrl; + } + + @Override + public void readFrom(StreamInput in) { + throw new UnsupportedOperationException("usage of Streamable is to be replaced by Writeable"); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeString(endSessionUrl); + } + + public String toString() { + return "{endSessionUrl=" + endSessionUrl + "}"; + } + + public String getEndSessionUrl() { + return endSessionUrl; + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/oidc/OpenIdConnectRealmSettings.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/oidc/OpenIdConnectRealmSettings.java index ce944ae3d6fb2..5eb4b0f37a118 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/oidc/OpenIdConnectRealmSettings.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/oidc/OpenIdConnectRealmSettings.java @@ -47,6 +47,15 @@ private OpenIdConnectRealmSettings() { throw new IllegalArgumentException("Invalid value [" + v + "] for [" + key + "]. Not a valid URI.", e); } }, Setting.Property.NodeScope)); + public static final Setting.AffixSetting RP_POST_LOGOUT_REDIRECT_URI + = Setting.affixKeySetting(RealmSettings.realmSettingPrefix(TYPE), "rp.post_logout_redirect_uri", + key -> Setting.simpleString(key, v -> { + try { + new URI(v); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid value [" + v + "] for [" + key + "]. Not a valid URI.", e); + } + }, Setting.Property.NodeScope)); public static final Setting.AffixSetting RP_RESPONSE_TYPE = Setting.affixKeySetting(RealmSettings.realmSettingPrefix(TYPE), "rp.response_type", key -> Setting.simpleString(key, v -> { @@ -95,6 +104,15 @@ private OpenIdConnectRealmSettings() { throw new IllegalArgumentException("Invalid value [" + v + "] for [" + key + "]. Not a valid URI.", e); } }, Setting.Property.NodeScope)); + public static final Setting.AffixSetting OP_ENDSESSION_ENDPOINT + = Setting.affixKeySetting(RealmSettings.realmSettingPrefix(TYPE), "op.endsession_endpoint", + key -> Setting.simpleString(key, v -> { + try { + new URI(v); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid value [" + v + "] for [" + key + "]. Not a valid URI.", e); + } + }, Setting.Property.NodeScope)); public static final Setting.AffixSetting OP_ISSUER = RealmSettings.simpleString(TYPE, "op.issuer", Setting.Property.NodeScope); public static final Setting.AffixSetting OP_JWKSET_PATH @@ -132,9 +150,9 @@ private OpenIdConnectRealmSettings() { public static Set> getSettings() { final Set> set = Sets.newHashSet( RP_CLIENT_ID, RP_REDIRECT_URI, RP_RESPONSE_TYPE, RP_REQUESTED_SCOPES, RP_CLIENT_SECRET, RP_SIGNATURE_ALGORITHM, - OP_NAME, OP_AUTHORIZATION_ENDPOINT, OP_TOKEN_ENDPOINT, OP_USERINFO_ENDPOINT, OP_ISSUER, OP_JWKSET_PATH, - HTTP_CONNECT_TIMEOUT, HTTP_CONNECTION_READ_TIMEOUT, HTTP_SOCKET_TIMEOUT, HTTP_MAX_CONNECTIONS, HTTP_MAX_ENDPOINT_CONNECTIONS, - ALLOWED_CLOCK_SKEW); + RP_POST_LOGOUT_REDIRECT_URI, OP_NAME, OP_AUTHORIZATION_ENDPOINT, OP_TOKEN_ENDPOINT, OP_USERINFO_ENDPOINT, + OP_ENDSESSION_ENDPOINT, OP_ISSUER, OP_JWKSET_PATH, HTTP_CONNECT_TIMEOUT, HTTP_CONNECTION_READ_TIMEOUT, HTTP_SOCKET_TIMEOUT, + HTTP_MAX_CONNECTIONS, HTTP_MAX_ENDPOINT_CONNECTIONS, ALLOWED_CLOCK_SKEW); set.addAll(DelegatedAuthorizationSettings.getSettings(TYPE)); set.addAll(RealmSettings.getStandardSettings(TYPE)); set.addAll(SSLConfigurationSettings.getRealmSettings(TYPE)); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index 06a66c38808d6..dfdf5e4ba5591 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -81,6 +81,7 @@ import org.elasticsearch.xpack.core.security.action.GetApiKeyAction; import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyAction; import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectAuthenticateAction; +import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectLogoutAction; import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectPrepareAuthenticationAction; import org.elasticsearch.xpack.core.security.action.privilege.DeletePrivilegesAction; import org.elasticsearch.xpack.core.security.action.privilege.GetPrivilegesAction; @@ -138,6 +139,7 @@ import org.elasticsearch.xpack.security.action.TransportInvalidateApiKeyAction; import org.elasticsearch.xpack.security.action.filter.SecurityActionFilter; import org.elasticsearch.xpack.security.action.oidc.TransportOpenIdConnectAuthenticateAction; +import org.elasticsearch.xpack.security.action.oidc.TransportOpenIdConnectLogoutAction; import org.elasticsearch.xpack.security.action.oidc.TransportOpenIdConnectPrepareAuthenticationAction; import org.elasticsearch.xpack.security.action.privilege.TransportDeletePrivilegesAction; import org.elasticsearch.xpack.security.action.privilege.TransportGetPrivilegesAction; @@ -197,6 +199,7 @@ import org.elasticsearch.xpack.security.rest.action.RestInvalidateApiKeyAction; import org.elasticsearch.xpack.security.rest.action.oauth2.RestGetTokenAction; import org.elasticsearch.xpack.security.rest.action.oauth2.RestInvalidateTokenAction; +import org.elasticsearch.xpack.security.rest.action.oidc.RestOpenIdConnectLogoutAction; import org.elasticsearch.xpack.security.rest.action.privilege.RestDeletePrivilegesAction; import org.elasticsearch.xpack.security.rest.action.privilege.RestGetPrivilegesAction; import org.elasticsearch.xpack.security.rest.action.privilege.RestPutPrivilegesAction; @@ -745,6 +748,7 @@ public void onIndexModule(IndexModule module) { new ActionHandler<>(OpenIdConnectPrepareAuthenticationAction.INSTANCE, TransportOpenIdConnectPrepareAuthenticationAction.class), new ActionHandler<>(OpenIdConnectAuthenticateAction.INSTANCE, TransportOpenIdConnectAuthenticateAction.class), + new ActionHandler<>(OpenIdConnectLogoutAction.INSTANCE, TransportOpenIdConnectLogoutAction.class), new ActionHandler<>(GetPrivilegesAction.INSTANCE, TransportGetPrivilegesAction.class), new ActionHandler<>(PutPrivilegesAction.INSTANCE, TransportPutPrivilegesAction.class), new ActionHandler<>(DeletePrivilegesAction.INSTANCE, TransportDeletePrivilegesAction.class), @@ -799,6 +803,7 @@ public List getRestHandlers(Settings settings, RestController restC new RestSamlInvalidateSessionAction(settings, restController, getLicenseState()), new RestOpenIdConnectPrepareAuthenticationAction(settings, restController, getLicenseState()), new RestOpenIdConnectAuthenticateAction(settings, restController, getLicenseState()), + new RestOpenIdConnectLogoutAction(settings, restController, getLicenseState()), new RestGetPrivilegesAction(settings, restController, getLicenseState()), new RestPutPrivilegesAction(settings, restController, getLicenseState()), new RestDeletePrivilegesAction(settings, restController, getLicenseState()), diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/oidc/TransportOpenIdConnectLogoutAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/oidc/TransportOpenIdConnectLogoutAction.java new file mode 100644 index 0000000000000..a6cb9f6e15c01 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/oidc/TransportOpenIdConnectLogoutAction.java @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.security.action.oidc; + +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTParser; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.HandledTransportAction; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.tasks.Task; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectLogoutAction; +import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectLogoutRequest; +import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectLogoutResponse; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.Realm; +import org.elasticsearch.xpack.core.security.authc.support.TokensInvalidationResult; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.security.authc.Realms; +import org.elasticsearch.xpack.security.authc.TokenService; +import org.elasticsearch.xpack.security.authc.oidc.OpenIdConnectRealm; + +import java.io.IOException; +import java.text.ParseException; +import java.util.Map; + +/** + * Transport action responsible for generating an OpenID connect logout request to be sent to an OpenID Connect Provider + */ +public class TransportOpenIdConnectLogoutAction extends HandledTransportAction { + + private final Realms realms; + private final TokenService tokenService; + private static final Logger logger = LogManager.getLogger(TransportOpenIdConnectLogoutAction.class); + + @Inject + public TransportOpenIdConnectLogoutAction(TransportService transportService, ActionFilters actionFilters, Realms realms, + TokenService tokenService) { + super(OpenIdConnectLogoutAction.NAME, transportService, actionFilters, + (Writeable.Reader) OpenIdConnectLogoutRequest::new); + this.realms = realms; + this.tokenService = tokenService; + } + + @Override + protected void doExecute(Task task, OpenIdConnectLogoutRequest request, ActionListener listener) { + invalidateRefreshToken(request.getRefreshToken(), ActionListener.wrap(ignore -> { + try { + final String token = request.getToken(); + tokenService.getAuthenticationAndMetaData(token, ActionListener.wrap( + tuple -> { + final Authentication authentication = tuple.v1(); + final Map tokenMetadata = tuple.v2(); + validateAuthenticationAndMetadata(authentication, tokenMetadata); + tokenService.invalidateAccessToken(token, ActionListener.wrap( + result -> { + if (logger.isTraceEnabled()) { + logger.trace("OpenID Connect Logout for user [{}] and token [{}...{}]", + authentication.getUser().principal(), + token.substring(0, 8), + token.substring(token.length() - 8)); + } + OpenIdConnectLogoutResponse response = buildResponse(authentication, tokenMetadata); + listener.onResponse(response); + }, listener::onFailure) + ); + }, listener::onFailure)); + } catch (IOException e) { + listener.onFailure(e); + } + }, listener::onFailure)); + } + + private OpenIdConnectLogoutResponse buildResponse(Authentication authentication, Map tokenMetadata) { + final String idTokenHint = (String) getFromMetadata(tokenMetadata, "id_token_hint"); + final Realm realm = this.realms.realm(authentication.getAuthenticatedBy().getName()); + final JWT idToken; + try { + idToken = JWTParser.parse(idTokenHint); + } catch (ParseException e) { + throw new ElasticsearchSecurityException("Token Metadata did not contain a valid IdToken", e); + } + return ((OpenIdConnectRealm) realm).buildLogoutResponse(idToken); + } + + private void validateAuthenticationAndMetadata(Authentication authentication, Map tokenMetadata) { + if (tokenMetadata == null) { + throw new ElasticsearchSecurityException("Authentication did not contain metadata"); + } + if (authentication == null) { + throw new ElasticsearchSecurityException("No active authentication"); + } + final User user = authentication.getUser(); + if (user == null) { + throw new ElasticsearchSecurityException("No active user"); + } + + final Authentication.RealmRef ref = authentication.getAuthenticatedBy(); + if (ref == null || Strings.isNullOrEmpty(ref.getName())) { + throw new ElasticsearchSecurityException("Authentication {} has no authenticating realm", + authentication); + } + final Realm realm = this.realms.realm(authentication.getAuthenticatedBy().getName()); + if (realm == null) { + throw new ElasticsearchSecurityException("Authenticating realm {} does not exist", ref.getName()); + } + if (realm instanceof OpenIdConnectRealm == false) { + throw new IllegalArgumentException("Access token is not valid for an OpenID Connect realm"); + } + } + + private Object getFromMetadata(Map metadata, String key) { + if (metadata.containsKey(key) == false) { + throw new ElasticsearchSecurityException("Authentication token does not have OpenID Connect metadata [{}]", key); + } + Object value = metadata.get(key); + if (null != value && value instanceof String == false) { + throw new ElasticsearchSecurityException("In authentication token, OpenID Connect metadata [{}] is [{}] rather than " + + "String", key, value.getClass()); + } + return value; + + } + private void invalidateRefreshToken(String refreshToken, ActionListener listener) { + if (refreshToken == null) { + listener.onResponse(null); + } else { + tokenService.invalidateRefreshToken(refreshToken, listener); + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectAuthenticator.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectAuthenticator.java index 5e88c376be532..25d1a87ae7def 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectAuthenticator.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectAuthenticator.java @@ -224,14 +224,21 @@ private void getUserClaims(@Nullable AccessToken accessToken, JWT idToken, Nonce if (LOGGER.isTraceEnabled()) { LOGGER.trace("Received and validated the Id Token for the user: [{}]", verifiedIdTokenClaims); } + // Add the Id Token string as a synthetic claim + final JSONObject verifiedIdTokenClaimsObject = verifiedIdTokenClaims.toJSONObject(); + final JWTClaimsSet idTokenClaim = new JWTClaimsSet.Builder().claim("id_token_hint", idToken.serialize()).build(); + verifiedIdTokenClaimsObject.merge(idTokenClaim.toJSONObject()); + final JWTClaimsSet enrichedVerifiedIdTokenClaims = JWTClaimsSet.parse(verifiedIdTokenClaimsObject); if (accessToken != null && opConfig.getUserinfoEndpoint() != null) { - getAndCombineUserInfoClaims(accessToken, verifiedIdTokenClaims, claimsListener); - } else if (accessToken == null && opConfig.getUserinfoEndpoint() != null) { - LOGGER.debug("UserInfo endpoint is configured but the OP didn't return an access token so we can't query it"); - } else if (accessToken != null && opConfig.getUserinfoEndpoint() == null) { - LOGGER.debug("OP returned an access token but the UserInfo endpoint is not configured."); + getAndCombineUserInfoClaims(accessToken, enrichedVerifiedIdTokenClaims, claimsListener); + } else { + if (accessToken == null && opConfig.getUserinfoEndpoint() != null) { + LOGGER.debug("UserInfo endpoint is configured but the OP didn't return an access token so we can't query it"); + } else if (accessToken != null && opConfig.getUserinfoEndpoint() == null) { + LOGGER.debug("OP returned an access token but the UserInfo endpoint is not configured."); + } + claimsListener.onResponse(enrichedVerifiedIdTokenClaims); } - claimsListener.onResponse(verifiedIdTokenClaims); } catch (BadJOSEException e) { // We only try to update the cached JWK set once if a remote source is used and // RSA or ECDSA is used for signatures @@ -248,7 +255,7 @@ private void getUserClaims(@Nullable AccessToken accessToken, JWT idToken, Nonce } else { claimsListener.onFailure(new ElasticsearchSecurityException("Failed to parse or validate the ID Token", e)); } - } catch (com.nimbusds.oauth2.sdk.ParseException | JOSEException e) { + } catch (com.nimbusds.oauth2.sdk.ParseException | ParseException | JOSEException e) { claimsListener.onFailure(new ElasticsearchSecurityException("Failed to parse or validate the ID Token", e)); } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectProviderConfiguration.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectProviderConfiguration.java index d6384515d6ebe..272ab283c75be 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectProviderConfiguration.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectProviderConfiguration.java @@ -19,15 +19,17 @@ public class OpenIdConnectProviderConfiguration { private final URI authorizationEndpoint; private final URI tokenEndpoint; private final URI userinfoEndpoint; + private final URI endsessionEndpoint; private final Issuer issuer; private final String jwkSetPath; public OpenIdConnectProviderConfiguration(String providerName, Issuer issuer, String jwkSetPath, URI authorizationEndpoint, - URI tokenEndpoint, @Nullable URI userinfoEndpoint) { + URI tokenEndpoint, @Nullable URI userinfoEndpoint, @Nullable URI endsessionEndpoint) { this.providerName = Objects.requireNonNull(providerName, "OP Name must be provided"); this.authorizationEndpoint = Objects.requireNonNull(authorizationEndpoint, "Authorization Endpoint must be provided"); this.tokenEndpoint = Objects.requireNonNull(tokenEndpoint, "Token Endpoint must be provided"); this.userinfoEndpoint = userinfoEndpoint; + this.endsessionEndpoint = endsessionEndpoint; this.issuer = Objects.requireNonNull(issuer, "OP Issuer must be provided"); this.jwkSetPath = Objects.requireNonNull(jwkSetPath, "jwkSetUrl must be provided"); } @@ -48,6 +50,10 @@ public URI getUserinfoEndpoint() { return userinfoEndpoint; } + public URI getEndsessionEndpoint() { + return endsessionEndpoint; + } + public Issuer getIssuer() { return issuer; } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectRealm.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectRealm.java index 6cf7f0a568b50..72b04951a9121 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectRealm.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectRealm.java @@ -6,6 +6,7 @@ package org.elasticsearch.xpack.security.authc.oidc; import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jwt.JWT; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.oauth2.sdk.ParseException; @@ -15,9 +16,11 @@ import com.nimbusds.oauth2.sdk.id.Issuer; import com.nimbusds.oauth2.sdk.id.State; import com.nimbusds.openid.connect.sdk.AuthenticationRequest; +import com.nimbusds.openid.connect.sdk.LogoutRequest; import com.nimbusds.openid.connect.sdk.Nonce; import org.apache.logging.log4j.Logger; import org.elasticsearch.ElasticsearchSecurityException; + import org.elasticsearch.action.ActionListener; import org.elasticsearch.common.Nullable; import org.elasticsearch.common.Strings; @@ -29,6 +32,7 @@ import org.elasticsearch.license.XPackLicenseState; import org.elasticsearch.watcher.ResourceWatcherService; import org.elasticsearch.xpack.core.XPackSettings; +import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectLogoutResponse; import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectPrepareAuthenticationResponse; import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; import org.elasticsearch.xpack.core.security.authc.AuthenticationToken; @@ -62,6 +66,7 @@ import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.MAIL_CLAIM; import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.NAME_CLAIM; import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.OP_AUTHORIZATION_ENDPOINT; +import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.OP_ENDSESSION_ENDPOINT; import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.OP_ISSUER; import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.OP_JWKSET_PATH; import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.OP_NAME; @@ -71,6 +76,7 @@ import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.PRINCIPAL_CLAIM; import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_CLIENT_ID; import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_CLIENT_SECRET; +import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_POST_LOGOUT_REDIRECT_URI; import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_REDIRECT_URI; import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_RESPONSE_TYPE; import static org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings.RP_REQUESTED_SCOPES; @@ -150,7 +156,9 @@ public void authenticate(AuthenticationToken token, ActionListener buildUserFromClaims(jwtClaimsSet, listener), + jwtClaimsSet -> { + buildUserFromClaims(jwtClaimsSet, listener); + }, e -> { logger.debug("Failed to consume the OpenIdConnectToken ", e); if (e instanceof ElasticsearchSecurityException) { @@ -178,8 +186,20 @@ private void buildUserFromClaims(JWTClaimsSet claims, ActionListener tokenMetadata = new HashMap<>(); + tokenMetadata.put("id_token_hint", claims.getClaim("id_token_hint")); + ActionListener wrappedAuthResultListener = ActionListener.wrap(auth -> { + if (auth.isAuthenticated()) { + // Add the ID Token as metadata on the authentication, so that it can be used for logout requests + Map metadata = new HashMap<>(auth.getMetadata()); + metadata.put(CONTEXT_TOKEN_DATA, tokenMetadata); + auth = AuthenticationResult.success(auth.getUser(), metadata); + } + authResultListener.onResponse(auth); + }, authResultListener::onFailure); + if (delegatedRealms.hasDelegation()) { - delegatedRealms.resolve(principal, authResultListener); + delegatedRealms.resolve(principal, wrappedAuthResultListener); return; } @@ -204,8 +224,8 @@ private void buildUserFromClaims(JWTClaimsSet claims, ActionListener { final User user = new User(principal, roles.toArray(Strings.EMPTY_ARRAY), name, mail, userMetadata, true); - authResultListener.onResponse(AuthenticationResult.success(user)); - }, authResultListener::onFailure)); + wrappedAuthResultListener.onResponse(AuthenticationResult.success(user)); + }, wrappedAuthResultListener::onFailure)); } @@ -218,6 +238,14 @@ private RelyingPartyConfiguration buildRelyingPartyConfiguration(RealmConfig con // This should never happen as it's already validated in the settings throw new SettingsException("Invalid URI:" + RP_REDIRECT_URI.getKey(), e); } + final String postLogoutRedirectUriString = config.getSetting(RP_POST_LOGOUT_REDIRECT_URI); + final URI postLogoutRedirectUri; + try { + postLogoutRedirectUri = new URI(postLogoutRedirectUriString); + } catch (URISyntaxException e) { + // This should never happen as it's already validated in the settings + throw new SettingsException("Invalid URI:" + RP_POST_LOGOUT_REDIRECT_URI.getKey(), e); + } final ClientID clientId = new ClientID(require(config, RP_CLIENT_ID)); final SecureString clientSecret = config.getSetting(RP_CLIENT_SECRET); final ResponseType responseType; @@ -235,7 +263,7 @@ private RelyingPartyConfiguration buildRelyingPartyConfiguration(RealmConfig con final JWSAlgorithm signatureAlgorithm = JWSAlgorithm.parse(require(config, RP_SIGNATURE_ALGORITHM)); return new RelyingPartyConfiguration(clientId, clientSecret, redirectUri, responseType, requestedScope, - signatureAlgorithm); + signatureAlgorithm, postLogoutRedirectUri); } private OpenIdConnectProviderConfiguration buildOpenIdConnectProviderConfiguration(RealmConfig config) { @@ -266,9 +294,17 @@ private OpenIdConnectProviderConfiguration buildOpenIdConnectProviderConfigurati // This should never happen as it's already validated in the settings throw new SettingsException("Invalid URI: " + OP_USERINFO_ENDPOINT.getKey(), e); } + URI endsessionEndpoint; + try { + endsessionEndpoint = (config.getSetting(OP_ENDSESSION_ENDPOINT, () -> null) == null) ? null : + new URI(config.getSetting(OP_ENDSESSION_ENDPOINT, () -> null)); + } catch (URISyntaxException e) { + // This should never happen as it's already validated in the settings + throw new SettingsException("Invalid URI: " + OP_ENDSESSION_ENDPOINT.getKey(), e); + } return new OpenIdConnectProviderConfiguration(providerName, issuer, jwkSetUrl, authorizationEndpoint, tokenEndpoint, - userinfoEndpoint); + userinfoEndpoint, endsessionEndpoint); } private static String require(RealmConfig config, Setting.AffixSetting setting) { @@ -321,6 +357,17 @@ public boolean isIssuerValid(String issuer) { return this.opConfiguration.getIssuer().getValue().equals(issuer); } + public OpenIdConnectLogoutResponse buildLogoutResponse(JWT idTokenHint) { + if (opConfiguration.getEndsessionEndpoint() != null) { + final State state = new State(); + final LogoutRequest logoutRequest = new LogoutRequest(opConfiguration.getEndsessionEndpoint(), idTokenHint, + rpConfiguration.getPostLogoutRedirectUri(), state); + return new OpenIdConnectLogoutResponse(logoutRequest.toURI().toString()); + } else { + return new OpenIdConnectLogoutResponse((String) null); + } + } + @Override public void close() { openIdConnectAuthenticator.close(); @@ -424,4 +471,3 @@ static ClaimParser forSetting(Logger logger, OpenIdConnectRealmSettings.ClaimSet } } } - diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/RelyingPartyConfiguration.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/RelyingPartyConfiguration.java index 370a3e6866af8..ed67974c0b0d2 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/RelyingPartyConfiguration.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/RelyingPartyConfiguration.java @@ -9,6 +9,7 @@ import com.nimbusds.oauth2.sdk.ResponseType; import com.nimbusds.oauth2.sdk.Scope; import com.nimbusds.oauth2.sdk.id.ClientID; +import org.elasticsearch.common.Nullable; import org.elasticsearch.common.settings.SecureString; import java.net.URI; @@ -24,16 +25,17 @@ public class RelyingPartyConfiguration { private final ResponseType responseType; private final Scope requestedScope; private final JWSAlgorithm signatureAlgorithm; + private final URI postLogoutRedirectUri; public RelyingPartyConfiguration(ClientID clientId, SecureString clientSecret, URI redirectUri, ResponseType responseType, - Scope requestedScope, - JWSAlgorithm algorithm) { + Scope requestedScope, JWSAlgorithm algorithm, @Nullable URI postLogoutRedirectUri) { this.clientId = Objects.requireNonNull(clientId, "clientId must be provided"); this.clientSecret = Objects.requireNonNull(clientSecret, "clientSecret must be provided"); this.redirectUri = Objects.requireNonNull(redirectUri, "redirectUri must be provided"); this.responseType = Objects.requireNonNull(responseType, "responseType must be provided"); this.requestedScope = Objects.requireNonNull(requestedScope, "responseType must be provided"); this.signatureAlgorithm = Objects.requireNonNull(algorithm, "algorithm must be provided"); + this.postLogoutRedirectUri = postLogoutRedirectUri; } public ClientID getClientId() { @@ -59,4 +61,8 @@ public Scope getRequestedScope() { public JWSAlgorithm getSignatureAlgorithm() { return signatureAlgorithm; } + + public URI getPostLogoutRedirectUri() { + return postLogoutRedirectUri; + } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/oidc/RestOpenIdConnectLogoutAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/oidc/RestOpenIdConnectLogoutAction.java new file mode 100644 index 0000000000000..e098e14c423b8 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/oidc/RestOpenIdConnectLogoutAction.java @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.security.rest.action.oidc; + +import org.elasticsearch.client.node.NodeClient; +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.ObjectParser; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.rest.BytesRestResponse; +import org.elasticsearch.rest.RestController; +import org.elasticsearch.rest.RestRequest; +import org.elasticsearch.rest.RestResponse; +import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.rest.action.RestBuilderListener; +import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectLogoutAction; +import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectLogoutRequest; +import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectLogoutResponse; + +import java.io.IOException; + +import static org.elasticsearch.rest.RestRequest.Method.POST; + +/** + * Rest handler that invalidates a security token for the given OpenID Connect realm and if the configuration of + * the realm supports it, generates a redirect to the `end_session_endpoint` of the OpenID Connect Provider. + */ +public class RestOpenIdConnectLogoutAction extends OpenIdConnectBaseRestHandler { + + static final ObjectParser PARSER = new ObjectParser<>("oidc_logout", + OpenIdConnectLogoutRequest::new); + + static { + PARSER.declareString(OpenIdConnectLogoutRequest::setToken, new ParseField("token")); + PARSER.declareString(OpenIdConnectLogoutRequest::setRefreshToken, new ParseField("refresh_token")); + } + + public RestOpenIdConnectLogoutAction(Settings settings, RestController controller, XPackLicenseState licenseState) { + super(settings, licenseState); + controller.registerHandler(POST, "/_security/oidc/logout", this); + } + + @Override + protected RestChannelConsumer innerPrepareRequest(RestRequest request, NodeClient client) throws IOException { + try (XContentParser parser = request.contentParser()) { + final OpenIdConnectLogoutRequest logoutRequest = PARSER.parse(parser, null); + return channel -> client.execute(OpenIdConnectLogoutAction.INSTANCE, logoutRequest, + new RestBuilderListener(channel) { + @Override + public RestResponse buildResponse(OpenIdConnectLogoutResponse response, XContentBuilder builder) throws Exception { + builder.startObject(); + builder.field("redirect", response.getEndSessionUrl()); + builder.endObject(); + return new BytesRestResponse(RestStatus.OK, builder); + } + }); + } + } + + @Override + public String getName() { + return "security_oidc_logout_action"; + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/oidc/TransportOpenIdConnectLogoutActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/oidc/TransportOpenIdConnectLogoutActionTests.java new file mode 100644 index 0000000000000..edc586644fef3 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/oidc/TransportOpenIdConnectLogoutActionTests.java @@ -0,0 +1,230 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.security.action.oidc; + +import com.nimbusds.jwt.JWT; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.bulk.BulkAction; +import org.elasticsearch.action.bulk.BulkItemResponse; +import org.elasticsearch.action.bulk.BulkRequest; +import org.elasticsearch.action.bulk.BulkRequestBuilder; +import org.elasticsearch.action.bulk.BulkResponse; +import org.elasticsearch.action.get.GetAction; +import org.elasticsearch.action.get.GetRequestBuilder; +import org.elasticsearch.action.index.IndexAction; +import org.elasticsearch.action.index.IndexRequest; +import org.elasticsearch.action.index.IndexRequestBuilder; +import org.elasticsearch.action.index.IndexResponse; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.action.update.UpdateAction; +import org.elasticsearch.action.update.UpdateRequest; +import org.elasticsearch.action.update.UpdateRequestBuilder; +import org.elasticsearch.client.Client; +import org.elasticsearch.cluster.service.ClusterService; +import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.env.Environment; +import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.tasks.Task; +import org.elasticsearch.test.ClusterServiceUtils; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.transport.Transport; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.watcher.ResourceWatcherService; +import org.elasticsearch.xpack.core.XPackSettings; +import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectLogoutRequest; +import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectLogoutResponse; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.RealmConfig; +import org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.core.ssl.SSLService; +import org.elasticsearch.xpack.security.authc.Realms; +import org.elasticsearch.xpack.security.authc.TokenService; +import org.elasticsearch.xpack.security.authc.UserToken; +import org.elasticsearch.xpack.security.authc.oidc.OpenIdConnectRealm; +import org.elasticsearch.xpack.security.authc.oidc.OpenIdConnectTestCase; +import org.elasticsearch.xpack.security.authc.support.UserRoleMapper; +import org.elasticsearch.xpack.security.support.SecurityIndexManager; +import org.junit.After; +import org.junit.Before; + +import java.time.Clock; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import static org.elasticsearch.xpack.security.authc.TokenServiceTests.mockGetTokenFromId; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.startsWith; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TransportOpenIdConnectLogoutActionTests extends OpenIdConnectTestCase { + + private OpenIdConnectRealm oidcRealm; + private TokenService tokenService; + private List indexRequests; + private List bulkRequests; + private Client client; + private TransportOpenIdConnectLogoutAction action; + + @Before + public void setup() throws Exception { + final Settings settings = getBasicRealmSettings() + .put(XPackSettings.TOKEN_SERVICE_ENABLED_SETTING.getKey(), true) + .put("path.home", createTempDir()) + .build(); + final Settings sslSettings = Settings.builder() + .put("xpack.security.authc.realms.oidc.oidc-realm.ssl.verification_mode", "certificate") + .put("path.home", createTempDir()) + .build(); + final ThreadContext threadContext = new ThreadContext(settings); + final ThreadPool threadPool = mock(ThreadPool.class); + when(threadPool.getThreadContext()).thenReturn(threadContext); + new Authentication(new User("kibana"), new Authentication.RealmRef("realm", "type", "node"), null).writeToContext(threadContext); + indexRequests = new ArrayList<>(); + bulkRequests = new ArrayList<>(); + client = mock(Client.class); + when(client.threadPool()).thenReturn(threadPool); + when(client.settings()).thenReturn(settings); + doAnswer(invocationOnMock -> { + GetRequestBuilder builder = new GetRequestBuilder(client, GetAction.INSTANCE); + builder.setIndex((String) invocationOnMock.getArguments()[0]) + .setType((String) invocationOnMock.getArguments()[1]) + .setId((String) invocationOnMock.getArguments()[2]); + return builder; + }).when(client).prepareGet(anyString(), anyString(), anyString()); + doAnswer(invocationOnMock -> { + IndexRequestBuilder builder = new IndexRequestBuilder(client, IndexAction.INSTANCE); + builder.setIndex((String) invocationOnMock.getArguments()[0]) + .setType((String) invocationOnMock.getArguments()[1]) + .setId((String) invocationOnMock.getArguments()[2]); + return builder; + }).when(client).prepareIndex(anyString(), anyString(), anyString()); + doAnswer(invocationOnMock -> { + UpdateRequestBuilder builder = new UpdateRequestBuilder(client, UpdateAction.INSTANCE); + builder.setIndex((String) invocationOnMock.getArguments()[0]) + .setType((String) invocationOnMock.getArguments()[1]) + .setId((String) invocationOnMock.getArguments()[2]); + return builder; + }).when(client).prepareUpdate(anyString(), anyString(), anyString()); + doAnswer(invocationOnMock -> { + BulkRequestBuilder builder = new BulkRequestBuilder(client, BulkAction.INSTANCE); + return builder; + }).when(client).prepareBulk(); + doAnswer(invocationOnMock -> { + IndexRequest indexRequest = (IndexRequest) invocationOnMock.getArguments()[0]; + ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; + indexRequests.add(indexRequest); + final IndexResponse response = new IndexResponse( + indexRequest.shardId(), indexRequest.type(), indexRequest.id(), 1, 1, 1, true); + listener.onResponse(response); + return Void.TYPE; + }).when(client).index(any(IndexRequest.class), any(ActionListener.class)); + doAnswer(invocationOnMock -> { + IndexRequest indexRequest = (IndexRequest) invocationOnMock.getArguments()[1]; + ActionListener listener = (ActionListener) invocationOnMock.getArguments()[2]; + indexRequests.add(indexRequest); + final IndexResponse response = new IndexResponse( + indexRequest.shardId(), indexRequest.type(), indexRequest.id(), 1, 1, 1, true); + listener.onResponse(response); + return Void.TYPE; + }).when(client).execute(eq(IndexAction.INSTANCE), any(IndexRequest.class), any(ActionListener.class)); + doAnswer(invocationOnMock -> { + BulkRequest bulkRequest = (BulkRequest) invocationOnMock.getArguments()[0]; + ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; + bulkRequests.add(bulkRequest); + final BulkResponse response = new BulkResponse(new BulkItemResponse[0], 1); + listener.onResponse(response); + return Void.TYPE; + }).when(client).bulk(any(BulkRequest.class), any(ActionListener.class)); + + final SecurityIndexManager securityIndex = mock(SecurityIndexManager.class); + doAnswer(inv -> { + ((Runnable) inv.getArguments()[1]).run(); + return null; + }).when(securityIndex).prepareIndexIfNeededThenExecute(any(Consumer.class), any(Runnable.class)); + doAnswer(inv -> { + ((Runnable) inv.getArguments()[1]).run(); + return null; + }).when(securityIndex).checkIndexVersionThenExecute(any(Consumer.class), any(Runnable.class)); + when(securityIndex.isAvailable()).thenReturn(true); + + final ClusterService clusterService = ClusterServiceUtils.createClusterService(threadPool); + tokenService = new TokenService(settings, Clock.systemUTC(), client, securityIndex, clusterService); + + final TransportService transportService = new TransportService(Settings.EMPTY, mock(Transport.class), null, + TransportService.NOOP_TRANSPORT_INTERCEPTOR, x -> null, null, Collections.emptySet()); + final Realms realms = mock(Realms.class); + action = new TransportOpenIdConnectLogoutAction(transportService, mock(ActionFilters.class), realms, tokenService); + + final Environment env = TestEnvironment.newEnvironment(settings); + + final RealmConfig.RealmIdentifier realmIdentifier = new RealmConfig.RealmIdentifier("oidc", REALM_NAME); + + final RealmConfig realmConfig = new RealmConfig(realmIdentifier, settings, env, threadContext); + oidcRealm = new OpenIdConnectRealm(realmConfig, new SSLService(sslSettings, env), mock(UserRoleMapper.class), + mock(ResourceWatcherService.class)); + when(realms.realm(realmConfig.name())).thenReturn(oidcRealm); + } + + public void testLogoutInvalidatesTokens() throws Exception { + final String subject = randomAlphaOfLength(8); + final JWT signedIdToken = generateIdToken(subject, randomAlphaOfLength(8), randomAlphaOfLength(8)); + final User user = new User("oidc-user", new String[]{"superuser"}, null, null, null, true); + final Authentication.RealmRef realmRef = new Authentication.RealmRef(oidcRealm.name(), OpenIdConnectRealmSettings.TYPE, "node01"); + final Authentication authentication = new Authentication(user, realmRef, null); + + final Map tokenMetadata = new HashMap<>(); + tokenMetadata.put("id_token_hint", signedIdToken.serialize()); + tokenMetadata.put("oidc_realm", REALM_NAME); + + final PlainActionFuture> future = new PlainActionFuture<>(); + tokenService.createUserToken(authentication, authentication, future, tokenMetadata, true); + final UserToken userToken = future.actionGet().v1(); + mockGetTokenFromId(userToken, false, client); + final String tokenString = tokenService.getUserTokenString(userToken); + + final OpenIdConnectLogoutRequest request = new OpenIdConnectLogoutRequest(); + request.setToken(tokenString); + + final PlainActionFuture listener = new PlainActionFuture<>(); + action.doExecute(mock(Task.class), request, listener); + final OpenIdConnectLogoutResponse response = listener.get(); + assertNotNull(response); + assertThat(response.getEndSessionUrl(), notNullValue()); + // One index request to create the token + assertThat(indexRequests.size(), equalTo(1)); + final IndexRequest indexRequest = indexRequests.get(0); + assertThat(indexRequest, notNullValue()); + assertThat(indexRequest.id(), startsWith("token")); + // One bulk request (containing one update request) to invalidate the token + assertThat(bulkRequests.size(), equalTo(1)); + final BulkRequest bulkRequest = bulkRequests.get(0); + assertThat(bulkRequest.requests().size(), equalTo(1)); + assertThat(bulkRequest.requests().get(0), instanceOf(UpdateRequest.class)); + assertThat(bulkRequest.requests().get(0).id(), startsWith("token_")); + assertThat(bulkRequest.requests().get(0).toString(), containsString("\"access_token\":{\"invalidated\":true")); + } + + @After + public void cleanup() { + oidcRealm.close(); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectAuthenticatorTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectAuthenticatorTests.java index 5985ea168c26a..0a8df3b21c891 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectAuthenticatorTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectAuthenticatorTests.java @@ -47,9 +47,7 @@ import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.env.Environment; import org.elasticsearch.env.TestEnvironment; -import org.elasticsearch.test.ESTestCase; import org.elasticsearch.xpack.core.security.authc.RealmConfig; -import org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings; import org.elasticsearch.xpack.core.ssl.SSLService; import org.junit.After; import org.junit.Before; @@ -74,7 +72,6 @@ import java.util.UUID; import static java.time.Instant.now; -import static org.elasticsearch.xpack.core.security.authc.RealmSettings.getFullSettingKey; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.instanceOf; @@ -82,10 +79,9 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -public class OpenIdConnectAuthenticatorTests extends ESTestCase { +public class OpenIdConnectAuthenticatorTests extends OpenIdConnectTestCase { private OpenIdConnectAuthenticator authenticator; - private static String REALM_NAME = "oidc-realm"; private Settings globalSettings; private Environment env; private ThreadContext threadContext; @@ -104,13 +100,13 @@ public void cleanup() { } private OpenIdConnectAuthenticator buildAuthenticator() throws URISyntaxException { - final RealmConfig config = buildConfig(getBasicRealmSettings().build()); + final RealmConfig config = buildConfig(getBasicRealmSettings().build(), threadContext); return new OpenIdConnectAuthenticator(config, getOpConfig(), getDefaultRpConfig(), new SSLService(globalSettings, env), null); } private OpenIdConnectAuthenticator buildAuthenticator(OpenIdConnectProviderConfiguration opConfig, RelyingPartyConfiguration rpConfig, OpenIdConnectAuthenticator.ReloadableJWKSource jwkSource) { - final RealmConfig config = buildConfig(getBasicRealmSettings().build()); + final RealmConfig config = buildConfig(getBasicRealmSettings().build(), threadContext); final JWSVerificationKeySelector keySelector = new JWSVerificationKeySelector(rpConfig.getSignatureAlgorithm(), jwkSource); final IDTokenValidator validator = new IDTokenValidator(opConfig.getIssuer(), rpConfig.getClientId(), keySelector, null); return new OpenIdConnectAuthenticator(config, opConfig, rpConfig, new SSLService(globalSettings, env), validator, @@ -119,7 +115,7 @@ private OpenIdConnectAuthenticator buildAuthenticator(OpenIdConnectProviderConfi private OpenIdConnectAuthenticator buildAuthenticator(OpenIdConnectProviderConfiguration opConfig, RelyingPartyConfiguration rpConfig) { - final RealmConfig config = buildConfig(getBasicRealmSettings().build()); + final RealmConfig config = buildConfig(getBasicRealmSettings().build(), threadContext); final IDTokenValidator validator = new IDTokenValidator(opConfig.getIssuer(), rpConfig.getClientId(), rpConfig.getSignatureAlgorithm(), new Secret(rpConfig.getClientSecret().toString())); return new OpenIdConnectAuthenticator(config, opConfig, rpConfig, new SSLService(globalSettings, env), validator, @@ -636,30 +632,14 @@ public void testImplicitFlowFailsWithUnsignedJwt() throws Exception { assertThat(e.getCause().getMessage(), containsString("Signed ID token expected")); } - private Settings.Builder getBasicRealmSettings() { - return Settings.builder() - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.OP_AUTHORIZATION_ENDPOINT), "https://op.example.org/login") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.OP_TOKEN_ENDPOINT), "https://op.example.org/token") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.OP_ISSUER), "https://op.example.com") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.OP_NAME), "the op") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.OP_JWKSET_PATH), "https://op.example.org/jwks.json") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.PRINCIPAL_CLAIM.getClaim()), "sub") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_REDIRECT_URI), "https://rp.elastic.co/cb") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_CLIENT_ID), "rp-my") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_RESPONSE_TYPE), randomFrom("code", "id_token")) - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.PRINCIPAL_CLAIM.getClaim()), "sub") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.GROUPS_CLAIM.getClaim()), "groups") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.MAIL_CLAIM.getClaim()), "mail") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.NAME_CLAIM.getClaim()), "name"); - } - private OpenIdConnectProviderConfiguration getOpConfig() throws URISyntaxException { return new OpenIdConnectProviderConfiguration("op_name", new Issuer("https://op.example.com"), "https://op.example.org/jwks.json", new URI("https://op.example.org/login"), new URI("https://op.example.org/token"), - null); + null, + new URI("https://op.example.org/logout")); } private RelyingPartyConfiguration getDefaultRpConfig() throws URISyntaxException { @@ -669,9 +649,9 @@ private RelyingPartyConfiguration getDefaultRpConfig() throws URISyntaxException new URI("https://rp.elastic.co/cb"), new ResponseType("id_token", "token"), new Scope("openid"), - JWSAlgorithm.RS384); + JWSAlgorithm.RS384, + new URI("https://rp.elastic.co/successfull_logout")); } - private RelyingPartyConfiguration getRpConfig(String alg) throws URISyntaxException { return new RelyingPartyConfiguration( new ClientID("rp-my"), @@ -679,7 +659,8 @@ private RelyingPartyConfiguration getRpConfig(String alg) throws URISyntaxExcept new URI("https://rp.elastic.co/cb"), new ResponseType("id_token", "token"), new Scope("openid"), - JWSAlgorithm.parse(alg)); + JWSAlgorithm.parse(alg), + new URI("https://rp.elastic.co/successfull_logout")); } private RelyingPartyConfiguration getRpConfigNoAccessToken(String alg) throws URISyntaxException { @@ -689,15 +670,8 @@ private RelyingPartyConfiguration getRpConfigNoAccessToken(String alg) throws UR new URI("https://rp.elastic.co/cb"), new ResponseType("id_token"), new Scope("openid"), - JWSAlgorithm.parse(alg)); - } - - private RealmConfig buildConfig(Settings realmSettings) { - final Settings settings = Settings.builder() - .put("path.home", createTempDir()) - .put(realmSettings).build(); - final Environment env = TestEnvironment.newEnvironment(settings); - return new RealmConfig(new RealmConfig.RealmIdentifier("oidc", REALM_NAME), settings, env, threadContext); + JWSAlgorithm.parse(alg), + new URI("https://rp.elastic.co/successfull_logout")); } private String buildAuthResponse(JWT idToken, @Nullable AccessToken accessToken, State state, URI redirectUri) { @@ -831,5 +805,4 @@ private ECKey.Curve curveFromHashSize(int size) { throw new IllegalArgumentException("Invalid hash size:" + size); } } - } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectRealmTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectRealmTests.java index fc3a26780b856..0d26c0b442c88 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectRealmTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectRealmTests.java @@ -5,6 +5,7 @@ */ package org.elasticsearch.xpack.security.authc.oidc; +import com.nimbusds.jwt.JWT; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.oauth2.sdk.id.State; import com.nimbusds.openid.connect.sdk.Nonce; @@ -15,7 +16,8 @@ import org.elasticsearch.env.Environment; import org.elasticsearch.env.TestEnvironment; import org.elasticsearch.license.XPackLicenseState; -import org.elasticsearch.test.ESTestCase; + +import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectLogoutResponse; import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectPrepareAuthenticationResponse; import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; import org.elasticsearch.xpack.core.security.authc.Realm; @@ -34,28 +36,29 @@ import java.util.Date; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import static java.time.Instant.now; import static org.elasticsearch.xpack.core.security.authc.RealmSettings.getFullSettingKey; +import static org.elasticsearch.xpack.security.authc.oidc.OpenIdConnectRealm.CONTEXT_TOKEN_DATA; import static org.hamcrest.Matchers.arrayContainingInAnyOrder; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.notNullValue; import static org.mockito.Matchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -public class OpenIdConnectRealmTests extends ESTestCase { +public class OpenIdConnectRealmTests extends OpenIdConnectTestCase { private Settings globalSettings; private Environment env; private ThreadContext threadContext; - private static final String REALM_NAME = "oidc-realm"; - @Before public void setupEnv() { globalSettings = Settings.builder().put("path.home", createTempDir()).build(); @@ -98,12 +101,16 @@ public void testWithAuthorizingRealm() throws Exception { assertThat(result.getUser().fullName(), equalTo("Clinton Barton")); assertThat(result.getUser().metadata().entrySet(), Matchers.iterableWithSize(1)); assertThat(result.getUser().metadata().get("is_lookup"), Matchers.equalTo(true)); + assertNotNull(result.getMetadata().get(CONTEXT_TOKEN_DATA)); + assertThat(result.getMetadata().get(CONTEXT_TOKEN_DATA), instanceOf(Map.class)); + Map tokenMetadata = (Map) result.getMetadata().get(CONTEXT_TOKEN_DATA); + assertThat(tokenMetadata.get("id_token_hint"), equalTo("thisis.aserialized.jwt")); } public void testClaimPatternParsing() throws Exception { final Settings.Builder builder = getBasicRealmSettings(); builder.put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.PRINCIPAL_CLAIM.getPattern()), "^OIDC-(.+)"); - final RealmConfig config = buildConfig(builder.build()); + final RealmConfig config = buildConfig(builder.build(), threadContext); final OpenIdConnectRealmSettings.ClaimSetting principalSetting = new OpenIdConnectRealmSettings.ClaimSetting("principal"); final OpenIdConnectRealm.ClaimParser parser = OpenIdConnectRealm.ClaimParser.forSetting(logger, principalSetting, config, true); final JWTClaimsSet claims = new JWTClaimsSet.Builder() @@ -122,7 +129,7 @@ public void testInvalidPrincipalClaimPatternParsing() { final OpenIdConnectToken token = new OpenIdConnectToken("", new State(), new Nonce()); final Settings.Builder builder = getBasicRealmSettings(); builder.put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.PRINCIPAL_CLAIM.getPattern()), "^OIDC-(.+)"); - final RealmConfig config = buildConfig(builder.build()); + final RealmConfig config = buildConfig(builder.build(), threadContext); final OpenIdConnectRealm realm = new OpenIdConnectRealm(config, authenticator, null); final JWTClaimsSet claims = new JWTClaimsSet.Builder() .subject("cbarton@avengers.com") @@ -160,7 +167,7 @@ public void testBuildRelyingPartyConfigWithoutOpenIdScope() { .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_RESPONSE_TYPE), "code") .putList(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_REQUESTED_SCOPES), Arrays.asList("scope1", "scope2")); - final OpenIdConnectRealm realm = new OpenIdConnectRealm(buildConfig(settingsBuilder.build()), null, + final OpenIdConnectRealm realm = new OpenIdConnectRealm(buildConfig(settingsBuilder.build(), threadContext), null, null); final OpenIdConnectPrepareAuthenticationResponse response = realm.buildAuthenticationRequestUri(null, null, null); final String state = response.getState(); @@ -183,7 +190,7 @@ public void testBuildingAuthenticationRequest() { .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_RESPONSE_TYPE), "code") .putList(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_REQUESTED_SCOPES), Arrays.asList("openid", "scope1", "scope2")); - final OpenIdConnectRealm realm = new OpenIdConnectRealm(buildConfig(settingsBuilder.build()), null, + final OpenIdConnectRealm realm = new OpenIdConnectRealm(buildConfig(settingsBuilder.build(), threadContext), null, null); final OpenIdConnectPrepareAuthenticationResponse response = realm.buildAuthenticationRequestUri(null, null, null); final String state = response.getState(); @@ -193,8 +200,7 @@ public void testBuildingAuthenticationRequest() { "&redirect_uri=https%3A%2F%2Frp.my.com%2Fcb&state=" + state + "&nonce=" + nonce + "&client_id=rp-my")); } - - public void testBuildingAuthenticationRequestWithDefaultScope() { + public void testBuilidingAuthenticationRequestWithDefaultScope() { final Settings.Builder settingsBuilder = Settings.builder() .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.OP_AUTHORIZATION_ENDPOINT), "https://op.example.com/login") .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.OP_TOKEN_ENDPOINT), "https://op.example.com/token") @@ -205,7 +211,7 @@ public void testBuildingAuthenticationRequestWithDefaultScope() { .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_REDIRECT_URI), "https://rp.my.com/cb") .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_CLIENT_ID), "rp-my") .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_RESPONSE_TYPE), "code"); - final OpenIdConnectRealm realm = new OpenIdConnectRealm(buildConfig(settingsBuilder.build()), null, + final OpenIdConnectRealm realm = new OpenIdConnectRealm(buildConfig(settingsBuilder.build(), threadContext), null, null); final OpenIdConnectPrepareAuthenticationResponse response = realm.buildAuthenticationRequestUri(null, null, null); final String state = response.getState(); @@ -214,6 +220,17 @@ public void testBuildingAuthenticationRequestWithDefaultScope() { "&redirect_uri=https%3A%2F%2Frp.my.com%2Fcb&state=" + state + "&nonce=" + nonce + "&client_id=rp-my")); } + public void testBuildLogoutResponse() throws Exception { + final OpenIdConnectRealm realm = new OpenIdConnectRealm(buildConfig(getBasicRealmSettings().build(), threadContext), null, + null); + // Random strings, as we will not validate the token here + final JWT idToken = generateIdToken(randomAlphaOfLength(8), randomAlphaOfLength(8), randomAlphaOfLength(8)); + final OpenIdConnectLogoutResponse logoutResponse = realm.buildLogoutResponse(idToken); + assertThat(logoutResponse.getEndSessionUrl(), containsString("https://op.example.org/logout?id_token_hint=")); + assertThat(logoutResponse.getEndSessionUrl(), + containsString("&post_logout_redirect_uri=https%3A%2F%2Frp.elastic.co%2Fsucc_logout&state=")); + } + public void testBuildingAuthenticationRequestWithExistingStateAndNonce() { final Settings.Builder settingsBuilder = Settings.builder() .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.OP_AUTHORIZATION_ENDPOINT), "https://op.example.com/login") @@ -225,7 +242,7 @@ public void testBuildingAuthenticationRequestWithExistingStateAndNonce() { .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_REDIRECT_URI), "https://rp.my.com/cb") .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_CLIENT_ID), "rp-my") .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_RESPONSE_TYPE), "code"); - final OpenIdConnectRealm realm = new OpenIdConnectRealm(buildConfig(settingsBuilder.build()), null, + final OpenIdConnectRealm realm = new OpenIdConnectRealm(buildConfig(settingsBuilder.build(), threadContext), null, null); final String state = new State().getValue(); final String nonce = new Nonce().getValue(); @@ -246,7 +263,7 @@ public void testBuildingAuthenticationRequestWithLoginHint() { .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_REDIRECT_URI), "https://rp.my.com/cb") .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_CLIENT_ID), "rp-my") .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_RESPONSE_TYPE), "code"); - final OpenIdConnectRealm realm = new OpenIdConnectRealm(buildConfig(settingsBuilder.build()), null, + final OpenIdConnectRealm realm = new OpenIdConnectRealm(buildConfig(settingsBuilder.build(), threadContext), null, null); final String state = new State().getValue(); final String nonce = new Nonce().getValue(); @@ -277,7 +294,7 @@ private AuthenticationResult authenticateWithOidc(UserRoleMapper roleMapper, boo lookupRealm.registerUser(new User(principal, new String[]{"lookup_user_role"}, "Clinton Barton", "cbarton@shield.gov", Collections.singletonMap("is_lookup", true), true)); } - final RealmConfig config = buildConfig(builder.build()); + final RealmConfig config = buildConfig(builder.build(), threadContext); final OpenIdConnectRealm realm = new OpenIdConnectRealm(config, authenticator, roleMapper); initializeRealms(realm, lookupRealm); final OpenIdConnectToken token = new OpenIdConnectToken("", new State(), new Nonce()); @@ -291,6 +308,7 @@ private AuthenticationResult authenticateWithOidc(UserRoleMapper roleMapper, boo .claim("groups", Arrays.asList("group1", "group2", "groups3")) .claim("mail", "cbarton@shield.gov") .claim("name", "Clinton Barton") + .claim("id_token_hint", "thisis.aserialized.jwt") .build(); doAnswer((i) -> { @@ -311,14 +329,6 @@ private AuthenticationResult authenticateWithOidc(UserRoleMapper roleMapper, boo return result; } - private RealmConfig buildConfig(Settings realmSettings) { - final Settings settings = Settings.builder() - .put("path.home", createTempDir()) - .put(realmSettings).build(); - final Environment env = TestEnvironment.newEnvironment(settings); - return new RealmConfig(new RealmConfig.RealmIdentifier("oidc", REALM_NAME), settings, env, threadContext); - } - private void initializeRealms(Realm... realms) { XPackLicenseState licenseState = mock(XPackLicenseState.class); when(licenseState.isAuthorizationRealmAllowed()).thenReturn(true); @@ -328,21 +338,4 @@ private void initializeRealms(Realm... realms) { realm.initialize(realmList, licenseState); } } - - private Settings.Builder getBasicRealmSettings() { - return Settings.builder() - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.OP_AUTHORIZATION_ENDPOINT), "https://op.example.org/login") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.OP_TOKEN_ENDPOINT), "https://op.example.org/token") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.OP_ISSUER), "https://op.example.com") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.OP_NAME), "the op") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.OP_JWKSET_PATH), "https://op.example.org/jwks.json") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.PRINCIPAL_CLAIM.getClaim()), "sub") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_REDIRECT_URI), "https://rp.elastic.co/cb") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_CLIENT_ID), "rp-my") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_RESPONSE_TYPE), randomFrom("code", "id_token")) - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.PRINCIPAL_CLAIM.getClaim()), "sub") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.GROUPS_CLAIM.getClaim()), "groups") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.MAIL_CLAIM.getClaim()), "mail") - .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.NAME_CLAIM.getClaim()), "name"); - } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectTestCase.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectTestCase.java new file mode 100644 index 0000000000000..cbada36fdaaf4 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectTestCase.java @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.security.authc.oidc; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.openid.connect.sdk.Nonce; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.env.Environment; +import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.core.security.authc.RealmConfig; +import org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.Date; + +import static java.time.Instant.now; +import static org.elasticsearch.xpack.core.security.authc.RealmSettings.getFullSettingKey; + +public abstract class OpenIdConnectTestCase extends ESTestCase { + + protected static final String REALM_NAME = "oidc-realm"; + + protected static Settings.Builder getBasicRealmSettings() { + return Settings.builder() + .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.OP_AUTHORIZATION_ENDPOINT), "https://op.example.org/login") + .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.OP_TOKEN_ENDPOINT), "https://op.example.org/token") + .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.OP_ENDSESSION_ENDPOINT), "https://op.example.org/logout") + .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.OP_ISSUER), "https://op.example.com") + .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.OP_NAME), "the op") + .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.OP_JWKSET_PATH), "https://op.example.org/jwks.json") + .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.PRINCIPAL_CLAIM.getClaim()), "sub") + .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_REDIRECT_URI), "https://rp.elastic.co/cb") + .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_POST_LOGOUT_REDIRECT_URI), "https://rp.elastic.co/succ_logout") + .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_CLIENT_ID), "rp-my") + .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.RP_RESPONSE_TYPE), randomFrom("code", "id_token")) + .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.PRINCIPAL_CLAIM.getClaim()), "sub") + .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.GROUPS_CLAIM.getClaim()), "groups") + .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.MAIL_CLAIM.getClaim()), "mail") + .put(getFullSettingKey(REALM_NAME, OpenIdConnectRealmSettings.NAME_CLAIM.getClaim()), "name"); + } + + protected JWT generateIdToken(String subject, String audience, String issuer) throws Exception { + int hashSize = randomFrom(256, 384, 512); + int keySize = randomFrom(2048, 4096); + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(keySize); + KeyPair keyPair = gen.generateKeyPair(); + JWTClaimsSet idTokenClaims = new JWTClaimsSet.Builder() + .jwtID(randomAlphaOfLength(8)) + .audience(audience) + .expirationTime(Date.from(now().plusSeconds(3600))) + .issuer(issuer) + .issueTime(Date.from(now().minusSeconds(4))) + .notBeforeTime(Date.from(now().minusSeconds(4))) + .claim("nonce", new Nonce()) + .subject(subject) + .build(); + + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.parse("RS" + hashSize)).build(), + idTokenClaims); + jwt.sign(new RSASSASigner(keyPair.getPrivate())); + return jwt; + } + + protected RealmConfig buildConfig(Settings realmSettings, ThreadContext threadContext) { + final Settings settings = Settings.builder() + .put("path.home", createTempDir()) + .put(realmSettings).build(); + final Environment env = TestEnvironment.newEnvironment(settings); + return new RealmConfig(new RealmConfig.RealmIdentifier("oidc", REALM_NAME), settings, env, threadContext); + } +}