Add wildcard service providers to IdP (#54477)
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. Backport of: #54148
This commit is contained in:
parent
915435bbe4
commit
a0853628cd
|
@ -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"
|
||||
|
|
|
@ -5,15 +5,34 @@
|
|||
*/
|
||||
package org.elasticsearch.xpack.idp;
|
||||
|
||||
import org.elasticsearch.client.RequestOptions;
|
||||
import org.elasticsearch.client.RestHighLevelClient;
|
||||
import org.elasticsearch.client.security.DeleteRoleRequest;
|
||||
import org.elasticsearch.client.security.DeleteUserRequest;
|
||||
import org.elasticsearch.client.security.PutRoleRequest;
|
||||
import org.elasticsearch.client.security.PutUserRequest;
|
||||
import org.elasticsearch.client.security.RefreshPolicy;
|
||||
import org.elasticsearch.client.security.user.User;
|
||||
import org.elasticsearch.client.security.user.privileges.ApplicationResourcePrivileges;
|
||||
import org.elasticsearch.client.security.user.privileges.IndicesPrivileges;
|
||||
import org.elasticsearch.client.security.user.privileges.Role;
|
||||
import org.elasticsearch.common.settings.SecureString;
|
||||
import org.elasticsearch.common.settings.Settings;
|
||||
import org.elasticsearch.common.util.concurrent.ThreadContext;
|
||||
import org.elasticsearch.test.rest.ESRestTestCase;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
|
||||
import static java.util.Arrays.asList;
|
||||
import static java.util.Collections.emptyMap;
|
||||
import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue;
|
||||
|
||||
public abstract class IdpRestTestCase extends ESRestTestCase {
|
||||
|
||||
private RestHighLevelClient highLevelAdminClient;
|
||||
|
||||
@Override
|
||||
protected Settings restAdminSettings() {
|
||||
String token = basicAuthHeaderValue("admin_user", new SecureString("admin-password".toCharArray()));
|
||||
|
@ -29,4 +48,48 @@ public abstract class IdpRestTestCase extends ESRestTestCase {
|
|||
.put(ThreadContext.PREFIX + ".Authorization", token)
|
||||
.build();
|
||||
}
|
||||
|
||||
private RestHighLevelClient getHighLevelAdminClient() {
|
||||
if (highLevelAdminClient == null) {
|
||||
highLevelAdminClient = new RestHighLevelClient(
|
||||
adminClient(),
|
||||
ignore -> {
|
||||
},
|
||||
Collections.emptyList()) {
|
||||
};
|
||||
}
|
||||
return highLevelAdminClient;
|
||||
}
|
||||
|
||||
protected User createUser(String username, SecureString password, String... roles) throws IOException {
|
||||
final RestHighLevelClient client = getHighLevelAdminClient();
|
||||
final User user = new User(username, asList(roles), emptyMap(), username + " in " + getTestName(), username + "@test.example.com");
|
||||
final PutUserRequest request = PutUserRequest.withPassword(user, password.getChars(), true, RefreshPolicy.WAIT_UNTIL);
|
||||
client.security().putUser(request, RequestOptions.DEFAULT);
|
||||
return user;
|
||||
}
|
||||
|
||||
protected void deleteUser(String username) throws IOException {
|
||||
final RestHighLevelClient client = getHighLevelAdminClient();
|
||||
final DeleteUserRequest request = new DeleteUserRequest(username, RefreshPolicy.WAIT_UNTIL);
|
||||
client.security().deleteUser(request, RequestOptions.DEFAULT);
|
||||
}
|
||||
|
||||
protected void createRole(String name, Collection<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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
package org.elasticsearch.xpack.idp;
|
||||
|
||||
import org.elasticsearch.client.Request;
|
||||
import org.elasticsearch.client.Response;
|
||||
import org.elasticsearch.client.security.user.User;
|
||||
import org.elasticsearch.client.security.user.privileges.ApplicationResourcePrivileges;
|
||||
import org.elasticsearch.common.bytes.BytesReference;
|
||||
import org.elasticsearch.common.collect.MapBuilder;
|
||||
import org.elasticsearch.common.settings.SecureString;
|
||||
import org.elasticsearch.common.xcontent.XContentBuilder;
|
||||
import org.elasticsearch.common.xcontent.XContentType;
|
||||
import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.hamcrest.Matchers.containsInAnyOrder;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.hamcrest.Matchers.instanceOf;
|
||||
import static org.hamcrest.Matchers.notNullValue;
|
||||
|
||||
public class WildcardServiceProviderRestIT extends IdpRestTestCase {
|
||||
|
||||
// From build.gradle
|
||||
private final String IDP_ENTITY_ID = "https://idp.test.es.elasticsearch.org/";
|
||||
// From SAMLConstants
|
||||
private final String REDIRECT_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";
|
||||
|
||||
public void testGetWildcardServiceProviderMetadata() throws Exception {
|
||||
final String owner = randomAlphaOfLength(8);
|
||||
final String service = randomAlphaOfLength(8);
|
||||
// From "wildcard_services.json"
|
||||
final String entityId = "service:" + owner + ":" + service;
|
||||
final String acs = "https://" + service + ".services.example.com/saml/acs";
|
||||
getMetaData(entityId, acs);
|
||||
}
|
||||
|
||||
public void testInitSingleSignOnToWildcardServiceProvider() throws Exception {
|
||||
final String owner = randomAlphaOfLength(8);
|
||||
final String service = randomAlphaOfLength(8);
|
||||
// From "wildcard_services.json"
|
||||
final String entityId = "service:" + owner + ":" + service;
|
||||
final String acs = "https://" + service + ".services.example.com/api/v1/saml";
|
||||
|
||||
final String username = randomAlphaOfLength(6);
|
||||
final SecureString password = new SecureString((randomAlphaOfLength(6) + randomIntBetween(10, 99)).toCharArray());
|
||||
final String roleName = username + "_role";
|
||||
final User user = createUser(username, password, roleName);
|
||||
|
||||
final ApplicationResourcePrivileges applicationPrivilege = new ApplicationResourcePrivileges(
|
||||
"elastic-cloud", Collections.singletonList("sso:admin"), Collections.singletonList("sso:" + entityId)
|
||||
);
|
||||
createRole(roleName, Collections.emptyList(), Collections.emptyList(), Collections.singletonList(applicationPrivilege));
|
||||
|
||||
final String samlResponse = initSso(entityId, acs, new UsernamePasswordToken(username, password));
|
||||
|
||||
for (String attr : Arrays.asList("principal", "email", "name", "roles")) {
|
||||
assertThat(samlResponse, containsString("Name=\"saml:attribute:" + attr + "\""));
|
||||
assertThat(samlResponse, containsString("FriendlyName=\"" + attr + "\""));
|
||||
}
|
||||
|
||||
assertThat(samlResponse, containsString(user.getUsername()));
|
||||
assertThat(samlResponse, containsString(user.getEmail()));
|
||||
assertThat(samlResponse, containsString(user.getFullName()));
|
||||
assertThat(samlResponse, containsString(">admin<"));
|
||||
|
||||
deleteUser(username);
|
||||
deleteRole(roleName);
|
||||
}
|
||||
|
||||
private void getMetaData(String entityId, String acs) throws IOException {
|
||||
final Map<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(MapBuilder.<String, Object>newMapBuilder().put("entity_id", entityId).put("acs", acs).map()));
|
||||
request.setOptions(request.getOptions().toBuilder().addHeader("es-secondary-authorization",
|
||||
UsernamePasswordToken.basicAuthHeaderValue(secondaryAuth.principal(), secondaryAuth.credentials())));
|
||||
Response response = client().performRequest(request);
|
||||
|
||||
final Map<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) throws UnsupportedEncodingException {
|
||||
return URLEncoder.encode(param, "UTF-8");
|
||||
}
|
||||
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -45,18 +45,21 @@ import org.elasticsearch.xpack.idp.action.TransportSamlInitiateSingleSignOnActio
|
|||
import org.elasticsearch.xpack.idp.action.TransportSamlMetadataAction;
|
||||
import org.elasticsearch.xpack.idp.action.TransportSamlValidateAuthnRequestAction;
|
||||
import org.elasticsearch.xpack.idp.privileges.UserPrivilegeResolver;
|
||||
import org.elasticsearch.xpack.idp.saml.rest.action.RestDeleteSamlServiceProviderAction;
|
||||
import org.elasticsearch.xpack.idp.saml.idp.SamlIdentityProvider;
|
||||
import org.elasticsearch.xpack.idp.saml.sp.ServiceProviderDefaults;
|
||||
import org.elasticsearch.xpack.idp.saml.idp.SamlIdentityProviderBuilder;
|
||||
import org.elasticsearch.xpack.idp.saml.rest.action.RestSamlMetadataAction;
|
||||
import org.elasticsearch.xpack.idp.saml.rest.action.RestSamlInitiateSingleSignOnAction;
|
||||
import org.elasticsearch.xpack.idp.saml.rest.action.RestSamlValidateAuthenticationRequestAction;
|
||||
import org.elasticsearch.xpack.idp.saml.support.SamlFactory;
|
||||
import org.elasticsearch.xpack.idp.saml.support.SamlInit;
|
||||
import org.elasticsearch.xpack.idp.saml.rest.action.RestDeleteSamlServiceProviderAction;
|
||||
import org.elasticsearch.xpack.idp.saml.rest.action.RestPutSamlServiceProviderAction;
|
||||
import org.elasticsearch.xpack.idp.saml.rest.action.RestSamlInitiateSingleSignOnAction;
|
||||
import org.elasticsearch.xpack.idp.saml.rest.action.RestSamlMetadataAction;
|
||||
import org.elasticsearch.xpack.idp.saml.rest.action.RestSamlValidateAuthenticationRequestAction;
|
||||
import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderFactory;
|
||||
import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderIndex;
|
||||
import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderResolver;
|
||||
import org.elasticsearch.xpack.idp.saml.sp.ServiceProviderCacheSettings;
|
||||
import org.elasticsearch.xpack.idp.saml.sp.ServiceProviderDefaults;
|
||||
import org.elasticsearch.xpack.idp.saml.sp.WildcardServiceProviderResolver;
|
||||
import org.elasticsearch.xpack.idp.saml.support.SamlFactory;
|
||||
import org.elasticsearch.xpack.idp.saml.support.SamlInit;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
|
@ -96,8 +99,12 @@ public class IdentityProviderPlugin extends Plugin implements ActionPlugin {
|
|||
|
||||
|
||||
final ServiceProviderDefaults serviceProviderDefaults = ServiceProviderDefaults.forSettings(settings);
|
||||
final SamlServiceProviderResolver resolver = new SamlServiceProviderResolver(settings, index, serviceProviderDefaults);
|
||||
final SamlIdentityProvider idp = SamlIdentityProvider.builder(resolver)
|
||||
final SamlServiceProviderFactory serviceProviderFactory = new SamlServiceProviderFactory(serviceProviderDefaults);
|
||||
final SamlServiceProviderResolver registeredServiceProviderResolver
|
||||
= new SamlServiceProviderResolver(settings, index, serviceProviderFactory);
|
||||
final WildcardServiceProviderResolver wildcardServiceProviderResolver
|
||||
= WildcardServiceProviderResolver.create(environment, resourceWatcherService, scriptService, serviceProviderFactory);
|
||||
final SamlIdentityProvider idp = SamlIdentityProvider.builder(registeredServiceProviderResolver, wildcardServiceProviderResolver)
|
||||
.fromSettings(environment)
|
||||
.serviceProviderDefaults(serviceProviderDefaults)
|
||||
.build();
|
||||
|
@ -148,7 +155,9 @@ public class IdentityProviderPlugin extends Plugin implements ActionPlugin {
|
|||
List<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);
|
||||
|
|
|
@ -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 + "'}";
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,18 +17,21 @@ import java.util.Objects;
|
|||
public class SamlValidateAuthnRequestResponse extends ActionResponse {
|
||||
|
||||
private final String spEntityId;
|
||||
private final String assertionConsumerService;
|
||||
private final boolean forceAuthn;
|
||||
private final Map<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 = Collections.unmodifiableMap(Objects.requireNonNull(authnState));
|
||||
}
|
||||
|
@ -37,6 +40,10 @@ public class SamlValidateAuthnRequestResponse extends ActionResponse {
|
|||
return spEntityId;
|
||||
}
|
||||
|
||||
public String getAssertionConsumerService() {
|
||||
return assertionConsumerService;
|
||||
}
|
||||
|
||||
public boolean isForceAuthn() {
|
||||
return forceAuthn;
|
||||
}
|
||||
|
@ -48,6 +55,7 @@ public class SamlValidateAuthnRequestResponse extends ActionResponse {
|
|||
@Override
|
||||
public void writeTo(StreamOutput out) throws IOException {
|
||||
out.writeString(spEntityId);
|
||||
out.writeString(assertionConsumerService);
|
||||
out.writeBoolean(forceAuthn);
|
||||
out.writeMap(authnState);
|
||||
}
|
||||
|
@ -55,6 +63,7 @@ public class SamlValidateAuthnRequestResponse extends ActionResponse {
|
|||
@Override
|
||||
public String toString() {
|
||||
return getClass().getSimpleName() + "{ spEntityId='" + getSpEntityId() + "',\n" +
|
||||
" acs='" + getAssertionConsumerService() + "',\n" +
|
||||
" forceAuthn='" + isForceAuthn() + "',\n" +
|
||||
" authnState='" + getAuthnState() + "' }";
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -101,7 +101,7 @@ public class SamlAuthnRequestValidator {
|
|||
return;
|
||||
}
|
||||
final AuthnRequest authnRequest = samlFactory.buildXmlObject(root, AuthnRequest.class);
|
||||
getSpFromIssuer(authnRequest.getIssuer(), ActionListener.wrap(
|
||||
getSpFromAuthnRequest(authnRequest.getIssuer(), authnRequest.getAssertionConsumerServiceURL(), ActionListener.wrap(
|
||||
sp -> {
|
||||
try {
|
||||
validateAuthnRequest(authnRequest, sp, parsedQueryString, listener);
|
||||
|
@ -179,11 +179,11 @@ public class SamlAuthnRequestValidator {
|
|||
}
|
||||
final Map<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));
|
||||
|
@ -228,17 +228,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);
|
||||
},
|
||||
|
@ -255,7 +255,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 ?
|
||||
|
@ -269,6 +269,7 @@ public class SamlAuthnRequestValidator {
|
|||
"request contained [{}]", RestStatus.BAD_REQUEST, sp.getAssertionConsumerService(), acs);
|
||||
}
|
||||
authnState.put(SamlAuthenticationState.Fields.ACS_URL.getPreferredName(), acs);
|
||||
return acs;
|
||||
}
|
||||
|
||||
protected Element parseSamlMessage(byte[] content) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -16,6 +16,7 @@ import org.elasticsearch.xpack.core.ssl.CertParsingUtils;
|
|||
import org.elasticsearch.xpack.core.ssl.X509KeyPairSettings;
|
||||
import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderResolver;
|
||||
import org.elasticsearch.xpack.idp.saml.sp.ServiceProviderDefaults;
|
||||
import org.elasticsearch.xpack.idp.saml.sp.WildcardServiceProviderResolver;
|
||||
import org.opensaml.saml.saml2.metadata.ContactPersonTypeEnumeration;
|
||||
import org.opensaml.security.x509.X509Credential;
|
||||
import org.opensaml.security.x509.impl.X509KeyManagerX509CredentialAdapter;
|
||||
|
@ -80,6 +81,7 @@ public class SamlIdentityProviderBuilder {
|
|||
public static final Setting<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;
|
||||
|
@ -91,8 +93,9 @@ public class SamlIdentityProviderBuilder {
|
|||
private SamlIdentityProvider.OrganizationInfo organization;
|
||||
private ServiceProviderDefaults serviceProviderDefaults;
|
||||
|
||||
SamlIdentityProviderBuilder(SamlServiceProviderResolver serviceProviderResolver) {
|
||||
SamlIdentityProviderBuilder(SamlServiceProviderResolver serviceProviderResolver, WildcardServiceProviderResolver wildcardResolver) {
|
||||
this.serviceProviderResolver = serviceProviderResolver;
|
||||
this.wildcardServiceResolver = wildcardResolver;
|
||||
this.ssoEndpoints = new HashMap<>();
|
||||
this.sloEndpoints = new HashMap<>();
|
||||
}
|
||||
|
@ -142,7 +145,8 @@ public class SamlIdentityProviderBuilder {
|
|||
signingCredential, metadataSigningCredential,
|
||||
technicalContact, organization,
|
||||
serviceProviderDefaults,
|
||||
serviceProviderResolver);
|
||||
serviceProviderResolver,
|
||||
wildcardServiceResolver);
|
||||
}
|
||||
|
||||
public SamlIdentityProviderBuilder fromSettings(Environment env) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -333,6 +333,10 @@ public class SamlServiceProviderDocument implements ToXContentObject, Writeable
|
|||
this.created = created;
|
||||
}
|
||||
|
||||
public void setLastModified(Instant lastModified) {
|
||||
this.lastModified = lastModified;
|
||||
}
|
||||
|
||||
public void setCreatedMillis(Long millis) {
|
||||
this.created = Instant.ofEpochMilli(millis);
|
||||
}
|
||||
|
@ -383,8 +387,8 @@ public class SamlServiceProviderDocument implements ToXContentObject, Writeable
|
|||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(docId, name, entityId, acs, enabled, created, lastModified, nameIdFormat, authenticationExpiryMillis,
|
||||
certificates, privileges, attributeNames);
|
||||
return Objects.hash(docId, name, entityId, acs, enabled, created, lastModified, nameIdFormat,
|
||||
authenticationExpiryMillis, certificates, privileges, attributeNames);
|
||||
}
|
||||
|
||||
private static final ObjectParser<SamlServiceProviderDocument, SamlServiceProviderDocument> DOC_PARSER
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
package org.elasticsearch.xpack.idp.saml.sp;
|
||||
|
||||
import org.elasticsearch.xpack.idp.privileges.ServiceProviderPrivileges;
|
||||
import org.joda.time.ReadableDuration;
|
||||
import org.opensaml.security.x509.BasicX509Credential;
|
||||
import org.opensaml.security.x509.X509Credential;
|
||||
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* A class for creating a {@link SamlServiceProvider} from a {@link SamlServiceProviderDocument}.
|
||||
*/
|
||||
public final class SamlServiceProviderFactory {
|
||||
|
||||
private final ServiceProviderDefaults defaults;
|
||||
|
||||
public SamlServiceProviderFactory(ServiceProviderDefaults defaults) {
|
||||
this.defaults = defaults;
|
||||
}
|
||||
|
||||
SamlServiceProvider buildServiceProvider(SamlServiceProviderDocument document) {
|
||||
final ServiceProviderPrivileges privileges = buildPrivileges(document.privileges);
|
||||
final SamlServiceProvider.AttributeNames attributes = new SamlServiceProvider.AttributeNames(
|
||||
document.attributeNames.principal, document.attributeNames.name, document.attributeNames.email, document.attributeNames.roles
|
||||
);
|
||||
final Set<X509Credential> credentials = document.certificates.getServiceProviderX509SigningCertificates()
|
||||
.stream()
|
||||
.map(BasicX509Credential::new)
|
||||
.collect(Collectors.collectingAndThen(Collectors.toSet(), Collections::unmodifiableSet));
|
||||
|
||||
final URL acs = parseUrl(document);
|
||||
String nameIdFormat = document.nameIdFormat;
|
||||
if (nameIdFormat == null) {
|
||||
nameIdFormat = defaults.nameIdFormat;
|
||||
}
|
||||
|
||||
final ReadableDuration authnExpiry = Optional.ofNullable(document.getAuthenticationExpiry())
|
||||
.orElse(defaults.authenticationExpiry);
|
||||
|
||||
final boolean signAuthnRequests = document.signMessages.contains(SamlServiceProviderDocument.SIGN_AUTHN);
|
||||
final boolean signLogoutRequests = document.signMessages.contains(SamlServiceProviderDocument.SIGN_LOGOUT);
|
||||
|
||||
return new CloudServiceProvider(document.entityId, document.name, document.enabled, acs, nameIdFormat, authnExpiry,
|
||||
privileges, attributes, credentials, signAuthnRequests, signLogoutRequests);
|
||||
}
|
||||
|
||||
private ServiceProviderPrivileges buildPrivileges(SamlServiceProviderDocument.Privileges configuredPrivileges) {
|
||||
final String resource = configuredPrivileges.resource;
|
||||
final Map<String, String> roles = Optional.ofNullable(configuredPrivileges.roleActions).orElse(Collections.emptyMap());
|
||||
return new ServiceProviderPrivileges(defaults.applicationName, resource, roles);
|
||||
}
|
||||
|
||||
private URL parseUrl(SamlServiceProviderDocument document) {
|
||||
final URL acs;
|
||||
try {
|
||||
acs = new URL(document.acs);
|
||||
} catch (MalformedURLException e) {
|
||||
final ServiceProviderException exception = new ServiceProviderException(
|
||||
"Service provider [{}] (doc {}) has an invalid ACS [{}]", e, document.entityId, document.docId, document.acs);
|
||||
exception.setEntityId(document.entityId);
|
||||
throw exception;
|
||||
}
|
||||
return acs;
|
||||
}
|
||||
}
|
|
@ -8,47 +8,24 @@ package org.elasticsearch.xpack.idp.saml.sp;
|
|||
|
||||
import org.elasticsearch.action.ActionListener;
|
||||
import org.elasticsearch.common.cache.Cache;
|
||||
import org.elasticsearch.common.cache.CacheBuilder;
|
||||
import org.elasticsearch.common.settings.Setting;
|
||||
import org.elasticsearch.common.settings.Settings;
|
||||
import org.elasticsearch.common.unit.TimeValue;
|
||||
import org.elasticsearch.common.util.iterable.Iterables;
|
||||
import org.elasticsearch.xpack.idp.privileges.ServiceProviderPrivileges;
|
||||
import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderIndex.DocumentSupplier;
|
||||
import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderIndex.DocumentVersion;
|
||||
import org.joda.time.ReadableDuration;
|
||||
import org.opensaml.security.x509.BasicX509Credential;
|
||||
import org.opensaml.security.x509.X509Credential;
|
||||
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class SamlServiceProviderResolver {
|
||||
|
||||
private static final int CACHE_SIZE_DEFAULT = 1000;
|
||||
private static final TimeValue CACHE_TTL_DEFAULT = TimeValue.timeValueMinutes(60);
|
||||
|
||||
public static final Setting<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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -76,68 +53,21 @@ public class SamlServiceProviderResolver {
|
|||
final CachedServiceProvider cached = cache.get(entityId);
|
||||
if (cached != null && cached.documentVersion.equals(doc.version)) {
|
||||
listener.onResponse(cached.serviceProvider);
|
||||
return;
|
||||
} else {
|
||||
populateCacheAndReturn(entityId, doc, listener);
|
||||
}
|
||||
},
|
||||
listener::onFailure
|
||||
));
|
||||
|
||||
}
|
||||
|
||||
private void populateCacheAndReturn(String entityId, DocumentSupplier doc, ActionListener<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.collectingAndThen(Collectors.toSet(), Collections::unmodifiableSet));
|
||||
|
||||
final URL acs = parseUrl(document);
|
||||
String nameIdFormat = document.nameIdFormat;
|
||||
if (nameIdFormat == null) {
|
||||
nameIdFormat = defaults.nameIdFormat;
|
||||
}
|
||||
|
||||
final ReadableDuration authnExpiry = Optional.ofNullable(document.getAuthenticationExpiry())
|
||||
.orElse(defaults.authenticationExpiry);
|
||||
|
||||
final boolean signAuthnRequests = document.signMessages.contains(SamlServiceProviderDocument.SIGN_AUTHN);
|
||||
final boolean signLogoutRequests = document.signMessages.contains(SamlServiceProviderDocument.SIGN_LOGOUT);
|
||||
|
||||
return new CloudServiceProvider(document.entityId, document.name, document.enabled, acs, nameIdFormat, authnExpiry,
|
||||
privileges, attributes, credentials, signAuthnRequests, signLogoutRequests);
|
||||
}
|
||||
|
||||
private ServiceProviderPrivileges buildPrivileges(SamlServiceProviderDocument.Privileges configuredPrivileges) {
|
||||
final String resource = configuredPrivileges.resource;
|
||||
final Map<String, String> roles = Optional.ofNullable(configuredPrivileges.roleActions).orElse(Collections.emptyMap());
|
||||
return new ServiceProviderPrivileges(defaults.applicationName, resource, roles);
|
||||
}
|
||||
|
||||
private URL parseUrl(SamlServiceProviderDocument document) {
|
||||
final URL acs;
|
||||
try {
|
||||
acs = new URL(document.acs);
|
||||
} catch (MalformedURLException e) {
|
||||
final ServiceProviderException exception = new ServiceProviderException(
|
||||
"Service provider [{}] (doc {}) has an invalid ACS [{}]", e, document.entityId, document.docId, document.acs);
|
||||
exception.setEntityId(document.entityId);
|
||||
throw exception;
|
||||
}
|
||||
return acs;
|
||||
}
|
||||
|
||||
private class CachedServiceProvider {
|
||||
private final String entityId;
|
||||
private final DocumentVersion documentVersion;
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
package org.elasticsearch.xpack.idp.saml.sp;
|
||||
|
||||
import org.elasticsearch.common.cache.Cache;
|
||||
import org.elasticsearch.common.cache.CacheBuilder;
|
||||
import org.elasticsearch.common.settings.Setting;
|
||||
import org.elasticsearch.common.settings.Settings;
|
||||
import org.elasticsearch.common.unit.TimeValue;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Represents standard settings for the ServiceProvider cache(s) in the IdP
|
||||
*/
|
||||
public final class ServiceProviderCacheSettings {
|
||||
private static final int CACHE_SIZE_DEFAULT = 1000;
|
||||
private static final TimeValue CACHE_TTL_DEFAULT = TimeValue.timeValueMinutes(60);
|
||||
|
||||
public static final Setting<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 Collections.unmodifiableList(Arrays.asList(CACHE_SIZE, CACHE_TTL));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,200 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
package org.elasticsearch.xpack.idp.saml.sp;
|
||||
|
||||
import org.elasticsearch.common.Nullable;
|
||||
import org.elasticsearch.common.ParseField;
|
||||
import org.elasticsearch.common.bytes.BytesArray;
|
||||
import org.elasticsearch.common.bytes.BytesReference;
|
||||
import org.elasticsearch.common.xcontent.ConstructingObjectParser;
|
||||
import org.elasticsearch.common.xcontent.LoggingDeprecationHandler;
|
||||
import org.elasticsearch.common.xcontent.NamedXContentRegistry;
|
||||
import org.elasticsearch.common.xcontent.XContentBuilder;
|
||||
import org.elasticsearch.common.xcontent.XContentHelper;
|
||||
import org.elasticsearch.common.xcontent.XContentParser;
|
||||
import org.elasticsearch.common.xcontent.XContentType;
|
||||
import org.elasticsearch.common.xcontent.json.JsonXContent;
|
||||
import org.elasticsearch.script.ScriptService;
|
||||
import org.elasticsearch.xpack.core.security.support.MustacheTemplateEvaluator;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* A model for a service provider (see {@link SamlServiceProvider} and {@link SamlServiceProviderDocument}) that uses wildcard matching
|
||||
* rules and a service-provider template.
|
||||
*/
|
||||
class WildcardServiceProvider {
|
||||
|
||||
private static final ConstructingObjectParser<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")),
|
||||
Collections.unmodifiableSet(new HashSet<>(Objects.requireNonNull(tokens, "Tokens collection may not be null"))),
|
||||
toMustacheScript(Objects.requireNonNull(serviceTemplate, "Service definition may not be null")));
|
||||
}
|
||||
|
||||
public static WildcardServiceProvider parse(XContentParser parser) throws IOException {
|
||||
return PARSER.parse(parser, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
final WildcardServiceProvider that = (WildcardServiceProvider) o;
|
||||
return matchEntityId.pattern().equals(that.matchEntityId.pattern()) &&
|
||||
matchAcs.pattern().equals(that.matchAcs.pattern()) &&
|
||||
tokens.equals(that.tokens) &&
|
||||
serviceTemplate.equals(that.serviceTemplate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(matchEntityId.pattern(), matchAcs.pattern(), tokens, serviceTemplate);
|
||||
}
|
||||
|
||||
private static BytesReference toMustacheScript(Map<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");
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,232 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
package org.elasticsearch.xpack.idp.saml.sp;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.elasticsearch.ElasticsearchException;
|
||||
import org.elasticsearch.common.ParseField;
|
||||
import org.elasticsearch.common.ParsingException;
|
||||
import org.elasticsearch.common.Strings;
|
||||
import org.elasticsearch.common.cache.Cache;
|
||||
import org.elasticsearch.common.collect.Tuple;
|
||||
import org.elasticsearch.common.settings.Setting;
|
||||
import org.elasticsearch.common.settings.Settings;
|
||||
import org.elasticsearch.common.util.iterable.Iterables;
|
||||
import org.elasticsearch.common.xcontent.LoggingDeprecationHandler;
|
||||
import org.elasticsearch.common.xcontent.NamedXContentRegistry;
|
||||
import org.elasticsearch.common.xcontent.XContentLocation;
|
||||
import org.elasticsearch.common.xcontent.XContentParser;
|
||||
import org.elasticsearch.common.xcontent.XContentParserUtils;
|
||||
import org.elasticsearch.common.xcontent.XContentType;
|
||||
import org.elasticsearch.env.Environment;
|
||||
import org.elasticsearch.script.ScriptService;
|
||||
import org.elasticsearch.watcher.FileChangesListener;
|
||||
import org.elasticsearch.watcher.FileWatcher;
|
||||
import org.elasticsearch.watcher.ResourceWatcherService;
|
||||
import org.elasticsearch.xpack.core.XPackPlugin;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
public class WildcardServiceProviderResolver {
|
||||
|
||||
public static final Setting<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(Collections.emptyMap()));
|
||||
}
|
||||
|
||||
/**
|
||||
* This is implemented as a factory method to facilitate testing - the core resolver just works on InputStreams, this method
|
||||
* handles all the Path/ResourceWatcher logic
|
||||
*/
|
||||
public static WildcardServiceProviderResolver create(Environment environment,
|
||||
ResourceWatcherService resourceWatcherService,
|
||||
ScriptService scriptService,
|
||||
SamlServiceProviderFactory spFactory) {
|
||||
final Settings settings = environment.settings();
|
||||
final Path path = XPackPlugin.resolveConfigFile(environment, FILE_PATH_SETTING.get(environment.settings()));
|
||||
|
||||
logger.info("Loading wildcard services from file [{}]", path.toAbsolutePath());
|
||||
|
||||
final WildcardServiceProviderResolver resolver = new WildcardServiceProviderResolver(settings, scriptService, spFactory);
|
||||
|
||||
if (Files.exists(path)) {
|
||||
try {
|
||||
resolver.reload(path);
|
||||
} catch (IOException e) {
|
||||
throw new ElasticsearchException("File [{}] (from setting [{}]) cannot be loaded",
|
||||
e, path.toAbsolutePath(), FILE_PATH_SETTING.getKey());
|
||||
}
|
||||
} else if (FILE_PATH_SETTING.exists(environment.settings())) {
|
||||
// A file was explicitly configured, but doesn't exist. That's a mistake...
|
||||
throw new ElasticsearchException("File [{}] (from setting [{}]) does not exist",
|
||||
path.toAbsolutePath(), FILE_PATH_SETTING.getKey());
|
||||
}
|
||||
|
||||
final FileWatcher fileWatcher = new FileWatcher(path);
|
||||
fileWatcher.addListener(new FileChangesListener() {
|
||||
@Override
|
||||
public void onFileCreated(Path file) {
|
||||
onFileChanged(file);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFileDeleted(Path file) {
|
||||
onFileChanged(file);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFileChanged(Path file) {
|
||||
try {
|
||||
resolver.reload(file);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
try {
|
||||
resourceWatcherService.add(fileWatcher);
|
||||
} catch (IOException e) {
|
||||
throw new ElasticsearchException("Failed to watch file [{}] (from setting [{}])",
|
||||
e, path.toAbsolutePath(), FILE_PATH_SETTING.getKey());
|
||||
}
|
||||
return resolver;
|
||||
}
|
||||
|
||||
public SamlServiceProvider resolve(String entityId, String acs) {
|
||||
final State currentState = stateRef.get();
|
||||
|
||||
Tuple<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 = Collections.unmodifiableMap(parse(parser));
|
||||
final State oldState = this.stateRef.get();
|
||||
if (newServices.equals(oldState.services) == false) {
|
||||
// Services have changed
|
||||
if (this.stateRef.compareAndSet(oldState, new State(newServices))) {
|
||||
logger.info("Reloaded cached wildcard service providers, new providers [{}]",
|
||||
Strings.collectionToCommaDelimitedString(newServices.keySet()));
|
||||
} else {
|
||||
// some other thread reloaded it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void reload(Path file) throws IOException {
|
||||
try (InputStream in = Files.newInputStream(file);
|
||||
XContentParser parser = buildServicesParser(in)) {
|
||||
reload(parser);
|
||||
}
|
||||
}
|
||||
|
||||
private static XContentParser buildServicesParser(InputStream in) throws IOException {
|
||||
return XContentType.JSON.xContent().createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, in);
|
||||
}
|
||||
|
||||
private static Map<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 Collections.singletonList(FILE_PATH_SETTING);
|
||||
}
|
||||
|
||||
public interface Fields {
|
||||
ParseField SERVICES = new ParseField("services");
|
||||
}
|
||||
|
||||
}
|
|
@ -27,8 +27,8 @@ import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken
|
|||
import org.elasticsearch.xpack.core.security.client.SecurityClient;
|
||||
import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderDocument;
|
||||
import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderIndex;
|
||||
import org.elasticsearch.xpack.idp.saml.test.IdentityProviderIntegTestCase;
|
||||
import org.elasticsearch.xpack.idp.saml.support.SamlFactory;
|
||||
import org.elasticsearch.xpack.idp.saml.test.IdentityProviderIntegTestCase;
|
||||
import org.opensaml.core.xml.util.XMLObjectSupport;
|
||||
import org.opensaml.saml.common.SAMLObject;
|
||||
import org.opensaml.saml.saml2.core.AuthnRequest;
|
||||
|
@ -81,7 +81,7 @@ public class SamlIdentityProviderTests extends IdentityProviderIntegTestCase {
|
|||
new SecureString(CONSOLE_USER_PASSWORD.toCharArray())))
|
||||
.addHeader("es-secondary-authorization", "ApiKey " + apiKeyCredentials)
|
||||
.build());
|
||||
request.setJsonEntity("{ \"entity_id\": \"" + entityId + "\"}");
|
||||
request.setJsonEntity("{ \"entity_id\": \"" + entityId + "\", \"acs\": \"" + acsUrl + "\" }");
|
||||
Response initResponse = getRestClient().performRequest(request);
|
||||
ObjectPath objectPath = ObjectPath.createFromResponse(initResponse);
|
||||
assertThat(objectPath.evaluate("post_url").toString(), equalTo(acsUrl));
|
||||
|
@ -110,9 +110,9 @@ public class SamlIdentityProviderTests extends IdentityProviderIntegTestCase {
|
|||
new SecureString(CONSOLE_USER_PASSWORD.toCharArray())))
|
||||
.addHeader("es-secondary-authorization", "ApiKey " + apiKeyCredentials)
|
||||
.build());
|
||||
request.setJsonEntity("{ \"entity_id\": \"" + entityId + randomAlphaOfLength(3) + "\"}");
|
||||
request.setJsonEntity("{ \"entity_id\": \"" + entityId + randomAlphaOfLength(3) + "\", \"acs\": \"" + acsUrl + "\" }");
|
||||
ResponseException e = expectThrows(ResponseException.class, () -> getRestClient().performRequest(request));
|
||||
assertThat(e.getMessage(), containsString("is not registered with this Identity Provider"));
|
||||
assertThat(e.getMessage(), containsString("is not known to this Identity Provider"));
|
||||
assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(RestStatus.BAD_REQUEST.getStatus()));
|
||||
}
|
||||
|
||||
|
@ -124,7 +124,7 @@ public class SamlIdentityProviderTests extends IdentityProviderIntegTestCase {
|
|||
// Make a request to init an SSO flow with the API Key as secondary authentication
|
||||
Request request = new Request("POST", "/_idp/saml/init");
|
||||
request.setOptions(REQUEST_OPTIONS_AS_CONSOLE_USER);
|
||||
request.setJsonEntity("{ \"entity_id\": \"" + entityId + "\"}");
|
||||
request.setJsonEntity("{ \"entity_id\": \"" + entityId + "\", \"acs\": \"" + acsUrl + "\" }");
|
||||
ResponseException e = expectThrows(ResponseException.class, () -> getRestClient().performRequest(request));
|
||||
assertThat(e.getMessage(), containsString("Request is missing secondary authentication"));
|
||||
}
|
||||
|
@ -149,6 +149,8 @@ public class SamlIdentityProviderTests extends IdentityProviderIntegTestCase {
|
|||
Map<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()));
|
||||
}
|
||||
|
||||
|
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,6 +27,7 @@ import org.elasticsearch.xpack.idp.saml.sp.CloudServiceProvider;
|
|||
import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProvider;
|
||||
import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderResolver;
|
||||
import org.elasticsearch.xpack.idp.saml.sp.ServiceProviderDefaults;
|
||||
import org.elasticsearch.xpack.idp.saml.sp.WildcardServiceProviderResolver;
|
||||
import org.elasticsearch.xpack.idp.saml.support.SamlFactory;
|
||||
import org.elasticsearch.xpack.idp.saml.test.IdpSamlTestCase;
|
||||
import org.joda.time.Duration;
|
||||
|
@ -90,7 +91,7 @@ public class TransportSamlInitiateSingleSignOnRequestTests extends IdpSamlTestCa
|
|||
|
||||
Exception e = expectThrows(Exception.class, () -> future.get());
|
||||
assertThat(e.getCause().getMessage(), containsString("https://sp2.other.org"));
|
||||
assertThat(e.getCause().getMessage(), containsString("is not registered with this Identity Provider"));
|
||||
assertThat(e.getCause().getMessage(), containsString("is not known to this Identity Provider"));
|
||||
}
|
||||
|
||||
private TransportSamlInitiateSingleSignOnAction setupTransportAction(boolean withSecondaryAuth) throws Exception {
|
||||
|
@ -125,7 +126,8 @@ public class TransportSamlInitiateSingleSignOnRequestTests extends IdpSamlTestCa
|
|||
.writeToContext(threadContext);
|
||||
}
|
||||
|
||||
final SamlServiceProviderResolver resolver = Mockito.mock(SamlServiceProviderResolver.class);
|
||||
final SamlServiceProviderResolver serviceResolver = Mockito.mock(SamlServiceProviderResolver.class);
|
||||
final WildcardServiceProviderResolver wildcardResolver = Mockito.mock(WildcardServiceProviderResolver.class);
|
||||
final CloudServiceProvider serviceProvider = new CloudServiceProvider("https://sp.some.org",
|
||||
"test sp",
|
||||
true,
|
||||
|
@ -139,13 +141,13 @@ public class TransportSamlInitiateSingleSignOnRequestTests extends IdpSamlTestCa
|
|||
"https://saml.elasticsearch.org/attributes/email",
|
||||
"https://saml.elasticsearch.org/attributes/roles"),
|
||||
null, false, false);
|
||||
mockRegisteredServiceProvider(resolver, "https://sp.some.org", serviceProvider);
|
||||
mockRegisteredServiceProvider(resolver, "https://sp2.other.org", null);
|
||||
mockRegisteredServiceProvider(serviceResolver, "https://sp.some.org", serviceProvider);
|
||||
mockRegisteredServiceProvider(serviceResolver, "https://sp2.other.org", null);
|
||||
final ServiceProviderDefaults defaults = new ServiceProviderDefaults(
|
||||
"elastic-cloud", TRANSIENT, Duration.standardMinutes(15));
|
||||
final X509Credential signingCredential = readCredentials("RSA", randomFrom(1024, 2048, 4096));
|
||||
final SamlIdentityProvider idp = SamlIdentityProvider
|
||||
.builder(resolver)
|
||||
.builder(serviceResolver, wildcardResolver)
|
||||
.fromSettings(env)
|
||||
.signingCredential(signingCredential)
|
||||
.serviceProviderDefaults(defaults)
|
||||
|
|
|
@ -196,7 +196,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"));
|
||||
}
|
||||
|
||||
|
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
@ -48,7 +48,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();
|
||||
|
||||
|
|
|
@ -48,7 +48,7 @@ public class SamlServiceProviderResolverTests extends ESTestCase {
|
|||
index = mock(SamlServiceProviderIndex.class);
|
||||
identityProvider = mock(SamlIdentityProvider.class);
|
||||
serviceProviderDefaults = configureIdentityProviderDefaults();
|
||||
resolver = new SamlServiceProviderResolver(Settings.EMPTY, index, serviceProviderDefaults);
|
||||
resolver = new SamlServiceProviderResolver(Settings.EMPTY, index, new SamlServiceProviderFactory(serviceProviderDefaults));
|
||||
}
|
||||
|
||||
public void testResolveWithoutCache() throws Exception {
|
||||
|
|
|
@ -0,0 +1,183 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
package org.elasticsearch.xpack.idp.saml.sp;
|
||||
|
||||
import org.elasticsearch.common.collect.MapBuilder;
|
||||
import org.elasticsearch.common.settings.Settings;
|
||||
import org.elasticsearch.common.xcontent.XContentType;
|
||||
import org.elasticsearch.script.ScriptModule;
|
||||
import org.elasticsearch.script.ScriptService;
|
||||
import org.elasticsearch.script.mustache.MustacheScriptEngine;
|
||||
import org.elasticsearch.xpack.idp.saml.test.IdpSamlTestCase;
|
||||
import org.joda.time.Duration;
|
||||
import org.junit.Before;
|
||||
import org.opensaml.saml.saml2.core.NameID;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.hamcrest.CoreMatchers.equalTo;
|
||||
import static org.hamcrest.CoreMatchers.sameInstance;
|
||||
import static org.hamcrest.Matchers.containsInAnyOrder;
|
||||
import static org.hamcrest.Matchers.notNullValue;
|
||||
import static org.hamcrest.Matchers.nullValue;
|
||||
|
||||
public class WildcardServiceProviderResolverTests extends IdpSamlTestCase {
|
||||
|
||||
private static final String SERVICES_JSON = "{"
|
||||
+ "\"services\": {"
|
||||
+ " \"service1a\": {"
|
||||
+ " \"entity_id\": \"https://(?<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(MapBuilder.newMapBuilder()
|
||||
.put("service", "abcdef")
|
||||
.put("entity_id", "https://abcdef.example.com/")
|
||||
.put("acs", "https://abcdef.service.example.com/saml2/acs")
|
||||
.map()
|
||||
));
|
||||
expectThrows(IllegalArgumentException.class, () ->
|
||||
service1a.extractTokens("https://abcdef.example.com/", "https://different.service.example.com/saml2/acs"));
|
||||
assertThat(service1a.extractTokens("urn:foo:bar", "https://something.example.org/foo/bar"), nullValue());
|
||||
assertThat(service1a.extractTokens("https://xyzzy.example.com/", "https://services.example.com/xyzzy/saml2/acs"), nullValue());
|
||||
|
||||
final WildcardServiceProvider service1b = resolver.services().get("service1b");
|
||||
assertThat(service1b.extractTokens("https://xyzzy.example.com/", "https://services.example.com/xyzzy/saml2/acs"),
|
||||
equalTo(MapBuilder.newMapBuilder()
|
||||
.put("service", "xyzzy")
|
||||
.put("entity_id", "https://xyzzy.example.com/")
|
||||
.put("acs", "https://services.example.com/xyzzy/saml2/acs")
|
||||
.map()
|
||||
));
|
||||
assertThat(service1b.extractTokens("https://abcdef.example.com/", "https://abcdef.service.example.com/saml2/acs"), nullValue());
|
||||
expectThrows(IllegalArgumentException.class, () ->
|
||||
service1b.extractTokens("https://abcdef.example.com/", "https://services.example.com/xyzzy/saml2/acs"));
|
||||
assertThat(service1b.extractTokens("urn:foo:bar", "https://something.example.org/foo/bar"), nullValue());
|
||||
}
|
||||
|
||||
public void testResolveServices() throws IOException {
|
||||
loadJsonServices();
|
||||
|
||||
final SamlServiceProvider sp1 = resolver.resolve("https://abcdef.example.com/", "https://abcdef.service.example.com/saml2/acs");
|
||||
|
||||
assertThat(sp1, notNullValue());
|
||||
assertThat(sp1.getEntityId(), equalTo("https://abcdef.example.com/"));
|
||||
assertThat(sp1.getAssertionConsumerService().toString(), equalTo("https://abcdef.service.example.com/saml2/acs"));
|
||||
assertThat(sp1.getName(), equalTo("abcdef at example.com (A)"));
|
||||
assertThat(sp1.getPrivileges().getResource(), equalTo("service1:example:abcdef"));
|
||||
|
||||
final SamlServiceProvider sp2 = resolver.resolve("https://qwerty.example.com/", "https://qwerty.service.example.com/saml2/acs");
|
||||
assertThat(sp2, notNullValue());
|
||||
assertThat(sp2.getEntityId(), equalTo("https://qwerty.example.com/"));
|
||||
assertThat(sp2.getAssertionConsumerService().toString(), equalTo("https://qwerty.service.example.com/saml2/acs"));
|
||||
assertThat(sp2.getName(), equalTo("qwerty at example.com (A)"));
|
||||
assertThat(sp2.getPrivileges().getResource(), equalTo("service1:example:qwerty"));
|
||||
|
||||
final SamlServiceProvider sp3 = resolver.resolve("https://xyzzy.example.com/", "https://services.example.com/xyzzy/saml2/acs");
|
||||
assertThat(sp3, notNullValue());
|
||||
assertThat(sp3.getEntityId(), equalTo("https://xyzzy.example.com/"));
|
||||
assertThat(sp3.getAssertionConsumerService().toString(), equalTo("https://services.example.com/xyzzy/saml2/acs"));
|
||||
assertThat(sp3.getName(), equalTo("xyzzy at example.com (B)"));
|
||||
assertThat(sp3.getPrivileges().getResource(), equalTo("service1:example:xyzzy"));
|
||||
|
||||
final SamlServiceProvider sp4 = resolver.resolve("https://service-12345.example.net/", "https://saml.example.net/12345/acs");
|
||||
assertThat(sp4, notNullValue());
|
||||
assertThat(sp4.getEntityId(), equalTo("https://service-12345.example.net/"));
|
||||
assertThat(sp4.getAssertionConsumerService().toString(), equalTo("https://saml.example.net/12345/acs"));
|
||||
assertThat(sp4.getName(), equalTo("12345 at example.net"));
|
||||
assertThat(sp4.getPrivileges().getResource(), equalTo("service2:example:12345"));
|
||||
}
|
||||
|
||||
public void testCaching() throws IOException {
|
||||
loadJsonServices();
|
||||
|
||||
final String serviceName = randomAlphaOfLengthBetween(4, 12);
|
||||
final String entityId = "https://" + serviceName + ".example.com/";
|
||||
final String acs = randomBoolean()
|
||||
? "https://" + serviceName + ".service.example.com/saml2/acs"
|
||||
: "https://services.example.com/" + serviceName + "/saml2/acs";
|
||||
|
||||
final SamlServiceProvider original = resolver.resolve(entityId, acs);
|
||||
for (int i = randomIntBetween(10, 20); i > 0; i--) {
|
||||
final SamlServiceProvider cached = resolver.resolve(entityId, acs);
|
||||
assertThat(cached, sameInstance(original));
|
||||
}
|
||||
}
|
||||
|
||||
private void loadJsonServices() throws IOException {
|
||||
assertThat("Resolver has not been setup correctly", resolver, notNullValue());
|
||||
resolver.reload(createParser(XContentType.JSON.xContent(), SERVICES_JSON));
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue