From 0dee713135f558c2804c9774c61fd930f84adae6 Mon Sep 17 00:00:00 2001 From: Marie Daly Date: Thu, 22 Oct 2026 14:58:22 +0100 Subject: [PATCH] Preserve email_verified from ID token when userinfo omits it. Closes #54245 Signed-off-by: Marie Daly (cherry picked from commit 585294cf06e0697526ca1b2665931964bcc33de2) --- .../topics/changes/changes-26_8_3.adoc | 19 ++ .../broker/oidc/OIDCIdentityProvider.java | 33 ++- .../broker/oidc/OIDCIdentityProviderTest.java | 240 ++++++++++++++++++ .../tests/broker/oidc/KcOidcBrokerTest.java | 139 +++++++++- 4 files changed, 427 insertions(+), 4 deletions(-) diff --git a/docs/documentation/upgrading/topics/changes/changes-26_8_3.adoc b/docs/documentation/upgrading/topics/changes/changes-26_8_3.adoc index 642159e55ca9..d4c66d2e26bb 100644 --- a/docs/documentation/upgrading/topics/changes/changes-26_8_3.adoc +++ b/docs/documentation/upgrading/topics/changes/changes-26_8_3.adoc @@ -141,6 +141,25 @@ Previously, the Redirect URL was used without validation, which could allow an o The `invite-existing-user` endpoint now also accepts an optional `client_id` query parameter, the same way `invite-user` already does. If omitted, both endpoints default to the `account` client. +=== OIDC identity provider now respects `email_verified=false` from the ID token when Trust Email is enabled +// https://github.com/keycloak/keycloak/issues/54245 + +When brokering through an OpenID Connect identity provider with *Trust Email* enabled, {project_name} now correctly honors an `email_verified=false` claim in the ID token, even when the UserInfo endpoint does not include an `email_verified` field. + +Previously, if a provider such as GitLab supplied `email_verified=false` in the ID token but omitted the claim from UserInfo, {project_name} ignored the token value and fell back to the `trustEmail` setting, marking the email as verified despite the IdP explicitly stating otherwise. + +After upgrading, the following behavior applies when *Trust Email* is enabled: + +* If UserInfo includes `email_verified`, that value is used and the ID token value is ignored. +* If UserInfo provides an email but omits `email_verified`, the ID token's `email_verified` value is used, provided the two emails match. +* If UserInfo returns a different email than the ID token, the ID token's `email_verified` claim is discarded because it does not apply to the email that was actually selected. {project_name} falls back to the default trust-email behavior for that email. +* If a custom provider stores the validated ID token under `VALIDATED_ID_TOKEN` in the brokered identity context but does not explicitly set an `EMAIL_VERIFIED` context entry, {project_name} reads `email_verified` directly from the token as a fallback, again only when the token and context emails match. + +The change affects existing users under FORCE synchronization mode. When UserInfo provides the user's email but omits `email_verified`, {project_name} now applies the ID token's `email_verified` value, whether `true` or `false`, on every login, even if the user's email address has not changed since the last login. +For example, if an administrator marked the user's email as not verified, a later login whose ID token carries `email_verified=true` sets the flag back to `true`. Conversely, a token carrying `email_verified=false` sets it to `false` even if the administrator marked the email as verified. +Previously, the flag was left unchanged for existing users with an unchanged address in this situation. +If this behavior is not desired, set *Trust Email* to *OFF* for that identity provider. {project_name} then leaves the flag unchanged for existing users whose email address has not changed, and sets it to `false` for new users until the email is verified locally. + // ------------------------ Deprecated features ------------------------ // == Deprecated features 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 09374a94355d..929b9ef82d97 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java @@ -639,8 +639,8 @@ protected BrokeredIdentityContext extractIdentity(AccessTokenResponse tokenRespo Boolean userInfoEmailVerified = userInfoEmailVerifiedClaim == null ? null : Boolean.valueOf(userInfoEmailVerifiedClaim); if (userInfoEmail != null) { + emailVerified = resolveEmailVerifiedFromUserInfo(userInfoEmail, userInfoEmailVerified, email, emailVerified); email = userInfoEmail; - emailVerified = userInfoEmailVerified; } AbstractJsonUserAttributeMapper.storeUserProfileForMapper(identity, userInfo, getConfig().getAlias()); @@ -703,6 +703,24 @@ protected BrokeredIdentityContext extractIdentity(AccessTokenResponse tokenRespo return identity; } + protected static Boolean resolveEmailVerifiedFromUserInfo(String userInfoEmail, Boolean userInfoEmailVerified, + String idTokenEmail, Boolean idTokenEmailVerified) { + // use UserInfo claim if present + if (userInfoEmailVerified != null) { + return userInfoEmailVerified; + } + // discard the ID token claim if addresses differ (to avoid applying it to the wrong address) + if (!isSameEmail(userInfoEmail, idTokenEmail)) { + return null; + } + return idTokenEmailVerified; + } + + // a missing address on either side is not considered a mismatch + private static boolean isSameEmail(String email1, String email2) { + return email1 == null || email2 == null || email1.equalsIgnoreCase(email2); + } + protected String getusernameClaimNameForIdToken() { return IDToken.PREFERRED_USERNAME; } @@ -1213,10 +1231,21 @@ protected void setEmailVerified(UserModel user, BrokeredIdentityContext context) OIDCIdentityProviderConfig config = getConfig(); Map contextData = context.getContextData(); + // the value stored for the selected email takes precedence Boolean emailVerified = (Boolean) contextData.get(IDToken.EMAIL_VERIFIED); + // Otherwise use the validated ID token claim, but only if the token has an email and it matches the selected one + if (emailVerified == null && contextData.get(VALIDATED_ID_TOKEN) instanceof JsonWebToken validatedIdToken) { + // a non-string email claim is ignored instead of failing the login + String tokenEmail = validatedIdToken.getOtherClaims().get(IDToken.EMAIL) instanceof String s ? s : null; + if (tokenEmail != null && isSameEmail(context.getEmail(), tokenEmail)) { + // no explicit entry, fallback to the claim in the validated token (providers that do not use the default extractIdentity) + emailVerified = getEmailVerifiedClaim(validatedIdToken); + } + } + + // fallback to the default behavior if trust is disabled or there is no email_verified claim if (Booleans.isFalse(config.isTrustEmail()) || emailVerified == null) { - // fallback to the default behavior if trust is disabled or there is no email_verified claim super.setEmailVerified(user, context); return; } diff --git a/services/src/test/java/org/keycloak/test/broker/oidc/OIDCIdentityProviderTest.java b/services/src/test/java/org/keycloak/test/broker/oidc/OIDCIdentityProviderTest.java index 0c6fe10d097b..17ceacce6b03 100644 --- a/services/src/test/java/org/keycloak/test/broker/oidc/OIDCIdentityProviderTest.java +++ b/services/src/test/java/org/keycloak/test/broker/oidc/OIDCIdentityProviderTest.java @@ -17,13 +17,22 @@ package org.keycloak.test.broker.oidc; import java.io.IOException; +import java.lang.reflect.Proxy; import org.keycloak.broker.oidc.OIDCIdentityProvider; import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; +import org.keycloak.broker.provider.AbstractIdentityProvider; import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.representations.IDToken; import org.keycloak.representations.JsonWebToken; +import org.keycloak.services.resteasy.ResteasyKeycloakSession; +import org.keycloak.services.resteasy.ResteasyKeycloakSessionFactory; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.storage.adapter.InMemoryUserAdapter; import org.junit.Assert; import org.junit.Test; @@ -39,6 +48,8 @@ public class OIDCIdentityProviderTest { private static final String SID_FROM_ID_TOKEN = "sid-from-id-token"; private static final String SESSION_STATE_FROM_TOKEN_RESPONSE = "session-state-from-token-response"; + private static final ResteasyKeycloakSessionFactory sessionFactory = new ResteasyKeycloakSessionFactory(); + @Test public void extractIdentity_sidInIdToken_noSessionState_usesSid() throws IOException { TestOIDCIdentityProvider provider = createProvider(); @@ -96,6 +107,226 @@ public void extractIdentity_sidMatchesSessionState_noWarningSideEffect() throws Assert.assertEquals(IDP_ALIAS + "." + SID_FROM_ID_TOKEN, identity.getBrokerSessionId()); } + @Test + public void setEmailVerified_fallsBackToValidatedIdTokenFalse_whenContextEntryAbsent() { + Assert.assertFalse("token false must be applied over the user's current true", + resolveEmailVerified(null, false, true)); + } + + @Test + public void setEmailVerified_fallsBackToValidatedIdTokenTrue_whenContextEntryAbsent() { + Assert.assertTrue(resolveEmailVerified(null, true, false)); + } + + @Test + public void setEmailVerified_contextEntryFalseTakesPrecedenceOverValidatedIdTokenTrue() { + Assert.assertFalse(resolveEmailVerified(false, true, true)); + } + + @Test + public void setEmailVerified_contextEntryTrueTakesPrecedenceOverValidatedIdTokenFalse() { + Assert.assertTrue(resolveEmailVerified(true, false, false)); + } + + @Test + public void setEmailVerified_doesNotFallBackToValidatedIdToken_whenEmailDiffers() { + TestOIDCIdentityProvider provider = createProvider(); + provider.getConfig().setTrustEmail(true); + + JsonWebToken idToken = buildIdToken(SUBJECT, null); + idToken.getOtherClaims().put(IDToken.EMAIL, "a@example.com"); + idToken.getOtherClaims().put(IDToken.EMAIL_VERIFIED, false); + + BrokeredIdentityContext context = new BrokeredIdentityContext(SUBJECT, provider.getConfig()); + context.setEmail("b@example.com"); + context.getContextData().put(OIDCIdentityProvider.VALIDATED_ID_TOKEN, idToken); + // the default behavior reads the new-user note from the session + context.setAuthenticationSession(authSession(true)); + + UserModel user = createUser(); + user.setEmail("b@example.com"); + user.setEmailVerified(false); + + provider.callSetEmailVerified(user, context); + + Assert.assertTrue("the default trust-email behavior must apply, not the claim of a different address", + user.isEmailVerified()); + Assert.assertEquals("the validated token must be kept intact for the mappers", + false, idToken.getOtherClaims().get(IDToken.EMAIL_VERIFIED)); + } + + @Test + public void setEmailVerified_fallsBackToValidatedIdToken_whenEmailDiffersOnlyInCase() { + JsonWebToken idToken = buildIdToken(SUBJECT, null); + idToken.getOtherClaims().put(IDToken.EMAIL, "a@example.com"); + idToken.getOtherClaims().put(IDToken.EMAIL_VERIFIED, false); + + Assert.assertFalse("a case-only difference is the same address, so the token claim applies", + setEmailVerifiedWithValidatedToken(idToken, "A@Example.COM", true)); + } + + @Test + public void setEmailVerified_usesDefault_whenValidatedIdTokenHasNoEmailVerifiedClaim() { + JsonWebToken idToken = buildIdToken(SUBJECT, null); + idToken.getOtherClaims().put(IDToken.EMAIL, "a@example.com"); + + Assert.assertTrue("without a claim the default trust-email behavior must apply", + setEmailVerifiedWithValidatedToken(idToken, "a@example.com", false)); + } + + @Test + public void setEmailVerified_usesDefault_whenValidatedIdTokenHasNoEmailClaim() { + JsonWebToken idToken = buildIdToken(SUBJECT, null); + idToken.getOtherClaims().put(IDToken.EMAIL_VERIFIED, false); + + Assert.assertTrue("without a token email the default trust-email behavior must apply, not the unanchored claim", + setEmailVerifiedWithValidatedToken(idToken, "a@example.com", false)); + } + + @Test + public void setEmailVerified_parsesStringEmailVerifiedClaim() { + JsonWebToken idToken = buildIdToken(SUBJECT, null); + idToken.getOtherClaims().put(IDToken.EMAIL, "a@example.com"); + idToken.getOtherClaims().put(IDToken.EMAIL_VERIFIED, "false"); + + Assert.assertFalse("the string \"false\" must be read as false, not ignored", + setEmailVerifiedWithValidatedToken(idToken, "a@example.com", true)); + } + + @Test + public void setEmailVerified_usesDefault_whenValidatedIdTokenIsNotAJsonWebToken() { + Assert.assertTrue("a raw string token cannot be read, so the default trust-email behavior must apply", + setEmailVerifiedWithValidatedToken("raw.id.token", "a@example.com", false)); + } + + @Test + public void resolveEmailVerified_userInfoEmailVerifiedWins_whenPresent() { + // UserInfo explicitly says true — overrides ID token false + Boolean result = TestOIDCIdentityProvider.callResolveEmailVerifiedFromUserInfo( + "b@example.com", Boolean.TRUE, "a@example.com", Boolean.FALSE); + Assert.assertTrue("UserInfo email_verified=true must win over ID token email_verified=false", result); + } + + @Test + public void resolveEmailVerified_userInfoEmailVerifiedFalseWins_whenPresent() { + // UserInfo explicitly says false — overrides ID token true + Boolean result = TestOIDCIdentityProvider.callResolveEmailVerifiedFromUserInfo( + "b@example.com", Boolean.FALSE, "a@example.com", Boolean.TRUE); + Assert.assertFalse("UserInfo email_verified=false must win over ID token email_verified=true", result); + } + + @Test + public void resolveEmailVerified_differentEmail_noUserInfoClaim_discardsIdTokenClaim() { + // UserInfo selects a different address and provides no email_verified — + // the ID token's false must NOT carry over to the new address + Boolean result = TestOIDCIdentityProvider.callResolveEmailVerifiedFromUserInfo( + "b@example.com", null, "a@example.com", Boolean.FALSE); + Assert.assertNull("ID token email_verified must be discarded when UserInfo email differs", result); + } + + @Test + public void resolveEmailVerified_differentEmail_noUserInfoClaim_discardsIdTokenTrue() { + // Same as above but ID token says true — must still be discarded + Boolean result = TestOIDCIdentityProvider.callResolveEmailVerifiedFromUserInfo( + "b@example.com", null, "a@example.com", Boolean.TRUE); + Assert.assertNull("ID token email_verified=true must be discarded when UserInfo email differs", result); + } + + @Test + public void resolveEmailVerified_sameEmail_noUserInfoClaim_keepsIdTokenClaim() { + // Same address, no UserInfo claim — retain the ID token value (GitLab scenario) + Boolean result = TestOIDCIdentityProvider.callResolveEmailVerifiedFromUserInfo( + "a@example.com", null, "a@example.com", Boolean.FALSE); + Assert.assertFalse("ID token email_verified must be kept when UserInfo has the same email and no claim", result); + } + + @Test + public void resolveEmailVerified_noIdTokenEmail_noUserInfoClaim_keepsIdTokenClaim() { + // null ID token email is treated as same (isSameEmail semantics) — retain value + Boolean result = TestOIDCIdentityProvider.callResolveEmailVerifiedFromUserInfo( + "b@example.com", null, null, Boolean.FALSE); + Assert.assertFalse("null ID token email must not be treated as a mismatch", result); + } + + @Test + public void resolveEmailVerified_caseInsensitiveMatch_noUserInfoClaim_keepsIdTokenClaim() { + // addresses differ only in case — equalsIgnoreCase must treat them as the same + Boolean result = TestOIDCIdentityProvider.callResolveEmailVerifiedFromUserInfo( + "A@Example.COM", null, "a@example.com", Boolean.FALSE); + Assert.assertFalse("addresses that match case-insensitively must not be treated as a mismatch", result); + } + + @Test + public void setEmailVerified_trustEmailDisabled_ignoresTokenFallback() { + TestOIDCIdentityProvider provider = createProvider(); + provider.getConfig().setTrustEmail(false); + + JsonWebToken idToken = buildIdToken(SUBJECT, null); + idToken.getOtherClaims().put(IDToken.EMAIL, "a@example.com"); + idToken.getOtherClaims().put(IDToken.EMAIL_VERIFIED, true); + + BrokeredIdentityContext context = new BrokeredIdentityContext(SUBJECT, provider.getConfig()); + context.setEmail("a@example.com"); + context.getContextData().put(OIDCIdentityProvider.VALIDATED_ID_TOKEN, idToken); + context.setAuthenticationSession(authSession(true)); + + UserModel user = createUser(); + user.setEmailVerified(true); + + provider.callSetEmailVerified(user, context); + + Assert.assertFalse("with trustEmail=false the superclass default applies; token fallback must not bypass it", + user.isEmailVerified()); + } + + private boolean resolveEmailVerified(Boolean contextValue, boolean tokenValue, boolean initialUserValue) { + TestOIDCIdentityProvider provider = createProvider(); + provider.getConfig().setTrustEmail(true); + + JsonWebToken idToken = buildIdToken(SUBJECT, null); + idToken.getOtherClaims().put(IDToken.EMAIL, "a@example.com"); + idToken.getOtherClaims().put(IDToken.EMAIL_VERIFIED, tokenValue); + + BrokeredIdentityContext context = new BrokeredIdentityContext(SUBJECT, provider.getConfig()); + context.setEmail("a@example.com"); + context.getContextData().put(OIDCIdentityProvider.VALIDATED_ID_TOKEN, idToken); + if (contextValue != null) { + context.getContextData().put(IDToken.EMAIL_VERIFIED, contextValue); + } + + UserModel user = createUser(); + user.setEmailVerified(initialUserValue); + provider.callSetEmailVerified(user, context); + return user.isEmailVerified(); + } + + private boolean setEmailVerifiedWithValidatedToken(Object validatedIdToken, String contextEmail, boolean initialUserValue) { + TestOIDCIdentityProvider provider = createProvider(); + provider.getConfig().setTrustEmail(true); + + BrokeredIdentityContext context = new BrokeredIdentityContext(SUBJECT, provider.getConfig()); + context.setEmail(contextEmail); + context.getContextData().put(OIDCIdentityProvider.VALIDATED_ID_TOKEN, validatedIdToken); + context.setAuthenticationSession(authSession(true)); + + UserModel user = createUser(); + user.setEmailVerified(initialUserValue); + provider.callSetEmailVerified(user, context); + return user.isEmailVerified(); + } + + private AuthenticationSessionModel authSession(boolean newUser) { + return (AuthenticationSessionModel) Proxy.newProxyInstance( + AuthenticationSessionModel.class.getClassLoader(), new Class[] { AuthenticationSessionModel.class }, + (proxy, method, args) -> "getAuthNote".equals(method.getName()) + && AbstractIdentityProvider.BROKER_REGISTERED_NEW_USER.equals(args[0]) + ? String.valueOf(newUser) : null); + } + + private UserModel createUser() { + return new InMemoryUserAdapter(new ResteasyKeycloakSession(sessionFactory), null, KeycloakModelUtils.generateId()); + } + private TestOIDCIdentityProvider createProvider() { IdentityProviderModel model = new IdentityProviderModel(); OIDCIdentityProviderConfig config = new OIDCIdentityProviderConfig(model); @@ -116,6 +347,15 @@ public BrokeredIdentityContext extractIdentity(AccessTokenResponse tokenResponse String accessToken, JsonWebToken idToken) throws IOException { return super.extractIdentity(tokenResponse, accessToken, idToken); } + + void callSetEmailVerified(UserModel user, BrokeredIdentityContext context) { + setEmailVerified(user, context); + } + + static Boolean callResolveEmailVerifiedFromUserInfo(String userInfoEmail, Boolean userInfoEmailVerified, + String idTokenEmail, Boolean idTokenEmailVerified) { + return resolveEmailVerifiedFromUserInfo(userInfoEmail, userInfoEmailVerified, idTokenEmail, idTokenEmailVerified); + } } private JsonWebToken buildIdToken(String subject, String sid) { diff --git a/tests/base/src/test/java/org/keycloak/tests/broker/oidc/KcOidcBrokerTest.java b/tests/base/src/test/java/org/keycloak/tests/broker/oidc/KcOidcBrokerTest.java index eba420776a9f..186786fe8de6 100644 --- a/tests/base/src/test/java/org/keycloak/tests/broker/oidc/KcOidcBrokerTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/broker/oidc/KcOidcBrokerTest.java @@ -1,12 +1,20 @@ package org.keycloak.tests.broker.oidc; +import jakarta.ws.rs.core.Response; + import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.ClientScopeResource; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.IdentityProviderSyncMode; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.mappers.HardcodedClaim; import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper; import org.keycloak.representations.IDToken; import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.representations.idm.ProtocolMapperRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.util.ApiUtil; import org.keycloak.tests.broker.AbstractKcOidcBrokerTest; import org.keycloak.tests.utils.admin.AdminApiUtil; import org.keycloak.testsuite.util.AccountHelper; @@ -14,6 +22,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @KeycloakIntegrationTest @@ -26,7 +35,43 @@ public void testTrustEmailWhenEmailVerifiedClaimIsMissingFromUserInfo() { idp.getConfig().put("disableUserInfo", "false"); consumerRealm.admin().identityProviders().get(getIdpAlias()).update(idp); - // Send the email only through UserInfo and omit email_verified. + sendEmailOnlyThroughUserInfoWithoutEmailVerified(); + + disableUpdateProfileOnFirstLogin(); + logInAsUserInIDP(); + assertTrue(oauth.parseLoginResponse().isSuccess()); + UserRepresentation consumerUser = AccountHelper.getUserRepresentation(consumerRealm.admin(), getUserLogin()); + assertEquals(getUserEmail(), consumerUser.getEmail()); + assertTrue(consumerUser.isEmailVerified()); + } + + @Test + public void testUserInfoOnlyEmailWithoutEmailVerifiedPreservesTokenEmailVerifiedFalse() { + IdentityProviderRepresentation idp = consumerRealm.admin().identityProviders().get(getIdpAlias()).toRepresentation(); + idp.setTrustEmail(true); + idp.getConfig().put("disableUserInfo", "false"); + consumerRealm.admin().identityProviders().get(getIdpAlias()).update(idp); + + // The ID token has no email claim and UserInfo has the email without email_verified. + sendEmailOnlyThroughUserInfoWithoutEmailVerified(); + + // The ID token carries only email_verified=false. + ClientResource providerClient = providerRealm.admin().clients().get( + providerRealm.admin().clients().findByClientId(CLIENT_ID).get(0).getId()); + addIdTokenEmailVerifiedFalseMapper(providerClient, "id-token-email-verified-false-no-email"); + + disableUpdateProfileOnFirstLogin(); + logInAsUserInIDP(); + assertTrue(oauth.parseLoginResponse().isSuccess()); + + UserRepresentation consumerUser = AccountHelper.getUserRepresentation(consumerRealm.admin(), getUserLogin()); + assertEquals(getUserEmail(), consumerUser.getEmail()); + assertFalse(consumerUser.isEmailVerified(), + "email_verified=false from ID token must be applied when the ID token has no email and UserInfo omits email_verified"); + } + + // Send the email only through UserInfo and omit email_verified from UserInfo. + private void sendEmailOnlyThroughUserInfoWithoutEmailVerified() { ClientScopeResource emailScope = AdminApiUtil.findClientScopeByName(providerRealm.admin(), "email"); emailScope.getProtocolMappers().getMappers().forEach(mapper -> { if (IDToken.EMAIL_VERIFIED.equals(mapper.getConfig().get(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME))) { @@ -44,12 +89,102 @@ public void testTrustEmailWhenEmailVerifiedClaimIsMissingFromUserInfo() { providerClient.getProtocolMappers().update(mapper.getId(), mapper); } }); + } + + @Test + public void testUserInfoEmailWithoutEmailVerifiedPreservesTokenValue() { + IdentityProviderRepresentation idpRep = consumerRealm.admin().identityProviders().get(getIdpAlias()).toRepresentation(); + idpRep.setTrustEmail(true); + idpRep.getConfig().put("disableUserInfo", "false"); + consumerRealm.admin().identityProviders().get(getIdpAlias()).update(idpRep); + + ClientScopeResource emailScope = AdminApiUtil.findClientScopeByName(providerRealm.admin(), "email"); + emailScope.getProtocolMappers().getMappers().forEach(mapper -> { + if (IDToken.EMAIL_VERIFIED.equals(mapper.getConfig().get(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME))) { + emailScope.getProtocolMappers().delete(mapper.getId()); + } + }); + + ClientResource brokerAppResource = providerRealm.admin().clients().get( + providerRealm.admin().clients().findByClientId(CLIENT_ID).get(0).getId()); + + addIdTokenEmailVerifiedFalseMapper(brokerAppResource, "id-token-email-verified-false"); disableUpdateProfileOnFirstLogin(); logInAsUserInIDP(); assertTrue(oauth.parseLoginResponse().isSuccess()); + UserRepresentation consumerUser = AccountHelper.getUserRepresentation(consumerRealm.admin(), getUserLogin()); assertEquals(getUserEmail(), consumerUser.getEmail()); - assertTrue(consumerUser.isEmailVerified()); + assertFalse(consumerUser.isEmailVerified(), + "email_verified=false from ID token should be preserved when userinfo provides email without email_verified"); + } + + @Test + public void testForceSyncReLoginPreservesTokenEmailVerifiedFalse() { + // configure the IdP: trustEmail=true, FORCE sync, UserInfo enabled + IdentityProviderRepresentation idpRep = consumerRealm.admin().identityProviders().get(getIdpAlias()).toRepresentation(); + idpRep.setTrustEmail(true); + idpRep.getConfig().put("disableUserInfo", "false"); + idpRep.getConfig().put(IdentityProviderModel.SYNC_MODE, IdentityProviderSyncMode.FORCE.toString()); + consumerRealm.admin().identityProviders().get(getIdpAlias()).update(idpRep); + + // remove email_verified from the email scope so it is absent from UserInfo + ClientScopeResource emailScope = AdminApiUtil.findClientScopeByName(providerRealm.admin(), "email"); + emailScope.getProtocolMappers().getMappers().forEach(mapper -> { + if (IDToken.EMAIL_VERIFIED.equals(mapper.getConfig().get(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME))) { + emailScope.getProtocolMappers().delete(mapper.getId()); + } + }); + + // add email_verified=false to the ID token only + ClientResource brokerAppResource = providerRealm.admin().clients().get( + providerRealm.admin().clients().findByClientId(CLIENT_ID).get(0).getId()); + + addIdTokenEmailVerifiedFalseMapper(brokerAppResource, "id-token-email-verified-false-force"); + + // first login: user is created with emailVerified=false + disableUpdateProfileOnFirstLogin(); + logInAsUserInIDP(); + assertTrue(oauth.parseLoginResponse().isSuccess()); + + UserRepresentation consumerUser = AccountHelper.getUserRepresentation(consumerRealm.admin(), getUserLogin()); + assertEquals(getUserEmail(), consumerUser.getEmail()); + assertFalse(consumerUser.isEmailVerified(), + "first login: email_verified=false from ID token must be applied"); + + // admin marks the account as verified + consumerUser.setEmailVerified(true); + consumerRealm.admin().users().get(consumerUser.getId()).update(consumerUser); + assertTrue(AccountHelper.getUserRepresentation(consumerRealm.admin(), getUserLogin()).isEmailVerified(), + "pre-condition: admin must be able to set emailVerified=true"); + + AccountHelper.logout(consumerRealm.admin(), getUserLogin()); + AccountHelper.logout(providerRealm.admin(), getUserLogin()); + + logInAsUserInIDP(); + assertTrue(oauth.parseLoginResponse().isSuccess()); + + // FORCE sync must re-apply the token's false, overriding the locally-set true + UserRepresentation reloginUser = AccountHelper.getUserRepresentation(consumerRealm.admin(), getUserLogin()); + assertFalse(reloginUser.isEmailVerified(), + "second login with FORCE sync: email_verified=false from ID token must override locally-set true"); + } + + private void addIdTokenEmailVerifiedFalseMapper(ClientResource client, String mapperName) { + ProtocolMapperRepresentation mapper = new ProtocolMapperRepresentation(); + mapper.setName(mapperName); + mapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + mapper.setProtocolMapper(HardcodedClaim.PROVIDER_ID); + mapper.getConfig().put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, IDToken.EMAIL_VERIFIED); + mapper.getConfig().put(OIDCAttributeMapperHelper.JSON_TYPE, "boolean"); + mapper.getConfig().put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "false"); + mapper.getConfig().put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true"); + mapper.getConfig().put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "false"); + mapper.getConfig().put(HardcodedClaim.CLAIM_VALUE, "false"); + + Response response = client.getProtocolMappers().createMapper(mapper); + // asserts the 201 status and closes the response + ApiUtil.getCreatedId(response); } }