Skip to content
Draft
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 @@ -18,7 +18,6 @@
package org.keycloak.models.workflow;

import java.time.Duration;
import java.time.Instant;
import java.util.stream.Stream;

import jakarta.persistence.EntityManager;
Expand Down Expand Up @@ -78,12 +77,12 @@ public ScheduleResult scheduleStep(Workflow workflow, WorkflowStep step, String
entity.setWorkflowId(workflow.getId());
entity.setExecutionId(executionId);
entity.setScheduledStepId(step.getId());
entity.setScheduledStepTimestamp(Instant.now().plus(duration).toEpochMilli());
entity.setScheduledStepTimestamp(Time.currentTimeMillis() + duration.toMillis());
em.persist(entity);
return ScheduleResult.CREATED;
} else {
entity.setScheduledStepId(step.getId());
entity.setScheduledStepTimestamp(Instant.now().plus(duration).toEpochMilli());
entity.setScheduledStepTimestamp(Time.currentTimeMillis() + duration.toMillis());
return ScheduleResult.UPDATED;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package org.keycloak.models.workflow.conditions;

import org.keycloak.models.KeycloakSession;
import org.keycloak.models.workflow.WorkflowConditionProviderFactory;

public class ClientAttributeWorkflowConditionFactory implements WorkflowConditionProviderFactory<ClientAttributeWorkflowConditionProvider> {

public static final String ID = "has-client-attribute";

@Override
public ClientAttributeWorkflowConditionProvider create(KeycloakSession session, String keyValuePair) {
return new ClientAttributeWorkflowConditionProvider(session, keyValuePair);
}

@Override
public String getId() {
return ID;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package org.keycloak.models.workflow.conditions;

import java.util.Objects;

import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.criteria.Subquery;

import org.keycloak.connections.jpa.JpaConnectionProvider;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.jpa.entities.ClientAttributeEntity;
import org.keycloak.models.workflow.ResourceType;
import org.keycloak.models.workflow.WorkflowConditionProvider;
import org.keycloak.models.workflow.WorkflowExecutionContext;
import org.keycloak.models.workflow.WorkflowInvalidStateException;

import org.hibernate.Session;

import static org.keycloak.models.workflow.conditions.UserAttributeWorkflowConditionProvider.parseKeyValuePair;

public class ClientAttributeWorkflowConditionProvider implements WorkflowConditionProvider {

private final String expectedAttribute;
private final KeycloakSession session;

public ClientAttributeWorkflowConditionProvider(KeycloakSession session, String expectedAttribute) {
this.session = session;
this.expectedAttribute = expectedAttribute;
}

@Override
public ResourceType getSupportedResourceType() {
return ResourceType.CLIENTS;
}

@Override
public boolean evaluate(WorkflowExecutionContext context) {
validate();

RealmModel realm = session.getContext().getRealm();
ClientModel client = session.clients().getClientById(realm, context.getResourceId());

if (client == null) {
return false;
}

String[] parsedKeyValuePair = parseKeyValuePair(expectedAttribute);
String key = parsedKeyValuePair[0];
String valuePart = parsedKeyValuePair[1];

// Presence-only: "key" -> true if the client has the attribute, regardless of its value
if (valuePart.isEmpty()) {
return client.getAttributes().containsKey(key);
}

// Client attributes are single-valued, so the expected value is compared literally
return Objects.equals(valuePart, client.getAttribute(key));
}

@Override
public Predicate toPredicate(CriteriaBuilder cb, CriteriaQuery<String> query, Root<?> path) {
validate();

String[] parsedKeyValuePair = parseKeyValuePair(expectedAttribute);
String attributeName = parsedKeyValuePair[0];
String valuePart = parsedKeyValuePair[1];

// Subquery to find if an attribute with this name (and value, if one is expected) exists for the client
Subquery<Integer> subquery = query.subquery(Integer.class);
Root<ClientAttributeEntity> attrRoot = subquery.from(ClientAttributeEntity.class);
subquery.select(cb.literal(1));

Predicate clientPredicate = cb.equal(attrRoot.get("client").get("id"), path.get("id"));
Predicate namePredicate = cb.equal(attrRoot.get("name"), attributeName);

// Presence-only: require the attribute to exist for the client, regardless of its value
if (valuePart.isEmpty()) {
subquery.where(cb.and(clientPredicate, namePredicate));
return cb.exists(subquery);
}

subquery.where(cb.and(clientPredicate, namePredicate, createValuePredicate(cb, attrRoot, valuePart)));
return cb.exists(subquery);
}

private Predicate createValuePredicate(CriteriaBuilder cb, Root<ClientAttributeEntity> attrRoot, String expectedValue) {
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();

//noinspection resource
String dbProductName = em.unwrap(Session.class).doReturningWork(connection -> connection.getMetaData().getDatabaseProductName());

if (dbProductName.equals("Oracle")) {
// Oracle is not able to compare a CLOB with a VARCHAR unless it being converted with TO_CHAR
// But for this all values in the table need to be smaller than 4K, otherwise the cast will fail with
// "ORA-22835: Buffer too small for CLOB to CHAR" (even if it is in another row).
// This leaves DBMS_LOB.COMPARE as the option to compare the CLOB with the value.
return cb.equal(cb.function("DBMS_LOB.COMPARE", Integer.class, attrRoot.get("value"), cb.literal(expectedValue)), 0);
} else if (dbProductName.equals("PostgreSQL")) {
// use the substr comparison and the full comparison in postgresql
return cb.and(
cb.equal(
cb.function("substr", Integer.class, attrRoot.get("value"), cb.literal(1), cb.literal(255)),
cb.function("substr", Integer.class, cb.literal(expectedValue), cb.literal(1), cb.literal(255))),
cb.equal(attrRoot.get("value"), expectedValue));
}

return cb.equal(attrRoot.get("value"), expectedValue);
}

@Override
public void validate() {
if (expectedAttribute == null) {
throw new WorkflowInvalidStateException("workflowConditionAttributeNotSet");
}
}

@Override
public void close() {

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package org.keycloak.models.workflow.events;

import org.keycloak.models.KeycloakSession;
import org.keycloak.models.workflow.WorkflowEventProvider;
import org.keycloak.models.workflow.WorkflowEventProviderFactory;

public class ClientActivityWorkflowEventFactory implements WorkflowEventProviderFactory<WorkflowEventProvider> {

public static final String ID = "client-activity";

@Override
public WorkflowEventProvider create(KeycloakSession session, String configParameter) {
return new ClientActivityWorkflowEventProvider(session, configParameter, this.getId());
}

@Override
public String getId() {
return ID;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package org.keycloak.models.workflow.events;

import java.util.EnumSet;
import java.util.Set;

import org.keycloak.events.Event;
import org.keycloak.events.EventType;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.workflow.AbstractWorkflowEventProvider;
import org.keycloak.models.workflow.ResourceType;

public class ClientActivityWorkflowEventProvider extends AbstractWorkflowEventProvider {

// User events that indicate a client is actively being used on behalf of a user. Unlike CLIENT_LOGIN,
// these events are also sent for public clients, which cannot authenticate on their own.
private static final Set<EventType> ACTIVITY_EVENT_TYPES = EnumSet.of(EventType.LOGIN, EventType.CODE_TO_TOKEN, EventType.REFRESH_TOKEN);

public ClientActivityWorkflowEventProvider(final KeycloakSession session, final String configParameter, final String providerId) {
super(session, configParameter, providerId);
}

@Override
public ResourceType getSupportedResourceType() {
return ResourceType.CLIENTS;
}

@Override
public boolean supports(Event event) {
return ACTIVITY_EVENT_TYPES.contains(event.getType());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# limitations under the License.
#

org.keycloak.models.workflow.conditions.ClientAttributeWorkflowConditionFactory
org.keycloak.models.workflow.conditions.GroupMembershipWorkflowConditionFactory
org.keycloak.models.workflow.conditions.IdentityProviderWorkflowConditionFactory
org.keycloak.models.workflow.conditions.UserAttributeWorkflowConditionFactory
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ org.keycloak.models.workflow.events.UserRoleGrantedWorkflowEventFactory
org.keycloak.models.workflow.events.UserRoleRevokedWorkflowEventFactory

# client event providers
org.keycloak.models.workflow.events.ClientActivityWorkflowEventFactory
org.keycloak.models.workflow.events.ClientAuthenticatedWorkflowEventFactory
org.keycloak.models.workflow.events.ClientCreatedWorkflowEventFactory
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package org.keycloak.tests.workflow.activation;

import java.time.Duration;

import org.keycloak.models.ClientModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.workflow.WorkflowProvider;
import org.keycloak.models.workflow.WorkflowStateProvider;
import org.keycloak.models.workflow.client.DisableClientStepProviderFactory;
import org.keycloak.models.workflow.events.ClientActivityWorkflowEventFactory;
import org.keycloak.representations.workflows.WorkflowRepresentation;
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
import org.keycloak.testframework.annotations.InjectUser;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.injection.LifeCycle;
import org.keycloak.testframework.realm.ManagedUser;
import org.keycloak.testframework.realm.UserBuilder;
import org.keycloak.testframework.realm.UserConfig;
import org.keycloak.testframework.remote.timeoffset.InjectTimeOffSet;
import org.keycloak.testframework.remote.timeoffset.TimeOffSet;
import org.keycloak.tests.workflow.AbstractWorkflowTest;
import org.keycloak.tests.workflow.config.WorkflowsBlockingServerConfig;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.empty;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* Tests activation of client workflows based on user-driven activity (login) events.
*/
@KeycloakIntegrationTest(config = WorkflowsBlockingServerConfig.class)
public class ClientActivityWorkflowTest extends AbstractWorkflowTest {

@InjectUser(ref = "alice", config = DefaultUserConfig.class, lifecycle = LifeCycle.METHOD, realmRef = DEFAULT_REALM_NAME)
private ManagedUser userAlice;

@InjectTimeOffSet
TimeOffSet timeOffSet;

@Test
public void testActivateWorkflowOnClientActivity() {
createWorkflow();

// login with alice - the login event carries the client, so this attaches the workflow to the
// client used for the login and schedules the first step
login();

// running the scheduled tasks now shouldn't pick up any step as none are due to run yet
runScheduledSteps(Duration.ZERO);

assertClientEnabled(true, "phase1: nothing due yet");

// move the clock 4 days ahead and login again - the activity resets the workflow, so the
// disable step is now due 9 days after the first login
timeOffSet.set(Duration.ofDays(4));
try {
login();
} finally {
timeOffSet.set(0);
}

// without the reset the disable step would be due now
runScheduledSteps(Duration.ofDays(6));

assertClientEnabled(true, "phase2: reset moved the step past day 6");

// setting the offset past the reset schedule should disable the client
runScheduledSteps(Duration.ofDays(10));

assertClientEnabled(false, "phase3: step due after day 9");
}

@Test
public void testFailedLoginDoesNotActivateWorkflow() {
createWorkflow();

// a failed attempt carries the client on the error event but must not activate the workflow -
// otherwise spamming bad credentials against a dormant client would keep its idle clock alive
oauth.openLoginForm();
loginPage.fillLogin(userAlice.getUsername(), "wrong-password");
loginPage.submit();

runOnServer.run((session -> {
WorkflowProvider provider = session.getProvider(WorkflowProvider.class);
WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class);
provider.getWorkflows().forEach(workflow ->
assertThat(stateProvider.getScheduledStepsByWorkflow(workflow.getId()).toList(), empty()));
}));
}

private void createWorkflow() {
managedRealm.admin().workflows().create(WorkflowRepresentation.withName("myworkflow")
.onEvent(ClientActivityWorkflowEventFactory.ID)
.concurrency().restartInProgress("true") // this setting enables restarting the workflow
.withSteps(
WorkflowStepRepresentation.create().of(DisableClientStepProviderFactory.ID)
.after(Duration.ofDays(5))
.build()
).build()).close();
}

private void login() {
oauth.openLoginForm();
loginPage.fillLogin(userAlice.getUsername(), userAlice.getPassword());
loginPage.submit();
Assertions.assertTrue(oauth.parseLoginResponse().isSuccess());
}

private void assertClientEnabled(boolean expectedEnabled, String phase) {
String clientId = oauth.getClientId();

runOnServer.run((session -> {
RealmModel realm = session.getContext().getRealm();
ClientModel client = session.clients().getClientByClientId(realm, clientId);
if (expectedEnabled) {
assertTrue(client.isEnabled(), phase);
} else {
assertFalse(client.isEnabled(), phase);
}
}));
}

private static class DefaultUserConfig implements UserConfig {

@Override
public UserBuilder configure(UserBuilder user) {
user.username("alice");
user.password("alice");
user.name("alice", "alice");
user.email("alice@example.org");
return user;
}
}
}
Loading