Skip to content

Commit b83cdaa

Browse files
committed
Implement SAML custom attributes support for Identity Provider
This commit adds support for custom attributes in SAML single sign-on requests in the Elasticsearch X-Pack Identity Provider plugin. This feature allows passage of custom key-value attributes in SAML requests and responses. Key components: - Added SamlInitiateSingleSignOnAttributes class for holding attributes - Added validation for null and empty attribute keys - Updated request and response objects to handle attributes - Modified authentication flow to process attributes - Added test coverage to validate attributes functionality The implementation follows Elasticsearch patterns with robust validation and serialization mechanisms, while maintaining backward compatibility.
1 parent c2561b5 commit b83cdaa

File tree

9 files changed

+621
-20
lines changed

9 files changed

+621
-20
lines changed

x-pack/plugin/identity-provider/qa/idp-rest-tests/src/javaRestTest/java/org/elasticsearch/xpack/idp/IdentityProviderAuthenticationIT.java

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@
1818
import org.elasticsearch.common.util.concurrent.ThreadContext;
1919
import org.elasticsearch.core.Nullable;
2020
import org.elasticsearch.xcontent.ObjectPath;
21+
import org.elasticsearch.xcontent.XContentBuilder;
2122
import org.elasticsearch.xcontent.json.JsonXContent;
2223
import org.elasticsearch.xpack.core.security.action.saml.SamlPrepareAuthenticationResponse;
2324
import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderIndex;
2425
import org.junit.Before;
2526

2627
import java.io.IOException;
2728
import java.nio.charset.StandardCharsets;
29+
import java.util.ArrayList;
2830
import java.util.Base64;
2931
import java.util.List;
3032
import java.util.Map;
@@ -74,6 +76,47 @@ public void testRegistrationAndIdpInitiatedSso() throws Exception {
7476
authenticateWithSamlResponse(samlResponse, null);
7577
}
7678

79+
public void testCustomAttributesInIdpInitiatedSso() throws Exception {
80+
final Map<String, Object> request = Map.ofEntries(
81+
Map.entry("name", "Test SP With Custom Attributes"),
82+
Map.entry("acs", SP_ACS),
83+
Map.entry("privileges", Map.ofEntries(Map.entry("resource", SP_ENTITY_ID), Map.entry("roles", List.of("sso:(\\w+)")))),
84+
Map.entry(
85+
"attributes",
86+
Map.ofEntries(
87+
Map.entry("principal", "https://idp.test.es.elasticsearch.org/attribute/principal"),
88+
Map.entry("name", "https://idp.test.es.elasticsearch.org/attribute/name"),
89+
Map.entry("email", "https://idp.test.es.elasticsearch.org/attribute/email"),
90+
Map.entry("roles", "https://idp.test.es.elasticsearch.org/attribute/roles")
91+
)
92+
)
93+
);
94+
final SamlServiceProviderIndex.DocumentVersion docVersion = createServiceProvider(SP_ENTITY_ID, request);
95+
checkIndexDoc(docVersion);
96+
ensureGreen(SamlServiceProviderIndex.INDEX_NAME);
97+
98+
// Create custom attributes
99+
List<Map<String, Object>> attributesList = new ArrayList<>();
100+
Map<String, Object> attr1 = Map.of("key", "department", "values", List.of("engineering", "product"));
101+
Map<String, Object> attr2 = Map.of("key", "region", "values", List.of("APAC"));
102+
attributesList.add(attr1);
103+
attributesList.add(attr2);
104+
105+
final Map<String, Object> requestAttributes = Map.of("attributes", attributesList);
106+
107+
// Generate SAML response with custom attributes
108+
final String samlResponse = generateSamlResponseWithAttributes(SP_ENTITY_ID, SP_ACS, null, requestAttributes);
109+
110+
// Verify the response includes our custom attributes
111+
assertThat(samlResponse, containsString("department"));
112+
assertThat(samlResponse, containsString("engineering"));
113+
assertThat(samlResponse, containsString("product"));
114+
assertThat(samlResponse, containsString("region"));
115+
assertThat(samlResponse, containsString("APAC"));
116+
117+
authenticateWithSamlResponse(samlResponse, null);
118+
}
119+
77120
public void testRegistrationAndSpInitiatedSso() throws Exception {
78121
final Map<String, Object> request = Map.ofEntries(
79122
Map.entry("name", "Test SP"),
@@ -125,17 +168,37 @@ private SamlPrepareAuthenticationResponse generateSamlAuthnRequest(String realmN
125168
}
126169
}
127170

128-
private String generateSamlResponse(String entityId, String acs, @Nullable Map<String, Object> authnState) throws Exception {
171+
private String generateSamlResponse(String entityId, String acs, @Nullable Map<String, Object> authnState) throws IOException {
172+
return generateSamlResponseWithAttributes(entityId, acs, authnState, null);
173+
}
174+
175+
private String generateSamlResponseWithAttributes(
176+
String entityId,
177+
String acs,
178+
@Nullable Map<String, Object> authnState,
179+
@Nullable Map<String, Object> attributes
180+
) throws IOException {
129181
final Request request = new Request("POST", "/_idp/saml/init");
130-
if (authnState != null && authnState.isEmpty() == false) {
131-
request.setJsonEntity(Strings.format("""
132-
{"entity_id":"%s", "acs":"%s","authn_state":%s}
133-
""", entityId, acs, Strings.toString(JsonXContent.contentBuilder().map(authnState))));
134-
} else {
135-
request.setJsonEntity(Strings.format("""
136-
{"entity_id":"%s", "acs":"%s"}
137-
""", entityId, acs));
182+
183+
XContentBuilder builder = JsonXContent.contentBuilder();
184+
builder.startObject();
185+
builder.field("entity_id", entityId);
186+
builder.field("acs", acs);
187+
188+
if (authnState != null) {
189+
builder.field("authn_state");
190+
builder.map(authnState);
138191
}
192+
193+
if (attributes != null) {
194+
builder.field("attributes");
195+
builder.map(attributes);
196+
}
197+
198+
builder.endObject();
199+
String jsonEntity = Strings.toString(builder);
200+
201+
request.setJsonEntity(jsonEntity);
139202
request.setOptions(
140203
RequestOptions.DEFAULT.toBuilder()
141204
.addHeader("es-secondary-authorization", basicAuthHeaderValue("idp_user", new SecureString("idp-password".toCharArray())))

x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnRequest.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import org.elasticsearch.common.io.stream.StreamInput;
1313
import org.elasticsearch.common.io.stream.StreamOutput;
1414
import org.elasticsearch.xpack.idp.saml.support.SamlAuthenticationState;
15+
import org.elasticsearch.xpack.idp.saml.support.SamlInitiateSingleSignOnAttributes;
1516

1617
import java.io.IOException;
1718

@@ -22,12 +23,14 @@ public class SamlInitiateSingleSignOnRequest extends ActionRequest {
2223
private String spEntityId;
2324
private String assertionConsumerService;
2425
private SamlAuthenticationState samlAuthenticationState;
26+
private SamlInitiateSingleSignOnAttributes attributes;
2527

2628
public SamlInitiateSingleSignOnRequest(StreamInput in) throws IOException {
2729
super(in);
2830
spEntityId = in.readString();
2931
assertionConsumerService = in.readString();
3032
samlAuthenticationState = in.readOptionalWriteable(SamlAuthenticationState::new);
33+
attributes = in.readOptionalWriteable(SamlInitiateSingleSignOnAttributes::new);
3134
}
3235

3336
public SamlInitiateSingleSignOnRequest() {}
@@ -68,17 +71,36 @@ public void setSamlAuthenticationState(SamlAuthenticationState samlAuthenticatio
6871
this.samlAuthenticationState = samlAuthenticationState;
6972
}
7073

74+
public SamlInitiateSingleSignOnAttributes getAttributes() {
75+
return attributes;
76+
}
77+
78+
public void setAttributes(SamlInitiateSingleSignOnAttributes attributes) {
79+
this.attributes = attributes;
80+
}
81+
7182
@Override
7283
public void writeTo(StreamOutput out) throws IOException {
7384
super.writeTo(out);
7485
out.writeString(spEntityId);
7586
out.writeString(assertionConsumerService);
7687
out.writeOptionalWriteable(samlAuthenticationState);
88+
out.writeOptionalWriteable(attributes);
7789
}
7890

7991
@Override
8092
public String toString() {
81-
return getClass().getSimpleName() + "{spEntityId='" + spEntityId + "', acs='" + assertionConsumerService + "'}";
93+
return getClass().getSimpleName()
94+
+ "{"
95+
+ "spEntityId='"
96+
+ spEntityId
97+
+ "', "
98+
+ "acs='"
99+
+ assertionConsumerService
100+
+ "', "
101+
+ "attributes="
102+
+ attributes
103+
+ "'}";
82104
}
83105

84106
}

x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnResponse.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import org.elasticsearch.common.io.stream.StreamOutput;
1111
import org.elasticsearch.core.Nullable;
1212
import org.elasticsearch.xcontent.XContentBuilder;
13+
import org.elasticsearch.xpack.idp.saml.support.SamlInitiateSingleSignOnAttributes;
1314

1415
import java.io.IOException;
1516

@@ -20,19 +21,32 @@ public class SamlInitiateSingleSignOnResponse extends ActionResponse {
2021
private final String entityId;
2122
private final String samlStatus;
2223
private final String error;
24+
private final SamlInitiateSingleSignOnAttributes attributes;
2325

2426
public SamlInitiateSingleSignOnResponse(
2527
String entityId,
2628
String postUrl,
2729
String samlResponse,
2830
String samlStatus,
2931
@Nullable String error
32+
) {
33+
this(entityId, postUrl, samlResponse, samlStatus, error, null);
34+
}
35+
36+
public SamlInitiateSingleSignOnResponse(
37+
String entityId,
38+
String postUrl,
39+
String samlResponse,
40+
String samlStatus,
41+
@Nullable String error,
42+
@Nullable SamlInitiateSingleSignOnAttributes attributes
3043
) {
3144
this.entityId = entityId;
3245
this.postUrl = postUrl;
3346
this.samlResponse = samlResponse;
3447
this.samlStatus = samlStatus;
3548
this.error = error;
49+
this.attributes = attributes;
3650
}
3751

3852
public String getPostUrl() {
@@ -55,13 +69,18 @@ public String getSamlStatus() {
5569
return samlStatus;
5670
}
5771

72+
public SamlInitiateSingleSignOnAttributes getAttributes() {
73+
return attributes;
74+
}
75+
5876
@Override
5977
public void writeTo(StreamOutput out) throws IOException {
6078
out.writeString(entityId);
6179
out.writeString(postUrl);
6280
out.writeString(samlResponse);
6381
out.writeString(samlStatus);
6482
out.writeOptionalString(error);
83+
out.writeOptionalWriteable(attributes);
6584
}
6685

6786
public void toXContent(XContentBuilder builder) throws IOException {

x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/TransportSamlInitiateSingleSignOnAction.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ protected void doExecute(
139139
identityProvider
140140
);
141141
try {
142-
final Response response = builder.build(user, authenticationState);
142+
final Response response = builder.build(user, authenticationState, request.getAttributes());
143143
listener.onResponse(
144144
new SamlInitiateSingleSignOnResponse(
145145
user.getServiceProvider().getEntityId(),

x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/authn/SuccessfulAuthenticationResponseMessageBuilder.java

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import org.elasticsearch.xpack.idp.saml.support.SamlAuthenticationState;
1919
import org.elasticsearch.xpack.idp.saml.support.SamlFactory;
2020
import org.elasticsearch.xpack.idp.saml.support.SamlInit;
21+
import org.elasticsearch.xpack.idp.saml.support.SamlInitiateSingleSignOnAttributes;
2122
import org.elasticsearch.xpack.idp.saml.support.SamlObjectSigner;
2223
import org.opensaml.core.xml.schema.XSString;
2324
import org.opensaml.saml.saml2.core.Assertion;
@@ -66,7 +67,11 @@ public SuccessfulAuthenticationResponseMessageBuilder(SamlFactory samlFactory, C
6667
this.idp = idp;
6768
}
6869

69-
public Response build(UserServiceAuthentication user, @Nullable SamlAuthenticationState authnState) {
70+
public Response build(
71+
UserServiceAuthentication user,
72+
@Nullable SamlAuthenticationState authnState,
73+
@Nullable SamlInitiateSingleSignOnAttributes customAttributes
74+
) {
7075
logger.debug("Building success response for [{}] from [{}]", user, authnState);
7176
final Instant now = clock.instant();
7277
final SamlServiceProvider serviceProvider = user.getServiceProvider();
@@ -87,10 +92,13 @@ public Response build(UserServiceAuthentication user, @Nullable SamlAuthenticati
8792
assertion.setIssueInstant(now);
8893
assertion.setConditions(buildConditions(now, serviceProvider));
8994
assertion.setSubject(buildSubject(now, user, authnState));
90-
assertion.getAuthnStatements().add(buildAuthnStatement(now, user));
91-
final AttributeStatement attributes = buildAttributes(user);
92-
if (attributes != null) {
93-
assertion.getAttributeStatements().add(attributes);
95+
96+
final AuthnStatement authnStatement = buildAuthnStatement(now, user);
97+
assertion.getAuthnStatements().add(authnStatement);
98+
99+
final AttributeStatement attributeStatement = buildAttributes(user, customAttributes);
100+
if (attributeStatement != null) {
101+
assertion.getAttributeStatements().add(attributeStatement);
94102
}
95103
response.getAssertions().add(assertion);
96104
return sign(response);
@@ -179,7 +187,10 @@ private static String resolveAuthnClass(Set<AuthenticationMethod> authentication
179187
}
180188
}
181189

182-
private AttributeStatement buildAttributes(UserServiceAuthentication user) {
190+
private AttributeStatement buildAttributes(
191+
UserServiceAuthentication user,
192+
@Nullable SamlInitiateSingleSignOnAttributes customAttributes
193+
) {
183194
final SamlServiceProvider serviceProvider = user.getServiceProvider();
184195
final AttributeStatement statement = samlFactory.object(AttributeStatement.class, AttributeStatement.DEFAULT_ELEMENT_NAME);
185196
final List<Attribute> attributes = new ArrayList<>();
@@ -199,6 +210,16 @@ private AttributeStatement buildAttributes(UserServiceAuthentication user) {
199210
if (name != null) {
200211
attributes.add(name);
201212
}
213+
// Add custom attributes if provided
214+
if (customAttributes != null && customAttributes.getAttributes().isEmpty() == false) {
215+
for (SamlInitiateSingleSignOnAttributes.Attribute customAttr : customAttributes.getAttributes()) {
216+
Attribute attribute = buildAttribute(customAttr.getKey(), customAttr.getKey(), customAttr.getValues());
217+
if (attribute != null) {
218+
attributes.add(attribute);
219+
}
220+
}
221+
}
222+
202223
if (attributes.isEmpty()) {
203224
return null;
204225
}

x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/rest/action/RestSamlInitiateSingleSignOnAction.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import org.elasticsearch.xpack.idp.action.SamlInitiateSingleSignOnRequest;
2121
import org.elasticsearch.xpack.idp.action.SamlInitiateSingleSignOnResponse;
2222
import org.elasticsearch.xpack.idp.saml.support.SamlAuthenticationState;
23+
import org.elasticsearch.xpack.idp.saml.support.SamlInitiateSingleSignOnAttributes;
2324

2425
import java.io.IOException;
2526
import java.util.Collections;
@@ -41,6 +42,11 @@ public class RestSamlInitiateSingleSignOnAction extends IdpBaseRestHandler {
4142
(p, c) -> SamlAuthenticationState.fromXContent(p),
4243
new ParseField("authn_state")
4344
);
45+
PARSER.declareObject(
46+
SamlInitiateSingleSignOnRequest::setAttributes,
47+
(p, c) -> SamlInitiateSingleSignOnAttributes.fromXContent(p),
48+
new ParseField("attributes")
49+
);
4450
}
4551

4652
public RestSamlInitiateSingleSignOnAction(XPackLicenseState licenseState) {

0 commit comments

Comments
 (0)