diff --git a/x-pack/plugin/identity-provider/qa/idp-rest-tests/build.gradle b/x-pack/plugin/identity-provider/qa/idp-rest-tests/build.gradle index 85884ef2d74..74437cab32a 100644 --- a/x-pack/plugin/identity-provider/qa/idp-rest-tests/build.gradle +++ b/x-pack/plugin/identity-provider/qa/idp-rest-tests/build.gradle @@ -27,6 +27,7 @@ testClusters.integTest { extraConfigFile 'roles.yml', file('src/test/resources/roles.yml') extraConfigFile 'idp-sign.crt', file('src/test/resources/idp-sign.crt') extraConfigFile 'idp-sign.key', file('src/test/resources/idp-sign.key') + extraConfigFile 'wildcard_services.json', file('src/test/resources/wildcard_services.json') user username: "admin_user", password: "admin-password" user username: "idp_user", password: "idp-password", role: "idp_role" diff --git a/x-pack/plugin/identity-provider/qa/idp-rest-tests/src/test/java/org/elasticsearch/xpack/idp/IdpRestTestCase.java b/x-pack/plugin/identity-provider/qa/idp-rest-tests/src/test/java/org/elasticsearch/xpack/idp/IdpRestTestCase.java index e833d644bf3..d9e8a1be029 100644 --- a/x-pack/plugin/identity-provider/qa/idp-rest-tests/src/test/java/org/elasticsearch/xpack/idp/IdpRestTestCase.java +++ b/x-pack/plugin/identity-provider/qa/idp-rest-tests/src/test/java/org/elasticsearch/xpack/idp/IdpRestTestCase.java @@ -5,15 +5,34 @@ */ package org.elasticsearch.xpack.idp; +import org.elasticsearch.client.RequestOptions; +import org.elasticsearch.client.RestHighLevelClient; +import org.elasticsearch.client.security.DeleteRoleRequest; +import org.elasticsearch.client.security.DeleteUserRequest; +import org.elasticsearch.client.security.PutRoleRequest; +import org.elasticsearch.client.security.PutUserRequest; +import org.elasticsearch.client.security.RefreshPolicy; +import org.elasticsearch.client.security.user.User; +import org.elasticsearch.client.security.user.privileges.ApplicationResourcePrivileges; +import org.elasticsearch.client.security.user.privileges.IndicesPrivileges; +import org.elasticsearch.client.security.user.privileges.Role; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.test.rest.ESRestTestCase; +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; + +import static java.util.Arrays.asList; +import static java.util.Collections.emptyMap; import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue; public abstract class IdpRestTestCase extends ESRestTestCase { + private RestHighLevelClient highLevelAdminClient; + @Override protected Settings restAdminSettings() { String token = basicAuthHeaderValue("admin_user", new SecureString("admin-password".toCharArray())); @@ -29,4 +48,48 @@ public abstract class IdpRestTestCase extends ESRestTestCase { .put(ThreadContext.PREFIX + ".Authorization", token) .build(); } + + private RestHighLevelClient getHighLevelAdminClient() { + if (highLevelAdminClient == null) { + highLevelAdminClient = new RestHighLevelClient( + adminClient(), + ignore -> { + }, + Collections.emptyList()) { + }; + } + return highLevelAdminClient; + } + + protected User createUser(String username, SecureString password, String... roles) throws IOException { + final RestHighLevelClient client = getHighLevelAdminClient(); + final User user = new User(username, asList(roles), emptyMap(), username + " in " + getTestName(), username + "@test.example.com"); + final PutUserRequest request = PutUserRequest.withPassword(user, password.getChars(), true, RefreshPolicy.WAIT_UNTIL); + client.security().putUser(request, RequestOptions.DEFAULT); + return user; + } + + protected void deleteUser(String username) throws IOException { + final RestHighLevelClient client = getHighLevelAdminClient(); + final DeleteUserRequest request = new DeleteUserRequest(username, RefreshPolicy.WAIT_UNTIL); + client.security().deleteUser(request, RequestOptions.DEFAULT); + } + + protected void createRole(String name, Collection clusterPrivileges, Collection indicesPrivileges, + Collection applicationPrivileges) throws IOException { + final RestHighLevelClient client = getHighLevelAdminClient(); + final Role role = Role.builder() + .name(name) + .clusterPrivileges(clusterPrivileges) + .indicesPrivileges(indicesPrivileges) + .applicationResourcePrivileges(applicationPrivileges) + .build(); + client.security().putRole(new PutRoleRequest(role, null), RequestOptions.DEFAULT); + } + + protected void deleteRole(String name) throws IOException { + final RestHighLevelClient client = getHighLevelAdminClient(); + final DeleteRoleRequest request = new DeleteRoleRequest(name, RefreshPolicy.WAIT_UNTIL); + client.security().deleteRole(request, RequestOptions.DEFAULT); + } } diff --git a/x-pack/plugin/identity-provider/qa/idp-rest-tests/src/test/java/org/elasticsearch/xpack/idp/WildcardServiceProviderRestIT.java b/x-pack/plugin/identity-provider/qa/idp-rest-tests/src/test/java/org/elasticsearch/xpack/idp/WildcardServiceProviderRestIT.java new file mode 100644 index 00000000000..5b1d235d0ab --- /dev/null +++ b/x-pack/plugin/identity-provider/qa/idp-rest-tests/src/test/java/org/elasticsearch/xpack/idp/WildcardServiceProviderRestIT.java @@ -0,0 +1,122 @@ +/* + * 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.idp; + +import org.elasticsearch.client.Request; +import org.elasticsearch.client.Response; +import org.elasticsearch.client.security.user.User; +import org.elasticsearch.client.security.user.privileges.ApplicationResourcePrivileges; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.collect.MapBuilder; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.notNullValue; + +public class WildcardServiceProviderRestIT extends IdpRestTestCase { + + // From build.gradle + private final String IDP_ENTITY_ID = "https://idp.test.es.elasticsearch.org/"; + // From SAMLConstants + private final String REDIRECT_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"; + + public void testGetWildcardServiceProviderMetadata() throws Exception { + final String owner = randomAlphaOfLength(8); + final String service = randomAlphaOfLength(8); + // From "wildcard_services.json" + final String entityId = "service:" + owner + ":" + service; + final String acs = "https://" + service + ".services.example.com/saml/acs"; + getMetaData(entityId, acs); + } + + public void testInitSingleSignOnToWildcardServiceProvider() throws Exception { + final String owner = randomAlphaOfLength(8); + final String service = randomAlphaOfLength(8); + // From "wildcard_services.json" + final String entityId = "service:" + owner + ":" + service; + final String acs = "https://" + service + ".services.example.com/api/v1/saml"; + + final String username = randomAlphaOfLength(6); + final SecureString password = new SecureString((randomAlphaOfLength(6) + randomIntBetween(10, 99)).toCharArray()); + final String roleName = username + "_role"; + final User user = createUser(username, password, roleName); + + final ApplicationResourcePrivileges applicationPrivilege = new ApplicationResourcePrivileges( + "elastic-cloud", Collections.singletonList("sso:admin"), Collections.singletonList("sso:" + entityId) + ); + createRole(roleName, Collections.emptyList(), Collections.emptyList(), Collections.singletonList(applicationPrivilege)); + + final String samlResponse = initSso(entityId, acs, new UsernamePasswordToken(username, password)); + + for (String attr : Arrays.asList("principal", "email", "name", "roles")) { + assertThat(samlResponse, containsString("Name=\"saml:attribute:" + attr + "\"")); + assertThat(samlResponse, containsString("FriendlyName=\"" + attr + "\"")); + } + + assertThat(samlResponse, containsString(user.getUsername())); + assertThat(samlResponse, containsString(user.getEmail())); + assertThat(samlResponse, containsString(user.getFullName())); + assertThat(samlResponse, containsString(">admin<")); + + deleteUser(username); + deleteRole(roleName); + } + + private void getMetaData(String entityId, String acs) throws IOException { + final Map map = getAsMap("/_idp/saml/metadata/" + encode(entityId) + "?acs=" + encode(acs)); + assertThat(map, notNullValue()); + assertThat(map.keySet(), containsInAnyOrder("metadata")); + final Object metadata = map.get("metadata"); + assertThat(metadata, notNullValue()); + assertThat(metadata, instanceOf(String.class)); + assertThat((String) metadata, containsString(IDP_ENTITY_ID)); + assertThat((String) metadata, containsString(REDIRECT_BINDING)); + } + + private String initSso(String entityId, String acs, UsernamePasswordToken secondaryAuth) throws IOException { + final Request request = new Request("POST", "/_idp/saml/init/"); + request.setJsonEntity(toJson(MapBuilder.newMapBuilder().put("entity_id", entityId).put("acs", acs).map())); + request.setOptions(request.getOptions().toBuilder().addHeader("es-secondary-authorization", + UsernamePasswordToken.basicAuthHeaderValue(secondaryAuth.principal(), secondaryAuth.credentials()))); + Response response = client().performRequest(request); + + final Map map = entityAsMap(response); + assertThat(map, notNullValue()); + assertThat(map.keySet(), containsInAnyOrder("post_url", "saml_response", "service_provider")); + assertThat(map.get("post_url"), equalTo(acs)); + assertThat(map.get("saml_response"), instanceOf(String.class)); + + final String samlResponse = (String) map.get("saml_response"); + assertThat(samlResponse, containsString(entityId)); + assertThat(samlResponse, containsString(acs)); + + return samlResponse; + } + + private String toJson(Map body) throws IOException { + try (XContentBuilder builder = XContentBuilder.builder(XContentType.JSON.xContent()).map(body)) { + return BytesReference.bytes(builder).utf8ToString(); + } + } + + private String encode(String param) throws UnsupportedEncodingException { + return URLEncoder.encode(param, "UTF-8"); + } + +} diff --git a/x-pack/plugin/identity-provider/qa/idp-rest-tests/src/test/resources/wildcard_services.json b/x-pack/plugin/identity-provider/qa/idp-rest-tests/src/test/resources/wildcard_services.json new file mode 100644 index 00000000000..4f95ae6560f --- /dev/null +++ b/x-pack/plugin/identity-provider/qa/idp-rest-tests/src/test/resources/wildcard_services.json @@ -0,0 +1,45 @@ +{ + "services": { + "wildcard-app1": { + "entity_id": "service:(?\\w+):(?\\w+)", + "acs": "https://(?\\w+).services.example.com/saml/acs", + "tokens": [ "service" ], + "template": { + "name": "Application 1 ({{service}})", + "privileges": { + "resource": "sso:{{entity_id}}", + "roles": { + "admin": "sso:admin" + } + }, + "attributes": { + "principal": "saml:attribute:principal", + "name": "saml:attribute:name", + "email": "saml:attribute:email", + "roles": "saml:attribute:roles" + } + } + }, + "wildcard-app2": { + "entity_id": "service:(?\\w+):(?\\w+)", + "acs": "https://(?\\w+).services.example.com/api/v1/saml", + "tokens": [ "service" ], + "template": { + "name": "Application 2 ({{service}})", + "privileges": { + "resource": "sso:{{entity_id}}", + "roles": { + "admin": "sso:admin" + } + }, + "attributes": { + "principal": "saml:attribute:principal", + "name": "saml:attribute:name", + "email": "saml:attribute:email", + "roles": "saml:attribute:roles" + } + } + } + } +} + diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/IdentityProviderPlugin.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/IdentityProviderPlugin.java index 6e7c23fc60e..19750de2aec 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/IdentityProviderPlugin.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/IdentityProviderPlugin.java @@ -45,18 +45,21 @@ import org.elasticsearch.xpack.idp.action.TransportSamlInitiateSingleSignOnActio import org.elasticsearch.xpack.idp.action.TransportSamlMetadataAction; import org.elasticsearch.xpack.idp.action.TransportSamlValidateAuthnRequestAction; import org.elasticsearch.xpack.idp.privileges.UserPrivilegeResolver; -import org.elasticsearch.xpack.idp.saml.rest.action.RestDeleteSamlServiceProviderAction; import org.elasticsearch.xpack.idp.saml.idp.SamlIdentityProvider; -import org.elasticsearch.xpack.idp.saml.sp.ServiceProviderDefaults; import org.elasticsearch.xpack.idp.saml.idp.SamlIdentityProviderBuilder; -import org.elasticsearch.xpack.idp.saml.rest.action.RestSamlMetadataAction; -import org.elasticsearch.xpack.idp.saml.rest.action.RestSamlInitiateSingleSignOnAction; -import org.elasticsearch.xpack.idp.saml.rest.action.RestSamlValidateAuthenticationRequestAction; -import org.elasticsearch.xpack.idp.saml.support.SamlFactory; -import org.elasticsearch.xpack.idp.saml.support.SamlInit; +import org.elasticsearch.xpack.idp.saml.rest.action.RestDeleteSamlServiceProviderAction; import org.elasticsearch.xpack.idp.saml.rest.action.RestPutSamlServiceProviderAction; +import org.elasticsearch.xpack.idp.saml.rest.action.RestSamlInitiateSingleSignOnAction; +import org.elasticsearch.xpack.idp.saml.rest.action.RestSamlMetadataAction; +import org.elasticsearch.xpack.idp.saml.rest.action.RestSamlValidateAuthenticationRequestAction; +import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderFactory; import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderIndex; import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderResolver; +import org.elasticsearch.xpack.idp.saml.sp.ServiceProviderCacheSettings; +import org.elasticsearch.xpack.idp.saml.sp.ServiceProviderDefaults; +import org.elasticsearch.xpack.idp.saml.sp.WildcardServiceProviderResolver; +import org.elasticsearch.xpack.idp.saml.support.SamlFactory; +import org.elasticsearch.xpack.idp.saml.support.SamlInit; import java.util.ArrayList; import java.util.Arrays; @@ -96,8 +99,12 @@ public class IdentityProviderPlugin extends Plugin implements ActionPlugin { final ServiceProviderDefaults serviceProviderDefaults = ServiceProviderDefaults.forSettings(settings); - final SamlServiceProviderResolver resolver = new SamlServiceProviderResolver(settings, index, serviceProviderDefaults); - final SamlIdentityProvider idp = SamlIdentityProvider.builder(resolver) + final SamlServiceProviderFactory serviceProviderFactory = new SamlServiceProviderFactory(serviceProviderDefaults); + final SamlServiceProviderResolver registeredServiceProviderResolver + = new SamlServiceProviderResolver(settings, index, serviceProviderFactory); + final WildcardServiceProviderResolver wildcardServiceProviderResolver + = WildcardServiceProviderResolver.create(environment, resourceWatcherService, scriptService, serviceProviderFactory); + final SamlIdentityProvider idp = SamlIdentityProvider.builder(registeredServiceProviderResolver, wildcardServiceProviderResolver) .fromSettings(environment) .serviceProviderDefaults(serviceProviderDefaults) .build(); @@ -148,7 +155,9 @@ public class IdentityProviderPlugin extends Plugin implements ActionPlugin { List> settings = new ArrayList<>(); settings.add(ENABLED_SETTING); settings.addAll(SamlIdentityProviderBuilder.getSettings()); + settings.addAll(ServiceProviderCacheSettings.getSettings()); settings.addAll(ServiceProviderDefaults.getSettings()); + settings.addAll(WildcardServiceProviderResolver.getSettings()); settings.addAll(X509KeyPairSettings.withPrefix("xpack.idp.signing.", false).getAllSettings()); settings.addAll(X509KeyPairSettings.withPrefix("xpack.idp.metadata_signing.", false).getAllSettings()); return Collections.unmodifiableList(settings); diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnRequest.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnRequest.java index f29f9cf066f..5b969c09c86 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnRequest.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnRequest.java @@ -13,18 +13,20 @@ import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.xpack.idp.saml.support.SamlAuthenticationState; -import static org.elasticsearch.action.ValidateActions.addValidationError; - import java.io.IOException; +import static org.elasticsearch.action.ValidateActions.addValidationError; + public class SamlInitiateSingleSignOnRequest extends ActionRequest { private String spEntityId; + private String assertionConsumerService; private SamlAuthenticationState samlAuthenticationState; public SamlInitiateSingleSignOnRequest(StreamInput in) throws IOException { super(in); spEntityId = in.readString(); + assertionConsumerService = in.readString(); samlAuthenticationState = in.readOptionalWriteable(SamlAuthenticationState::new); } @@ -37,12 +39,16 @@ public class SamlInitiateSingleSignOnRequest extends ActionRequest { if (Strings.isNullOrEmpty(spEntityId)) { validationException = addValidationError("entity_id is missing", validationException); } + if (Strings.isNullOrEmpty(assertionConsumerService)) { + validationException = addValidationError("acs is missing", validationException); + } if (samlAuthenticationState != null) { final ValidationException authnStateException = samlAuthenticationState.validate(); - if (validationException != null) { - ActionRequestValidationException actionRequestValidationException = new ActionRequestValidationException(); - actionRequestValidationException.addValidationErrors(authnStateException.validationErrors()); - validationException = addValidationError("entity_id is missing", actionRequestValidationException); + if (authnStateException != null && authnStateException.validationErrors().isEmpty() == false) { + if (validationException == null) { + validationException = new ActionRequestValidationException(); + } + validationException.addValidationErrors(authnStateException.validationErrors()); } } return validationException; @@ -56,6 +62,14 @@ public class SamlInitiateSingleSignOnRequest extends ActionRequest { this.spEntityId = spEntityId; } + public String getAssertionConsumerService() { + return assertionConsumerService; + } + + public void setAssertionConsumerService(String assertionConsumerService) { + this.assertionConsumerService = assertionConsumerService; + } + public SamlAuthenticationState getSamlAuthenticationState() { return samlAuthenticationState; } @@ -68,11 +82,13 @@ public class SamlInitiateSingleSignOnRequest extends ActionRequest { public void writeTo(StreamOutput out) throws IOException { super.writeTo(out); out.writeString(spEntityId); + out.writeString(assertionConsumerService); out.writeOptionalWriteable(samlAuthenticationState); } @Override public String toString() { - return getClass().getSimpleName() + "{spEntityId='" + spEntityId + "'}"; + return getClass().getSimpleName() + "{spEntityId='" + spEntityId + "', acs='" + assertionConsumerService + "'}"; } + } diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlMetadataRequest.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlMetadataRequest.java index 86ba297629a..bebe907fc54 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlMetadataRequest.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlMetadataRequest.java @@ -7,7 +7,9 @@ package org.elasticsearch.xpack.idp.action; import org.elasticsearch.action.ActionRequest; import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.common.Nullable; import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; import java.io.IOException; import java.util.Objects; @@ -15,14 +17,24 @@ import java.util.Objects; public class SamlMetadataRequest extends ActionRequest { private String spEntityId; + private String assertionConsumerService; public SamlMetadataRequest(StreamInput in) throws IOException { super(in); spEntityId = in.readString(); + assertionConsumerService = in.readOptionalString(); } - public SamlMetadataRequest(String spEntityId) { + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeString(spEntityId); + out.writeOptionalString(assertionConsumerService); + } + + public SamlMetadataRequest(String spEntityId, @Nullable String acs) { this.spEntityId = Objects.requireNonNull(spEntityId, "Service Provider entity id must be provided"); + this.assertionConsumerService = acs; } public SamlMetadataRequest() { @@ -44,7 +56,14 @@ public class SamlMetadataRequest extends ActionRequest { @Override public String toString() { - return getClass().getSimpleName() + "{spEntityId='" + spEntityId + "'}"; + return getClass().getSimpleName() + "{spEntityId='" + spEntityId + "' acs='" + assertionConsumerService + "'}"; } + public String getAssertionConsumerService() { + return assertionConsumerService; + } + + public void setAssertionConsumerService(String assertionConsumerService) { + this.assertionConsumerService = assertionConsumerService; + } } diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlValidateAuthnRequestResponse.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlValidateAuthnRequestResponse.java index 98a9936477f..224afb076f6 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlValidateAuthnRequestResponse.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlValidateAuthnRequestResponse.java @@ -17,18 +17,21 @@ import java.util.Objects; public class SamlValidateAuthnRequestResponse extends ActionResponse { private final String spEntityId; + private final String assertionConsumerService; private final boolean forceAuthn; private final Map authnState; public SamlValidateAuthnRequestResponse(StreamInput in) throws IOException { super(in); this.spEntityId = in.readString(); + this.assertionConsumerService = in.readString(); this.forceAuthn = in.readBoolean(); this.authnState = in.readMap(); } - public SamlValidateAuthnRequestResponse(String spEntityId, boolean forceAuthn, Map authnState) { + public SamlValidateAuthnRequestResponse(String spEntityId, String acs, boolean forceAuthn, Map authnState) { this.spEntityId = Objects.requireNonNull(spEntityId, "spEntityId is required for successful responses"); + this.assertionConsumerService = Objects.requireNonNull(acs, "ACS is required for successful responses"); this.forceAuthn = forceAuthn; this.authnState = Collections.unmodifiableMap(Objects.requireNonNull(authnState)); } @@ -37,6 +40,10 @@ public class SamlValidateAuthnRequestResponse extends ActionResponse { return spEntityId; } + public String getAssertionConsumerService() { + return assertionConsumerService; + } + public boolean isForceAuthn() { return forceAuthn; } @@ -48,6 +55,7 @@ public class SamlValidateAuthnRequestResponse extends ActionResponse { @Override public void writeTo(StreamOutput out) throws IOException { out.writeString(spEntityId); + out.writeString(assertionConsumerService); out.writeBoolean(forceAuthn); out.writeMap(authnState); } @@ -55,6 +63,7 @@ public class SamlValidateAuthnRequestResponse extends ActionResponse { @Override public String toString() { return getClass().getSimpleName() + "{ spEntityId='" + getSpEntityId() + "',\n" + + " acs='" + getAssertionConsumerService() + "',\n" + " forceAuthn='" + isForceAuthn() + "',\n" + " authnState='" + getAuthnState() + "' }"; } diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/TransportSamlInitiateSingleSignOnAction.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/TransportSamlInitiateSingleSignOnAction.java index 11aff7bf81d..d473b8eb835 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/TransportSamlInitiateSingleSignOnAction.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/TransportSamlInitiateSingleSignOnAction.java @@ -57,11 +57,15 @@ public class TransportSamlInitiateSingleSignOnAction protected void doExecute(Task task, SamlInitiateSingleSignOnRequest request, ActionListener listener) { final SamlAuthenticationState authenticationState = request.getSamlAuthenticationState(); - identityProvider.getRegisteredServiceProvider(request.getSpEntityId(), false, ActionListener.wrap( + identityProvider.resolveServiceProvider( + request.getSpEntityId(), + request.getAssertionConsumerService(), + false, + ActionListener.wrap( sp -> { if (null == sp) { - final String message = "Service Provider with Entity ID [" + request.getSpEntityId() - + "] is not registered with this Identity Provider"; + final String message = "Service Provider with Entity ID [" + request.getSpEntityId() + "] and ACS [" + + request.getAssertionConsumerService() + "] is not known to this Identity Provider"; logger.debug(message); possiblyReplyWithSamlFailure(authenticationState, StatusCode.RESPONDER, new IllegalArgumentException(message), listener); diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/TransportSamlMetadataAction.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/TransportSamlMetadataAction.java index 9d17fae407a..bc3189ac607 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/TransportSamlMetadataAction.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/TransportSamlMetadataAction.java @@ -30,8 +30,7 @@ public class TransportSamlMetadataAction extends HandledTransportAction listener) { - final String spEntityId = request.getSpEntityId(); final SamlMetadataGenerator generator = new SamlMetadataGenerator(samlFactory, identityProvider); - generator.generateMetadata(spEntityId, listener); + generator.generateMetadata(request.getSpEntityId(), request.getAssertionConsumerService(), listener); } } diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/authn/SamlAuthnRequestValidator.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/authn/SamlAuthnRequestValidator.java index 9c52b063231..63d9a5b2a74 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/authn/SamlAuthnRequestValidator.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/authn/SamlAuthnRequestValidator.java @@ -101,7 +101,7 @@ public class SamlAuthnRequestValidator { return; } final AuthnRequest authnRequest = samlFactory.buildXmlObject(root, AuthnRequest.class); - getSpFromIssuer(authnRequest.getIssuer(), ActionListener.wrap( + getSpFromAuthnRequest(authnRequest.getIssuer(), authnRequest.getAssertionConsumerServiceURL(), ActionListener.wrap( sp -> { try { validateAuthnRequest(authnRequest, sp, parsedQueryString, listener); @@ -179,11 +179,11 @@ public class SamlAuthnRequestValidator { } final Map authnState = new HashMap<>(); checkDestination(authnRequest); - checkAcs(authnRequest, sp, authnState); + final String acs = checkAcs(authnRequest, sp, authnState); validateNameIdPolicy(authnRequest, sp, authnState); authnState.put(SamlAuthenticationState.Fields.ENTITY_ID.getPreferredName(), sp.getEntityId()); authnState.put(SamlAuthenticationState.Fields.AUTHN_REQUEST_ID.getPreferredName(), authnRequest.getID()); - final SamlValidateAuthnRequestResponse response = new SamlValidateAuthnRequestResponse(sp.getEntityId(), + final SamlValidateAuthnRequestResponse response = new SamlValidateAuthnRequestResponse(sp.getEntityId(), acs, authnRequest.isForceAuthn(), authnState); logger.trace(new ParameterizedMessage("Validated AuthnResponse from queryString [{}] and extracted [{}]", parsedQueryString.queryString, response)); @@ -228,17 +228,17 @@ public class SamlAuthnRequestValidator { }); } - private void getSpFromIssuer(Issuer issuer, ActionListener listener) { + private void getSpFromAuthnRequest(Issuer issuer, String acs, ActionListener listener) { if (issuer == null || issuer.getValue() == null) { throw new ElasticsearchSecurityException("SAML authentication request has no issuer", RestStatus.BAD_REQUEST); } final String issuerString = issuer.getValue(); - idp.getRegisteredServiceProvider(issuerString, false, ActionListener.wrap( + idp.resolveServiceProvider(issuerString, acs, false, ActionListener.wrap( serviceProvider -> { if (null == serviceProvider) { throw new ElasticsearchSecurityException( - "Service Provider with Entity ID [{}] is not registered with this Identity Provider", RestStatus.BAD_REQUEST, - issuerString); + "Service Provider with Entity ID [{}] and ACS [{}] is not known to this Identity Provider", RestStatus.BAD_REQUEST, + issuerString, acs); } listener.onResponse(serviceProvider); }, @@ -255,7 +255,7 @@ public class SamlAuthnRequestValidator { } } - private void checkAcs(AuthnRequest request, SamlServiceProvider sp, Map authnState) { + private String checkAcs(AuthnRequest request, SamlServiceProvider sp, Map authnState) { final String acs = request.getAssertionConsumerServiceURL(); if (Strings.hasText(acs) == false) { final String message = request.getAssertionConsumerServiceIndex() == null ? @@ -269,6 +269,7 @@ public class SamlAuthnRequestValidator { "request contained [{}]", RestStatus.BAD_REQUEST, sp.getAssertionConsumerService(), acs); } authnState.put(SamlAuthenticationState.Fields.ACS_URL.getPreferredName(), acs); + return acs; } protected Element parseSamlMessage(byte[] content) { diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/SamlIdentityProvider.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/SamlIdentityProvider.java index 037894cd3b6..512b99480c4 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/SamlIdentityProvider.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/SamlIdentityProvider.java @@ -10,11 +10,13 @@ package org.elasticsearch.xpack.idp.saml.idp; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.elasticsearch.action.ActionListener; +import org.elasticsearch.common.Nullable; import org.elasticsearch.common.Strings; import org.elasticsearch.common.collect.MapBuilder; import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProvider; import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderResolver; import org.elasticsearch.xpack.idp.saml.sp.ServiceProviderDefaults; +import org.elasticsearch.xpack.idp.saml.sp.WildcardServiceProviderResolver; import org.opensaml.saml.saml2.metadata.ContactPersonTypeEnumeration; import org.opensaml.security.x509.X509Credential; @@ -40,6 +42,7 @@ public class SamlIdentityProvider { private final ServiceProviderDefaults serviceProviderDefaults; private final X509Credential signingCredential; private final SamlServiceProviderResolver serviceProviderResolver; + private final WildcardServiceProviderResolver wildcardServiceResolver; private final X509Credential metadataSigningCredential; private ContactInfo technicalContact; private OrganizationInfo organization; @@ -47,8 +50,8 @@ public class SamlIdentityProvider { // Package access - use Builder instead SamlIdentityProvider(String entityId, Map ssoEndpoints, Map sloEndpoints, Set allowedNameIdFormats, X509Credential signingCredential, X509Credential metadataSigningCredential, - ContactInfo technicalContact, OrganizationInfo organization, - ServiceProviderDefaults serviceProviderDefaults, SamlServiceProviderResolver serviceProviderResolver) { + ContactInfo technicalContact, OrganizationInfo organization, ServiceProviderDefaults serviceProviderDefaults, + SamlServiceProviderResolver serviceProviderResolver, WildcardServiceProviderResolver wildcardServiceResolver) { this.entityId = entityId; this.ssoEndpoints = ssoEndpoints; this.sloEndpoints = sloEndpoints; @@ -59,10 +62,12 @@ public class SamlIdentityProvider { this.technicalContact = technicalContact; this.organization = organization; this.serviceProviderResolver = serviceProviderResolver; + this.wildcardServiceResolver = wildcardServiceResolver; } - public static SamlIdentityProviderBuilder builder(SamlServiceProviderResolver resolver) { - return new SamlIdentityProviderBuilder(resolver); + public static SamlIdentityProviderBuilder builder(SamlServiceProviderResolver serviceResolver, + WildcardServiceProviderResolver wildcardResolver) { + return new SamlIdentityProviderBuilder(serviceResolver, wildcardResolver); } public String getEntityId() { @@ -103,23 +108,26 @@ public class SamlIdentityProvider { /** * Asynchronously lookup the specified {@link SamlServiceProvider} by entity-id. + * @param spEntityId The (URI) entity ID of the service provider + * @param acs The ACS of the service provider - only used if there is no registered service provider and we need to dynamically define + * one from a template (wildcard). May be null, in which case wildcard services will not be resolved. * @param allowDisabled whether to return service providers that are not {@link SamlServiceProvider#isEnabled() enabled}. * For security reasons, callers should typically avoid working with disabled service providers. * @param listener Responds with the requested Service Provider object, or {@code null} if no such SP exists. - * {@link ActionListener#onFailure} is only used for fatal errors (e.g. being unable to access - * the backing store (elasticsearch index) that hold the SP data). + * {@link ActionListener#onFailure} is only used for fatal errors (e.g. being unable to access */ - public void getRegisteredServiceProvider(String spEntityId, boolean allowDisabled, ActionListener listener) { + public void resolveServiceProvider(String spEntityId, @Nullable String acs, boolean allowDisabled, + ActionListener listener) { serviceProviderResolver.resolve(spEntityId, ActionListener.wrap( sp -> { if (sp == null) { - logger.info("No service provider exists for entityId [{}]", spEntityId); - listener.onResponse(null); + logger.debug("No explicitly registered service provider exists for entityId [{}]", spEntityId); + resolveWildcardService(spEntityId, acs, listener); } else if (allowDisabled == false && sp.isEnabled() == false) { - logger.info("Service provider [{}][{}] is not enabled", sp.getEntityId(), sp.getName()); + logger.info("Service provider [{}][{}] is not enabled", spEntityId, sp.getName()); listener.onResponse(null); } else { - logger.debug("Service provider for [{}] is [{}]", sp.getEntityId(), sp); + logger.debug("Service provider for [{}] is [{}]", spEntityId, sp); listener.onResponse(sp); } }, @@ -127,6 +135,21 @@ public class SamlIdentityProvider { )); } + private void resolveWildcardService(String entityId, String acs, ActionListener listener) { + if (acs == null) { + logger.debug("No ACS provided for [{}], skipping wildcard matching", entityId); + listener.onResponse(null); + } else { + try { + final SamlServiceProvider sp = wildcardServiceResolver.resolve(entityId, acs); + logger.debug("Wildcard service provider for [{}][{}] is [{}]", entityId, acs, sp); + listener.onResponse(sp); + } catch (Exception e) { + listener.onFailure(e); + } + } + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/SamlIdentityProviderBuilder.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/SamlIdentityProviderBuilder.java index ef0c5cdff13..c32ce823032 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/SamlIdentityProviderBuilder.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/SamlIdentityProviderBuilder.java @@ -16,6 +16,7 @@ import org.elasticsearch.xpack.core.ssl.CertParsingUtils; import org.elasticsearch.xpack.core.ssl.X509KeyPairSettings; import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderResolver; import org.elasticsearch.xpack.idp.saml.sp.ServiceProviderDefaults; +import org.elasticsearch.xpack.idp.saml.sp.WildcardServiceProviderResolver; import org.opensaml.saml.saml2.metadata.ContactPersonTypeEnumeration; import org.opensaml.security.x509.X509Credential; import org.opensaml.security.x509.impl.X509KeyManagerX509CredentialAdapter; @@ -80,6 +81,7 @@ public class SamlIdentityProviderBuilder { public static final Setting IDP_CONTACT_EMAIL = Setting.simpleString("xpack.idp.contact.email", Setting.Property.NodeScope); private final SamlServiceProviderResolver serviceProviderResolver; + private final WildcardServiceProviderResolver wildcardServiceResolver; private String entityId; private Map ssoEndpoints; @@ -91,8 +93,9 @@ public class SamlIdentityProviderBuilder { private SamlIdentityProvider.OrganizationInfo organization; private ServiceProviderDefaults serviceProviderDefaults; - SamlIdentityProviderBuilder(SamlServiceProviderResolver serviceProviderResolver) { + SamlIdentityProviderBuilder(SamlServiceProviderResolver serviceProviderResolver, WildcardServiceProviderResolver wildcardResolver) { this.serviceProviderResolver = serviceProviderResolver; + this.wildcardServiceResolver = wildcardResolver; this.ssoEndpoints = new HashMap<>(); this.sloEndpoints = new HashMap<>(); } @@ -142,7 +145,8 @@ public class SamlIdentityProviderBuilder { signingCredential, metadataSigningCredential, technicalContact, organization, serviceProviderDefaults, - serviceProviderResolver); + serviceProviderResolver, + wildcardServiceResolver); } public SamlIdentityProviderBuilder fromSettings(Environment env) { diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/SamlMetadataGenerator.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/SamlMetadataGenerator.java index a6425e341e3..19dfd6c9a8b 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/SamlMetadataGenerator.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/SamlMetadataGenerator.java @@ -41,8 +41,8 @@ public class SamlMetadataGenerator { SamlInit.initialize(); } - public void generateMetadata(String spEntityId, ActionListener listener) { - idp.getRegisteredServiceProvider(spEntityId, true, ActionListener.wrap( + public void generateMetadata(String spEntityId, String acs, ActionListener listener) { + idp.resolveServiceProvider(spEntityId, acs, true, ActionListener.wrap( sp -> { try { if (null == sp) { diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/rest/action/RestSamlInitiateSingleSignOnAction.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/rest/action/RestSamlInitiateSingleSignOnAction.java index 1903f4bca7b..ebaf3f3ff77 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/rest/action/RestSamlInitiateSingleSignOnAction.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/rest/action/RestSamlInitiateSingleSignOnAction.java @@ -34,6 +34,7 @@ public class RestSamlInitiateSingleSignOnAction extends IdpBaseRestHandler { static { PARSER.declareString(SamlInitiateSingleSignOnRequest::setSpEntityId, new ParseField("entity_id")); + PARSER.declareString(SamlInitiateSingleSignOnRequest::setAssertionConsumerService, new ParseField("acs")); PARSER.declareObject(SamlInitiateSingleSignOnRequest::setSamlAuthenticationState, (p, c) -> SamlAuthenticationState.fromXContent(p), new ParseField("authn_state")); } diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/rest/action/RestSamlMetadataAction.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/rest/action/RestSamlMetadataAction.java index 9212d956d16..451e0a09972 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/rest/action/RestSamlMetadataAction.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/rest/action/RestSamlMetadataAction.java @@ -42,7 +42,8 @@ public class RestSamlMetadataAction extends IdpBaseRestHandler { @Override protected RestChannelConsumer innerPrepareRequest(RestRequest request, NodeClient client) throws IOException { final String spEntityId = request.param("sp_entity_id"); - final SamlMetadataRequest metadataRequest = new SamlMetadataRequest(spEntityId); + final String acs = request.param("acs"); + final SamlMetadataRequest metadataRequest = new SamlMetadataRequest(spEntityId, acs); return channel -> client.execute(SamlMetadataAction.INSTANCE, metadataRequest, new RestBuilderListener(channel) { @Override diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/rest/action/RestSamlValidateAuthenticationRequestAction.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/rest/action/RestSamlValidateAuthenticationRequestAction.java index 14fb48ad2fc..af761d0e035 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/rest/action/RestSamlValidateAuthenticationRequestAction.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/rest/action/RestSamlValidateAuthenticationRequestAction.java @@ -60,6 +60,7 @@ public class RestSamlValidateAuthenticationRequestAction extends IdpBaseRestHand builder.startObject(); builder.startObject("service_provider"); builder.field("entity_id", response.getSpEntityId()); + builder.field("acs", response.getAssertionConsumerService()); builder.endObject(); builder.field("force_authn", response.isForceAuthn()); builder.field("authn_state", response.getAuthnState()); diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocument.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocument.java index 55fee073e68..64dd6696812 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocument.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocument.java @@ -333,6 +333,10 @@ public class SamlServiceProviderDocument implements ToXContentObject, Writeable this.created = created; } + public void setLastModified(Instant lastModified) { + this.lastModified = lastModified; + } + public void setCreatedMillis(Long millis) { this.created = Instant.ofEpochMilli(millis); } @@ -383,8 +387,8 @@ public class SamlServiceProviderDocument implements ToXContentObject, Writeable @Override public int hashCode() { - return Objects.hash(docId, name, entityId, acs, enabled, created, lastModified, nameIdFormat, authenticationExpiryMillis, - certificates, privileges, attributeNames); + return Objects.hash(docId, name, entityId, acs, enabled, created, lastModified, nameIdFormat, + authenticationExpiryMillis, certificates, privileges, attributeNames); } private static final ObjectParser DOC_PARSER diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderFactory.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderFactory.java new file mode 100644 index 00000000000..03db3e0c480 --- /dev/null +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderFactory.java @@ -0,0 +1,77 @@ +/* + * 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.idp.saml.sp; + +import org.elasticsearch.xpack.idp.privileges.ServiceProviderPrivileges; +import org.joda.time.ReadableDuration; +import org.opensaml.security.x509.BasicX509Credential; +import org.opensaml.security.x509.X509Credential; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * A class for creating a {@link SamlServiceProvider} from a {@link SamlServiceProviderDocument}. + */ +public final class SamlServiceProviderFactory { + + private final ServiceProviderDefaults defaults; + + public SamlServiceProviderFactory(ServiceProviderDefaults defaults) { + this.defaults = defaults; + } + + SamlServiceProvider buildServiceProvider(SamlServiceProviderDocument document) { + final ServiceProviderPrivileges privileges = buildPrivileges(document.privileges); + final SamlServiceProvider.AttributeNames attributes = new SamlServiceProvider.AttributeNames( + document.attributeNames.principal, document.attributeNames.name, document.attributeNames.email, document.attributeNames.roles + ); + final Set credentials = document.certificates.getServiceProviderX509SigningCertificates() + .stream() + .map(BasicX509Credential::new) + .collect(Collectors.collectingAndThen(Collectors.toSet(), Collections::unmodifiableSet)); + + final URL acs = parseUrl(document); + String nameIdFormat = document.nameIdFormat; + if (nameIdFormat == null) { + nameIdFormat = defaults.nameIdFormat; + } + + final ReadableDuration authnExpiry = Optional.ofNullable(document.getAuthenticationExpiry()) + .orElse(defaults.authenticationExpiry); + + final boolean signAuthnRequests = document.signMessages.contains(SamlServiceProviderDocument.SIGN_AUTHN); + final boolean signLogoutRequests = document.signMessages.contains(SamlServiceProviderDocument.SIGN_LOGOUT); + + return new CloudServiceProvider(document.entityId, document.name, document.enabled, acs, nameIdFormat, authnExpiry, + privileges, attributes, credentials, signAuthnRequests, signLogoutRequests); + } + + private ServiceProviderPrivileges buildPrivileges(SamlServiceProviderDocument.Privileges configuredPrivileges) { + final String resource = configuredPrivileges.resource; + final Map roles = Optional.ofNullable(configuredPrivileges.roleActions).orElse(Collections.emptyMap()); + return new ServiceProviderPrivileges(defaults.applicationName, resource, roles); + } + + private URL parseUrl(SamlServiceProviderDocument document) { + final URL acs; + try { + acs = new URL(document.acs); + } catch (MalformedURLException e) { + final ServiceProviderException exception = new ServiceProviderException( + "Service provider [{}] (doc {}) has an invalid ACS [{}]", e, document.entityId, document.docId, document.acs); + exception.setEntityId(document.entityId); + throw exception; + } + return acs; + } +} diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderResolver.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderResolver.java index 20a9a4b561a..8c93653a560 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderResolver.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderResolver.java @@ -8,47 +8,24 @@ package org.elasticsearch.xpack.idp.saml.sp; import org.elasticsearch.action.ActionListener; import org.elasticsearch.common.cache.Cache; -import org.elasticsearch.common.cache.CacheBuilder; -import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Settings; -import org.elasticsearch.common.unit.TimeValue; import org.elasticsearch.common.util.iterable.Iterables; -import org.elasticsearch.xpack.idp.privileges.ServiceProviderPrivileges; import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderIndex.DocumentSupplier; import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderIndex.DocumentVersion; -import org.joda.time.ReadableDuration; -import org.opensaml.security.x509.BasicX509Credential; -import org.opensaml.security.x509.X509Credential; -import java.net.MalformedURLException; -import java.net.URL; -import java.util.Collections; -import java.util.Map; -import java.util.Optional; -import java.util.Set; import java.util.stream.Collectors; public class SamlServiceProviderResolver { - private static final int CACHE_SIZE_DEFAULT = 1000; - private static final TimeValue CACHE_TTL_DEFAULT = TimeValue.timeValueMinutes(60); - - public static final Setting CACHE_SIZE - = Setting.intSetting("xpack.idp.sp.cache.size", CACHE_SIZE_DEFAULT, Setting.Property.NodeScope); - public static final Setting CACHE_TTL - = Setting.timeSetting("xpack.idp.sp.cache.ttl", CACHE_TTL_DEFAULT, Setting.Property.NodeScope); - private final Cache cache; private final SamlServiceProviderIndex index; - private final ServiceProviderDefaults defaults; + private final SamlServiceProviderFactory serviceProviderFactory; - public SamlServiceProviderResolver(Settings settings, SamlServiceProviderIndex index, ServiceProviderDefaults defaults) { - this.cache = CacheBuilder.builder() - .setMaximumWeight(CACHE_SIZE.get(settings)) - .setExpireAfterAccess(CACHE_TTL.get(settings)) - .build(); + public SamlServiceProviderResolver(Settings settings, SamlServiceProviderIndex index, + SamlServiceProviderFactory serviceProviderFactory) { + this.cache = ServiceProviderCacheSettings.buildCache(settings); this.index = index; - this.defaults = defaults; + this.serviceProviderFactory = serviceProviderFactory; } /** @@ -76,68 +53,21 @@ public class SamlServiceProviderResolver { final CachedServiceProvider cached = cache.get(entityId); if (cached != null && cached.documentVersion.equals(doc.version)) { listener.onResponse(cached.serviceProvider); - return; } else { populateCacheAndReturn(entityId, doc, listener); } }, listener::onFailure )); - } private void populateCacheAndReturn(String entityId, DocumentSupplier doc, ActionListener listener) { - final SamlServiceProvider serviceProvider = buildServiceProvider(doc.document.get()); + final SamlServiceProvider serviceProvider = serviceProviderFactory.buildServiceProvider(doc.document.get()); final CachedServiceProvider cacheEntry = new CachedServiceProvider(entityId, doc.version, serviceProvider); cache.put(entityId, cacheEntry); listener.onResponse(serviceProvider); } - private SamlServiceProvider buildServiceProvider(SamlServiceProviderDocument document) { - final ServiceProviderPrivileges privileges = buildPrivileges(document.privileges); - final SamlServiceProvider.AttributeNames attributes = new SamlServiceProvider.AttributeNames( - document.attributeNames.principal, document.attributeNames.name, document.attributeNames.email, document.attributeNames.roles - ); - final Set credentials = document.certificates.getServiceProviderX509SigningCertificates() - .stream() - .map(BasicX509Credential::new) - .collect(Collectors.collectingAndThen(Collectors.toSet(), Collections::unmodifiableSet)); - - final URL acs = parseUrl(document); - String nameIdFormat = document.nameIdFormat; - if (nameIdFormat == null) { - nameIdFormat = defaults.nameIdFormat; - } - - final ReadableDuration authnExpiry = Optional.ofNullable(document.getAuthenticationExpiry()) - .orElse(defaults.authenticationExpiry); - - final boolean signAuthnRequests = document.signMessages.contains(SamlServiceProviderDocument.SIGN_AUTHN); - final boolean signLogoutRequests = document.signMessages.contains(SamlServiceProviderDocument.SIGN_LOGOUT); - - return new CloudServiceProvider(document.entityId, document.name, document.enabled, acs, nameIdFormat, authnExpiry, - privileges, attributes, credentials, signAuthnRequests, signLogoutRequests); - } - - private ServiceProviderPrivileges buildPrivileges(SamlServiceProviderDocument.Privileges configuredPrivileges) { - final String resource = configuredPrivileges.resource; - final Map roles = Optional.ofNullable(configuredPrivileges.roleActions).orElse(Collections.emptyMap()); - return new ServiceProviderPrivileges(defaults.applicationName, resource, roles); - } - - private URL parseUrl(SamlServiceProviderDocument document) { - final URL acs; - try { - acs = new URL(document.acs); - } catch (MalformedURLException e) { - final ServiceProviderException exception = new ServiceProviderException( - "Service provider [{}] (doc {}) has an invalid ACS [{}]", e, document.entityId, document.docId, document.acs); - exception.setEntityId(document.entityId); - throw exception; - } - return acs; - } - private class CachedServiceProvider { private final String entityId; private final DocumentVersion documentVersion; diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/ServiceProviderCacheSettings.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/ServiceProviderCacheSettings.java new file mode 100644 index 00000000000..b5b49abb583 --- /dev/null +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/ServiceProviderCacheSettings.java @@ -0,0 +1,41 @@ +/* + * 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.idp.saml.sp; + +import org.elasticsearch.common.cache.Cache; +import org.elasticsearch.common.cache.CacheBuilder; +import org.elasticsearch.common.settings.Setting; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.unit.TimeValue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Represents standard settings for the ServiceProvider cache(s) in the IdP + */ +public final class ServiceProviderCacheSettings { + private static final int CACHE_SIZE_DEFAULT = 1000; + private static final TimeValue CACHE_TTL_DEFAULT = TimeValue.timeValueMinutes(60); + + public static final Setting CACHE_SIZE + = Setting.intSetting("xpack.idp.sp.cache.size", CACHE_SIZE_DEFAULT, Setting.Property.NodeScope); + public static final Setting CACHE_TTL + = Setting.timeSetting("xpack.idp.sp.cache.ttl", CACHE_TTL_DEFAULT, Setting.Property.NodeScope); + + static Cache buildCache(Settings settings) { + return CacheBuilder.builder() + .setMaximumWeight(CACHE_SIZE.get(settings)) + .setExpireAfterAccess(CACHE_TTL.get(settings)) + .build(); + } + + public static List> getSettings() { + return Collections.unmodifiableList(Arrays.asList(CACHE_SIZE, CACHE_TTL)); + } +} diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/WildcardServiceProvider.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/WildcardServiceProvider.java new file mode 100644 index 00000000000..37b91b052d2 --- /dev/null +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/WildcardServiceProvider.java @@ -0,0 +1,200 @@ +/* + * 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.idp.saml.sp; + +import org.elasticsearch.common.Nullable; +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.bytes.BytesArray; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.xcontent.ConstructingObjectParser; +import org.elasticsearch.common.xcontent.LoggingDeprecationHandler; +import org.elasticsearch.common.xcontent.NamedXContentRegistry; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentHelper; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.common.xcontent.json.JsonXContent; +import org.elasticsearch.script.ScriptService; +import org.elasticsearch.xpack.core.security.support.MustacheTemplateEvaluator; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A model for a service provider (see {@link SamlServiceProvider} and {@link SamlServiceProviderDocument}) that uses wildcard matching + * rules and a service-provider template. + */ +class WildcardServiceProvider { + + private static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + "wildcard_service", + args -> { + final String entityId = (String) args[0]; + final String acs = (String) args[1]; + final Collection tokens = (Collection) args[2]; + final Map definition = (Map) args[3]; + return new WildcardServiceProvider(entityId, acs, tokens, definition); + }); + + static { + PARSER.declareString(ConstructingObjectParser.constructorArg(), Fields.ENTITY_ID); + PARSER.declareString(ConstructingObjectParser.constructorArg(), Fields.ACS); + PARSER.declareStringArray(ConstructingObjectParser.constructorArg(), Fields.TOKENS); + PARSER.declareObject(ConstructingObjectParser.constructorArg(), (p, ignore) -> p.map(), Fields.TEMPLATE); + } + + private final Pattern matchEntityId; + private final Pattern matchAcs; + private final Set tokens; + private final BytesReference serviceTemplate; + + private WildcardServiceProvider(Pattern matchEntityId, Pattern matchAcs, Set tokens, BytesReference serviceTemplate) { + this.matchEntityId = Objects.requireNonNull(matchEntityId); + this.matchAcs = Objects.requireNonNull(matchAcs); + this.tokens = Objects.requireNonNull(tokens); + this.serviceTemplate = Objects.requireNonNull(serviceTemplate); + } + + WildcardServiceProvider(String matchEntityId, String matchAcs, Collection tokens, Map serviceTemplate) { + this(Pattern.compile(Objects.requireNonNull(matchEntityId, "EntityID to match cannot be null")), + Pattern.compile(Objects.requireNonNull(matchAcs, "ACS to match cannot be null")), + Collections.unmodifiableSet(new HashSet<>(Objects.requireNonNull(tokens, "Tokens collection may not be null"))), + toMustacheScript(Objects.requireNonNull(serviceTemplate, "Service definition may not be null"))); + } + + public static WildcardServiceProvider parse(XContentParser parser) throws IOException { + return PARSER.parse(parser, null); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final WildcardServiceProvider that = (WildcardServiceProvider) o; + return matchEntityId.pattern().equals(that.matchEntityId.pattern()) && + matchAcs.pattern().equals(that.matchAcs.pattern()) && + tokens.equals(that.tokens) && + serviceTemplate.equals(that.serviceTemplate); + } + + @Override + public int hashCode() { + return Objects.hash(matchEntityId.pattern(), matchAcs.pattern(), tokens, serviceTemplate); + } + + private static BytesReference toMustacheScript(Map serviceDefinition) { + try { + XContentBuilder builder = JsonXContent.contentBuilder(); + builder.startObject(); + builder.field("source"); + builder.map(serviceDefinition); + builder.endObject(); + return BytesReference.bytes(builder); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Nullable + public SamlServiceProviderDocument apply(ScriptService scriptService, final String entityId, final String acs) { + Map parameters = extractTokens(entityId, acs); + if (parameters == null) { + return null; + } + try { + String serviceJson = evaluateTemplate(scriptService, parameters); + final SamlServiceProviderDocument doc = toServiceProviderDocument(serviceJson); + final Instant now = Instant.now(); + doc.setEntityId(entityId); + doc.setAcs(acs); + doc.setCreated(now); + doc.setLastModified(now); + return doc; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + // package protected for testing + Map extractTokens(String entityId, String acs) { + final Matcher entityIdMatcher = this.matchEntityId.matcher(entityId); + if (entityIdMatcher.matches() == false) { + return null; + } + final Matcher acsMatcher = this.matchAcs.matcher(acs); + if (acsMatcher.matches() == false) { + return null; + } + + Map parameters = new HashMap<>(); + for (String token : this.tokens) { + String entityIdToken = extractGroup(entityIdMatcher, token); + String acsToken = extractGroup(acsMatcher, token); + if (entityIdToken != null) { + if (acsToken != null) { + if (entityIdToken.equals(acsToken) == false) { + throw new IllegalArgumentException("Extracted token [" + token + "] values from EntityID ([" + entityIdToken + + "] from [" + entityId + "]) and ACS ([" + acsToken + "] from [" + acs + "]) do not match"); + } + } + parameters.put(token, entityIdToken); + } else if (acsToken != null) { + parameters.put(token, acsToken); + } + } + parameters.putIfAbsent("entity_id", entityId); + parameters.putIfAbsent("acs", acs); + return parameters; + } + + private String evaluateTemplate(ScriptService scriptService, Map parameters) throws IOException { + try (XContentParser templateParser = parser(serviceTemplate)) { + return MustacheTemplateEvaluator.evaluate(scriptService, templateParser, parameters); + } + } + + private SamlServiceProviderDocument toServiceProviderDocument(String serviceJson) throws IOException { + try (XContentParser docParser = parser(new BytesArray(serviceJson))) { + return SamlServiceProviderDocument.fromXContent(null, docParser); + } + } + + private static XContentParser parser(BytesReference body) throws IOException { + return XContentHelper.createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, body, XContentType.JSON); + } + + private String extractGroup(Matcher matcher, String name) { + try { + return matcher.group(name); + } catch (IllegalArgumentException e) { + // Stoopid java API, ignore + return null; + } + } + + public interface Fields { + ParseField ENTITY_ID = new ParseField("entity_id"); + ParseField ACS = new ParseField("acs"); + ParseField TOKENS = new ParseField("tokens"); + ParseField TEMPLATE = new ParseField("template"); + } + +} diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/WildcardServiceProviderResolver.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/WildcardServiceProviderResolver.java new file mode 100644 index 00000000000..912079791b5 --- /dev/null +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/WildcardServiceProviderResolver.java @@ -0,0 +1,232 @@ +/* + * 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.idp.saml.sp; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.ParsingException; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.cache.Cache; +import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.common.settings.Setting; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.iterable.Iterables; +import org.elasticsearch.common.xcontent.LoggingDeprecationHandler; +import org.elasticsearch.common.xcontent.NamedXContentRegistry; +import org.elasticsearch.common.xcontent.XContentLocation; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.XContentParserUtils; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.env.Environment; +import org.elasticsearch.script.ScriptService; +import org.elasticsearch.watcher.FileChangesListener; +import org.elasticsearch.watcher.FileWatcher; +import org.elasticsearch.watcher.ResourceWatcherService; +import org.elasticsearch.xpack.core.XPackPlugin; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +public class WildcardServiceProviderResolver { + + public static final Setting FILE_PATH_SETTING = Setting.simpleString("xpack.idp.sp.wildcard.path", + "wildcard_services.json", Setting.Property.NodeScope); + + private class State { + final Map services; + final Cache, SamlServiceProvider> cache; + + private State(Map services) { + this.services = services; + this.cache = ServiceProviderCacheSettings.buildCache(settings); + } + } + + private static final Logger logger = LogManager.getLogger(); + + private final Settings settings; + private final ScriptService scriptService; + private final SamlServiceProviderFactory serviceProviderFactory; + private final AtomicReference stateRef; + + WildcardServiceProviderResolver(Settings settings, ScriptService scriptService, SamlServiceProviderFactory serviceProviderFactory) { + this.settings = settings; + this.scriptService = scriptService; + this.serviceProviderFactory = serviceProviderFactory; + this.stateRef = new AtomicReference<>(new State(Collections.emptyMap())); + } + + /** + * This is implemented as a factory method to facilitate testing - the core resolver just works on InputStreams, this method + * handles all the Path/ResourceWatcher logic + */ + public static WildcardServiceProviderResolver create(Environment environment, + ResourceWatcherService resourceWatcherService, + ScriptService scriptService, + SamlServiceProviderFactory spFactory) { + final Settings settings = environment.settings(); + final Path path = XPackPlugin.resolveConfigFile(environment, FILE_PATH_SETTING.get(environment.settings())); + + logger.info("Loading wildcard services from file [{}]", path.toAbsolutePath()); + + final WildcardServiceProviderResolver resolver = new WildcardServiceProviderResolver(settings, scriptService, spFactory); + + if (Files.exists(path)) { + try { + resolver.reload(path); + } catch (IOException e) { + throw new ElasticsearchException("File [{}] (from setting [{}]) cannot be loaded", + e, path.toAbsolutePath(), FILE_PATH_SETTING.getKey()); + } + } else if (FILE_PATH_SETTING.exists(environment.settings())) { + // A file was explicitly configured, but doesn't exist. That's a mistake... + throw new ElasticsearchException("File [{}] (from setting [{}]) does not exist", + path.toAbsolutePath(), FILE_PATH_SETTING.getKey()); + } + + final FileWatcher fileWatcher = new FileWatcher(path); + fileWatcher.addListener(new FileChangesListener() { + @Override + public void onFileCreated(Path file) { + onFileChanged(file); + } + + @Override + public void onFileDeleted(Path file) { + onFileChanged(file); + } + + @Override + public void onFileChanged(Path file) { + try { + resolver.reload(file); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + }); + try { + resourceWatcherService.add(fileWatcher); + } catch (IOException e) { + throw new ElasticsearchException("Failed to watch file [{}] (from setting [{}])", + e, path.toAbsolutePath(), FILE_PATH_SETTING.getKey()); + } + return resolver; + } + + public SamlServiceProvider resolve(String entityId, String acs) { + final State currentState = stateRef.get(); + + Tuple cacheKey = new Tuple<>(entityId, acs); + final SamlServiceProvider cached = currentState.cache.get(cacheKey); + if (cached != null) { + logger.trace("Service for [{}] [{}] is cached [{}]", entityId, acs, cached); + return cached; + } + + final Map matches = new HashMap<>(); + currentState.services.forEach((name, wildcard) -> { + final SamlServiceProviderDocument doc = wildcard.apply(scriptService, entityId, acs); + if (doc != null) { + final SamlServiceProvider sp = serviceProviderFactory.buildServiceProvider(doc); + matches.put(name, sp); + } + }); + + switch (matches.size()) { + case 0: + logger.trace("No wildcard services found for [{}] [{}]", entityId, acs); + return null; + + case 1: + final SamlServiceProvider serviceProvider = Iterables.get(matches.values(), 0); + logger.trace("Found exactly 1 wildcard service for [{}] [{}] - [{}]", entityId, acs, serviceProvider); + currentState.cache.put(cacheKey, serviceProvider); + return serviceProvider; + + default: + final String names = Strings.collectionToCommaDelimitedString(matches.keySet()); + logger.warn("Found multiple matching wildcard services for [{}] [{}] - [{}]", entityId, acs, names); + throw new IllegalStateException( + "Found multiple wildcard service providers for entity ID [" + entityId + "] and ACS [" + acs + + "] - wildcard service names [" + names + "]"); + } + } + + // For testing + Map services() { + return stateRef.get().services; + } + + // Accessible for testing + void reload(XContentParser parser) throws IOException { + final Map newServices = Collections.unmodifiableMap(parse(parser)); + final State oldState = this.stateRef.get(); + if (newServices.equals(oldState.services) == false) { + // Services have changed + if (this.stateRef.compareAndSet(oldState, new State(newServices))) { + logger.info("Reloaded cached wildcard service providers, new providers [{}]", + Strings.collectionToCommaDelimitedString(newServices.keySet())); + } else { + // some other thread reloaded it + } + } + } + + private void reload(Path file) throws IOException { + try (InputStream in = Files.newInputStream(file); + XContentParser parser = buildServicesParser(in)) { + reload(parser); + } + } + + private static XContentParser buildServicesParser(InputStream in) throws IOException { + return XContentType.JSON.xContent().createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, in); + } + + private static Map parse(XContentParser parser) throws IOException { + final XContentParser.Token token = parser.currentToken() == null ? parser.nextToken() : parser.currentToken(); + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, token, parser::getTokenLocation); + + XContentParserUtils.ensureFieldName(parser, parser.nextToken(), Fields.SERVICES.getPreferredName()); + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, parser.nextToken(), parser::getTokenLocation); + final Map services = new HashMap<>(); + while (parser.nextToken() != XContentParser.Token.END_OBJECT) { + XContentParserUtils.ensureExpectedToken(XContentParser.Token.FIELD_NAME, parser.currentToken(), parser::getTokenLocation); + String name = parser.currentName(); + final XContentLocation location = parser.getTokenLocation(); + try { + services.put(name, WildcardServiceProvider.parse(parser)); + } catch (Exception e) { + throw new ParsingException(location, "failed to parse wildcard service [{}]", e, name); + } + } + XContentParserUtils.ensureExpectedToken(XContentParser.Token.END_OBJECT, parser.currentToken(), parser::getTokenLocation); + + XContentParserUtils.ensureExpectedToken(XContentParser.Token.END_OBJECT, parser.nextToken(), parser::getTokenLocation); + return services; + } + + public static Collection> getSettings() { + return Collections.singletonList(FILE_PATH_SETTING); + } + + public interface Fields { + ParseField SERVICES = new ParseField("services"); + } + +} diff --git a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/SamlIdentityProviderTests.java b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/SamlIdentityProviderTests.java index fe54e517c9a..8d60bdbb398 100644 --- a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/SamlIdentityProviderTests.java +++ b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/SamlIdentityProviderTests.java @@ -27,8 +27,8 @@ import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken import org.elasticsearch.xpack.core.security.client.SecurityClient; import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderDocument; import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderIndex; -import org.elasticsearch.xpack.idp.saml.test.IdentityProviderIntegTestCase; import org.elasticsearch.xpack.idp.saml.support.SamlFactory; +import org.elasticsearch.xpack.idp.saml.test.IdentityProviderIntegTestCase; import org.opensaml.core.xml.util.XMLObjectSupport; import org.opensaml.saml.common.SAMLObject; import org.opensaml.saml.saml2.core.AuthnRequest; @@ -81,7 +81,7 @@ public class SamlIdentityProviderTests extends IdentityProviderIntegTestCase { new SecureString(CONSOLE_USER_PASSWORD.toCharArray()))) .addHeader("es-secondary-authorization", "ApiKey " + apiKeyCredentials) .build()); - request.setJsonEntity("{ \"entity_id\": \"" + entityId + "\"}"); + request.setJsonEntity("{ \"entity_id\": \"" + entityId + "\", \"acs\": \"" + acsUrl + "\" }"); Response initResponse = getRestClient().performRequest(request); ObjectPath objectPath = ObjectPath.createFromResponse(initResponse); assertThat(objectPath.evaluate("post_url").toString(), equalTo(acsUrl)); @@ -110,9 +110,9 @@ public class SamlIdentityProviderTests extends IdentityProviderIntegTestCase { new SecureString(CONSOLE_USER_PASSWORD.toCharArray()))) .addHeader("es-secondary-authorization", "ApiKey " + apiKeyCredentials) .build()); - request.setJsonEntity("{ \"entity_id\": \"" + entityId + randomAlphaOfLength(3) + "\"}"); + request.setJsonEntity("{ \"entity_id\": \"" + entityId + randomAlphaOfLength(3) + "\", \"acs\": \"" + acsUrl + "\" }"); ResponseException e = expectThrows(ResponseException.class, () -> getRestClient().performRequest(request)); - assertThat(e.getMessage(), containsString("is not registered with this Identity Provider")); + assertThat(e.getMessage(), containsString("is not known to this Identity Provider")); assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(RestStatus.BAD_REQUEST.getStatus())); } @@ -124,7 +124,7 @@ public class SamlIdentityProviderTests extends IdentityProviderIntegTestCase { // Make a request to init an SSO flow with the API Key as secondary authentication Request request = new Request("POST", "/_idp/saml/init"); request.setOptions(REQUEST_OPTIONS_AS_CONSOLE_USER); - request.setJsonEntity("{ \"entity_id\": \"" + entityId + "\"}"); + request.setJsonEntity("{ \"entity_id\": \"" + entityId + "\", \"acs\": \"" + acsUrl + "\" }"); ResponseException e = expectThrows(ResponseException.class, () -> getRestClient().performRequest(request)); assertThat(e.getMessage(), containsString("Request is missing secondary authentication")); } @@ -149,6 +149,8 @@ public class SamlIdentityProviderTests extends IdentityProviderIntegTestCase { Map serviceProvider = validateResponseObject.evaluate("service_provider"); assertThat(serviceProvider, hasKey("entity_id")); assertThat(serviceProvider.get("entity_id"), equalTo(entityId)); + assertThat(serviceProvider, hasKey("acs")); + assertThat(serviceProvider.get("acs"), equalTo(authnRequest.getAssertionConsumerServiceURL())); assertThat(validateResponseObject.evaluate("force_authn"), equalTo(forceAuthn)); Map authnState = validateResponseObject.evaluate("authn_state"); assertThat(authnState, hasKey("nameid_format")); @@ -172,7 +174,11 @@ public class SamlIdentityProviderTests extends IdentityProviderIntegTestCase { .build()); XContentBuilder authnStateBuilder = jsonBuilder(); authnStateBuilder.map(authnState); - initRequest.setJsonEntity("{ \"entity_id\":\"" + entityId + "\", \"authn_state\":" + Strings.toString(authnStateBuilder) + "}"); + initRequest.setJsonEntity("{" + + ("\"entity_id\":\"" + entityId + "\",") + + ("\"acs\":\"" + serviceProvider.get("acs") + "\",") + + ("\"authn_state\":" + Strings.toString(authnStateBuilder)) + + "}"); Response initResponse = getRestClient().performRequest(initRequest); ObjectPath initResponseObject = ObjectPath.createFromResponse(initResponse); assertThat(initResponseObject.evaluate("post_url").toString(), equalTo(acsUrl)); @@ -204,7 +210,7 @@ public class SamlIdentityProviderTests extends IdentityProviderIntegTestCase { final String query = getQueryString(authnRequest, relayString, false, null); validateRequest.setJsonEntity("{\"authn_request_query\":\"" + query + "\"}"); ResponseException e = expectThrows(ResponseException.class, () -> getRestClient().performRequest(validateRequest)); - assertThat(e.getMessage(), containsString("is not registered with this Identity Provider")); + assertThat(e.getMessage(), containsString("is not known to this Identity Provider")); assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(RestStatus.BAD_REQUEST.getStatus())); } diff --git a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnRequestTests.java b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnRequestTests.java index 4969067eb90..58c12dfda10 100644 --- a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnRequestTests.java +++ b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnRequestTests.java @@ -11,27 +11,31 @@ import org.elasticsearch.test.ESTestCase; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; public class SamlInitiateSingleSignOnRequestTests extends ESTestCase { public void testSerialization() throws Exception { final SamlInitiateSingleSignOnRequest request = new SamlInitiateSingleSignOnRequest(); request.setSpEntityId("https://kibana_url"); + request.setAssertionConsumerService("https://kibana_url/acs"); + assertThat("An invalid request is not guaranteed to serialize correctly", request.validate(), nullValue()); final BytesStreamOutput out = new BytesStreamOutput(); request.writeTo(out); final SamlInitiateSingleSignOnRequest request1 = new SamlInitiateSingleSignOnRequest(out.bytes().streamInput()); assertThat(request1.getSpEntityId(), equalTo(request.getSpEntityId())); + assertThat(request1.getAssertionConsumerService(), equalTo(request.getAssertionConsumerService())); final ActionRequestValidationException validationException = request1.validate(); assertNull(validationException); } public void testValidation() { - final SamlInitiateSingleSignOnRequest request1 = new SamlInitiateSingleSignOnRequest(); final ActionRequestValidationException validationException = request1.validate(); assertNotNull(validationException); - assertThat(validationException.validationErrors().size(), equalTo(1)); + assertThat(validationException.validationErrors().size(), equalTo(2)); assertThat(validationException.validationErrors().get(0), containsString("entity_id is missing")); + assertThat(validationException.validationErrors().get(1), containsString("acs is missing")); } } diff --git a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/TransportSamlInitiateSingleSignOnRequestTests.java b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/TransportSamlInitiateSingleSignOnRequestTests.java index ad85c46127f..f51760b1966 100644 --- a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/TransportSamlInitiateSingleSignOnRequestTests.java +++ b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/TransportSamlInitiateSingleSignOnRequestTests.java @@ -27,6 +27,7 @@ import org.elasticsearch.xpack.idp.saml.sp.CloudServiceProvider; import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProvider; import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderResolver; import org.elasticsearch.xpack.idp.saml.sp.ServiceProviderDefaults; +import org.elasticsearch.xpack.idp.saml.sp.WildcardServiceProviderResolver; import org.elasticsearch.xpack.idp.saml.support.SamlFactory; import org.elasticsearch.xpack.idp.saml.test.IdpSamlTestCase; import org.joda.time.Duration; @@ -90,7 +91,7 @@ public class TransportSamlInitiateSingleSignOnRequestTests extends IdpSamlTestCa Exception e = expectThrows(Exception.class, () -> future.get()); assertThat(e.getCause().getMessage(), containsString("https://sp2.other.org")); - assertThat(e.getCause().getMessage(), containsString("is not registered with this Identity Provider")); + assertThat(e.getCause().getMessage(), containsString("is not known to this Identity Provider")); } private TransportSamlInitiateSingleSignOnAction setupTransportAction(boolean withSecondaryAuth) throws Exception { @@ -125,7 +126,8 @@ public class TransportSamlInitiateSingleSignOnRequestTests extends IdpSamlTestCa .writeToContext(threadContext); } - final SamlServiceProviderResolver resolver = Mockito.mock(SamlServiceProviderResolver.class); + final SamlServiceProviderResolver serviceResolver = Mockito.mock(SamlServiceProviderResolver.class); + final WildcardServiceProviderResolver wildcardResolver = Mockito.mock(WildcardServiceProviderResolver.class); final CloudServiceProvider serviceProvider = new CloudServiceProvider("https://sp.some.org", "test sp", true, @@ -139,13 +141,13 @@ public class TransportSamlInitiateSingleSignOnRequestTests extends IdpSamlTestCa "https://saml.elasticsearch.org/attributes/email", "https://saml.elasticsearch.org/attributes/roles"), null, false, false); - mockRegisteredServiceProvider(resolver, "https://sp.some.org", serviceProvider); - mockRegisteredServiceProvider(resolver, "https://sp2.other.org", null); + mockRegisteredServiceProvider(serviceResolver, "https://sp.some.org", serviceProvider); + mockRegisteredServiceProvider(serviceResolver, "https://sp2.other.org", null); final ServiceProviderDefaults defaults = new ServiceProviderDefaults( "elastic-cloud", TRANSIENT, Duration.standardMinutes(15)); final X509Credential signingCredential = readCredentials("RSA", randomFrom(1024, 2048, 4096)); final SamlIdentityProvider idp = SamlIdentityProvider - .builder(resolver) + .builder(serviceResolver, wildcardResolver) .fromSettings(env) .signingCredential(signingCredential) .serviceProviderDefaults(defaults) diff --git a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/authn/SamlAuthnRequestValidatorTests.java b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/authn/SamlAuthnRequestValidatorTests.java index a028aed0402..12d27bc18cc 100644 --- a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/authn/SamlAuthnRequestValidatorTests.java +++ b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/authn/SamlAuthnRequestValidatorTests.java @@ -196,7 +196,7 @@ public class SamlAuthnRequestValidatorTests extends IdpSamlTestCase { PlainActionFuture future = new PlainActionFuture<>(); validator.processQueryString(getQueryString(authnRequest, relayState), future); ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, future::actionGet); - assertThat(e.getMessage(), containsString("is not registered with this Identity Provider")); + assertThat(e.getMessage(), containsString("is not known to this Identity Provider")); assertThat(e.getMessage(), containsString("https://unknown.kibana.org")); } diff --git a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/idp/SamlIdentityProviderBuilderTests.java b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/idp/SamlIdentityProviderBuilderTests.java index b3645449f8c..0012d998cd0 100644 --- a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/idp/SamlIdentityProviderBuilderTests.java +++ b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/idp/SamlIdentityProviderBuilderTests.java @@ -16,6 +16,7 @@ import org.elasticsearch.xpack.core.ssl.CertParsingUtils; import org.elasticsearch.xpack.core.ssl.PemUtils; import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderResolver; import org.elasticsearch.xpack.idp.saml.sp.ServiceProviderDefaults; +import org.elasticsearch.xpack.idp.saml.sp.WildcardServiceProviderResolver; import org.elasticsearch.xpack.idp.saml.test.IdpSamlTestCase; import org.hamcrest.Matchers; import org.joda.time.Duration; @@ -85,9 +86,13 @@ public class SamlIdentityProviderBuilderTests extends IdpSamlTestCase { .put("xpack.idp.signing.certificate", destSigningCertPath) .build(); final Environment env = TestEnvironment.newEnvironment(settings); - final SamlServiceProviderResolver resolver = Mockito.mock(SamlServiceProviderResolver.class); + final SamlServiceProviderResolver serviceResolver = Mockito.mock(SamlServiceProviderResolver.class); + final WildcardServiceProviderResolver wildcardResolver = Mockito.mock(WildcardServiceProviderResolver.class); final ServiceProviderDefaults defaults = ServiceProviderDefaults.forSettings(settings); - final SamlIdentityProvider idp = SamlIdentityProvider.builder(resolver).fromSettings(env).serviceProviderDefaults(defaults).build(); + final SamlIdentityProvider idp = SamlIdentityProvider.builder(serviceResolver, wildcardResolver) + .fromSettings(env) + .serviceProviderDefaults(defaults) + .build(); assertThat(idp.getEntityId(), equalTo("urn:elastic:cloud:idp")); assertThat(idp.getSingleSignOnEndpoint(SAML2_REDIRECT_BINDING_URI).toString(), equalTo("https://idp.org/sso/redirect")); assertThat(idp.getSingleSignOnEndpoint(SAML2_POST_BINDING_URI).toString(), equalTo("https://idp.org/sso/post")); @@ -121,12 +126,16 @@ public class SamlIdentityProviderBuilderTests extends IdpSamlTestCase { .put(IDP_CONTACT_EMAIL.getKey(), "tony@starkindustries.com") .build(); final Environment env = TestEnvironment.newEnvironment(settings); - final SamlServiceProviderResolver resolver = Mockito.mock(SamlServiceProviderResolver.class); + final SamlServiceProviderResolver serviceResolver = Mockito.mock(SamlServiceProviderResolver.class); + final WildcardServiceProviderResolver wildcardResolver = Mockito.mock(WildcardServiceProviderResolver.class); final ServiceProviderDefaults defaults = new ServiceProviderDefaults( randomAlphaOfLengthBetween(4, 8), randomFrom(TRANSIENT, PERSISTENT), Duration.standardMinutes(randomIntBetween(2, 90))); IllegalArgumentException e = LuceneTestCase.expectThrows(IllegalArgumentException.class, - () -> SamlIdentityProvider.builder(resolver).fromSettings(env).serviceProviderDefaults(defaults).build()); + () -> SamlIdentityProvider.builder(serviceResolver, wildcardResolver) + .fromSettings(env) + .serviceProviderDefaults(defaults) + .build()); assertThat(e, instanceOf(ValidationException.class)); assertThat(e.getMessage(), containsString("Signing credential must be specified")); } @@ -161,9 +170,13 @@ public class SamlIdentityProviderBuilderTests extends IdpSamlTestCase { .put("xpack.idp.signing.certificate", destSigningCertPath) .build(); final Environment env = TestEnvironment.newEnvironment(settings); - final SamlServiceProviderResolver resolver = Mockito.mock(SamlServiceProviderResolver.class); + final SamlServiceProviderResolver serviceResolver = Mockito.mock(SamlServiceProviderResolver.class); + final WildcardServiceProviderResolver wildcardResolver = Mockito.mock(WildcardServiceProviderResolver.class); final ServiceProviderDefaults defaults = ServiceProviderDefaults.forSettings(settings); - final SamlIdentityProvider idp = SamlIdentityProvider.builder(resolver).fromSettings(env).serviceProviderDefaults(defaults).build(); + final SamlIdentityProvider idp = SamlIdentityProvider.builder(serviceResolver, wildcardResolver) + .fromSettings(env) + .serviceProviderDefaults(defaults) + .build(); assertThat(idp.getAllowedNameIdFormats(), hasSize(1)); assertThat(idp.getAllowedNameIdFormats(), Matchers.contains(TRANSIENT)); } @@ -198,10 +211,11 @@ public class SamlIdentityProviderBuilderTests extends IdpSamlTestCase { .put("xpack.idp.signing.certificate", destSigningCertPath) .build(); final Environment env = TestEnvironment.newEnvironment(settings); - final SamlServiceProviderResolver resolver = Mockito.mock(SamlServiceProviderResolver.class); + final SamlServiceProviderResolver serviceResolver = Mockito.mock(SamlServiceProviderResolver.class); + final WildcardServiceProviderResolver wildcardResolver = Mockito.mock(WildcardServiceProviderResolver.class); final ServiceProviderDefaults defaults = ServiceProviderDefaults.forSettings(settings); - IllegalArgumentException e = LuceneTestCase.expectThrows(IllegalArgumentException.class, - () -> SamlIdentityProvider.builder(resolver).fromSettings(env).build()); + IllegalArgumentException e = LuceneTestCase.expectThrows(IllegalArgumentException.class, () -> + SamlIdentityProvider.builder(serviceResolver, wildcardResolver).fromSettings(env).serviceProviderDefaults(defaults).build()); assertThat(e.getMessage(), containsString("are not valid NameID formats. Allowed values are")); assertThat(e.getMessage(), containsString(PERSISTENT)); } @@ -213,9 +227,10 @@ public class SamlIdentityProviderBuilderTests extends IdpSamlTestCase { .put(IDP_SSO_REDIRECT_ENDPOINT.getKey(), "not a url") .build(); final Environment env = TestEnvironment.newEnvironment(settings); - final SamlServiceProviderResolver resolver = Mockito.mock(SamlServiceProviderResolver.class); + final SamlServiceProviderResolver serviceResolver = Mockito.mock(SamlServiceProviderResolver.class); + final WildcardServiceProviderResolver wildcardResolver = Mockito.mock(WildcardServiceProviderResolver.class); IllegalArgumentException e = LuceneTestCase.expectThrows(IllegalArgumentException.class, - () -> SamlIdentityProvider.builder(resolver).fromSettings(env).build()); + () -> SamlIdentityProvider.builder(serviceResolver, wildcardResolver).fromSettings(env).build()); assertThat(e.getMessage(), containsString(IDP_SSO_REDIRECT_ENDPOINT.getKey())); assertThat(e.getMessage(), containsString("Not a valid URL")); } @@ -229,9 +244,10 @@ public class SamlIdentityProviderBuilderTests extends IdpSamlTestCase { .put(IDP_CONTACT_EMAIL.getKey(), "tony@starkindustries.com") .build(); final Environment env = TestEnvironment.newEnvironment(settings); - final SamlServiceProviderResolver resolver = Mockito.mock(SamlServiceProviderResolver.class); + final SamlServiceProviderResolver serviceResolver = Mockito.mock(SamlServiceProviderResolver.class); + final WildcardServiceProviderResolver wildcardResolver = Mockito.mock(WildcardServiceProviderResolver.class); IllegalArgumentException e = LuceneTestCase.expectThrows(IllegalArgumentException.class, - () -> SamlIdentityProvider.builder(resolver).fromSettings(env).build()); + () -> SamlIdentityProvider.builder(serviceResolver, wildcardResolver).fromSettings(env).build()); assertThat(e.getMessage(), containsString(IDP_SSO_REDIRECT_ENDPOINT.getKey())); assertThat(e.getMessage(), containsString("is required")); } @@ -246,9 +262,10 @@ public class SamlIdentityProviderBuilderTests extends IdpSamlTestCase { .put(IDP_ORGANIZATION_NAME.getKey(), "The Organization") .build(); final Environment env = TestEnvironment.newEnvironment(settings); - final SamlServiceProviderResolver resolver = Mockito.mock(SamlServiceProviderResolver.class); + final SamlServiceProviderResolver serviceResolver = Mockito.mock(SamlServiceProviderResolver.class); + final WildcardServiceProviderResolver wildcardResolver = Mockito.mock(WildcardServiceProviderResolver.class); IllegalArgumentException e = LuceneTestCase.expectThrows(IllegalArgumentException.class, - () -> SamlIdentityProvider.builder(resolver).fromSettings(env).build()); + () -> SamlIdentityProvider.builder(serviceResolver, wildcardResolver).fromSettings(env).build()); assertThat(e.getMessage(), containsString(IDP_ORGANIZATION_URL.getKey())); assertThat(e.getMessage(), containsString("Not a valid URL")); } diff --git a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/idp/SamlMetadataGeneratorTests.java b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/idp/SamlMetadataGeneratorTests.java index 9db6fafe5e1..9ad762dd6c1 100644 --- a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/idp/SamlMetadataGeneratorTests.java +++ b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/idp/SamlMetadataGeneratorTests.java @@ -48,7 +48,7 @@ public class SamlMetadataGeneratorTests extends IdpSamlTestCase { SamlFactory factory = new SamlFactory(); SamlMetadataGenerator generator = new SamlMetadataGenerator(factory, idp); PlainActionFuture future = new PlainActionFuture<>(); - generator.generateMetadata("https://sp.org", future); + generator.generateMetadata("https://sp.org", null, future); SamlMetadataResponse response = future.actionGet(); final String xml = response.getXmlString(); diff --git a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderResolverTests.java b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderResolverTests.java index 6f76a465eeb..01529b363f4 100644 --- a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderResolverTests.java +++ b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderResolverTests.java @@ -48,7 +48,7 @@ public class SamlServiceProviderResolverTests extends ESTestCase { index = mock(SamlServiceProviderIndex.class); identityProvider = mock(SamlIdentityProvider.class); serviceProviderDefaults = configureIdentityProviderDefaults(); - resolver = new SamlServiceProviderResolver(Settings.EMPTY, index, serviceProviderDefaults); + resolver = new SamlServiceProviderResolver(Settings.EMPTY, index, new SamlServiceProviderFactory(serviceProviderDefaults)); } public void testResolveWithoutCache() throws Exception { diff --git a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/WildcardServiceProviderResolverTests.java b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/WildcardServiceProviderResolverTests.java new file mode 100644 index 00000000000..314d4e951bc --- /dev/null +++ b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/WildcardServiceProviderResolverTests.java @@ -0,0 +1,183 @@ +/* + * 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.idp.saml.sp; + +import org.elasticsearch.common.collect.MapBuilder; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.script.ScriptModule; +import org.elasticsearch.script.ScriptService; +import org.elasticsearch.script.mustache.MustacheScriptEngine; +import org.elasticsearch.xpack.idp.saml.test.IdpSamlTestCase; +import org.joda.time.Duration; +import org.junit.Before; +import org.opensaml.saml.saml2.core.NameID; + +import java.io.IOException; +import java.util.Collections; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.sameInstance; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +public class WildcardServiceProviderResolverTests extends IdpSamlTestCase { + + private static final String SERVICES_JSON = "{" + + "\"services\": {" + + " \"service1a\": {" + + " \"entity_id\": \"https://(?\\\\w+)\\\\.example\\\\.com/\"," + + " \"acs\": \"https://(?\\\\w+)\\\\.service\\\\.example\\\\.com/saml2/acs\"," + + " \"tokens\": [ \"service\" ]," + + " \"template\": { " + + " \"name\": \"{{service}} at example.com (A)\"," + + " \"privileges\": {" + + " \"resource\": \"service1:example:{{service}}\"" + + " }," + + " \"attributes\": {" + + " \"principal\": \"http://cloud.elastic.co/saml/principal\"," + + " \"name\": \"http://cloud.elastic.co/saml/name\"," + + " \"email\": \"http://cloud.elastic.co/saml/email\"," + + " \"roles\": \"http://cloud.elastic.co/saml/roles\"" + + " }" + + " }" + + " }," + + " \"service1b\": {" + + " \"entity_id\": \"https://(?\\\\w+)\\\\.example\\\\.com/\"," + + " \"acs\": \"https://services\\\\.example\\\\.com/(?\\\\w+)/saml2/acs\"," + + " \"tokens\": [ \"service\" ]," + + " \"template\": { " + + " \"name\": \"{{service}} at example.com (B)\"," + + " \"privileges\": {" + + " \"resource\": \"service1:example:{{service}}\"" + + " }," + + " \"attributes\": {" + + " \"principal\": \"http://cloud.elastic.co/saml/principal\"," + + " \"name\": \"http://cloud.elastic.co/saml/name\"," + + " \"email\": \"http://cloud.elastic.co/saml/email\"," + + " \"roles\": \"http://cloud.elastic.co/saml/roles\"" + + " }" + + " }" + + " }," + + " \"service2\": {" + + " \"entity_id\": \"https://service-(?\\\\d+)\\\\.example\\\\.net/\"," + + " \"acs\": \"https://saml\\\\.example\\\\.net/(?\\\\d+)/acs\"," + + " \"tokens\": [ \"id\" ]," + + " \"template\": { " + + " \"name\": \"{{id}} at example.net\"," + + " \"privileges\": {" + + " \"resource\": \"service2:example:{{id}}\"" + + " }," + + " \"attributes\": {" + + " \"principal\": \"http://cloud.elastic.co/saml/principal\"," + + " \"name\": \"http://cloud.elastic.co/saml/name\"," + + " \"email\": \"http://cloud.elastic.co/saml/email\"," + + " \"roles\": \"http://cloud.elastic.co/saml/roles\"" + + " }" // attributes + + " }" // template + + " }" // service2 + + " }" // services + + "}"; // root object + private WildcardServiceProviderResolver resolver; + + @Before + public void setUpResolver() { + final Settings settings = Settings.EMPTY; + final ScriptService scriptService = new ScriptService(settings, + Collections.singletonMap(MustacheScriptEngine.NAME, new MustacheScriptEngine()), ScriptModule.CORE_CONTEXTS); + final ServiceProviderDefaults samlDefaults = new ServiceProviderDefaults("elastic-cloud", NameID.TRANSIENT, + Duration.standardMinutes(15)); + resolver = new WildcardServiceProviderResolver(settings, scriptService, new SamlServiceProviderFactory(samlDefaults)); + } + + public void testParsingOfServices() throws IOException { + loadJsonServices(); + assertThat(resolver.services().keySet(), containsInAnyOrder("service1a", "service1b", "service2")); + + final WildcardServiceProvider service1a = resolver.services().get("service1a"); + assertThat( + service1a.extractTokens("https://abcdef.example.com/", "https://abcdef.service.example.com/saml2/acs"), + equalTo(MapBuilder.newMapBuilder() + .put("service", "abcdef") + .put("entity_id", "https://abcdef.example.com/") + .put("acs", "https://abcdef.service.example.com/saml2/acs") + .map() + )); + expectThrows(IllegalArgumentException.class, () -> + service1a.extractTokens("https://abcdef.example.com/", "https://different.service.example.com/saml2/acs")); + assertThat(service1a.extractTokens("urn:foo:bar", "https://something.example.org/foo/bar"), nullValue()); + assertThat(service1a.extractTokens("https://xyzzy.example.com/", "https://services.example.com/xyzzy/saml2/acs"), nullValue()); + + final WildcardServiceProvider service1b = resolver.services().get("service1b"); + assertThat(service1b.extractTokens("https://xyzzy.example.com/", "https://services.example.com/xyzzy/saml2/acs"), + equalTo(MapBuilder.newMapBuilder() + .put("service", "xyzzy") + .put("entity_id", "https://xyzzy.example.com/") + .put("acs", "https://services.example.com/xyzzy/saml2/acs") + .map() + )); + assertThat(service1b.extractTokens("https://abcdef.example.com/", "https://abcdef.service.example.com/saml2/acs"), nullValue()); + expectThrows(IllegalArgumentException.class, () -> + service1b.extractTokens("https://abcdef.example.com/", "https://services.example.com/xyzzy/saml2/acs")); + assertThat(service1b.extractTokens("urn:foo:bar", "https://something.example.org/foo/bar"), nullValue()); + } + + public void testResolveServices() throws IOException { + loadJsonServices(); + + final SamlServiceProvider sp1 = resolver.resolve("https://abcdef.example.com/", "https://abcdef.service.example.com/saml2/acs"); + + assertThat(sp1, notNullValue()); + assertThat(sp1.getEntityId(), equalTo("https://abcdef.example.com/")); + assertThat(sp1.getAssertionConsumerService().toString(), equalTo("https://abcdef.service.example.com/saml2/acs")); + assertThat(sp1.getName(), equalTo("abcdef at example.com (A)")); + assertThat(sp1.getPrivileges().getResource(), equalTo("service1:example:abcdef")); + + final SamlServiceProvider sp2 = resolver.resolve("https://qwerty.example.com/", "https://qwerty.service.example.com/saml2/acs"); + assertThat(sp2, notNullValue()); + assertThat(sp2.getEntityId(), equalTo("https://qwerty.example.com/")); + assertThat(sp2.getAssertionConsumerService().toString(), equalTo("https://qwerty.service.example.com/saml2/acs")); + assertThat(sp2.getName(), equalTo("qwerty at example.com (A)")); + assertThat(sp2.getPrivileges().getResource(), equalTo("service1:example:qwerty")); + + final SamlServiceProvider sp3 = resolver.resolve("https://xyzzy.example.com/", "https://services.example.com/xyzzy/saml2/acs"); + assertThat(sp3, notNullValue()); + assertThat(sp3.getEntityId(), equalTo("https://xyzzy.example.com/")); + assertThat(sp3.getAssertionConsumerService().toString(), equalTo("https://services.example.com/xyzzy/saml2/acs")); + assertThat(sp3.getName(), equalTo("xyzzy at example.com (B)")); + assertThat(sp3.getPrivileges().getResource(), equalTo("service1:example:xyzzy")); + + final SamlServiceProvider sp4 = resolver.resolve("https://service-12345.example.net/", "https://saml.example.net/12345/acs"); + assertThat(sp4, notNullValue()); + assertThat(sp4.getEntityId(), equalTo("https://service-12345.example.net/")); + assertThat(sp4.getAssertionConsumerService().toString(), equalTo("https://saml.example.net/12345/acs")); + assertThat(sp4.getName(), equalTo("12345 at example.net")); + assertThat(sp4.getPrivileges().getResource(), equalTo("service2:example:12345")); + } + + public void testCaching() throws IOException { + loadJsonServices(); + + final String serviceName = randomAlphaOfLengthBetween(4, 12); + final String entityId = "https://" + serviceName + ".example.com/"; + final String acs = randomBoolean() + ? "https://" + serviceName + ".service.example.com/saml2/acs" + : "https://services.example.com/" + serviceName + "/saml2/acs"; + + final SamlServiceProvider original = resolver.resolve(entityId, acs); + for (int i = randomIntBetween(10, 20); i > 0; i--) { + final SamlServiceProvider cached = resolver.resolve(entityId, acs); + assertThat(cached, sameInstance(original)); + } + } + + private void loadJsonServices() throws IOException { + assertThat("Resolver has not been setup correctly", resolver, notNullValue()); + resolver.reload(createParser(XContentType.JSON.xContent(), SERVICES_JSON)); + } +} diff --git a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/test/IdpSamlTestCase.java b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/test/IdpSamlTestCase.java index d667be220ac..ab9e6d3e7b8 100644 --- a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/test/IdpSamlTestCase.java +++ b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/test/IdpSamlTestCase.java @@ -89,14 +89,15 @@ public abstract class IdpSamlTestCase extends ESTestCase { protected static void mockRegisteredServiceProvider(SamlIdentityProvider idp, String entityId, SamlServiceProvider sp) { Mockito.doAnswer(inv -> { final Object[] args = inv.getArguments(); - assertThat(args, Matchers.arrayWithSize(3)); + assertThat(args, Matchers.arrayWithSize(4)); assertThat(args[0], Matchers.equalTo(entityId)); - assertThat(args[args.length-1], Matchers.instanceOf(ActionListener.class)); - ActionListener listener = (ActionListener) args[args.length-1]; + assertThat(args[args.length - 1], Matchers.instanceOf(ActionListener.class)); + ActionListener listener = (ActionListener) args[args.length - 1]; listener.onResponse(sp); return null; - }).when(idp).getRegisteredServiceProvider(Mockito.eq(entityId), Mockito.anyBoolean(), Mockito.any(ActionListener.class)); + }).when(idp).resolveServiceProvider(Mockito.eq(entityId), Mockito.anyString(), Mockito.anyBoolean(), + Mockito.any(ActionListener.class)); } protected static void mockRegisteredServiceProvider(SamlServiceProviderResolver resolverMock, String entityId,