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:
Tim Vernum 2020-03-31 16:53:13 +11:00 committed by GitHub
parent 915435bbe4
commit a0853628cd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1177 additions and 162 deletions

View File

@ -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"

View File

@ -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);
}
}

View File

@ -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");
}
}

View File

@ -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"
}
}
}
}
}

View File

@ -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);

View File

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

View File

@ -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;
}
}

View File

@ -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() + "' }";
}

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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) {

View File

@ -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;

View File

@ -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) {

View File

@ -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) {

View File

@ -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"));
}

View File

@ -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

View File

@ -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());

View File

@ -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

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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));
}
}

View File

@ -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");
}
}

View File

@ -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");
}
}

View File

@ -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()));
}

View File

@ -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"));
}
}

View File

@ -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)

View File

@ -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"));
}

View File

@ -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"));
}

View File

@ -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();

View File

@ -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 {

View File

@ -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));
}
}

View File

@ -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,