Fix ClassCastException on mixing AddressMapper with ClaimsMapper (#44457)

closes #44455


Signed-off-by: Pascal Knüppel <pascal.knueppel@governikus.de>
Signed-off-by: Captain-P-Goldfish <captain.p.goldfish@gmx.de>
This commit is contained in:
Pascal Knüppel 2025-12-01 14:55:44 +01:00 committed by GitHub
parent cd350082f7
commit 9b870d3d8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 389 additions and 35 deletions

View File

@ -17,6 +17,11 @@
package org.keycloak.representations;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
@ -49,6 +54,8 @@ public class AddressClaimSet {
@JsonProperty(COUNTRY)
protected String country;
protected Map<String, Object> otherClaims = new HashMap<>();
public String getFormattedAddress() {
return this.formattedAddress;
}
@ -97,4 +104,13 @@ public class AddressClaimSet {
this.country = country;
}
@JsonAnyGetter
public Map<String, Object> getOtherClaims() {
return otherClaims;
}
@JsonAnySetter
public void setOtherClaims(String name, Object value) {
otherClaims.put(name, value);
}
}

View File

@ -17,7 +17,11 @@
package org.keycloak.representations;
import java.util.Map;
import java.util.Optional;
import org.keycloak.TokenCategory;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
@ -127,7 +131,7 @@ public class IDToken extends JsonWebToken {
protected Boolean phoneNumberVerified;
@JsonProperty(ADDRESS)
protected AddressClaimSet address;
protected Map<String, Object> address;
@JsonProperty(UPDATED_AT)
protected Long updatedAt;
@ -328,14 +332,30 @@ public class IDToken extends JsonWebToken {
this.phoneNumberVerified = phoneNumberVerified;
}
public AddressClaimSet getAddress() {
@JsonProperty("address")
public Map<String, Object> getAddressClaimsMap() {
return address;
}
public void setAddress(AddressClaimSet address) {
@JsonIgnore
public AddressClaimSet getAddress() {
return Optional.ofNullable(address).map(a -> {
return JsonSerialization.mapper.convertValue(a, AddressClaimSet.class);
})
.orElse(null);
}
public void setAddress(Map<String, Object> address) {
this.address = address;
}
@JsonIgnore
public void setAddress(AddressClaimSet address) {
this.address = Optional.ofNullable(address)
.map(a -> JsonSerialization.mapper.convertValue(a, Map.class))
.orElse(null);
}
public Long getUpdatedAt() {
return this.updatedAt;
}

View File

@ -16,11 +16,14 @@
*/
package org.keycloak.representations;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import org.keycloak.json.StringOrArrayDeserializer;
import org.keycloak.json.StringOrArraySerializer;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
@ -97,7 +100,7 @@ public class UserInfo {
protected Boolean phoneNumberVerified;
@JsonProperty("address")
protected AddressClaimSet address;
protected Map<String, Object> address;
@JsonProperty("updated_at")
protected Long updatedAt;
@ -277,14 +280,30 @@ public class UserInfo {
this.phoneNumberVerified = phoneNumberVerified;
}
public AddressClaimSet getAddress() {
@JsonProperty("address")
public Map<String, Object> getAddressClaimsMap() {
return address;
}
public void setAddress(AddressClaimSet address) {
@JsonIgnore
public AddressClaimSet getAddress() {
return Optional.ofNullable(address).map(a -> {
return JsonSerialization.mapper.convertValue(a, AddressClaimSet.class);
})
.orElse(null);
}
public void setAddress(Map<String, Object> address) {
this.address = address;
}
@JsonIgnore
public void setAddress(AddressClaimSet address) {
this.address = Optional.ofNullable(address)
.map(a -> JsonSerialization.mapper.convertValue(a, Map.class))
.orElse(null);
}
public Long getUpdatedAt() {
return this.updatedAt;
}
@ -323,4 +342,13 @@ public class UserInfo {
public void setOtherClaims(String name, Object value) {
otherClaims.put(name, value);
}
@Override
public String toString() {
try {
return JsonSerialization.writeValueAsString(this);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -0,0 +1,46 @@
package org.keycloak.representations;
import java.util.Map;
import org.junit.Assert;
import org.junit.Test;
/**
* @author Pascal Knueppel
* @since 28.11.2025
*/
public class IDTokenTest {
/**
* makes sure that the setAddress and getAddress method for the idToken works as expected
*/
@Test
public void testSetAddressMethodWorks() {
AddressClaimSet addressClaimSet = new AddressClaimSet();
addressClaimSet.setFormattedAddress("test");
addressClaimSet.setCountry("test1");
addressClaimSet.setLocality("test2");
addressClaimSet.setStreetAddress("test3");
addressClaimSet.setRegion("test4");
addressClaimSet.setPostalCode("test5");
IDToken idToken = new IDToken();
idToken.setAddress(addressClaimSet);
AddressClaimSet parsedAddress = idToken.getAddress();
Assert.assertEquals(addressClaimSet.getFormattedAddress(), parsedAddress.getFormattedAddress());
Assert.assertEquals(addressClaimSet.getStreetAddress(), parsedAddress.getStreetAddress());
Assert.assertEquals(addressClaimSet.getCountry(), parsedAddress.getCountry());
Assert.assertEquals(addressClaimSet.getLocality(), parsedAddress.getLocality());
Assert.assertEquals(addressClaimSet.getRegion(), parsedAddress.getRegion());
Assert.assertEquals(addressClaimSet.getPostalCode(), parsedAddress.getPostalCode());
Map<String, Object> addressClaimsSet = idToken.getAddressClaimsMap();
Assert.assertEquals(addressClaimSet.getFormattedAddress(), addressClaimsSet.get("formatted"));
Assert.assertEquals(addressClaimSet.getStreetAddress(), addressClaimsSet.get("street_address"));
Assert.assertEquals(addressClaimSet.getCountry(), addressClaimsSet.get("country"));
Assert.assertEquals(addressClaimSet.getLocality(), addressClaimsSet.get("locality"));
Assert.assertEquals(addressClaimSet.getRegion(), addressClaimsSet.get("region"));
Assert.assertEquals(addressClaimSet.getPostalCode(), addressClaimsSet.get("postal_code"));
}
}

View File

@ -0,0 +1,46 @@
package org.keycloak.representations;
import java.util.Map;
import org.junit.Assert;
import org.junit.Test;
/**
* @author Pascal Knueppel
* @since 28.11.2025
*/
public class UserInfoTest {
/**
* makes sure that the setAddress and getAddress method for the userInfo works as expected
*/
@Test
public void testSetAddressMethodWorks() {
AddressClaimSet addressClaimSet = new AddressClaimSet();
addressClaimSet.setFormattedAddress("test");
addressClaimSet.setCountry("test1");
addressClaimSet.setLocality("test2");
addressClaimSet.setStreetAddress("test3");
addressClaimSet.setRegion("test4");
addressClaimSet.setPostalCode("test5");
UserInfo userInfo = new UserInfo();
userInfo.setAddress(addressClaimSet);
AddressClaimSet parsedAddress = userInfo.getAddress();
Assert.assertEquals(addressClaimSet.getFormattedAddress(), parsedAddress.getFormattedAddress());
Assert.assertEquals(addressClaimSet.getStreetAddress(), parsedAddress.getStreetAddress());
Assert.assertEquals(addressClaimSet.getCountry(), parsedAddress.getCountry());
Assert.assertEquals(addressClaimSet.getLocality(), parsedAddress.getLocality());
Assert.assertEquals(addressClaimSet.getRegion(), parsedAddress.getRegion());
Assert.assertEquals(addressClaimSet.getPostalCode(), parsedAddress.getPostalCode());
Map<String, Object> addressClaimsSet = userInfo.getAddressClaimsMap();
Assert.assertEquals(addressClaimSet.getFormattedAddress(), addressClaimsSet.get("formatted"));
Assert.assertEquals(addressClaimSet.getStreetAddress(), addressClaimsSet.get("street_address"));
Assert.assertEquals(addressClaimSet.getCountry(), addressClaimsSet.get("country"));
Assert.assertEquals(addressClaimSet.getLocality(), addressClaimsSet.get("locality"));
Assert.assertEquals(addressClaimSet.getRegion(), addressClaimsSet.get("region"));
Assert.assertEquals(addressClaimSet.getPostalCode(), addressClaimsSet.get("postal_code"));
}
}

View File

@ -21,6 +21,7 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.UserModel;
@ -123,14 +124,29 @@ public class AddressMapper extends AbstractOIDCProtocolMapper implements OIDCAcc
@Override
protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession) {
UserModel user = userSession.getUser();
AddressClaimSet addressSet = new AddressClaimSet();
addressSet.setStreetAddress(getUserModelAttributeValue(user, mappingModel, STREET));
addressSet.setLocality(getUserModelAttributeValue(user, mappingModel, AddressClaimSet.LOCALITY));
addressSet.setRegion(getUserModelAttributeValue(user, mappingModel, AddressClaimSet.REGION));
addressSet.setPostalCode(getUserModelAttributeValue(user, mappingModel, AddressClaimSet.POSTAL_CODE));
addressSet.setCountry(getUserModelAttributeValue(user, mappingModel, AddressClaimSet.COUNTRY));
addressSet.setFormattedAddress(getUserModelAttributeValue(user, mappingModel, AddressClaimSet.FORMATTED));
token.getOtherClaims().put("address", addressSet);
Map<String, Object> addressSet = Optional.ofNullable(token.getAddressClaimsMap()).orElseGet(() -> {
return Optional.ofNullable(token.getOtherClaims().get(IDToken.ADDRESS))
.filter(Map.class::isInstance)
.map(o -> (HashMap<String, Object>) o)
.orElseGet(HashMap::new);
});
Optional.ofNullable(getUserModelAttributeValue(user, mappingModel, STREET))
.ifPresent(street -> addressSet.put(AddressClaimSet.STREET_ADDRESS, street));
Optional.ofNullable(getUserModelAttributeValue(user, mappingModel, AddressClaimSet.LOCALITY))
.ifPresent(locality -> addressSet.put(AddressClaimSet.LOCALITY, locality));
Optional.ofNullable(getUserModelAttributeValue(user, mappingModel, AddressClaimSet.REGION))
.ifPresent(region -> addressSet.put(AddressClaimSet.REGION, region));
Optional.ofNullable(getUserModelAttributeValue(user, mappingModel, AddressClaimSet.POSTAL_CODE))
.ifPresent(postalCode -> addressSet.put(AddressClaimSet.POSTAL_CODE, postalCode));
Optional.ofNullable(getUserModelAttributeValue(user, mappingModel, AddressClaimSet.COUNTRY))
.ifPresent(country -> addressSet.put(AddressClaimSet.COUNTRY, country));
Optional.ofNullable(getUserModelAttributeValue(user, mappingModel, AddressClaimSet.FORMATTED))
.ifPresent(formatted -> addressSet.put(AddressClaimSet.FORMATTED, formatted));
if (!addressSet.isEmpty()) {
token.setAddress(addressSet);
token.getOtherClaims().put(IDToken.ADDRESS, addressSet);
}
}
private String getUserModelAttributeValue(UserModel user, ProtocolMapperModel mappingModel, String claim) {

View File

@ -74,6 +74,7 @@ import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
import org.keycloak.testsuite.util.userprofile.UserProfileUtil;
import org.hamcrest.CoreMatchers;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
@ -132,6 +133,15 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
UserProfileUtil.enableUnmanagedAttributes(upResource);
}
@After
public void cleanTestUserAttributes() {
UserResource userResource = findUserByUsernameId(adminClient.realm("test"), "test-user@localhost");
UserRepresentation user = userResource.toRepresentation();
// rollback user-changes
user.setAttributes(new HashMap<>());
userResource.update(user);
}
private void deleteMappers(ProtocolMappersResource protocolMappers) {
ProtocolMapperRepresentation mapper = ProtocolMapperUtil.getMapperByNameAndProtocol(protocolMappers, OIDCLoginProtocol.LOGIN_PROTOCOL, "Realm roles mapper");
@ -187,8 +197,177 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
}
@Test
public void testTokenMapping() throws Exception {
public void testAddressMappingWithAdditionalMapper() {
// prepare test
{
UserResource userResource = findUserByUsernameId(adminClient.realm("test"), "test-user@localhost");
UserRepresentation user = userResource.toRepresentation();
user.singleAttribute("street", "5 Yawkey Way");
user.singleAttribute("locality", "Boston");
user.singleAttribute("region", "MA");
user.singleAttribute("postal_code", "02115");
user.singleAttribute("country", "USA");
user.singleAttribute("formatted", "6 Foo Street");
user.singleAttribute("address_type", "STRUCTURED");
userResource.update(user);
ProtocolMapperRepresentation addressMapper = createAddressMapper(true, true, true, true);
ProtocolMapperRepresentation addressTypeMapper = createClaimMapper("additional-address-field",
"address_type",
"address.type",
"String",
true,
true,
true,
false);
ClientResource app = findClientResourceByClientId(adminClient.realm("test"), "test-app");
app.getProtocolMappers().createMapper(addressMapper).close();
app.getProtocolMappers().createMapper(addressTypeMapper).close();
}
{
AccessTokenResponse response = browserLogin("test-user@localhost", "password");
IDToken idToken = oauth.verifyIDToken(response.getIdToken());
assertNotNull(idToken.getAddress());
AddressClaimSet idTokenAddress = idToken.getAddress();
assertEquals("Tom Brady", idToken.getName());
assertEquals("5 Yawkey Way", idTokenAddress.getStreetAddress());
assertEquals("Boston", idTokenAddress.getLocality());
assertEquals("MA", idTokenAddress.getRegion());
assertEquals("02115", idTokenAddress.getPostalCode());
assertEquals("USA", idTokenAddress.getCountry());
assertEquals("STRUCTURED", idTokenAddress.getOtherClaims().get("type"));
}
// undo mappers
{
ClientResource app = findClientByClientId(adminClient.realm("test"), "test-app");
ClientRepresentation clientRepresentation = app.toRepresentation();
for (ProtocolMapperRepresentation model : clientRepresentation.getProtocolMappers()) {
if (model.getName().equals("address") || model.getName().equals("additional-address-field")) {
app.getProtocolMappers().delete(model.getId());
}
}
}
events.clear();
}
@Test
public void testAddressMappingWithAdditionalMapperReversedOrder() {
// prepare test
{
UserResource userResource = findUserByUsernameId(adminClient.realm("test"), "test-user@localhost");
UserRepresentation user = userResource.toRepresentation();
user.singleAttribute("street", "5 Yawkey Way");
user.singleAttribute("locality", "Boston");
user.singleAttribute("region", "MA");
user.singleAttribute("postal_code", "02115");
user.singleAttribute("country", "USA");
user.singleAttribute("formatted", "6 Foo Street");
user.singleAttribute("address_type", "STRUCTURED");
userResource.update(user);
ProtocolMapperRepresentation addressTypeMapper = createClaimMapper("additional-address-field",
"address_type",
"address.type",
"String",
true,
true,
true,
false);
ProtocolMapperRepresentation addressMapper = createAddressMapper(true, true, true, true);
ClientResource app = findClientResourceByClientId(adminClient.realm("test"), "test-app");
app.getProtocolMappers().createMapper(addressMapper).close();
app.getProtocolMappers().createMapper(addressTypeMapper).close();
}
{
AccessTokenResponse response = browserLogin("test-user@localhost", "password");
IDToken idToken = oauth.verifyIDToken(response.getIdToken());
assertNotNull(idToken.getAddress());
AddressClaimSet idTokenAddress = idToken.getAddress();
assertEquals("Tom Brady", idToken.getName());
assertEquals("5 Yawkey Way", idTokenAddress.getStreetAddress());
assertEquals("Boston", idTokenAddress.getLocality());
assertEquals("MA", idTokenAddress.getRegion());
assertEquals("02115", idTokenAddress.getPostalCode());
assertEquals("USA", idTokenAddress.getCountry());
assertEquals("STRUCTURED", idTokenAddress.getOtherClaims().get("type"));
}
// undo mappers
{
ClientResource app = findClientByClientId(adminClient.realm("test"), "test-app");
ClientRepresentation clientRepresentation = app.toRepresentation();
for (ProtocolMapperRepresentation model : clientRepresentation.getProtocolMappers()) {
if (model.getName().equals("address") || model.getName().equals("additional-address-field")) {
app.getProtocolMappers().delete(model.getId());
}
}
}
events.clear();
}
@Test
public void testAddressMappingWithoutPresentAddress() {
// prepare test
{
UserResource userResource = findUserByUsernameId(adminClient.realm("test"), "test-user@localhost");
// user has no address
UserRepresentation user = userResource.toRepresentation();
userResource.update(user);
ProtocolMapperRepresentation addressTypeMapper = createClaimMapper("additional-address-field",
"address_type",
"address.type",
"String",
true,
true,
true,
false);
ProtocolMapperRepresentation addressMapper = createAddressMapper(true, true, true, true);
ClientResource app = findClientResourceByClientId(adminClient.realm("test"), "test-app");
app.getProtocolMappers().createMapper(addressMapper).close();
app.getProtocolMappers().createMapper(addressTypeMapper).close();
}
{
AccessTokenResponse response = browserLogin("test-user@localhost", "password");
IDToken idToken = oauth.verifyIDToken(response.getIdToken());
assertNull(idToken.getAddress());
}
// undo mappers
{
ClientResource app = findClientByClientId(adminClient.realm("test"), "test-app");
ClientRepresentation clientRepresentation = app.toRepresentation();
for (ProtocolMapperRepresentation model : clientRepresentation.getProtocolMappers()) {
if (model.getName().equals("address") || model.getName().equals("additional-address-field")) {
app.getProtocolMappers().delete(model.getId());
}
}
}
events.clear();
}
@Test
public void testTokenMapping() {
{
UserResource userResource = findUserByUsernameId(adminClient.realm("test"), "test-user@localhost");
UserRepresentation user = userResource.toRepresentation();
@ -247,13 +426,14 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
IDToken idToken = oauth.verifyIDToken(response.getIdToken());
assertNotNull(idToken.getAddress());
assertEquals(idToken.getName(), "Tom Brady");
assertEquals(idToken.getAddress().getStreetAddress(), "5 Yawkey Way");
assertEquals(idToken.getAddress().getLocality(), "Boston");
assertEquals(idToken.getAddress().getRegion(), "MA");
assertEquals(idToken.getAddress().getPostalCode(), "02115");
assertNull(idToken.getAddress().getCountry()); // Null because we changed userAttribute name to "country_some", but user contains "country"
assertEquals(idToken.getAddress().getFormattedAddress(), "6 Foo Street");
AddressClaimSet idTokenAddress = idToken.getAddress();
assertEquals("Tom Brady", idToken.getName());
assertEquals("5 Yawkey Way", idTokenAddress.getStreetAddress());
assertEquals("Boston", idTokenAddress.getLocality());
assertEquals("MA", idTokenAddress.getRegion());
assertEquals("02115", idTokenAddress.getPostalCode());
assertNull(idTokenAddress.getCountry()); // Null because we changed userAttribute name to "country_some", but user contains "country"
assertEquals("6 Foo Street", idTokenAddress.getFormattedAddress());
assertNotNull(idToken.getOtherClaims().get("home_phone"));
assertThat((List<String>) idToken.getOtherClaims().get("home_phone"), hasItems("617-777-6666"));
assertNotNull(idToken.getOtherClaims().get("home.phone"));
@ -284,13 +464,15 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
assertEquals(accessToken.getName(), "Tom Brady");
assertNotNull(accessToken.getAddress());
assertEquals(accessToken.getAddress().getStreetAddress(), "5 Yawkey Way");
assertEquals(accessToken.getAddress().getLocality(), "Boston");
assertEquals(accessToken.getAddress().getRegion(), "MA");
assertEquals(accessToken.getAddress().getPostalCode(), "02115");
assertNull(idToken.getAddress().getCountry()); // Null because we changed userAttribute name to "country_some", but user contains "country"
assertEquals(idToken.getAddress().getFormattedAddress(), "6 Foo Street");
AddressClaimSet accessTokenAddress = accessToken.getAddress();
assertEquals("5 Yawkey Way", accessTokenAddress.getStreetAddress());
assertEquals("Boston", accessTokenAddress.getLocality());
assertEquals("MA", accessTokenAddress.getRegion());
assertEquals("02115", accessTokenAddress.getPostalCode());
assertNull(accessTokenAddress.getCountry()); // Null because we changed userAttribute name to "country_some", but user contains "country"
assertEquals("6 Foo Street", accessTokenAddress.getFormattedAddress());
assertNotNull(accessToken.getOtherClaims().get("home_phone"));
assertThat((List<String>) accessToken.getOtherClaims().get("home_phone"), hasItems("617-777-6666"));
assertEquals("coded", accessToken.getOtherClaims().get("hard"));
@ -369,7 +551,7 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
}
@Test
public void testTokenPropertiesMapping() throws Exception {
UserResource userResource = findUserByUsernameId(adminClient.realm("test"), "test-user@localhost");
UserRepresentation user = userResource.toRepresentation();
@ -529,7 +711,7 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
}
@Test
public void testNullOrEmptyTokenMapping() throws Exception {
{
UserResource userResource = findUserByUsernameId(adminClient.realm("test"), "test-user@localhost");
@ -587,7 +769,7 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
@Test
public void testUserRoleToAttributeMappers() throws Exception {
// Add mapper for realm roles
ProtocolMapperRepresentation realmMapper = ProtocolMapperUtil.createUserRealmRoleMappingMapper("pref.", "Realm roles mapper", "roles-custom.realm", true, true, true);
@ -620,7 +802,7 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
// Test to update protocolMappers to not have roles on the default position (realm_access and resource_access properties)
@Test
public void testUserRolesMovedFromAccessTokenProperties() throws Exception {
RealmResource realm = adminClient.realm("test");
ClientScopeResource rolesScope = ApiUtil.findClientScopeByName(realm, OIDCLoginProtocolFactory.ROLES_SCOPE);
@ -842,7 +1024,7 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
@Test
public void testUserGroupRoleToAttributeMappers() throws Exception {
// Add mapper for realm roles
String clientId = "test-app";

View File

@ -173,7 +173,7 @@ public class OIDCScopeTest extends AbstractOIDCScopeTest {
}
}
@Test
public void testBuiltinOptionalScopes() throws Exception {
// Login. Assert that just 'profile' and 'email' data are there. 'Address' and 'phone' not