Skip to content

Commit 23e4d46

Browse files
authored
Merge claims from userinfo and ID Token correctly (#42277)
Enhance the handling of merging the claims sets of the ID Token and the UserInfo response. JsonObject#merge would throw a runtime exception when attempting to merge two objects with the same key and different values. This could happen for an OP that returns different vales for the same claim in the ID Token and the UserInfo response ( Google does that for profile claim ). If a claim is contained in both sets, we attempt to merge the values if they are objects or arrays, otherwise the ID Token claim value takes presedence and overwrites the userinfo response.
1 parent d589cad commit 23e4d46

File tree

2 files changed

+226
-3
lines changed

2 files changed

+226
-3
lines changed

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectAuthenticator.java

+86-2
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import com.nimbusds.openid.connect.sdk.token.OIDCTokens;
3838
import com.nimbusds.openid.connect.sdk.validators.AccessTokenValidator;
3939
import com.nimbusds.openid.connect.sdk.validators.IDTokenValidator;
40+
import net.minidev.json.JSONArray;
4041
import net.minidev.json.JSONObject;
4142
import org.apache.commons.codec.Charsets;
4243
import org.apache.http.Header;
@@ -401,15 +402,16 @@ private void handleUserinfoResponse(HttpResponse httpResponse, JWTClaimsSet veri
401402
if (httpResponse.getStatusLine().getStatusCode() == 200) {
402403
if (ContentType.parse(contentHeader.getValue()).getMimeType().equals("application/json")) {
403404
final JWTClaimsSet userInfoClaims = JWTClaimsSet.parse(contentAsString);
405+
validateUserInfoResponse(userInfoClaims, verifiedIdTokenClaims.getSubject(), claimsListener);
404406
if (LOGGER.isTraceEnabled()) {
405407
LOGGER.trace("Successfully retrieved user information: [{}]", userInfoClaims.toJSONObject().toJSONString());
406408
}
407409
final JSONObject combinedClaims = verifiedIdTokenClaims.toJSONObject();
408-
combinedClaims.merge(userInfoClaims.toJSONObject());
410+
mergeObjects(combinedClaims, userInfoClaims.toJSONObject());
409411
claimsListener.onResponse(JWTClaimsSet.parse(combinedClaims));
410412
} else if (ContentType.parse(contentHeader.getValue()).getMimeType().equals("application/jwt")) {
411413
//TODO Handle validating possibly signed responses
412-
claimsListener.onFailure(new IllegalStateException("Unable to parse Userinfo Response. Signed/encryopted JWTs are" +
414+
claimsListener.onFailure(new IllegalStateException("Unable to parse Userinfo Response. Signed/encrypted JWTs are" +
413415
"not currently supported"));
414416
} else {
415417
claimsListener.onFailure(new IllegalStateException("Unable to parse Userinfo Response. Content type was expected to " +
@@ -435,6 +437,19 @@ private void handleUserinfoResponse(HttpResponse httpResponse, JWTClaimsSet veri
435437
}
436438
}
437439

440+
/**
441+
* Validates that the userinfo response contains a sub Claim and that this claim value is the same as the one returned in the ID Token
442+
*/
443+
private void validateUserInfoResponse(JWTClaimsSet userInfoClaims, String expectedSub, ActionListener<JWTClaimsSet> claimsListener) {
444+
if (userInfoClaims.getSubject().isEmpty()) {
445+
claimsListener.onFailure(new ElasticsearchSecurityException("Userinfo Response did not contain a sub Claim"));
446+
} else if (userInfoClaims.getSubject().equals(expectedSub) == false) {
447+
claimsListener.onFailure(new ElasticsearchSecurityException("Userinfo Response is not valid as it is for " +
448+
"subject [{}] while the ID Token was for subject [{}]", userInfoClaims.getSubject(),
449+
expectedSub));
450+
}
451+
}
452+
438453
/**
439454
* Attempts to make a request to the Token Endpoint of the OpenID Connect provider in order to exchange an
440455
* authorization code for an Id Token (and potentially an Access Token)
@@ -606,6 +621,75 @@ private void setMetadataFileWatcher(String jwkSetPath) throws IOException {
606621
watcherService.add(watcher, ResourceWatcherService.Frequency.MEDIUM);
607622
}
608623

624+
/**
625+
* Merges the JsonObject with the claims of the ID Token with the JsonObject with the claims of the UserInfo response. This is
626+
* necessary as some OPs return slightly different values for some claims (i.e. Google for the profile picture) and
627+
* {@link JSONObject#merge(Object)} would throw a runtime exception. The merging is performed based on the following rules:
628+
* <ul>
629+
* <li>If the values for a given claim are primitives (of the the same type), the value from the ID Token is retained</li>
630+
* <li>If the values for a given claim are Objects, the values are merged</li>
631+
* <li>If the values for a given claim are Arrays, the values are merged without removing duplicates</li>
632+
* <li>If the values for a given claim are of different types, an exception is thrown</li>
633+
* </ul>
634+
*
635+
* @param userInfo The JsonObject with the ID Token claims
636+
* @param idToken The JsonObject with the UserInfo Response claims
637+
* @return the merged JsonObject
638+
*/
639+
// pkg protected for testing
640+
static JSONObject mergeObjects(JSONObject idToken, JSONObject userInfo) {
641+
for (Map.Entry<String, Object> entry : idToken.entrySet()) {
642+
Object value1 = entry.getValue();
643+
Object value2 = userInfo.get(entry.getKey());
644+
if (value2 == null) {
645+
continue;
646+
}
647+
if (value1 instanceof JSONArray) {
648+
idToken.put(entry.getKey(), mergeArrays((JSONArray) value1, value2));
649+
} else if (value1 instanceof JSONObject) {
650+
idToken.put(entry.getKey(), mergeObjects((JSONObject) value1, value2));
651+
} else if (value1.getClass().equals(value2.getClass()) == false) {
652+
throw new IllegalStateException("Error merging ID token and userinfo claim value for claim [" + entry.getKey() + "]. " +
653+
"Cannot merge [" + value1.getClass().getName() + "] with [" + value2.getClass().getName() + "]");
654+
}
655+
}
656+
for (Map.Entry<String, Object> entry : userInfo.entrySet()) {
657+
if (idToken.containsKey(entry.getKey()) == false) {
658+
idToken.put(entry.getKey(), entry.getValue());
659+
}
660+
}
661+
return idToken;
662+
}
663+
664+
private static JSONObject mergeObjects(JSONObject jsonObject1, Object jsonObject2) {
665+
if (jsonObject2 == null) {
666+
return jsonObject1;
667+
}
668+
if (jsonObject2 instanceof JSONObject) {
669+
return mergeObjects(jsonObject1, (JSONObject) jsonObject2);
670+
}
671+
throw new IllegalStateException("Error while merging ID token and userinfo claims. " +
672+
"Cannot merge JSONObject with [" + jsonObject2.getClass().getName() + "]");
673+
}
674+
675+
private static JSONArray mergeArrays(JSONArray jsonArray1, Object jsonArray2) {
676+
if (jsonArray2 == null) {
677+
return jsonArray1;
678+
}
679+
if (jsonArray2 instanceof JSONArray) {
680+
return mergeArrays(jsonArray1, (JSONArray) jsonArray2);
681+
}
682+
if (jsonArray2 instanceof String) {
683+
jsonArray1.add(jsonArray2);
684+
}
685+
return jsonArray1;
686+
}
687+
688+
private static JSONArray mergeArrays(JSONArray jsonArray1, JSONArray jsonArray2) {
689+
jsonArray1.addAll(jsonArray2);
690+
return jsonArray1;
691+
}
692+
609693
protected void close() {
610694
try {
611695
this.httpClient.close();

x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/oidc/OpenIdConnectAuthenticatorTests.java

+140-1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
import com.nimbusds.openid.connect.sdk.claims.AccessTokenHash;
3838
import com.nimbusds.openid.connect.sdk.validators.IDTokenValidator;
3939
import com.nimbusds.openid.connect.sdk.validators.InvalidHashException;
40+
import net.minidev.json.JSONArray;
41+
import net.minidev.json.JSONObject;
4042
import org.elasticsearch.ElasticsearchSecurityException;
4143
import org.elasticsearch.action.ActionListener;
4244
import org.elasticsearch.action.support.PlainActionFuture;
@@ -72,6 +74,7 @@
7274
import java.util.UUID;
7375

7476
import static java.time.Instant.now;
77+
import static org.hamcrest.Matchers.containsInAnyOrder;
7578
import static org.hamcrest.Matchers.containsString;
7679
import static org.hamcrest.Matchers.equalTo;
7780
import static org.hamcrest.Matchers.instanceOf;
@@ -96,7 +99,9 @@ public void setup() {
9699

97100
@After
98101
public void cleanup() {
99-
authenticator.close();
102+
if (authenticator != null) {
103+
authenticator.close();
104+
}
100105
}
101106

102107
private OpenIdConnectAuthenticator buildAuthenticator() throws URISyntaxException {
@@ -632,6 +637,140 @@ public void testImplicitFlowFailsWithUnsignedJwt() throws Exception {
632637
assertThat(e.getCause().getMessage(), containsString("Signed ID token expected"));
633638
}
634639

640+
public void testJsonObjectMerging() throws Exception {
641+
final Nonce nonce = new Nonce();
642+
final String subject = "janedoe";
643+
final Tuple<Key, JWKSet> keyMaterial = getRandomJwkForType(randomFrom("ES", "RS"));
644+
final JWK jwk = keyMaterial.v2().getKeys().get(0);
645+
RelyingPartyConfiguration rpConfig = getRpConfig(jwk.getAlgorithm().getName());
646+
OpenIdConnectProviderConfiguration opConfig = getOpConfig();
647+
JSONObject address = new JWTClaimsSet.Builder()
648+
.claim("street_name", "12, Test St.")
649+
.claim("locality", "New York")
650+
.claim("region", "NY")
651+
.claim("country", "USA")
652+
.build()
653+
.toJSONObject();
654+
JSONObject idTokenObject = new JWTClaimsSet.Builder()
655+
.jwtID(randomAlphaOfLength(8))
656+
.audience(rpConfig.getClientId().getValue())
657+
.expirationTime(Date.from(now().plusSeconds(3600)))
658+
.issuer(opConfig.getIssuer().getValue())
659+
.issueTime(Date.from(now().minusSeconds(200)))
660+
.notBeforeTime(Date.from(now().minusSeconds(200)))
661+
.claim("nonce", nonce)
662+
.claim("given_name", "Jane Doe")
663+
.claim("family_name", "Doe")
664+
.claim("profile", "https://test-profiles.com/jane.doe")
665+
.claim("name", "Jane")
666+
.claim("email", "[email protected]")
667+
.claim("roles", new JSONArray().appendElement("role1").appendElement("role2").appendElement("role3"))
668+
.claim("address", address)
669+
.subject(subject)
670+
.build()
671+
.toJSONObject();
672+
673+
JSONObject userinfoObject = new JWTClaimsSet.Builder()
674+
.claim("given_name", "Jane Doe")
675+
.claim("family_name", "Doe")
676+
.claim("profile", "https://test-profiles.com/jane.doe")
677+
.claim("name", "Jane")
678+
.claim("email", "[email protected]")
679+
.subject(subject)
680+
.build()
681+
.toJSONObject();
682+
683+
OpenIdConnectAuthenticator.mergeObjects(idTokenObject, userinfoObject);
684+
assertTrue(idTokenObject.containsKey("given_name"));
685+
assertTrue(idTokenObject.containsKey("family_name"));
686+
assertTrue(idTokenObject.containsKey("profile"));
687+
assertTrue(idTokenObject.containsKey("name"));
688+
assertTrue(idTokenObject.containsKey("email"));
689+
assertTrue(idTokenObject.containsKey("address"));
690+
assertTrue(idTokenObject.containsKey("roles"));
691+
assertTrue(idTokenObject.containsKey("nonce"));
692+
assertTrue(idTokenObject.containsKey("sub"));
693+
assertTrue(idTokenObject.containsKey("jti"));
694+
assertTrue(idTokenObject.containsKey("aud"));
695+
assertTrue(idTokenObject.containsKey("exp"));
696+
assertTrue(idTokenObject.containsKey("iss"));
697+
assertTrue(idTokenObject.containsKey("iat"));
698+
assertTrue(idTokenObject.containsKey("email"));
699+
700+
// Claims with different types throw an error
701+
JSONObject wrongTypeInfo = new JWTClaimsSet.Builder()
702+
.claim("given_name", "Jane Doe")
703+
.claim("family_name", 123334434)
704+
.claim("profile", "https://test-profiles.com/jane.doe")
705+
.claim("name", "Jane")
706+
.claim("email", "[email protected]")
707+
.subject(subject)
708+
.build()
709+
.toJSONObject();
710+
711+
final IllegalStateException e = expectThrows(IllegalStateException.class, () -> {
712+
OpenIdConnectAuthenticator.mergeObjects(idTokenObject, wrongTypeInfo);
713+
});
714+
715+
// Userinfo Claims overwrite ID Token claims
716+
JSONObject overwriteUserInfo = new JWTClaimsSet.Builder()
717+
.claim("given_name", "Jane Doe")
718+
.claim("family_name", "Doe")
719+
.claim("profile", "https://test-profiles.com/jane.doe2")
720+
.claim("name", "Jane")
721+
.claim("email", "[email protected]")
722+
.subject(subject)
723+
.build()
724+
.toJSONObject();
725+
726+
OpenIdConnectAuthenticator.mergeObjects(idTokenObject, overwriteUserInfo);
727+
assertThat(idTokenObject.getAsString("email"), equalTo("[email protected]"));
728+
assertThat(idTokenObject.getAsString("profile"), equalTo("https://test-profiles.com/jane.doe"));
729+
730+
// Merging Arrays
731+
JSONObject userInfoWithRoles = new JWTClaimsSet.Builder()
732+
.claim("given_name", "Jane Doe")
733+
.claim("family_name", "Doe")
734+
.claim("profile", "https://test-profiles.com/jane.doe")
735+
.claim("name", "Jane")
736+
.claim("email", "[email protected]")
737+
.claim("roles", new JSONArray().appendElement("role4").appendElement("role5"))
738+
.subject(subject)
739+
.build()
740+
.toJSONObject();
741+
742+
OpenIdConnectAuthenticator.mergeObjects(idTokenObject, userInfoWithRoles);
743+
assertThat((JSONArray) idTokenObject.get("roles"), containsInAnyOrder("role1", "role2", "role3", "role4", "role5"));
744+
745+
// Merging nested objects
746+
JSONObject addressUserInfo = new JWTClaimsSet.Builder()
747+
.claim("street_name", "12, Test St.")
748+
.claim("locality", "New York")
749+
.claim("postal_code", "10024")
750+
.build()
751+
.toJSONObject();
752+
JSONObject userInfoWithAddress = new JWTClaimsSet.Builder()
753+
.claim("given_name", "Jane Doe")
754+
.claim("family_name", "Doe")
755+
.claim("profile", "https://test-profiles.com/jane.doe")
756+
.claim("name", "Jane")
757+
.claim("email", "[email protected]")
758+
.claim("roles", new JSONArray().appendElement("role4").appendElement("role5"))
759+
.claim("address", addressUserInfo)
760+
.subject(subject)
761+
.build()
762+
.toJSONObject();
763+
OpenIdConnectAuthenticator.mergeObjects(idTokenObject, userInfoWithAddress);
764+
assertTrue(idTokenObject.containsKey("address"));
765+
JSONObject combinedAddress = (JSONObject) idTokenObject.get("address");
766+
assertTrue(combinedAddress.containsKey("street_name"));
767+
assertTrue(combinedAddress.containsKey("locality"));
768+
assertTrue(combinedAddress.containsKey("street_name"));
769+
assertTrue(combinedAddress.containsKey("postal_code"));
770+
assertTrue(combinedAddress.containsKey("region"));
771+
assertTrue(combinedAddress.containsKey("country"));
772+
}
773+
635774
private OpenIdConnectProviderConfiguration getOpConfig() throws URISyntaxException {
636775
return new OpenIdConnectProviderConfiguration(
637776
new Issuer("https://op.example.com"),

0 commit comments

Comments
 (0)