Skip to content

Commit 860fd30

Browse files
committed
Adding API for generating SAML SP metadata
Resolve elastic#49018
1 parent 709264a commit 860fd30

File tree

8 files changed

+332
-0
lines changed

8 files changed

+332
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
package org.elasticsearch.xpack.core.security.action.saml;
8+
9+
import org.elasticsearch.action.ActionType;
10+
11+
public class SamlSPMetadataAction extends ActionType<SamlSPMetadataResponse> {
12+
public static final String NAME = "cluster:admin/xpack/security/saml/metadata";
13+
public static final SamlSPMetadataAction INSTANCE = new SamlSPMetadataAction();
14+
15+
private SamlSPMetadataAction() {
16+
super(NAME, SamlSPMetadataResponse::new);
17+
}
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
package org.elasticsearch.xpack.core.security.action.saml;
8+
9+
import org.elasticsearch.action.ActionRequest;
10+
import org.elasticsearch.action.ActionRequestValidationException;
11+
import org.elasticsearch.common.Strings;
12+
import org.elasticsearch.common.io.stream.StreamInput;
13+
import org.elasticsearch.common.io.stream.StreamOutput;
14+
15+
import java.io.IOException;
16+
17+
import static org.elasticsearch.action.ValidateActions.addValidationError;
18+
19+
public class SamlSPMetadataRequest extends ActionRequest {
20+
21+
String realmName;
22+
23+
public SamlSPMetadataRequest(StreamInput in) throws IOException {
24+
super(in);
25+
realmName = in.readOptionalString();
26+
}
27+
28+
public SamlSPMetadataRequest() {
29+
}
30+
31+
@Override
32+
public ActionRequestValidationException validate() {
33+
ActionRequestValidationException validationException = null;
34+
if (Strings.hasText(realmName) == false) {
35+
validationException = addValidationError("realm may not be empty", validationException);
36+
}
37+
return validationException;
38+
}
39+
40+
public String getRealmName() {
41+
return realmName;
42+
}
43+
44+
public void setRealmName(String realmName) {
45+
this.realmName = realmName;
46+
}
47+
48+
@Override
49+
public String toString() {
50+
return getClass().getSimpleName() + "{" +
51+
"realmName=" + realmName +
52+
'}';
53+
}
54+
55+
@Override
56+
public void writeTo(StreamOutput out) throws IOException {
57+
super.writeTo(out);
58+
out.writeOptionalString(realmName);
59+
}
60+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
package org.elasticsearch.xpack.core.security.action.saml;
8+
9+
import org.elasticsearch.action.ActionResponse;
10+
import org.elasticsearch.common.io.stream.StreamInput;
11+
import org.elasticsearch.common.io.stream.StreamOutput;
12+
13+
import java.io.IOException;
14+
15+
/**
16+
* Response containing a SAML SP metadata for a specific realm as XML.
17+
*/
18+
public class SamlSPMetadataResponse extends ActionResponse {
19+
public String getXMLString() {
20+
return XMLString;
21+
}
22+
23+
private String XMLString;
24+
25+
public SamlSPMetadataResponse(StreamInput in) throws IOException {
26+
super(in);
27+
XMLString = in.readString();
28+
}
29+
30+
public SamlSPMetadataResponse(String XMLString) {
31+
this.XMLString = XMLString;
32+
}
33+
34+
@Override
35+
public void writeTo(StreamOutput out) throws IOException {
36+
out.writeString(XMLString);
37+
}
38+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
package org.elasticsearch.xpack.core.security.action.saml;
8+
9+
import org.elasticsearch.action.ActionRequestValidationException;
10+
import org.elasticsearch.common.io.stream.BytesStreamOutput;
11+
import org.elasticsearch.common.io.stream.StreamInput;
12+
import org.elasticsearch.test.ESTestCase;
13+
14+
import java.io.IOException;
15+
16+
import static org.hamcrest.Matchers.containsString;
17+
18+
public class SamlSPMetadataRequestTests extends ESTestCase {
19+
20+
public void testValidateFailsWhenRealmNotSet() {
21+
final SamlSPMetadataRequest samlSPMetadataRequest = new SamlSPMetadataRequest();
22+
final ActionRequestValidationException validationException = samlSPMetadataRequest.validate();
23+
assertThat(validationException.getMessage(), containsString("realm may not be empty"));
24+
}
25+
26+
public void testValidateSerialization() throws IOException {
27+
final SamlSPMetadataRequest samlSPMetadataRequest = new SamlSPMetadataRequest();
28+
samlSPMetadataRequest.setRealmName("saml1");
29+
try (BytesStreamOutput out = new BytesStreamOutput()) {
30+
samlSPMetadataRequest.writeTo(out);
31+
try (StreamInput in = out.bytes().streamInput()) {
32+
final SamlSPMetadataRequest serialized = new SamlSPMetadataRequest(in);
33+
assertEquals(samlSPMetadataRequest.getRealmName(), serialized.getRealmName());
34+
}
35+
}
36+
}
37+
38+
public void testValidateToString() {
39+
final SamlSPMetadataRequest samlSPMetadataRequest = new SamlSPMetadataRequest();
40+
samlSPMetadataRequest.setRealmName("saml1");
41+
assertThat(samlSPMetadataRequest.toString(), containsString("{realmName=saml1}"));
42+
}
43+
}

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
import org.elasticsearch.xpack.core.security.action.saml.SamlLogoutAction;
109109
import org.elasticsearch.xpack.core.security.action.saml.SamlCompleteLogoutAction;
110110
import org.elasticsearch.xpack.core.security.action.saml.SamlPrepareAuthenticationAction;
111+
import org.elasticsearch.xpack.core.security.action.saml.SamlSPMetadataAction;
111112
import org.elasticsearch.xpack.core.security.action.token.CreateTokenAction;
112113
import org.elasticsearch.xpack.core.security.action.token.InvalidateTokenAction;
113114
import org.elasticsearch.xpack.core.security.action.token.RefreshTokenAction;
@@ -174,6 +175,7 @@
174175
import org.elasticsearch.xpack.security.action.saml.TransportSamlLogoutAction;
175176
import org.elasticsearch.xpack.security.action.saml.TransportSamlCompleteLogoutAction;
176177
import org.elasticsearch.xpack.security.action.saml.TransportSamlPrepareAuthenticationAction;
178+
import org.elasticsearch.xpack.security.action.saml.TransportSamlSPMetadataAction;
177179
import org.elasticsearch.xpack.security.action.token.TransportCreateTokenAction;
178180
import org.elasticsearch.xpack.security.action.token.TransportInvalidateTokenAction;
179181
import org.elasticsearch.xpack.security.action.token.TransportRefreshTokenAction;
@@ -243,6 +245,7 @@
243245
import org.elasticsearch.xpack.security.rest.action.saml.RestSamlLogoutAction;
244246
import org.elasticsearch.xpack.security.rest.action.saml.RestSamlCompleteLogoutAction;
245247
import org.elasticsearch.xpack.security.rest.action.saml.RestSamlPrepareAuthenticationAction;
248+
import org.elasticsearch.xpack.security.rest.action.saml.RestSamlSPMetadataAction;
246249
import org.elasticsearch.xpack.security.rest.action.user.RestChangePasswordAction;
247250
import org.elasticsearch.xpack.security.rest.action.user.RestDeleteUserAction;
248251
import org.elasticsearch.xpack.security.rest.action.user.RestGetUserPrivilegesAction;
@@ -781,6 +784,7 @@ public void onIndexModule(IndexModule module) {
781784
new ActionHandler<>(SamlLogoutAction.INSTANCE, TransportSamlLogoutAction.class),
782785
new ActionHandler<>(SamlInvalidateSessionAction.INSTANCE, TransportSamlInvalidateSessionAction.class),
783786
new ActionHandler<>(SamlCompleteLogoutAction.INSTANCE, TransportSamlCompleteLogoutAction.class),
787+
new ActionHandler<>(SamlSPMetadataAction.INSTANCE, TransportSamlSPMetadataAction.class),
784788
new ActionHandler<>(OpenIdConnectPrepareAuthenticationAction.INSTANCE,
785789
TransportOpenIdConnectPrepareAuthenticationAction.class),
786790
new ActionHandler<>(OpenIdConnectAuthenticateAction.INSTANCE, TransportOpenIdConnectAuthenticateAction.class),
@@ -841,6 +845,7 @@ public List<RestHandler> getRestHandlers(Settings settings, RestController restC
841845
new RestSamlLogoutAction(settings, getLicenseState()),
842846
new RestSamlInvalidateSessionAction(settings, getLicenseState()),
843847
new RestSamlCompleteLogoutAction(settings, getLicenseState()),
848+
new RestSamlSPMetadataAction(settings, getLicenseState()),
844849
new RestOpenIdConnectPrepareAuthenticationAction(settings, getLicenseState()),
845850
new RestOpenIdConnectAuthenticateAction(settings, getLicenseState()),
846851
new RestOpenIdConnectLogoutAction(settings, getLicenseState()),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
package org.elasticsearch.xpack.security.action.saml;
8+
9+
import org.elasticsearch.action.ActionListener;
10+
import org.elasticsearch.action.support.ActionFilters;
11+
import org.elasticsearch.action.support.HandledTransportAction;
12+
import org.elasticsearch.common.inject.Inject;
13+
import org.elasticsearch.tasks.Task;
14+
import org.elasticsearch.transport.TransportService;
15+
import org.elasticsearch.xpack.core.security.action.saml.SamlSPMetadataAction;
16+
import org.elasticsearch.xpack.core.security.action.saml.SamlSPMetadataRequest;
17+
import org.elasticsearch.xpack.core.security.action.saml.SamlSPMetadataResponse;
18+
import org.elasticsearch.xpack.security.authc.Realms;
19+
import org.elasticsearch.xpack.security.authc.saml.SamlMetadataCommand;
20+
import org.elasticsearch.xpack.security.authc.saml.SamlRealm;
21+
import org.elasticsearch.xpack.security.authc.saml.SamlUtils;
22+
import org.opensaml.saml.saml2.metadata.EntityDescriptor;
23+
import org.opensaml.saml.saml2.metadata.impl.EntityDescriptorMarshaller;
24+
import org.w3c.dom.Element;
25+
26+
import javax.xml.transform.OutputKeys;
27+
import javax.xml.transform.Transformer;
28+
import javax.xml.transform.dom.DOMSource;
29+
import javax.xml.transform.stream.StreamResult;
30+
import java.io.StringWriter;
31+
import java.util.List;
32+
33+
import static org.elasticsearch.xpack.security.authc.saml.SamlRealm.findSamlRealms;
34+
35+
/**
36+
* Transport action responsible for generating a SAML SP Metadata.
37+
*/
38+
public class TransportSamlSPMetadataAction
39+
extends HandledTransportAction<SamlSPMetadataRequest, SamlSPMetadataResponse> {
40+
41+
private final Realms realms;
42+
43+
@Inject
44+
public TransportSamlSPMetadataAction(TransportService transportService, ActionFilters actionFilters, Realms realms) {
45+
super(SamlSPMetadataAction.NAME, transportService, actionFilters, SamlSPMetadataRequest::new
46+
);
47+
this.realms = realms;
48+
}
49+
50+
@Override
51+
protected void doExecute(Task task, SamlSPMetadataRequest request,
52+
ActionListener<SamlSPMetadataResponse> listener) {
53+
List<SamlRealm> realms = findSamlRealms(this.realms, request.getRealmName(), null);
54+
if (realms.isEmpty()) {
55+
listener.onFailure(SamlUtils.samlException("Cannot find any matching realm for [{}]", request));
56+
} else if (realms.size() > 1) {
57+
listener.onFailure(SamlUtils.samlException("Found multiple matching realms [{}] for [{}]", realms, request));
58+
} else {
59+
prepareMetadata(realms.get(0), listener);
60+
}
61+
}
62+
63+
private void prepareMetadata(SamlRealm realm, ActionListener<SamlSPMetadataResponse> listener) {
64+
try {
65+
final EntityDescriptorMarshaller marshaller = new EntityDescriptorMarshaller();
66+
final EntityDescriptor descriptor = SamlMetadataCommand.buildEntityDescriptorFromSamlRealm(realm);
67+
final Element element = marshaller.marshall(descriptor);
68+
final StringWriter writer = new StringWriter();
69+
final Transformer serializer = SamlUtils.getHardenedXMLTransformer();
70+
serializer.setOutputProperty(OutputKeys.INDENT, "yes");
71+
serializer.transform(new DOMSource(element), new StreamResult(writer));
72+
listener.onResponse(new SamlSPMetadataResponse(writer.toString()));
73+
} catch (Exception e) {
74+
logger.debug("Internal exception during SAML SP metadata generation", e);
75+
listener.onFailure(e);
76+
}
77+
}
78+
}

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlMetadataCommand.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,18 @@ public static void main(String[] args) throws Exception {
9393
exit(new SamlMetadataCommand().main(args, Terminal.DEFAULT));
9494
}
9595

96+
public static EntityDescriptor buildEntityDescriptorFromSamlRealm(SamlRealm samlRealm) throws Exception {
97+
final SpConfiguration spConfig = samlRealm.getLogoutHandler().getSpConfiguration();
98+
final Locale locale = Locale.getDefault();
99+
final SamlSpMetadataBuilder builder = new SamlSpMetadataBuilder(locale, spConfig.getEntityId())
100+
.assertionConsumerServiceUrl(spConfig.getAscUrl())
101+
.singleLogoutServiceUrl(spConfig.getLogoutUrl())
102+
.encryptionCredentials(spConfig.getEncryptionCredentials())
103+
.signingCredential(spConfig.getSigningConfiguration().getCredential())
104+
.authnRequestsSigned(spConfig.getSigningConfiguration().shouldSign(AuthnRequest.DEFAULT_ELEMENT_LOCAL_NAME));
105+
return builder.build();
106+
}
107+
96108
public SamlMetadataCommand() {
97109
this((environment) -> {
98110
KeyStoreWrapper ksWrapper = KeyStoreWrapper.load(environment.configFile());
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
package org.elasticsearch.xpack.security.rest.action.saml;
8+
9+
import org.elasticsearch.client.node.NodeClient;
10+
import org.elasticsearch.common.ParseField;
11+
import org.elasticsearch.common.settings.Settings;
12+
import org.elasticsearch.common.xcontent.ObjectParser;
13+
import org.elasticsearch.common.xcontent.XContentBuilder;
14+
import org.elasticsearch.common.xcontent.XContentParser;
15+
import org.elasticsearch.license.XPackLicenseState;
16+
import org.elasticsearch.rest.BytesRestResponse;
17+
import org.elasticsearch.rest.RestRequest;
18+
import org.elasticsearch.rest.RestResponse;
19+
import org.elasticsearch.rest.RestStatus;
20+
import org.elasticsearch.rest.action.RestBuilderListener;
21+
import org.elasticsearch.xpack.core.security.action.saml.SamlSPMetadataAction;
22+
import org.elasticsearch.xpack.core.security.action.saml.SamlSPMetadataRequest;
23+
import org.elasticsearch.xpack.core.security.action.saml.SamlSPMetadataResponse;
24+
25+
import java.io.IOException;
26+
import java.util.Collections;
27+
import java.util.List;
28+
29+
import static org.elasticsearch.rest.RestRequest.Method.GET;
30+
31+
public class RestSamlSPMetadataAction extends SamlBaseRestHandler {
32+
33+
static class Input {
34+
String realm;
35+
void setRealm(String realm) {
36+
this.realm = realm;
37+
}
38+
}
39+
40+
static final ObjectParser<SamlSPMetadataRequest, Void> PARSER = new ObjectParser<>("security_saml_metadata",
41+
SamlSPMetadataRequest::new);
42+
43+
static {
44+
PARSER.declareStringOrNull(SamlSPMetadataRequest::setRealmName, new ParseField("realm"));
45+
}
46+
47+
public RestSamlSPMetadataAction(Settings settings, XPackLicenseState licenseState) {
48+
super(settings, licenseState);
49+
}
50+
51+
@Override
52+
public List<Route> routes() {
53+
return Collections.singletonList(
54+
new Route(GET, "/_security/saml/metadata"));
55+
}
56+
57+
@Override
58+
public String getName() {
59+
return "security_saml_metadata_action";
60+
}
61+
62+
@Override
63+
public RestChannelConsumer innerPrepareRequest(RestRequest request, NodeClient client) throws IOException {
64+
try (XContentParser parser = request.contentParser()) {
65+
final SamlSPMetadataRequest SamlSPRequest = PARSER.parse(parser, null);
66+
return channel -> client.execute(SamlSPMetadataAction.INSTANCE, SamlSPRequest,
67+
new RestBuilderListener<SamlSPMetadataResponse>(channel) {
68+
@Override
69+
public RestResponse buildResponse(SamlSPMetadataResponse response, XContentBuilder builder) throws Exception {
70+
builder.startObject();
71+
builder.field("xml_metadata", response.getXMLString());
72+
builder.endObject();
73+
return new BytesRestResponse(RestStatus.OK, builder);
74+
}
75+
});
76+
}
77+
}
78+
}

0 commit comments

Comments
 (0)