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 @@ -77,6 +77,7 @@
import org.keycloak.models.ModelException;
import org.keycloak.models.ModelIllegalStateException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RequiredActionProviderModel;
import org.keycloak.models.UserConsentModel;
import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserLoginFailureModel;
Expand All @@ -93,7 +94,6 @@
import org.keycloak.protocol.oid4vc.resources.admin.UserVerifiableCredentialResource;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.FederatedIdentityRepresentation;
Expand Down Expand Up @@ -304,18 +304,20 @@ public static void updateUserFromRep(UserProfile profile, UserModel user, UserRe
List<String> reqActions = rep.getRequiredActions();

if (reqActions != null) {
session.getKeycloakSessionFactory()
.getProviderFactoriesStream(RequiredActionProvider.class)
.map(ProviderFactory::getId)
.distinct()
.sorted()
.forEach(action -> {
if (reqActions.contains(action)) {
user.addRequiredAction(action);
} else if (removeMissingRequiredActions) {
user.removeRequiredAction(action);
}
});
if (removeMissingRequiredActions) {
user.getRequiredActionsStream().toList().forEach(user::removeRequiredAction);
}

reqActions.stream()
.filter(action -> {
// Required-action values stored on a user are realm aliases, which are not
// necessarily equal to the provider factory id. Resolve the realm model by
// alias first, then validate that its provider id maps to a registered factory.
RequiredActionProviderModel model = session.getContext().getRealm().getRequiredActionProviderByAlias(action);
return model != null && session.getKeycloakSessionFactory()
.getProviderFactory(RequiredActionProvider.class, model.getProviderId()) != null;
})
.forEach(user::addRequiredAction);
}

List<CredentialRepresentation> credentials = rep.getCredentials();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
package org.keycloak.tests.admin.user;

import java.util.List;

import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderSimpleRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.events.AdminEventAssertion;
import org.keycloak.testframework.server.KeycloakServerConfig;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
import org.keycloak.tests.providers.actions.DummyRequiredActionFactory;
import org.keycloak.tests.suites.DatabaseTest;
import org.keycloak.tests.utils.admin.AdminEventPaths;

import org.junit.jupiter.api.Assertions;
Expand All @@ -16,9 +23,15 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

@KeycloakIntegrationTest
@KeycloakIntegrationTest(config = UserRequiredActionsTest.CustomProvidersServerConfig.class)
public class UserRequiredActionsTest extends AbstractUserTest {

// Deliberately different from DummyRequiredActionFactory.PROVIDER_ID so that the required action
// stored on the user (a realm alias) is not equal to any registered provider factory id. This is
// what reproduces the reported failure (#48144): the previous logic only iterated over provider
// factory ids and therefore could neither remove nor retain an action keyed by such an alias.
private static final String CUSTOM_ALIAS = "custom-dummy-alias";

@Test
public void addRequiredAction() {
String id = createUser();
Expand Down Expand Up @@ -74,4 +87,120 @@ public void testDefaultRequiredActionAdded() {
managedRealm.admin().flows().updateRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD.toString(), updatePasswordReqAction);
AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.UPDATE, AdminEventPaths.authRequiredActionPath(UserModel.RequiredAction.UPDATE_PASSWORD.toString()), updatePasswordReqAction, ResourceType.REQUIRED_ACTION);
}

@Test
@DatabaseTest
public void removeCustomRequiredAction() {
registerDummyRequiredAction();

String id = createUser();
UserResource user = managedRealm.admin().users().get(id);

// Add the custom required action (by its realm alias) to the user
UserRepresentation userRep = user.toRepresentation();
userRep.getRequiredActions().add(CUSTOM_ALIAS);
updateUser(user, userRep);

userRep = user.toRepresentation();
assertEquals(1, userRep.getRequiredActions().size());
assertEquals(CUSTOM_ALIAS, userRep.getRequiredActions().get(0));

// Remove the custom required action by sending an empty list
userRep.getRequiredActions().clear();
updateUser(user, userRep);

userRep = user.toRepresentation();
assertTrue(userRep.getRequiredActions().isEmpty(),
"Custom required action should be removed but was still present: " + userRep.getRequiredActions());
}

@Test
@DatabaseTest
public void removeCustomRequiredActionKeepBuiltIn() {
registerDummyRequiredAction();

String id = createUser();
UserResource user = managedRealm.admin().users().get(id);

// Add both a built-in and custom required action
UserRepresentation userRep = user.toRepresentation();
userRep.getRequiredActions().add(UserModel.RequiredAction.UPDATE_PASSWORD.toString());
userRep.getRequiredActions().add(CUSTOM_ALIAS);
updateUser(user, userRep);

userRep = user.toRepresentation();
assertEquals(2, userRep.getRequiredActions().size());

// Remove only the custom action, keep the built-in one
userRep.setRequiredActions(List.of(UserModel.RequiredAction.UPDATE_PASSWORD.toString()));
updateUser(user, userRep);

userRep = user.toRepresentation();
assertEquals(1, userRep.getRequiredActions().size());
assertEquals(UserModel.RequiredAction.UPDATE_PASSWORD.toString(), userRep.getRequiredActions().get(0));
}

@Test
@DatabaseTest
public void keepCustomRequiredActionWithDistinctAlias() {
registerDummyRequiredAction();

String id = createUser();
UserResource user = managedRealm.admin().users().get(id);

// Add a built-in and a custom action whose alias differs from its provider id
UserRepresentation userRep = user.toRepresentation();
userRep.setRequiredActions(List.of(UserModel.RequiredAction.UPDATE_PASSWORD.toString(), CUSTOM_ALIAS));
updateUser(user, userRep);

userRep = user.toRepresentation();
assertEquals(2, userRep.getRequiredActions().size());

// Re-send both actions; the alias-keyed custom action must be retained, not silently dropped
userRep.setRequiredActions(List.of(UserModel.RequiredAction.UPDATE_PASSWORD.toString(), CUSTOM_ALIAS));
updateUser(user, userRep);

userRep = user.toRepresentation();
assertEquals(2, userRep.getRequiredActions().size(),
"Custom required action with a distinct alias should be retained but was: " + userRep.getRequiredActions());
assertTrue(userRep.getRequiredActions().contains(CUSTOM_ALIAS),
"Custom required action should be retained but was dropped: " + userRep.getRequiredActions());
assertTrue(userRep.getRequiredActions().contains(UserModel.RequiredAction.UPDATE_PASSWORD.toString()));
}

private void registerDummyRequiredAction() {
RequiredActionProviderSimpleRepresentation action = managedRealm.admin().flows().getUnregisteredRequiredActions()
.stream()
.filter(a -> a.getProviderId().equals(DummyRequiredActionFactory.PROVIDER_ID))
.findFirst()
.orElseThrow(() -> new AssertionError("Dummy required action not found"));
managedRealm.admin().flows().registerRequiredAction(action);
AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.CREATE,
AdminEventPaths.authMgmtBasePath() + "/register-required-action", action, ResourceType.REQUIRED_ACTION);

// Rename the alias so it no longer matches the provider factory id. Required-action values
// stored on a user are realm aliases, so this makes the tests exercise the alias/provider-id
// distinction rather than the trivial case where the two happen to be equal.
RequiredActionProviderRepresentation registered = managedRealm.admin().flows().getRequiredAction(DummyRequiredActionFactory.PROVIDER_ID);
registered.setAlias(CUSTOM_ALIAS);
managedRealm.admin().flows().updateRequiredAction(DummyRequiredActionFactory.PROVIDER_ID, registered);
AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.UPDATE,
AdminEventPaths.authRequiredActionPath(DummyRequiredActionFactory.PROVIDER_ID), registered, ResourceType.REQUIRED_ACTION);

managedRealm.cleanup().add(r -> {
try {
r.flows().removeRequiredAction(CUSTOM_ALIAS);
} catch (jakarta.ws.rs.NotFoundException ignored) {
}
});
}

public static class CustomProvidersServerConfig implements KeycloakServerConfig {

@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
return config.dependency("org.keycloak.tests", "keycloak-tests-custom-providers");
}

}
}