From a3e12ac28a34700441bbe91b26be467d7af5c18b Mon Sep 17 00:00:00 2001 From: Giuseppe Graziano Date: Wed, 8 Jul 2026 16:31:25 +0200 Subject: [PATCH] Manage OIDC broker email verification Closes #50618 Signed-off-by: Giuseppe Graziano --- .../broker/oidc/OIDCIdentityProvider.java | 21 +++-- .../testsuite/broker/KcOidcBrokerTest.java | 80 +++++++++++++++++++ 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java index 01d6c8ee3f30..e30a2b55c2ee 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java @@ -23,7 +23,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Optional; import java.util.stream.Stream; import jakarta.ws.rs.GET; @@ -562,6 +561,7 @@ protected BrokeredIdentityContext extractIdentity(AccessTokenResponse tokenRespo String familyName = (String)idToken.getOtherClaims().get(IDToken.FAMILY_NAME); String preferredUsername = (String) idToken.getOtherClaims().get(getusernameClaimNameForIdToken()); String email = (String) idToken.getOtherClaims().get(IDToken.EMAIL); + Boolean emailVerified = getEmailVerifiedClaim(idToken); if (!getConfig().isDisableUserInfoService()) { String userInfoUrl = getUserInfoUrl(); @@ -594,11 +594,23 @@ protected BrokeredIdentityContext extractIdentity(AccessTokenResponse tokenRespo givenName = getJsonProperty(userInfo, IDToken.GIVEN_NAME); familyName = getJsonProperty(userInfo, IDToken.FAMILY_NAME); preferredUsername = getUsernameFromUserInfo(userInfo); - email = getJsonProperty(userInfo, "email"); + String userInfoEmail = getJsonProperty(userInfo, "email"); + Boolean userInfoEmailVerified = Boolean.parseBoolean(getJsonProperty(userInfo, IDToken.EMAIL_VERIFIED)); + + if (userInfoEmail != null) { + email = userInfoEmail; + emailVerified = userInfoEmailVerified; + } + AbstractJsonUserAttributeMapper.storeUserProfileForMapper(identity, userInfo, getConfig().getAlias()); } } } + + if (emailVerified != null) { + identity.getContextData().put(IDToken.EMAIL_VERIFIED, emailVerified); + } + identity.getContextData().put(VALIDATED_ID_TOKEN, idToken); identity.setId(id); @@ -1100,9 +1112,8 @@ public Stream resolveKeys(TrustMaterialRequest request) { protected void setEmailVerified(UserModel user, BrokeredIdentityContext context) { OIDCIdentityProviderConfig config = getConfig(); Map contextData = context.getContextData(); - JsonWebToken token = (JsonWebToken) Optional.ofNullable(contextData.get(VALIDATED_ID_TOKEN)) - .orElseGet(() -> contextData.get(VALIDATED_ACCESS_TOKEN)); - Boolean emailVerified = getEmailVerifiedClaim(token); + + Boolean emailVerified = (Boolean) contextData.get(IDToken.EMAIL_VERIFIED); if (Booleans.isFalse(config.isTrustEmail()) || emailVerified == null) { // fallback to the default behavior if trust is disabled or there is no email_verified claim diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTest.java index c6542ec73ebc..ca6416b610b9 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTest.java @@ -36,6 +36,7 @@ import org.keycloak.protocol.oidc.mappers.HardcodedClaim; import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper; import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.representations.IDToken; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.FederatedIdentityRepresentation; import org.keycloak.representations.idm.IdentityProviderMapperRepresentation; @@ -76,6 +77,7 @@ import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; /** * Final class as it's not intended to be overriden. Feel free to remove "final" if you really know what you are doing. @@ -494,6 +496,84 @@ public void testVerifyEmailWhenUpdateProfileAndEmailVerifiedAtIdP() { assertThat(users.get(0).getEmail(), is("updated@keycloak.org")); } + @Test + public void testEmailVerifiedFromUserInfo() { + RealmResource providerRealm = adminClient.realm(bc.providerRealmName()); + RealmResource consumerRealm = adminClient.realm(bc.consumerRealmName()); + + IdentityProviderRepresentation idpRep = identityProviderResource.toRepresentation(); + idpRep.setTrustEmail(true); + idpRep.getConfig().put("disableUserInfo", "false"); + identityProviderResource.update(idpRep); + + ClientScopeResource emailClientScope = AdminApiUtil.findClientScopeByName(providerRealm, "email"); + List originalMappers = emailClientScope.getProtocolMappers().getMappers(); + originalMappers.forEach(protocolMapper -> emailClientScope.getProtocolMappers().delete(protocolMapper.getId())); + + // Save and remove the "email" protocol mapper from the brokerapp client so that the email claim + // is not included in the ID token, allowing us to test that the email comes from userinfo only + ClientRepresentation brokerAppClient = providerRealm.clients().findByClientId("brokerapp").get(0); + ClientResource brokerAppResource = providerRealm.clients().get(brokerAppClient.getId()); + ProtocolMapperRepresentation brokerAppEmailMapper = brokerAppResource.getProtocolMappers().getMappers().stream() + .filter(mapper -> "email".equals(mapper.getName())) + .findFirst() + .orElse(null); + brokerAppResource.getProtocolMappers().delete(brokerAppEmailMapper.getId()); + + ProtocolMapperRepresentation userinfoEmailVerifiedMapper = new ProtocolMapperRepresentation(); + userinfoEmailVerifiedMapper.setName("userinfo-email-verified"); + userinfoEmailVerifiedMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + userinfoEmailVerifiedMapper.setProtocolMapper(HardcodedClaim.PROVIDER_ID); + userinfoEmailVerifiedMapper.getConfig().put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, IDToken.EMAIL_VERIFIED); + userinfoEmailVerifiedMapper.getConfig().put(OIDCAttributeMapperHelper.JSON_TYPE, "boolean"); + userinfoEmailVerifiedMapper.getConfig().put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "false"); + userinfoEmailVerifiedMapper.getConfig().put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "false"); + userinfoEmailVerifiedMapper.getConfig().put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "true"); + userinfoEmailVerifiedMapper.getConfig().put(HardcodedClaim.CLAIM_VALUE, "false"); + emailClientScope.getProtocolMappers().createMapper(userinfoEmailVerifiedMapper).close(); + + ProtocolMapperRepresentation userinfoEmailMapper = new ProtocolMapperRepresentation(); + userinfoEmailMapper.setName("userinfo-email"); + userinfoEmailMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + userinfoEmailMapper.setProtocolMapper(HardcodedClaim.PROVIDER_ID); + userinfoEmailMapper.getConfig().put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, IDToken.EMAIL); + userinfoEmailMapper.getConfig().put(OIDCAttributeMapperHelper.JSON_TYPE, "String"); + userinfoEmailMapper.getConfig().put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "false"); + userinfoEmailMapper.getConfig().put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "false"); + userinfoEmailMapper.getConfig().put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "true"); + userinfoEmailMapper.getConfig().put(HardcodedClaim.CLAIM_VALUE, "different-email@test.com"); + emailClientScope.getProtocolMappers().createMapper(userinfoEmailMapper).close(); + + + oauth.client("broker-app"); + loginPage.open(bc.consumerRealmName()); + logInWithBroker(bc); + + waitForPage(driver, "update account information", false); + updateAccountInformationPage.assertCurrent(); + updateAccountInformationPage.updateAccountInformation("FirstName", "LastName"); + + // Verify user was created with email from ID token, not userinfo + List users = consumerRealm.users().search(bc.getUserLogin()); + assertEquals(1, users.size()); + UserRepresentation consumerUser = users.get(0); + + assertEquals(consumerUser.getEmail(), "different-email@test.com"); + + // Email should be not verified because it came from ID token which has email_verified=false + assertFalse(consumerUser.isEmailVerified()); + + emailClientScope.getProtocolMappers().getMappers().forEach(protocolMapper -> emailClientScope.getProtocolMappers().delete(protocolMapper.getId())); + emailClientScope.getProtocolMappers().createMapper(originalMappers); + + // Restore the "email" protocol mapper on the brokerapp client + if (brokerAppEmailMapper != null) { + brokerAppEmailMapper.setId(null); + brokerAppResource.getProtocolMappers().createMapper(brokerAppEmailMapper).close(); + } + + } + @Test public void testVerifyEmailWhenUpdateProfileSameEmailAndEmailVerifiedAtIdP() { RealmResource providerRealm = adminClient.realm(bc.providerRealmName());