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.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ When calling {project_name}'s experimental AuthZEN endpoints, the caller now nee

The Admin REST API endpoints for listing client sessions (`GET /admin/realms/\{realm}/clients/\{id}/user-sessions` and `GET /admin/realms/\{realm}/clients/\{id}/offline-sessions`) now filter out sessions belonging to users that the caller does not have permission to view. Previously, any caller with the `view-clients` role could see all user sessions for a client, potentially exposing user identities to callers without the `view-users` role. After upgrading, callers without `view-users` permission will see fewer results from these endpoints.

=== Email is no longer marked as verified by other flows

Previously, some flows marked the email of a user as verified even though their purpose was not email verification.
This could make an account registered by a different party than the owner of the email address look trusted.
An email is now marked as verified only when the user completes an email verification.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gaoyikeshuer Seems worth to check

@gaoyikeshuer gaoyikeshuer Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @mabartos ! The issue is about the flows toggled the emailVerified by accident as a side effect nobody asked for them. but these two spots are the opposite, that they only run when an admin turned on Trust Email, which is off by default. I will add a line to the upgrading note to not overpromise causing misunderstanding.

This changes the behavior of identity provider account linking by email, of admin-initiated action emails that do not include the *Verify Email* action,
and of the verifiable credential offer emails of the experimental OID4VCI feature.
The *Trust Email* setting of identity providers and LDAP federation is not affected, as it is an explicit decision of the administrator to trust the email from the external source.

// ------------------------ Deprecated features ------------------------ //
== Deprecated features

Expand All @@ -35,4 +44,3 @@ The following sections provide details on deprecated features.
The following features have been removed from this release.

=== <TODO>

Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public interface Errors {
String USERNAME_IN_USE = "username_in_use";
String EMAIL_IN_USE = "email_in_use";
String EMAIL_ALREADY_VERIFIED = "email_already_verified";
String IDENTITY_PROVIDER_LINK_CONFIRMED_ALREADY = "identity_provider_link_confirmed_already";
String ORG_NOT_FOUND = "org_not_found";
String ORG_DISABLED = "org_disabled";
String USER_ORG_MEMBER_ALREADY = "user_org_member_already";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,9 @@ public Response handleToken(ExecuteActionsActionToken token, ActionTokenContext<
token.getRequiredActions().stream().forEach(authSession::addRequiredAction);

UserModel user = tokenContext.getAuthenticationSession().getAuthenticatedUser();
// verify user email as we know it is valid as this entry point would never have gotten here.
user.setEmailVerified(true);
if (token.getRequiredActions().contains(UserModel.RequiredAction.VERIFY_EMAIL.name())) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even when this is in the list of required actions, it does not say it is completed, right? Shouldn't be really the place where it's set only after the email verification?

user.setEmailVerified(true);
}

String nextAction = AuthenticationManager.nextRequiredAction(tokenContext.getSession(), authSession, tokenContext.getRequest(), tokenContext.getEvent());
return AuthenticationManager.redirectToRequiredActions(tokenContext.getSession(), tokenContext.getRealm(), authSession, tokenContext.getUriInfo(), nextAction);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public Response handleToken(IdpVerifyAccountLinkActionToken token, ActionTokenCo
AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession();

if (authSession.getAuthNote(IdpEmailVerificationAuthenticator.VERIFY_ACCOUNT_IDP_USERNAME) != null) {
return sendEmailAlreadyVerified(session, event, user);
return sendLinkConfirmedAlready(session, event, user, token);
}

AuthenticationSessionManager asm = new AuthenticationSessionManager(session);
Expand All @@ -96,7 +96,7 @@ public Response handleToken(IdpVerifyAccountLinkActionToken token, ActionTokenCo
AuthenticationSessionModel origAuthSession = asm.getAuthenticationSessionByIdAndClient(realm,
compoundId.getRootSessionId(), originalClient, compoundId.getTabId());
if (origAuthSession == null || origAuthSession.getAuthNote(IdpEmailVerificationAuthenticator.VERIFY_ACCOUNT_IDP_USERNAME) != null) {
return sendEmailAlreadyVerified(session, event, user);
return sendLinkConfirmedAlready(session, event, user, token);
}

token.setOriginalCompoundAuthenticationSessionId(token.getCompoundAuthenticationSessionId());
Expand All @@ -115,8 +115,6 @@ public Response handleToken(IdpVerifyAccountLinkActionToken token, ActionTokenCo
.createInfoPage();
}

// verify user email as we know it is valid as this entry point would never have gotten here.
user.setEmailVerified(true);
event.success();

if (token.getOriginalCompoundAuthenticationSessionId() != null) {
Comment thread
gaoyikeshuer marked this conversation as resolved.
Expand All @@ -134,6 +132,7 @@ public Response handleToken(IdpVerifyAccountLinkActionToken token, ActionTokenCo

return session.getProvider(LoginFormsProvider.class)
.setAuthenticationSession(authSession)
.setAttribute("messageHeader", Messages.IDENTITY_PROVIDER_LINK_SUCCESS_HEADER)
.setSuccess(Messages.IDENTITY_PROVIDER_LINK_SUCCESS, token.getIdentityProviderAlias(), token.getIdentityProviderUsername())
.setAttribute(Constants.SKIP_LINK, true)
.createInfoPage();
Expand Down Expand Up @@ -171,11 +170,12 @@ private static String getUserVerifiedSingleObjectKey(String userId, String idpAl
return "kc.brokering.user.verified." + userId + "." + idpAlias + "." + externalId;
}

private Response sendEmailAlreadyVerified(KeycloakSession session, EventBuilder event, UserModel user) {
event.user(user).error(Errors.EMAIL_ALREADY_VERIFIED);
private Response sendLinkConfirmedAlready(KeycloakSession session, EventBuilder event, UserModel user, IdpVerifyAccountLinkActionToken token) {
event.user(user).error(Errors.IDENTITY_PROVIDER_LINK_CONFIRMED_ALREADY);
return session.getProvider(LoginFormsProvider.class)
.setAuthenticationSession(session.getContext().getAuthenticationSession())
.setInfo(Messages.EMAIL_VERIFIED_ALREADY, user.getEmail())
.setAttribute("messageHeader", Messages.IDENTITY_PROVIDER_LINK_CONFIRMED_ALREADY_HEADER)
.setInfo(Messages.IDENTITY_PROVIDER_LINK_CONFIRMED_ALREADY, token.getIdentityProviderAlias(), token.getIdentityProviderUsername())
.createInfoPage();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ public class IdpEmailVerificationAuthenticator extends AbstractIdpAuthenticator

public static final String VERIFY_ACCOUNT_IDP_USERNAME = "VERIFY_ACCOUNT_IDP_USERNAME";

public static final String IDP_LINK_CONFIRMATION_EMAIL_KEY = "IDP_LINK_CONFIRMATION_EMAIL_KEY";

@Override
protected void authenticateImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) {
KeycloakSession session = context.getSession();
Expand Down Expand Up @@ -95,8 +97,8 @@ protected void authenticateImpl(AuthenticationFlowContext context, SerializedBro
UserModel existingUser = getExistingUser(session, realm, authSession);

// Do not allow resending e-mail by simple page refresh
if (! Objects.equals(authSession.getAuthNote(Constants.VERIFY_EMAIL_KEY), existingUser.getEmail())) {
authSession.setAuthNote(Constants.VERIFY_EMAIL_KEY, existingUser.getEmail());
if (! Objects.equals(authSession.getAuthNote(IDP_LINK_CONFIRMATION_EMAIL_KEY), existingUser.getEmail())) {
authSession.setAuthNote(IDP_LINK_CONFIRMATION_EMAIL_KEY, existingUser.getEmail());
sendVerifyEmail(session, context, existingUser, brokerContext);
} else {
showEmailSentPage(context, brokerContext);
Expand All @@ -108,7 +110,7 @@ protected void actionImpl(AuthenticationFlowContext context, SerializedBrokeredI
logger.debugf("Re-sending email requested for user, details follow");

// This will allow user to re-send email again
context.getAuthenticationSession().removeAuthNote(Constants.VERIFY_EMAIL_KEY);
context.getAuthenticationSession().removeAuthNote(IDP_LINK_CONFIRMATION_EMAIL_KEY);

authenticateImpl(context, serializedCtx, brokerContext);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ public static Long getLastChangedTimestamp(KeycloakSession session, RealmModel r

@Override
public void action(AuthenticationFlowContext context) {
context.getUser().setEmailVerified(true);
context.success();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,6 @@ public Response handleToken(CredentialOfferActionToken token, ActionTokenContext
throw ErrorResponse.error("Invalid credential configuration action", Response.Status.BAD_REQUEST);
}

// verify user email as we know it is valid as this entry point would never have gotten here.
user.setEmailVerified(true);

String nextAction = AuthenticationManager.nextRequiredAction(tokenContext.getSession(), authSession, tokenContext.getRequest(), tokenContext.getEvent());
return AuthenticationManager.redirectToRequiredActions(tokenContext.getSession(), tokenContext.getRealm(), authSession, tokenContext.getUriInfo(), nextAction);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,12 @@ public class Messages {

public static final String IDENTITY_PROVIDER_LINK_SUCCESS = "identityProviderLinkSuccess";

public static final String IDENTITY_PROVIDER_LINK_SUCCESS_HEADER = "identityProviderLinkSuccessHeader";

public static final String IDENTITY_PROVIDER_LINK_CONFIRMED_ALREADY = "identityProviderLinkConfirmedAlreadyMessage";

public static final String IDENTITY_PROVIDER_LINK_CONFIRMED_ALREADY_HEADER = "identityProviderLinkConfirmedAlreadyMessageHeader";

public static final String CONFIRM_ACCOUNT_LINKING = "confirmAccountLinking";

public static final String CONFIRM_ACCOUNT_LINKING_BODY = "confirmAccountLinkingBody";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,72 @@ public void sendResetPasswordEmailSuccess() throws IOException {
assertEquals("Action expired. Please continue with login now.", errorPage.getError());
}

@Test
public void sendExecuteActionsEmailWithoutVerifyEmailDoesNotVerifyEmail() throws IOException {
UserRepresentation userRep = UserBuilder.create()
.username("user1").name("User", "One").email("user1@test.com").emailVerified(false).build();

String id = createUser(userRep);

UserResource user = managedRealm.admin().users().get(id);
Assertions.assertFalse(user.toRepresentation().isEmailVerified());

user.executeActionsEmail(Collections.singletonList(UserModel.RequiredAction.UPDATE_PASSWORD.name()));
AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.ACTION, AdminEventPaths.userResourcePath(id) + "/execute-actions-email", ResourceType.USER);

Assertions.assertEquals(1, mailServer.getReceivedMessages().length);

String link = MailUtils.getPasswordResetEmailLink(mailServer.getReceivedMessages()[0]);

driver.open(link);

proceedPage.assertCurrent();
assertThat(proceedPage.getInfo(), Matchers.containsString("Update Password"));
proceedPage.clickProceedLink();
passwordUpdatePage.assertCurrent();

passwordUpdatePage.changePassword("new-pass", "new-pass");

assertEquals("Your account has been updated.", infoPage.getInfo());

//the email should not be marked as verified as VERIFY_EMAIL was not among the actions
Assertions.assertFalse(user.toRepresentation().isEmailVerified());
}

@Test
public void sendExecuteActionsEmailWithVerifyEmailVerifiesEmail() throws IOException {
UserRepresentation userRep = UserBuilder.create()
.username("user1").name("User", "One").email("user1@test.com").emailVerified(false).build();

String id = createUser(userRep);

UserResource user = managedRealm.admin().users().get(id);
Assertions.assertFalse(user.toRepresentation().isEmailVerified());

user.executeActionsEmail(Arrays.asList(
UserModel.RequiredAction.VERIFY_EMAIL.name(),
UserModel.RequiredAction.UPDATE_PASSWORD.name()));
AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.ACTION, AdminEventPaths.userResourcePath(id) + "/execute-actions-email", ResourceType.USER);

Assertions.assertEquals(1, mailServer.getReceivedMessages().length);

String link = MailUtils.getPasswordResetEmailLink(mailServer.getReceivedMessages()[0]);

driver.open(link);

proceedPage.assertCurrent();
proceedPage.clickProceedLink();

// VERIFY_EMAIL was among the actions, so the email is marked as verified
passwordUpdatePage.assertCurrent();

passwordUpdatePage.changePassword("new-pass", "new-pass");

assertEquals("Your account has been updated.", infoPage.getInfo());

Assertions.assertTrue(user.toRepresentation().isEmailVerified());
}

@Test
public void sendTermsAndConditionsEmailSuccess() throws IOException {
RequiredActionProviderRepresentation termsAndConds = managedRealm.admin().flows().getRequiredAction(UserModel.RequiredAction.TERMS_AND_CONDITIONS.name());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,28 @@ public void testAdminCredentialOfferReply() throws Exception {
assertEquals("Action expired. Please continue with login now.", errorPage.getError());
}

@Test
public void testAdminCredentialOfferDoesNotSetEmailVerified() throws Exception {
user.updateWithCleanup(u -> u.emailVerified(false));
adminEvents.clear();

String link = sendEmailAndGetLink(null, null, null, "This link will expire within 12 hours");

driver.open(link);

proceedPage.assertCurrent();
proceedPage.clickProceedLink();

credentialOfferPage.assertCurrent();
credentialOfferPage.clickContinueButton();

infoPage.assertCurrent();
assertEquals("Your account has been updated.", infoPage.getInfo());

assertFalse(user.admin().toRepresentation().isEmailVerified(),
"Opening the credential offer link must not mark the user's email as verified");
}

@Test
public void testAdminCredentialOfferClientId() throws Exception {
String link = sendEmailAndGetLink("oidc-client", null, 7200, "This link will expire within 2 hours");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,17 @@ public void testLinkAccountWithEmailVerified() {
log.info("navigating to url from email: " + url);
driver.navigate().to(url);


verifyEmailPage.assertCurrent();
assertFalse(realm.users().get(linkedUserId).toRepresentation().isEmailVerified());

assertEquals(2, mail.getReceivedMessages().length);
String verificationUrl = assertEmailAndGetUrl(mail.getLastReceivedMessage(), MailServerConfiguration.FROM, USER_EMAIL,
"verify your email address");

log.info("navigating to url from email: " + verificationUrl);
driver.navigate().to(verificationUrl.trim());

//test if user is logged in
assertTrue(driver.getCurrentUrl().startsWith(getConsumerRoot() + "/auth/realms/master/app/"));

Expand Down Expand Up @@ -1165,20 +1176,30 @@ public void testLinkAccountByEmailVerificationTwice() {
String url = assertEmailAndGetUrl(mail.getLastReceivedMessage(), MailServerConfiguration.FROM, USER_EMAIL,
"Someone wants to link your ");
driver.navigate().to(url);

//confirm the account linking does not verify the email, the verify email required action follows
verifyEmailPage.assertCurrent();
assertFalse(adminClient.realm(bc.consumerRealmName()).users().get(consumerUser.getId()).toRepresentation().isEmailVerified());

assertEquals(2, mail.getReceivedMessages().length);
String verificationUrl = assertEmailAndGetUrl(mail.getLastReceivedMessage(), MailServerConfiguration.FROM, USER_EMAIL,
"verify your email address");
driver.navigate().to(verificationUrl.trim());

//test if user is logged in
assertTrue(driver.getCurrentUrl().startsWith(getConsumerRoot() + "/auth/realms/master/app/"));
//test if the user has verified email
assertTrue(adminClient.realm(bc.consumerRealmName()).users().get(consumerUser.getId()).toRepresentation().isEmailVerified());

driver.navigate().to(url);
waitForPage(driver, "your email address has been verified already.", false);
waitForPage(driver, "account linking already confirmed", false);
AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), "consumer");

driver.navigate().to(url);
waitForPage(driver, "your email address has been verified already.", false);
waitForPage(driver, "account linking already confirmed", false);

driver2.navigate().to(url);
waitForPage(driver, "your email address has been verified already.", false);
waitForPage(driver, "account linking already confirmed", false);
}

@Test
Expand Down Expand Up @@ -1217,7 +1238,7 @@ public void testLinkAccountByEmailVerificationInAnotherBrowser() {
assertThat(driver2.findElement(By.id("kc-page-title")).getText(), startsWith("Confirm linking the account"));
assertThat(driver2.findElement(By.className("instruction")).getText(), startsWith("If you link the account, you will also be able to login using account"));
driver2.findElement(By.linkText("» Click here to proceed")).click();
assertThat(driver2.findElement(By.className("instruction")).getText(), startsWith("You successfully verified your email."));
assertThat(driver2.findElement(By.className("instruction")).getText(), startsWith("You successfully confirmed linking your account"));

idpLinkEmailPage.continueLink();

Expand Down Expand Up @@ -1349,7 +1370,21 @@ public void testVerifyEmailInNewBrowserWithPreserveClient() {
MatcherAssert.assertThat(link, Matchers.containsString("client_id=broker-app"));
proceedLink.click();

assertThat(driver2.getPageSource(), Matchers.containsString("You successfully verified your email. Please go back to your original browser and continue there with the login."));
assertThat(driver2.getPageSource(), Matchers.containsString("Please go back to your original browser and continue there with the login."));

//confirm the account linking does not verify the email
assertFalse(consumerRealm.users().get(linkedUserId).toRepresentation().isEmailVerified());

idpLinkEmailPage.continueLink();
verifyEmailPage.assertCurrent();

assertEquals(2, mail.getReceivedMessages().length);
String verificationUrl = assertEmailAndGetUrl(mail.getLastReceivedMessage(), MailServerConfiguration.FROM, USER_EMAIL,
"verify your email address");
driver.navigate().to(verificationUrl.trim());

//test if user is logged in and redirected to the client used for the login, which is preserved
assertTrue(driver.getCurrentUrl().startsWith(getConsumerRoot() + "/auth/realms/" + bc.consumerRealmName() + "/app"));

//test if the user has verified email
assertTrue(consumerRealm.users().get(linkedUserId).toRepresentation().isEmailVerified());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,15 @@ public void resetPassword() throws IOException, MessagingException {
resetPassword("login-test");
}

@Test
public void resetPasswordDoesNotSetEmailVerified() throws IOException {
assertFalse(managedRealm.admin().users().get(userId).toRepresentation().isEmailVerified());

resetPassword("login-test");

assertFalse(managedRealm.admin().users().get(userId).toRepresentation().isEmailVerified());
}

@Test
public void resetPasswordTwice() throws IOException {
String changePasswordUrl = resetPassword("login-test");
Expand Down
Loading
Loading