Fix duplicate address claim in IDToken (#45423)

Closes #45250

Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
(cherry picked from commit db1f75a1cf)
This commit is contained in:
Giuseppe Graziano 2026-01-15 08:48:31 +01:00 committed by Marek Posolda
parent cb1ed34eb8
commit ed3fc2fcfb
5 changed files with 86 additions and 98 deletions

View file

@ -18,7 +18,6 @@
package org.keycloak.representations;
import java.util.Map;
import java.util.Optional;
import org.keycloak.TokenCategory;
import org.keycloak.util.JsonSerialization;
@ -130,9 +129,6 @@ public class IDToken extends JsonWebToken {
@JsonProperty(PHONE_NUMBER_VERIFIED)
protected Boolean phoneNumberVerified;
@JsonProperty(ADDRESS)
protected Map<String, Object> address;
@JsonProperty(UPDATED_AT)
protected Long updatedAt;
@ -332,28 +328,30 @@ public class IDToken extends JsonWebToken {
this.phoneNumberVerified = phoneNumberVerified;
}
@JsonProperty("address")
@JsonIgnore
public Map<String, Object> getAddressClaimsMap() {
return address;
Object value = getOtherClaims().get(ADDRESS);
return value instanceof Map ? (Map<String, Object>) value : null;
}
@JsonIgnore
public AddressClaimSet getAddress() {
return Optional.ofNullable(address).map(a -> {
return JsonSerialization.mapper.convertValue(a, AddressClaimSet.class);
})
.orElse(null);
}
Object value = getOtherClaims().get(ADDRESS);
if (value == null) {
return null;
}
public void setAddress(Map<String, Object> address) {
this.address = address;
return JsonSerialization.mapper.convertValue(value, AddressClaimSet.class);
}
@JsonIgnore
public void setAddress(AddressClaimSet address) {
this.address = Optional.ofNullable(address)
.map(a -> JsonSerialization.mapper.convertValue(a, Map.class))
.orElse(null);
getOtherClaims().put(ADDRESS, JsonSerialization.mapper.convertValue(address, Map.class));
}
@JsonIgnore
public void setAddress(Map<String, Object> address) {
getOtherClaims().put(ADDRESS, address);
}
public Long getUpdatedAt() {

View file

@ -16,10 +16,8 @@
*/
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;
@ -99,9 +97,6 @@ public class UserInfo {
@JsonProperty("phone_number_verified")
protected Boolean phoneNumberVerified;
@JsonProperty("address")
protected Map<String, Object> address;
@JsonProperty("updated_at")
protected Long updatedAt;
@ -280,28 +275,30 @@ public class UserInfo {
this.phoneNumberVerified = phoneNumberVerified;
}
@JsonProperty("address")
@JsonIgnore
public Map<String, Object> getAddressClaimsMap() {
return address;
Object value = getOtherClaims().get(IDToken.ADDRESS);
return value instanceof Map ? (Map<String, Object>) value : null;
}
@JsonIgnore
public AddressClaimSet getAddress() {
return Optional.ofNullable(address).map(a -> {
return JsonSerialization.mapper.convertValue(a, AddressClaimSet.class);
})
.orElse(null);
}
Object value = getOtherClaims().get(IDToken.ADDRESS);
if (value == null) {
return null;
}
public void setAddress(Map<String, Object> address) {
this.address = address;
return JsonSerialization.mapper.convertValue(value, AddressClaimSet.class);
}
@JsonIgnore
public void setAddress(AddressClaimSet address) {
this.address = Optional.ofNullable(address)
.map(a -> JsonSerialization.mapper.convertValue(a, Map.class))
.orElse(null);
getOtherClaims().put(IDToken.ADDRESS, JsonSerialization.mapper.convertValue(address, Map.class));
}
@JsonIgnore
public void setAddress(Map<String, Object> address) {
getOtherClaims().put(IDToken.ADDRESS, address);
}
public Long getUpdatedAt() {
@ -342,13 +339,4 @@ 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

@ -124,12 +124,10 @@ public class AddressMapper extends AbstractOIDCProtocolMapper implements OIDCAcc
@Override
protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession) {
UserModel user = userSession.getUser();
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);
});
Map<String, Object> addressSet = Optional.ofNullable(token.getAddressClaimsMap()).orElseGet(() -> 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))
@ -145,7 +143,6 @@ public class AddressMapper extends AbstractOIDCProtocolMapper implements OIDCAcc
if (!addressSet.isEmpty()) {
token.setAddress(addressSet);
token.getOtherClaims().put(IDToken.ADDRESS, addressSet);
}
}

View file

@ -72,7 +72,9 @@ import org.keycloak.testsuite.util.UserInfoClientUtil;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
import org.keycloak.testsuite.util.userprofile.UserProfileUtil;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.JsonParser;
import org.hamcrest.CoreMatchers;
import org.junit.After;
import org.junit.Before;
@ -198,6 +200,8 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
@Test
public void testAddressMappingWithAdditionalMapper() {
//throws an exception if the json contains a duplicate claim
JsonSerialization.mapper.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION);
// prepare test
{
UserResource userResource = findUserByUsernameId(adminClient.realm("test"), "test-user@localhost");
@ -215,13 +219,13 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
ProtocolMapperRepresentation addressMapper = createAddressMapper(true, true, true, true);
ProtocolMapperRepresentation addressTypeMapper = createClaimMapper("additional-address-field",
"address_type",
"address.type",
"String",
true,
true,
true,
false);
"address_type",
"address.type",
"String",
true,
true,
true,
false);
ClientResource app = findClientResourceByClientId(adminClient.realm("test"), "test-app");
@ -256,6 +260,7 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
}
events.clear();
JsonSerialization.mapper.disable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION);
}
@Test
@ -276,13 +281,13 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
userResource.update(user);
ProtocolMapperRepresentation addressTypeMapper = createClaimMapper("additional-address-field",
"address_type",
"address.type",
"String",
true,
true,
true,
false);
"address_type",
"address.type",
"String",
true,
true,
true,
false);
ProtocolMapperRepresentation addressMapper = createAddressMapper(true, true, true, true);
@ -331,13 +336,13 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
userResource.update(user);
ProtocolMapperRepresentation addressTypeMapper = createClaimMapper("additional-address-field",
"address_type",
"address.type",
"String",
true,
true,
true,
false);
"address_type",
"address.type",
"String",
true,
true,
true,
false);
ProtocolMapperRepresentation addressMapper = createAddressMapper(true, true, true, true);
ClientResource app = findClientResourceByClientId(adminClient.realm("test"), "test-app");
@ -525,7 +530,7 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
|| model.getName().equals("hard-app")
|| model.getName().equals("test-script-mapper")
|| model.getName().equals("json-attribute-mapper")
) {
) {
app.getProtocolMappers().delete(model.getId());
}
}
@ -746,7 +751,7 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
for (ProtocolMapperRepresentation model : clientRepresentation.getProtocolMappers()) {
if (model.getName().equals("empty")
|| model.getName().equals("null")
) {
) {
app.getProtocolMappers().delete(model.getId());
}
}
@ -1044,17 +1049,17 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
List<String> realmRoleMappings = (List<String>) roleMappings.get("realm");
List<String> testAppMappings = (List<String>) roleMappings.get(clientId);
assertRolesString(realmRoleMappings,
"pref.admin", // from direct assignment to /roleRichGroup/level2group
"pref.user", // from parent group of /roleRichGroup/level2group, i.e. from /roleRichGroup
"pref.customer-user-premium", // from client role customer-admin-composite-role - realm role for test-app
"pref.realm-composite-role", // from parent group of /roleRichGroup/level2group, i.e. from /roleRichGroup
"pref.sample-realm-role" // from realm role realm-composite-role
"pref.admin", // from direct assignment to /roleRichGroup/level2group
"pref.user", // from parent group of /roleRichGroup/level2group, i.e. from /roleRichGroup
"pref.customer-user-premium", // from client role customer-admin-composite-role - realm role for test-app
"pref.realm-composite-role", // from parent group of /roleRichGroup/level2group, i.e. from /roleRichGroup
"pref.sample-realm-role" // from realm role realm-composite-role
);
assertRolesString(testAppMappings,
"ta.customer-user", // from direct assignment to /roleRichGroup/level2group
"ta.customer-admin-composite-role", // from direct assignment to /roleRichGroup/level2group
"ta.customer-admin", // from client role customer-admin-composite-role - client role for test-app
"ta.sample-client-role" // from realm role realm-composite-role - client role for test-app
"ta.customer-user", // from direct assignment to /roleRichGroup/level2group
"ta.customer-admin-composite-role", // from direct assignment to /roleRichGroup/level2group
"ta.customer-admin", // from client role customer-admin-composite-role - client role for test-app
"ta.sample-client-role" // from realm role realm-composite-role - client role for test-app
);
// Revert
@ -1089,11 +1094,11 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
List<String> realmRoleMappings = (List<String>) roleMappings.get("realm");
List<String> testAppAuthzMappings = (List<String>) roleMappings.get(clientId);
assertRolesString(realmRoleMappings,
"pref.admin", // from direct assignment to /roleRichGroup/level2group
"pref.user", // from parent group of /roleRichGroup/level2group, i.e. from /roleRichGroup
"pref.customer-user-premium", // from client role customer-admin-composite-role - realm role for test-app
"pref.realm-composite-role", // from parent group of /roleRichGroup/level2group, i.e. from /roleRichGroup
"pref.sample-realm-role" // from realm role realm-composite-role
"pref.admin", // from direct assignment to /roleRichGroup/level2group
"pref.user", // from parent group of /roleRichGroup/level2group, i.e. from /roleRichGroup
"pref.customer-user-premium", // from client role customer-admin-composite-role - realm role for test-app
"pref.realm-composite-role", // from parent group of /roleRichGroup/level2group, i.e. from /roleRichGroup
"pref.sample-realm-role" // from realm role realm-composite-role
);
assertNull(testAppAuthzMappings); // There is no client role defined for test-app-authz
@ -1122,12 +1127,12 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
List<String> realmRoleMappings = (List<String>) roleMappings.get("realm");
List<String> testAppScopeMappings = (List<String>) roleMappings.get(clientId);
assertRolesString(realmRoleMappings,
"pref.admin", // from direct assignment to /roleRichGroup/level2group
"pref.user", // from parent group of /roleRichGroup/level2group, i.e. from /roleRichGroup
"pref.customer-user-premium"
"pref.admin", // from direct assignment to /roleRichGroup/level2group
"pref.user", // from parent group of /roleRichGroup/level2group, i.e. from /roleRichGroup
"pref.customer-user-premium"
);
assertRolesString(testAppScopeMappings,
"test-app-allowed-by-scope", // from direct assignment to roleRichUser, present as scope allows it
"test-app-allowed-by-scope", // from direct assignment to roleRichUser, present as scope allows it
"test-app-disallowed-by-scope"
);
@ -1156,15 +1161,15 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest {
List<String> realmRoleMappings = (List<String>) roleMappings.get("realm");
List<String> testAppScopeMappings = (List<String>) roleMappings.get(clientId);
assertRolesString(realmRoleMappings,
"pref.admin", // from direct assignment to /roleRichGroup/level2group
"pref.user", // from parent group of /roleRichGroup/level2group, i.e. from /roleRichGroup
"pref.customer-user-premium"
"pref.admin", // from direct assignment to /roleRichGroup/level2group
"pref.user", // from parent group of /roleRichGroup/level2group, i.e. from /roleRichGroup
"pref.customer-user-premium"
);
assertRolesString(testAppScopeMappings,
"test-app-allowed-by-scope", // from direct assignment to roleRichUser, present as scope allows it
"test-app-disallowed-by-scope", // from direct assignment to /roleRichGroup/level2group, present as scope allows it
"customer-admin-composite-role", // from the other application
"customer-admin"
"test-app-allowed-by-scope", // from direct assignment to roleRichUser, present as scope allows it
"test-app-disallowed-by-scope", // from direct assignment to /roleRichGroup/level2group, present as scope allows it
"customer-admin-composite-role", // from the other application
"customer-admin"
);
// Revert

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