Skip to content

Commit f1641e3

Browse files
Improve SAML error handling by adding metadata (elastic#137598)
in case when there is a mismatch between the expected request ID (that comes from a cookie) and the actual in-response-to field in the SAML content, return more specific error to allow for better error handling at the client closes elastic#128179
1 parent ce51f85 commit f1641e3

File tree

10 files changed

+408
-216
lines changed

10 files changed

+408
-216
lines changed

docs/changelog/137598.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pr: 137598
2+
summary: Improve SAML error handling by adding metadata
3+
area: Authentication
4+
type: enhancement
5+
issues:
6+
- 128179
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.security.authc.saml;
9+
10+
import org.apache.http.util.EntityUtils;
11+
import org.elasticsearch.client.Request;
12+
import org.elasticsearch.client.ResponseException;
13+
import org.elasticsearch.common.Strings;
14+
import org.elasticsearch.test.rest.ObjectPath;
15+
import org.elasticsearch.xcontent.json.JsonXContent;
16+
17+
import java.net.URL;
18+
import java.nio.charset.StandardCharsets;
19+
import java.util.Base64;
20+
import java.util.HashMap;
21+
import java.util.Map;
22+
23+
import static org.hamcrest.Matchers.containsString;
24+
import static org.hamcrest.Matchers.equalTo;
25+
import static org.hamcrest.Matchers.hasEntry;
26+
import static org.hamcrest.Matchers.hasKey;
27+
import static org.hamcrest.Matchers.is;
28+
29+
public class SamlInResponseToIT extends SamlRestTestCase {
30+
31+
private static final int REALM_NUMBER = 1;
32+
33+
public void testInResponseTo_matchingValues() throws Exception {
34+
final String username = randomAlphaOfLengthBetween(4, 12);
35+
String requestId = generateRandomRequestId();
36+
var response = authUser(username, requestId, requestId);
37+
assertThat(response, hasKey("access_token"));
38+
}
39+
40+
public void testInResponseTo_requestAndTokenHaveDifferentValues() throws Exception {
41+
final String username = randomAlphaOfLengthBetween(4, 12);
42+
String requestIdFromRequest = generateRandomRequestId();
43+
String requestIdFromToken = generateDifferentRandomRequestId(requestIdFromRequest);
44+
45+
var exception = expectThrows(ResponseException.class, () -> authUser(username, requestIdFromRequest, requestIdFromToken));
46+
assertThat(exception.getResponse().getStatusLine().getStatusCode(), is(401));
47+
String errorEntity = EntityUtils.toString(exception.getResponse().getEntity());
48+
assertThat(errorEntity, containsString("\"security.saml.unsolicited_in_response_to\":\"" + requestIdFromToken + "\""));
49+
}
50+
51+
public void testInResponseTo_requestNullTokenNotNull() throws Exception {
52+
final String username = randomAlphaOfLengthBetween(4, 12);
53+
String requestIdFromToken = generateRandomRequestId();
54+
55+
var exception = expectThrows(ResponseException.class, () -> authUser(username, null, requestIdFromToken));
56+
assertThat(exception.getResponse().getStatusLine().getStatusCode(), is(401));
57+
String errorEntity = EntityUtils.toString(exception.getResponse().getEntity());
58+
assertThat(errorEntity, containsString("\"security.saml.unsolicited_in_response_to\":\"" + requestIdFromToken + "\""));
59+
}
60+
61+
public void testInResponseTo_requestNotNullTokenNull() throws Exception {
62+
final String username = randomAlphaOfLengthBetween(4, 12);
63+
String requestIdFromRequest = generateRandomRequestId();
64+
65+
var response = authUser(username, requestIdFromRequest, null);
66+
assertThat(response, hasKey("access_token"));
67+
}
68+
69+
public void testInResponseTo_requestNullTokenNull() throws Exception {
70+
final String username = randomAlphaOfLengthBetween(4, 12);
71+
var response = authUser(username, null, null);
72+
assertThat(response, hasKey("access_token"));
73+
}
74+
75+
private String generateRandomRequestId() {
76+
return randomAlphaOfLength(1) + randomAlphanumericOfLength(random().nextInt(10));
77+
}
78+
79+
private String generateDifferentRandomRequestId(String existingId) {
80+
String newId;
81+
do {
82+
newId = generateRandomRequestId();
83+
} while (newId.equals(existingId));
84+
return newId;
85+
}
86+
87+
private Map<String, Object> authUser(String username, String inResponseToFromHeader, String inResponseToInSamlToken) throws Exception {
88+
89+
var httpsAddress = getAcsHttpsAddress();
90+
var message = new SamlResponseBuilder().spEntityId("https://sp" + REALM_NUMBER + ".example.org/")
91+
.idpEntityId(getIdpEntityId(REALM_NUMBER))
92+
.acs(new URL("https://" + httpsAddress.getHostName() + ":" + httpsAddress.getPort() + "/acs/" + REALM_NUMBER))
93+
.attribute("urn:oid:2.5.4.3", username)
94+
.sign(getDataPath(SAML_SIGNING_CRT), getDataPath(SAML_SIGNING_KEY), new char[0])
95+
.inResponseTo(inResponseToInSamlToken)
96+
.asString();
97+
98+
final Map<String, Object> body = new HashMap<>();
99+
body.put("content", Base64.getEncoder().encodeToString(message.getBytes(StandardCharsets.UTF_8)));
100+
body.put("realm", getSamlRealmName(REALM_NUMBER));
101+
if (inResponseToFromHeader != null) {
102+
body.put("ids", inResponseToFromHeader);
103+
}
104+
105+
var req = new Request("POST", "_security/saml/authenticate");
106+
req.setJsonEntity(Strings.toString(JsonXContent.contentBuilder().map(body)));
107+
var resp = entityAsMap(client().performRequest(req));
108+
assertThat(resp, hasEntry("username", username));
109+
assertThat(ObjectPath.evaluate(resp, "authentication.authentication_realm.name"), equalTo(getSamlRealmName(REALM_NUMBER)));
110+
return resp;
111+
}
112+
}

x-pack/plugin/security/qa/saml-rest-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/authc/saml/SamlResponseBuilder.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ public SamlResponseBuilder attribute(String name, String value) {
122122
return this;
123123
}
124124

125+
public SamlResponseBuilder inResponseTo(String requestId) {
126+
this.inResponseTo = requestId;
127+
return this;
128+
}
129+
125130
public SamlResponseBuilder sign(Path certPath, Path keyPath, char[] keyPassword) throws GeneralSecurityException, IOException {
126131
var privateKey = PemUtils.readPrivateKey(keyPath, () -> keyPassword);
127132
var certificates = PemUtils.readCertificates(List.of(certPath));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.security.authc.saml;
9+
10+
import com.sun.net.httpserver.HttpExchange;
11+
import com.sun.net.httpserver.HttpsServer;
12+
13+
import org.elasticsearch.common.settings.SecureString;
14+
import org.elasticsearch.common.settings.Settings;
15+
import org.elasticsearch.common.util.concurrent.ThreadContext;
16+
import org.elasticsearch.core.PathUtils;
17+
import org.elasticsearch.http.PemHttpsConfigurator;
18+
import org.elasticsearch.mocksocket.MockHttpServer;
19+
import org.elasticsearch.test.cluster.ElasticsearchCluster;
20+
import org.elasticsearch.test.cluster.local.model.User;
21+
import org.elasticsearch.test.cluster.util.resource.Resource;
22+
import org.elasticsearch.test.junit.RunnableTestRuleAdapter;
23+
import org.elasticsearch.test.rest.ESRestTestCase;
24+
import org.junit.AfterClass;
25+
import org.junit.BeforeClass;
26+
import org.junit.ClassRule;
27+
import org.junit.rules.RuleChain;
28+
import org.junit.rules.TestRule;
29+
30+
import java.io.FileNotFoundException;
31+
import java.io.IOException;
32+
import java.net.InetAddress;
33+
import java.net.InetSocketAddress;
34+
import java.net.URISyntaxException;
35+
import java.net.URL;
36+
import java.nio.charset.StandardCharsets;
37+
import java.nio.file.Path;
38+
import java.security.GeneralSecurityException;
39+
import java.security.cert.CertificateException;
40+
import java.util.HashMap;
41+
import java.util.List;
42+
import java.util.Map;
43+
44+
public class SamlRestTestCase extends ESRestTestCase {
45+
protected static final String SAML_SIGNING_CRT = "/saml/signing.crt";
46+
protected static final String SAML_SIGNING_KEY = "/saml/signing.key";
47+
48+
private static HttpsServer httpsServer;
49+
private static final Map<Integer, Boolean> metadataAvailable = new HashMap<>();
50+
public static final ElasticsearchCluster cluster = initTestCluster();
51+
private static Path caPath;
52+
53+
@ClassRule
54+
public static TestRule ruleChain = RuleChain.outerRule(new RunnableTestRuleAdapter(SamlRestTestCase::initWebserver))
55+
.around(cluster)
56+
// during the startup, the metadata remains unavailable to prevent caching. After cluster init, make metadata available.
57+
.around(new RunnableTestRuleAdapter(() -> makeMetadataAvailable(1, 2, 3)));
58+
59+
private static void initWebserver() {
60+
try {
61+
final InetSocketAddress address = new InetSocketAddress(InetAddress.getLoopbackAddress().getHostAddress(), 0);
62+
final Path cert = getDataResource("/ssl/http.crt");
63+
final Path key = getDataResource("/ssl/http.key");
64+
httpsServer = MockHttpServer.createHttps(address, 0);
65+
httpsServer.setHttpsConfigurator(new PemHttpsConfigurator(cert, key, new char[0]));
66+
httpsServer.start();
67+
List.of(1, 2, 3).forEach(realmNumber -> {
68+
try {
69+
configureMetadataResource(realmNumber);
70+
} catch (CertificateException | IOException | URISyntaxException e) {
71+
throw new RuntimeException("Cannot configure metadata for realm " + realmNumber, e);
72+
}
73+
});
74+
} catch (URISyntaxException | IOException | GeneralSecurityException e) {
75+
throw new RuntimeException("Failed to initialise mock web server", e);
76+
}
77+
}
78+
79+
@AfterClass
80+
public static void shutdownWebserver() {
81+
httpsServer.stop(0);
82+
httpsServer = null;
83+
}
84+
85+
private static ElasticsearchCluster initTestCluster() {
86+
return ElasticsearchCluster.local()
87+
.nodes(1)
88+
.module("analysis-common")
89+
.setting("xpack.license.self_generated.type", "trial")
90+
.setting("xpack.security.enabled", "true")
91+
.setting("xpack.security.authc.token.enabled", "true")
92+
.setting("xpack.security.authc.api_key.enabled", "true")
93+
.setting("xpack.security.http.ssl.enabled", "true")
94+
.setting("xpack.security.http.ssl.certificate", "node.crt")
95+
.setting("xpack.security.http.ssl.key", "node.key")
96+
.setting("xpack.security.http.ssl.certificate_authorities", "ca.crt")
97+
.setting("xpack.security.transport.ssl.enabled", "true")
98+
.setting("xpack.security.transport.ssl.certificate", "node.crt")
99+
.setting("xpack.security.transport.ssl.key", "node.key")
100+
.setting("xpack.security.transport.ssl.certificate_authorities", "ca.crt")
101+
.setting("xpack.security.transport.ssl.verification_mode", "certificate")
102+
.keystore("bootstrap.password", "x-pack-test-password")
103+
.user("test_admin", "x-pack-test-password", User.ROOT_USER_ROLE, true)
104+
.user("rest_test", "rest_password", User.ROOT_USER_ROLE, false)
105+
.configFile("node.key", Resource.fromClasspath("ssl/node.key"))
106+
.configFile("node.crt", Resource.fromClasspath("ssl/node.crt"))
107+
.configFile("ca.crt", Resource.fromClasspath("ssl/ca.crt"))
108+
.settings(node -> {
109+
var acsWebServerAddress = getAcsHttpsAddress();
110+
var acsHttps = "https://" + acsWebServerAddress.getHostName() + ":" + acsWebServerAddress.getPort() + "/";
111+
var idpWebServerAddress = getIdpHttpsAddress();
112+
var idpHttps = "https://" + idpWebServerAddress.getHostName() + ":" + idpWebServerAddress.getPort() + "/";
113+
var settings = new HashMap<String, String>();
114+
for (int realmNumber : List.of(1, 2, 3)) {
115+
var prefix = "xpack.security.authc.realms.saml.saml" + realmNumber;
116+
var idpEntityId = getIdpEntityId(realmNumber);
117+
settings.put(prefix + ".order", String.valueOf(realmNumber));
118+
settings.put(prefix + ".idp.entity_id", idpEntityId);
119+
settings.put(prefix + ".idp.metadata.path", idpHttps + "metadata/" + realmNumber + ".xml");
120+
settings.put(prefix + ".sp.entity_id", "https://sp" + realmNumber + ".example.org/");
121+
settings.put(prefix + ".sp.acs", acsHttps + "acs/" + realmNumber);
122+
settings.put(prefix + ".attributes.principal", "urn:oid:2.5.4.3");
123+
settings.put(prefix + ".ssl.certificate_authorities", "ca.crt");
124+
}
125+
return settings;
126+
})
127+
.build();
128+
}
129+
130+
private static Path getDataResource(String relativePath) throws URISyntaxException {
131+
return PathUtils.get(SamlRestTestCase.class.getResource(relativePath).toURI());
132+
}
133+
134+
protected static String getIdpEntityId(int realmNumber) {
135+
return "https://idp" + realmNumber + ".example.org/";
136+
}
137+
138+
protected static String getSamlRealmName(int realmNumber) {
139+
return "saml" + realmNumber;
140+
}
141+
142+
protected static void makeMetadataAvailable(int... realms) {
143+
for (int r : realms) {
144+
metadataAvailable.put(Integer.valueOf(r), true);
145+
}
146+
}
147+
148+
protected static void makeAllMetadataUnavailable() {
149+
metadataAvailable.keySet().forEach(k -> metadataAvailable.put(k, false));
150+
}
151+
152+
protected static InetSocketAddress getAcsHttpsAddress() {
153+
return httpsServer.getAddress();
154+
}
155+
156+
protected static InetSocketAddress getIdpHttpsAddress() {
157+
return httpsServer.getAddress();
158+
}
159+
160+
private static void configureMetadataResource(int realmNumber) throws CertificateException, IOException, URISyntaxException {
161+
metadataAvailable.putIfAbsent(realmNumber, false);
162+
163+
var signingCert = getDataResource(SAML_SIGNING_CRT);
164+
var metadataBody = new SamlIdpMetadataBuilder().entityId(getIdpEntityId(realmNumber)).sign(signingCert).asString();
165+
httpsServer.createContext("/metadata/" + realmNumber + ".xml", http -> {
166+
if (metadataAvailable.get(realmNumber)) {
167+
sendXmlContent(metadataBody, http);
168+
} else {
169+
if (randomBoolean()) {
170+
http.sendResponseHeaders(randomFrom(404, 401, 403, 500), 0);
171+
http.getResponseBody().close();
172+
} else {
173+
sendXmlContent("not valid xml", http);
174+
}
175+
}
176+
});
177+
}
178+
179+
private static void sendXmlContent(String bodyContent, HttpExchange http) throws IOException {
180+
http.getResponseHeaders().add("Content-Type", "text/xml");
181+
http.sendResponseHeaders(200, bodyContent.length());
182+
try (var out = http.getResponseBody()) {
183+
out.write(bodyContent.getBytes(StandardCharsets.UTF_8));
184+
}
185+
}
186+
187+
@BeforeClass
188+
public static void loadCertificateAuthority() throws Exception {
189+
URL resource = SamlRestTestCase.class.getResource("/ssl/ca.crt");
190+
if (resource == null) {
191+
throw new FileNotFoundException("Cannot find classpath resource /ssl/ca.crt");
192+
}
193+
caPath = PathUtils.get(resource.toURI());
194+
}
195+
196+
@Override
197+
protected String getTestRestCluster() {
198+
return cluster.getHttpAddresses();
199+
}
200+
201+
@Override
202+
protected String getProtocol() {
203+
return "https";
204+
}
205+
206+
@Override
207+
protected Settings restAdminSettings() {
208+
final String token = basicAuthHeaderValue("test_admin", new SecureString("x-pack-test-password".toCharArray()));
209+
return Settings.builder().put(ThreadContext.PREFIX + ".Authorization", token).put(CERTIFICATE_AUTHORITIES, caPath).build();
210+
}
211+
212+
@Override
213+
protected Settings restClientSettings() {
214+
final String token = basicAuthHeaderValue("rest_test", new SecureString("rest_password".toCharArray()));
215+
return Settings.builder().put(ThreadContext.PREFIX + ".Authorization", token).put(CERTIFICATE_AUTHORITIES, caPath).build();
216+
}
217+
}

0 commit comments

Comments
 (0)