8000 Custom OIDC identity providers parse email_verified from validated tokens when not pre-populated in context (26.8) by msdaly200 · Pull Request #55100 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files. 8000
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-26_8_3.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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` cla 8000 im 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -1213,10 +1231,21 @@ protected void setEmailVerified(UserModel user, BrokeredIdentityContext context)
OIDCIdentityProviderConfig config = getConfig();
Map<String, Object> 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. 8000 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;
Expand All @@ -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();
Expand Down Expand Up @@ -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", resul 10000 t);
}

@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);
Expand All @@ -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) {
Expand Down
Loading
Loading
0