Răsfoiți Sursa

Add wildcard service providers to IdP (#54148)

This adds the ability for the IdP to define wildcard service
providers in a JSON file within the ES node's config directory.

If a request is made for a service provider that has not been
registered, then the set of wildcard services is consulted. If the
SP entity-id and ACS match one of the wildcard patterns, then a
dynamic service provider is defined from the associated mustache
template.
Tim Vernum 5 ani în urmă
părinte
comite
ef45e06027
32 a modificat fișierele cu 1165 adăugiri și 161 ștergeri
  1. 1 0
      x-pack/plugin/identity-provider/qa/idp-rest-tests/build.gradle
  2. 62 0
      x-pack/plugin/identity-provider/qa/idp-rest-tests/src/test/java/org/elasticsearch/xpack/idp/IdpRestTestCase.java
  3. 120 0
      x-pack/plugin/identity-provider/qa/idp-rest-tests/src/test/java/org/elasticsearch/xpack/idp/WildcardServiceProviderRestIT.java
  4. 45 0
      x-pack/plugin/identity-provider/qa/idp-rest-tests/src/test/resources/wildcard_services.json
  5. 17 8
      x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/IdentityProviderPlugin.java
  6. 23 7
      x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnRequest.java
  7. 21 2
      x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlMetadataRequest.java
  8. 10 1
      x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlValidateAuthnRequestResponse.java
  9. 7 3
      x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/TransportSamlInitiateSingleSignOnAction.java
  10. 1 2
      x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/TransportSamlMetadataAction.java
  11. 9 8
      x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/authn/SamlAuthnRequestValidator.java
  12. 34 11
      x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/SamlIdentityProvider.java
  13. 6 2
      x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/SamlIdentityProviderBuilder.java
  14. 2 2
      x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/SamlMetadataGenerator.java
  15. 1 0
      x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/rest/action/RestSamlInitiateSingleSignOnAction.java
  16. 2 1
      x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/rest/action/RestSamlMetadataAction.java
  17. 1 0
      x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/rest/action/RestSamlValidateAuthenticationRequestAction.java
  18. 7 3
      x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocument.java
  19. 76 0
      x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderFactory.java
  20. 6 75
      x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderResolver.java
  21. 39 0
      x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/ServiceProviderCacheSettings.java
  22. 198 0
      x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/WildcardServiceProvider.java
  23. 232 0
      x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/WildcardServiceProviderResolver.java
  24. 13 7
      x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/SamlIdentityProviderTests.java
  25. 6 2
      x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnRequestTests.java
  26. 7 5
      x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/TransportSamlInitiateSingleSignOnRequestTests.java
  27. 1 1
      x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/authn/SamlAuthnRequestValidatorTests.java
  28. 32 15
      x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/idp/SamlIdentityProviderBuilderTests.java
  29. 1 1
      x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/idp/SamlMetadataGeneratorTests.java
  30. 1 1
      x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderResolverTests.java
  31. 179 0
      x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/WildcardServiceProviderResolverTests.java
  32. 5 4
      x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/test/IdpSamlTestCase.java

+ 1 - 0
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"

+ 62 - 0
x-pack/plugin/identity-provider/qa/idp-rest-tests/src/test/java/org/elasticsearch/xpack/idp/IdpRestTestCase.java

@@ -5,15 +5,33 @@
  */
 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.List;
+import java.util.Map;
+
 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 +47,48 @@ public abstract class IdpRestTestCase extends ESRestTestCase {
             .put(ThreadContext.PREFIX + ".Authorization", token)
             .build();
     }
+
+    private RestHighLevelClient getHighLevelAdminClient() {
+        if (highLevelAdminClient == null) {
+            highLevelAdminClient = new RestHighLevelClient(
+                adminClient(),
+                ignore -> {
+                },
+                List.of()) {
+            };
+        }
+        return highLevelAdminClient;
+    }
+
+    protected User createUser(String username, SecureString password, String... roles) throws IOException {
+        final RestHighLevelClient client = getHighLevelAdminClient();
+        final User user = new User(username, List.of(roles), Map.of(), 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<String> clusterPrivileges, Collection<IndicesPrivileges> indicesPrivileges,
+                              Collection<ApplicationResourcePrivileges> 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);
+    }
 }

+ 120 - 0
x-pack/plugin/identity-provider/qa/idp-rest-tests/src/test/java/org/elasticsearch/xpack/idp/WildcardServiceProviderRestIT.java

@@ -0,0 +1,120 @@
+/*
+ * 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.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.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+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", List.of("sso:admin"), List.of("sso:" + entityId)
+        );
+        createRole(roleName, List.of(), List.of(), List.of(applicationPrivilege));
+
+        final String samlResponse = initSso(entityId, acs, new UsernamePasswordToken(username, password));
+
+        for (String attr : List.of("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<String, Object> 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(Map.of("entity_id", entityId, "acs", acs)));
+        request.setOptions(request.getOptions().toBuilder().addHeader("es-secondary-authorization",
+            UsernamePasswordToken.basicAuthHeaderValue(secondaryAuth.principal(), secondaryAuth.credentials())));
+        Response response = client().performRequest(request);
+
+        final Map<String, Object> 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<String, Object> body) throws IOException {
+        try (XContentBuilder builder = XContentBuilder.builder(XContentType.JSON.xContent()).map(body)) {
+            return BytesReference.bytes(builder).utf8ToString();
+        }
+    }
+
+    private String encode(String param) {
+        return URLEncoder.encode(param, StandardCharsets.UTF_8);
+    }
+
+}

+ 45 - 0
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:(?<owner>\\w+):(?<service>\\w+)",
+      "acs": "https://(?<service>\\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:(?<owner>\\w+):(?<service>\\w+)",
+      "acs": "https://(?<service>\\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"
+        }
+      }
+    }
+  }
+}
+

+ 17 - 8
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.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.support.SamlFactory;
-import org.elasticsearch.xpack.idp.saml.support.SamlInit;
-import org.elasticsearch.xpack.idp.saml.rest.action.RestPutSamlServiceProviderAction;
+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.Collection;
@@ -95,8 +98,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();
@@ -147,7 +154,9 @@ public class IdentityProviderPlugin extends Plugin implements ActionPlugin {
         List<Setting<?>> 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);

+ 23 - 7
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 + "'}";
     }
+
 }

+ 21 - 2
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;
+    }
 }

+ 10 - 1
x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlValidateAuthnRequestResponse.java

@@ -16,18 +16,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<String, Object> 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<String, Object> authnState) {
+    public SamlValidateAuthnRequestResponse(String spEntityId, String acs, boolean forceAuthn, Map<String, Object> 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 = Map.copyOf(Objects.requireNonNull(authnState));
     }
@@ -36,6 +39,10 @@ public class SamlValidateAuthnRequestResponse extends ActionResponse {
         return spEntityId;
     }
 
+    public String getAssertionConsumerService() {
+        return assertionConsumerService;
+    }
+
     public boolean isForceAuthn() {
         return forceAuthn;
     }
@@ -47,6 +54,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);
     }
@@ -54,6 +62,7 @@ public class SamlValidateAuthnRequestResponse extends ActionResponse {
     @Override
     public String toString() {
         return getClass().getSimpleName() + "{ spEntityId='" + getSpEntityId() + "',\n" +
+            " acs='" + getAssertionConsumerService() + "',\n" +
             " forceAuthn='" + isForceAuthn() + "',\n" +
             " authnState='" + getAuthnState() + "' }";
     }

+ 7 - 3
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<SamlInitiateSingleSignOnResponse> 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);

+ 1 - 2
x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/TransportSamlMetadataAction.java

@@ -30,8 +30,7 @@ public class TransportSamlMetadataAction extends HandledTransportAction<SamlMeta
 
     @Override
     protected void doExecute(Task task, SamlMetadataRequest request, ActionListener<SamlMetadataResponse> listener) {
-        final String spEntityId = request.getSpEntityId();
         final SamlMetadataGenerator generator = new SamlMetadataGenerator(samlFactory, identityProvider);
-        generator.generateMetadata(spEntityId, listener);
+        generator.generateMetadata(request.getSpEntityId(), request.getAssertionConsumerService(), listener);
     }
 }

+ 9 - 8
x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/authn/SamlAuthnRequestValidator.java

@@ -99,7 +99,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);
@@ -177,11 +177,11 @@ public class SamlAuthnRequestValidator {
         }
         final Map<String, Object> 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));
@@ -226,17 +226,17 @@ public class SamlAuthnRequestValidator {
         });
     }
 
-    private void getSpFromIssuer(Issuer issuer, ActionListener<SamlServiceProvider> listener) {
+    private void getSpFromAuthnRequest(Issuer issuer, String acs, ActionListener<SamlServiceProvider> 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);
             },
@@ -253,7 +253,7 @@ public class SamlAuthnRequestValidator {
         }
     }
 
-    private void checkAcs(AuthnRequest request, SamlServiceProvider sp, Map<String, Object> authnState) {
+    private String checkAcs(AuthnRequest request, SamlServiceProvider sp, Map<String, Object> authnState) {
         final String acs = request.getAssertionConsumerServiceURL();
         if (Strings.hasText(acs) == false) {
             final String message = request.getAssertionConsumerServiceIndex() == null ?
@@ -267,6 +267,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) {

+ 34 - 11
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<String, URL> ssoEndpoints, Map<String, URL> sloEndpoints, Set<String> 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<SamlServiceProvider> listener) {
+    public void resolveServiceProvider(String spEntityId, @Nullable String acs, boolean allowDisabled,
+                                       ActionListener<SamlServiceProvider> 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<SamlServiceProvider> 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;

+ 6 - 2
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;
@@ -79,6 +80,7 @@ public class SamlIdentityProviderBuilder {
     public static final Setting<String> 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<String, URL> ssoEndpoints;
@@ -90,8 +92,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<>();
     }
@@ -141,7 +144,8 @@ public class SamlIdentityProviderBuilder {
             signingCredential, metadataSigningCredential,
             technicalContact, organization,
             serviceProviderDefaults,
-            serviceProviderResolver);
+            serviceProviderResolver,
+            wildcardServiceResolver);
     }
 
     public SamlIdentityProviderBuilder fromSettings(Environment env) {

+ 2 - 2
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<SamlMetadataResponse> listener) {
-        idp.getRegisteredServiceProvider(spEntityId, true, ActionListener.wrap(
+    public void generateMetadata(String spEntityId, String acs, ActionListener<SamlMetadataResponse> listener) {
+        idp.resolveServiceProvider(spEntityId, acs, true, ActionListener.wrap(
             sp -> {
                 try {
                     if (null == sp) {

+ 1 - 0
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"));
     }

+ 2 - 1
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<SamlMetadataResponse>(channel) {
                 @Override

+ 1 - 0
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());

+ 7 - 3
x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderDocument.java

@@ -227,7 +227,7 @@ public class SamlServiceProviderDocument implements ToXContentObject, Writeable
     public String entityId;
 
     public String acs;
-    
+
     @Nullable
     public String nameIdFormat;
 
@@ -325,6 +325,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);
     }
@@ -374,8 +378,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<SamlServiceProviderDocument, SamlServiceProviderDocument> DOC_PARSER

+ 76 - 0
x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderFactory.java

@@ -0,0 +1,76 @@
+/*
+ * 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.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<X509Credential> credentials = document.certificates.getServiceProviderX509SigningCertificates()
+            .stream()
+            .map(BasicX509Credential::new)
+            .collect(Collectors.toUnmodifiableSet());
+
+        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<String, String> roles = Optional.ofNullable(configuredPrivileges.roleActions).orElse(Map.of());
+        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;
+    }
+}

+ 6 - 75
x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderResolver.java

@@ -8,46 +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.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<Integer> CACHE_SIZE
-        = Setting.intSetting("xpack.idp.sp.cache.size", CACHE_SIZE_DEFAULT, Setting.Property.NodeScope);
-    public static final Setting<TimeValue> CACHE_TTL
-        = Setting.timeSetting("xpack.idp.sp.cache.ttl", CACHE_TTL_DEFAULT, Setting.Property.NodeScope);
-
     private final Cache<String, CachedServiceProvider> cache;
     private final SamlServiceProviderIndex index;
-    private final ServiceProviderDefaults defaults;
+    private final SamlServiceProviderFactory serviceProviderFactory;
 
-    public SamlServiceProviderResolver(Settings settings, SamlServiceProviderIndex index, ServiceProviderDefaults defaults) {
-        this.cache = CacheBuilder.<String, CachedServiceProvider>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;
     }
 
     /**
@@ -75,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<SamlServiceProvider> 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<X509Credential> credentials = document.certificates.getServiceProviderX509SigningCertificates()
-            .stream()
-            .map(BasicX509Credential::new)
-            .collect(Collectors.toUnmodifiableSet());
-
-        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<String, String> roles = Optional.ofNullable(configuredPrivileges.roleActions).orElse(Map.of());
-        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;

+ 39 - 0
x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/ServiceProviderCacheSettings.java

@@ -0,0 +1,39 @@
+/*
+ * 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.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<Integer> CACHE_SIZE
+        = Setting.intSetting("xpack.idp.sp.cache.size", CACHE_SIZE_DEFAULT, Setting.Property.NodeScope);
+    public static final Setting<TimeValue> CACHE_TTL
+        = Setting.timeSetting("xpack.idp.sp.cache.ttl", CACHE_TTL_DEFAULT, Setting.Property.NodeScope);
+
+    static <K, V> Cache<K, V> buildCache(Settings settings) {
+        return CacheBuilder.<K, V>builder()
+            .setMaximumWeight(CACHE_SIZE.get(settings))
+            .setExpireAfterAccess(CACHE_TTL.get(settings))
+            .build();
+    }
+
+    public static List<Setting<?>> getSettings() {
+        return List.of(CACHE_SIZE, CACHE_TTL);
+    }
+}

+ 198 - 0
x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/WildcardServiceProvider.java

@@ -0,0 +1,198 @@
+/*
+ * 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.HashMap;
+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<WildcardServiceProvider, Void> PARSER = new ConstructingObjectParser<>(
+        "wildcard_service",
+        args -> {
+            final String entityId = (String) args[0];
+            final String acs = (String) args[1];
+            final Collection<String> tokens = (Collection<String>) args[2];
+            final Map<String, Object> definition = (Map<String, Object>) 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<String> tokens;
+    private final BytesReference serviceTemplate;
+
+    private WildcardServiceProvider(Pattern matchEntityId, Pattern matchAcs, Set<String> 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<String> tokens, Map<String, Object> serviceTemplate) {
+        this(Pattern.compile(Objects.requireNonNull(matchEntityId, "EntityID to match cannot be null")),
+            Pattern.compile(Objects.requireNonNull(matchAcs, "ACS to match cannot be null")),
+            Set.copyOf(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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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");
+    }
+
+}

+ 232 - 0
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.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+public class WildcardServiceProviderResolver {
+
+    public static final Setting<String> FILE_PATH_SETTING = Setting.simpleString("xpack.idp.sp.wildcard.path",
+        "wildcard_services.json", Setting.Property.NodeScope);
+
+    private class State {
+        final Map<String, WildcardServiceProvider> services;
+        final Cache<Tuple<String, String>, SamlServiceProvider> cache;
+
+        private State(Map<String, WildcardServiceProvider> 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<State> stateRef;
+
+    WildcardServiceProviderResolver(Settings settings, ScriptService scriptService, SamlServiceProviderFactory serviceProviderFactory) {
+        this.settings = settings;
+        this.scriptService = scriptService;
+        this.serviceProviderFactory = serviceProviderFactory;
+        this.stateRef = new AtomicReference<>(new State(Map.of()));
+    }
+
+    /**
+     * 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<String, String> 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<String, SamlServiceProvider> 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<String, WildcardServiceProvider> services() {
+        return stateRef.get().services;
+    }
+
+    // Accessible for testing
+    void reload(XContentParser parser) throws IOException {
+        final Map<String, WildcardServiceProvider> newServices = Map.copyOf(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<String, WildcardServiceProvider> 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<String, WildcardServiceProvider> 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<? extends Setting<?>> getSettings() {
+        return List.of(FILE_PATH_SETTING);
+    }
+
+    public interface Fields {
+        ParseField SERVICES = new ParseField("services");
+    }
+
+}

+ 13 - 7
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.action.CreateApiKeyResponse;
 import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken;
 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<String, String> 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<String, String> 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()));
     }
 

+ 6 - 2
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"));
     }
 }

+ 7 - 5
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;
@@ -89,7 +90,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 {
@@ -124,7 +125,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,
@@ -138,13 +140,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)

+ 1 - 1
x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/authn/SamlAuthnRequestValidatorTests.java

@@ -195,7 +195,7 @@ public class SamlAuthnRequestValidatorTests extends IdpSamlTestCase {
         PlainActionFuture<SamlValidateAuthnRequestResponse> 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"));
     }
 

+ 32 - 15
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"));
     }

+ 1 - 1
x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/idp/SamlMetadataGeneratorTests.java

@@ -47,7 +47,7 @@ public class SamlMetadataGeneratorTests extends IdpSamlTestCase {
         SamlFactory factory = new SamlFactory();
         SamlMetadataGenerator generator = new SamlMetadataGenerator(factory, idp);
         PlainActionFuture<SamlMetadataResponse> 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();
 

+ 1 - 1
x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/SamlServiceProviderResolverTests.java

@@ -45,7 +45,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 {

+ 179 - 0
x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/saml/sp/WildcardServiceProviderResolverTests.java

@@ -0,0 +1,179 @@
+/*
+ * 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.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 java.util.Map;
+
+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://(?<service>\\\\w+)\\\\.example\\\\.com/\","
+        + "      \"acs\": \"https://(?<service>\\\\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://(?<service>\\\\w+)\\\\.example\\\\.com/\","
+        + "      \"acs\": \"https://services\\\\.example\\\\.com/(?<service>\\\\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-(?<id>\\\\d+)\\\\.example\\\\.net/\","
+        + "      \"acs\": \"https://saml\\\\.example\\\\.net/(?<id>\\\\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(Map.ofEntries(
+                Map.entry("service", "abcdef"),
+                Map.entry("entity_id", "https://abcdef.example.com/"),
+                Map.entry("acs", "https://abcdef.service.example.com/saml2/acs"))));
+        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(Map.ofEntries(
+                Map.entry("service", "xyzzy"),
+                Map.entry("entity_id", "https://xyzzy.example.com/"),
+                Map.entry("acs", "https://services.example.com/xyzzy/saml2/acs"))));
+        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));
+    }
+}

+ 5 - 4
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<SamlServiceProvider> listener = (ActionListener<SamlServiceProvider>) args[args.length-1];
+            assertThat(args[args.length - 1], Matchers.instanceOf(ActionListener.class));
+            ActionListener<SamlServiceProvider> listener = (ActionListener<SamlServiceProvider>) 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,