Make all required actions one time action by default

Closes CVE-2026-37982
Closes #49112

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
Ricardo Martin
2026-05-19 23:40:06 +02:00
committed by GitHub
parent 918a74f930
commit 2d1a24f501
7 changed files with 62 additions and 22 deletions
@@ -9,4 +9,8 @@ The WebAuthn policy presents the option **Acceptable AAGUIDs** to restrict the a
Since this release, when this option is set up, the attestation is required to be present and signed with a valid certificate for the {project_name} trust-store. The `None` attestation format is explicitly not permitted. Previously, there were some corner cases in which a self attestation was accepted. The change is expected to be harmless, but maybe there are combinations of authenticators and WebAuthn policies that can present issues.
See chapter link:{adminguide_link}#_webauthn-policy[Managing policy] in the {adminguide_name} for more information.
See chapter link:{adminguide_link}#_webauthn-policy[Managing policy] in the {adminguide_name} for more information.
=== Required actions are one-time actions by default
Since this release, all _Required Actions_ are now one-time actions by default to ensure they cannot be reused once completed. This change impacts the email action tokens created by the `execute-actions-email` endpoint, which is executed via the **Credential reset** button in the Admin console. Any action token generated through this endpoint (with whatever reset actions) is now strictly one-time use, ensuring that once a user completes the required steps, the token is immediately invalidated.
@@ -67,7 +67,7 @@ public interface RequiredActionFactory extends ProviderFactory<RequiredActionPro
* @return
*/
default boolean isOneTimeAction() {
return false;
return true;
}
/**
@@ -181,11 +181,6 @@ public class DeleteAccount implements RequiredActionProvider, RequiredActionFact
return InitiatedActionSupport.SUPPORTED;
}
@Override
public boolean isOneTimeAction() {
return true;
}
@Override
public int getMaxAuthAge(KeycloakSession session) {
return 0;
@@ -90,11 +90,6 @@ public class RecoveryAuthnCodesAction implements RequiredActionProvider, Require
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public boolean isOneTimeAction() {
return true;
}
@Override
public InitiatedActionSupport initiatedActionSupport() {
return InitiatedActionSupport.SUPPORTED;
@@ -198,11 +198,6 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac
return UserModel.RequiredAction.UPDATE_PASSWORD.name();
}
@Override
public boolean isOneTimeAction() {
return true;
}
@Override
public int getMaxAuthAge(KeycloakSession session) {
if (session == null) {
@@ -277,9 +277,4 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory
public String getCredentialType(KeycloakSession session, AuthenticationSessionModel authenticationSession) {
return OTPCredentialModel.TYPE;
}
@Override
public boolean isOneTimeAction() {
return true;
}
}
@@ -28,6 +28,7 @@ import org.keycloak.representations.AccessToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testframework.annotations.InjectClient;
import org.keycloak.testframework.annotations.InjectKeycloakUrls;
@@ -50,6 +51,7 @@ import org.keycloak.testframework.ui.page.ErrorPage;
import org.keycloak.testframework.ui.page.InfoPage;
import org.keycloak.testframework.ui.page.LoginPasswordUpdatePage;
import org.keycloak.testframework.ui.page.ProceedPage;
import org.keycloak.testframework.ui.page.TermsAndConditionsPage;
import org.keycloak.testframework.util.ApiUtil;
import org.keycloak.tests.utils.MailUtils;
import org.keycloak.tests.utils.admin.AdminEventPaths;
@@ -88,6 +90,9 @@ public class UserEmailTest extends AbstractUserTest {
@InjectPage
LoginPasswordUpdatePage passwordUpdatePage;
@InjectPage
TermsAndConditionsPage termsPage;
@InjectPage
InfoPage infoPage;
@@ -205,6 +210,57 @@ public class UserEmailTest extends AbstractUserTest {
driver.open(link);
errorPage.assertCurrent();
assertEquals("Action expired. Please continue with login now.", errorPage.getError());
}
@Test
public void sendTermsAndConditionsEmailSuccess() throws IOException {
RequiredActionProviderRepresentation termsAndConds = managedRealm.admin().flows().getRequiredAction(UserModel.RequiredAction.TERMS_AND_CONDITIONS.name());
termsAndConds.setEnabled(true);
managedRealm.admin().flows().updateRequiredAction(termsAndConds.getAlias(), termsAndConds);
AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.UPDATE,
AdminEventPaths.authRequiredActionPath(termsAndConds.getAlias()), termsAndConds, ResourceType.REQUIRED_ACTION);
termsAndConds.setEnabled(false);
managedRealm.cleanup().add(r -> r.flows().updateRequiredAction(termsAndConds.getAlias(), termsAndConds));
UserRepresentation userRep = UserBuilder.create()
.username("user1").name("User", "One").email("user1@test.com").build();
String id = createUser(userRep);
UserResource user = managedRealm.admin().users().get(id);
List<String> actions = new LinkedList<>();
actions.add(UserModel.RequiredAction.TERMS_AND_CONDITIONS.name());
user.executeActionsEmail(actions);
AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.ACTION, AdminEventPaths.userResourcePath(id) + "/execute-actions-email", ResourceType.USER);
Assertions.assertEquals(1, mailServer.getReceivedMessages().length);
MimeMessage message = mailServer.getReceivedMessages()[0];
MailUtils.EmailBody body = MailUtils.getBody(message);
assertTrue(body.getText().contains("Terms and Conditions"));
assertTrue(body.getText().contains("your Default account"));
assertTrue(body.getText().contains("This link will expire within 12 hours"));
String link = MailUtils.getPasswordResetEmailLink(body);
driver.open(link);
proceedPage.assertCurrent();
assertThat(proceedPage.getInfo(), Matchers.containsString("Terms and Conditions"));
proceedPage.clickProceedLink();
termsPage.assertCurrent();
termsPage.acceptTerms();
assertThat(driver.getCurrentUrl(), Matchers.containsString("client_id=" + Constants.ACCOUNT_MANAGEMENT_CLIENT_ID));
assertEquals("Your account has been updated.", infoPage.getInfo());
driver.open(link);
errorPage.assertCurrent();
assertEquals("Action expired. Please continue with login now.", errorPage.getError());
}
@Test