Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
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
@@ -0,0 +1,66 @@
package org.keycloak.common.util;

import java.time.Duration;
import java.time.format.DateTimeParseException;
import java.util.regex.Pattern;

public class DurationConverter {

private static final String PERIOD = "P";
private static final String PERIOD_OF_TIME = "PT";
public static final Pattern DIGITS = Pattern.compile("^[-+]?\\d+$");
private static final Pattern DIGITS_AND_UNIT = Pattern.compile("^(?:[-+]?\\d+(?:\\.\\d+)?(?i)[hms])+$");
private static final Pattern DAYS = Pattern.compile("^[-+]?\\d+(?i)d$");
private static final Pattern MILLIS = Pattern.compile("^[-+]?\\d+(?i)ms$");

/**
* If the {@code value} starts with a number, then:
* <ul>
* <li>If the value is only a number, it is treated as a number of seconds.</li>
* <li>If the value is a number followed by {@code ms}, it is treated as a number of milliseconds.</li>
* <li>If the value is a number followed by {@code h}, {@code m}, or {@code s}, it is prefixed with {@code PT}
* and {@link Duration#parse(CharSequence)} is called.</li>
* <li>If the value is a number followed by {@code d}, it is prefixed with {@code P}
* and {@link Duration#parse(CharSequence)} is called.</li>
* </ul>
*
* Otherwise, {@link Duration#parse(CharSequence)} is called.
*
* @param value a string duration
* @return the parsed {@link Duration}
* @throws IllegalArgumentException in case of parse failure
*/
public static Duration parseDuration(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
if (DIGITS.asPredicate().test(value)) {
return Duration.ofSeconds(Long.parseLong(value));
} else if (MILLIS.asPredicate().test(value)) {
return Duration.ofMillis(Long.parseLong(value.substring(0, value.length() - 2)));
}

try {
if (DIGITS_AND_UNIT.asPredicate().test(value)) {
return Duration.parse(PERIOD_OF_TIME + value);
} else if (DAYS.asPredicate().test(value)) {
return Duration.parse(PERIOD + value);
}

return Duration.parse(value);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException(e);
}
}

/**
* Checks whether the given value represents a positive duration.
*
* @param value a string duration following the same format as in {@link #parseDuration(String)}
* @return true if the value represents a positive duration, false otherwise
*/
public static boolean isPositiveDuration(String value) {
Duration duration = parseDuration(value);
return duration != null && !duration.isNegative() && !duration.isZero();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ public String getAfter() {
return getConfigValue(CONFIG_AFTER, String.class);
}

public void setAfter(long ms) {
setConfig(CONFIG_AFTER, String.valueOf(ms));
public void setAfter(String after) {
setConfig(CONFIG_AFTER, after);
}

public String getPriority() {
Expand Down Expand Up @@ -84,21 +84,16 @@ public Builder of(String providerId) {
}

public Builder after(Duration duration) {
step.setAfter(duration.toMillis());
return this;
return after(String.valueOf(duration.getSeconds()));
}

public Builder id(String id) {
step.setId(id);
public Builder after(String after) {
step.setAfter(after);
return this;
}

public Builder before(WorkflowStepRepresentation targetStep, Duration timeBeforeTarget) {
// Calculate absolute time: targetStep.after - timeBeforeTarget
String targetAfter = targetStep.getConfig().get(CONFIG_AFTER).get(0);
long targetTime = Long.parseLong(targetAfter);
long thisTime = targetTime - timeBeforeTarget.toMillis();
step.setAfter(thisTime);
public Builder id(String id) {
step.setId(id);
return this;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,6 @@ public interface WorkflowResource {
@Path("bind/{type}/{resourceId}")
@POST
@Consumes(MediaType.APPLICATION_JSON)
void bind(@PathParam("type") String type, @PathParam("resourceId") String resourceId, Long milliseconds);
void bind(@PathParam("type") String type, @PathParam("resourceId") String resourceId, String notBefore);

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import jakarta.ws.rs.BadRequestException;
import org.jboss.logging.Logger;
import org.keycloak.common.util.DurationConverter;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.component.ComponentFactory;
import org.keycloak.component.ComponentModel;
Expand Down Expand Up @@ -228,8 +229,8 @@ private void processEvent(Stream<Workflow> workflows, WorkflowEvent event) {
if (isAlreadyScheduledInSession(event, workflow)) {
return;
}
// If the workflow has a notBefore set, schedule the first step with it
if (workflow.getNotBefore() != null && workflow.getNotBefore() > 0) {
// If the workflow has a positive notBefore set, schedule the first step with it
if (DurationConverter.isPositiveDuration(workflow.getNotBefore())) {
scheduleWorkflow(event, workflow);
} else {
DefaultWorkflowExecutionContext context = new DefaultWorkflowExecutionContext(session, workflow, event);
Expand Down Expand Up @@ -316,7 +317,7 @@ private void validateWorkflow(WorkflowRepresentation rep) {
throw new WorkflowInvalidStateException("Workflow restart step must be the last step.");
}
boolean hasScheduledStep = steps.stream()
.anyMatch(step -> Integer.parseInt(ofNullable(step.getAfter()).orElse("0")) > 0);
.anyMatch(step -> DurationConverter.isPositiveDuration(step.getAfter()));
if (!hasScheduledStep) {
throw new WorkflowInvalidStateException("A workflow with a restart step must have at least one step with a time delay.");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,15 @@
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import org.jboss.logging.Logger;
import org.keycloak.common.util.DurationConverter;
import org.keycloak.common.util.Time;
import org.keycloak.connections.jpa.JpaConnectionProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.utils.StringUtil;

import java.time.Duration;
import java.time.Instant;
import java.util.List;

public class JpaWorkflowStateProvider implements WorkflowStateProvider {
Expand Down Expand Up @@ -57,17 +60,24 @@ public ScheduledStep getScheduledStep(String workflowId, String resourceId) {
@Override
public void scheduleStep(Workflow workflow, WorkflowStep step, String resourceId, String executionId) {
WorkflowStateEntity entity = em.find(WorkflowStateEntity.class, executionId);
Duration duration = DurationConverter.parseDuration(step.getAfter());
if (duration == null) {
// shouldn't happen as the step duration should have been validated before
throw new IllegalArgumentException("Invalid duration (%s) found when scheduling step %s in workflow %s"
.formatted(step.getAfter(), step.getProviderId(), workflow.getName()));
}

if (entity == null) {
entity = new WorkflowStateEntity();
entity.setResourceId(resourceId);
entity.setWorkflowId(workflow.getId());
entity.setExecutionId(executionId);
entity.setScheduledStepId(step.getId());
entity.setScheduledStepTimestamp(Time.currentTimeMillis() + step.getAfter());
entity.setScheduledStepTimestamp(Instant.now().plus(duration).toEpochMilli());
em.persist(entity);
} else {
entity.setScheduledStepId(step.getId());
entity.setScheduledStepTimestamp(Time.currentTimeMillis() + step.getAfter());
entity.setScheduledStepTimestamp(Instant.now().plus(duration).toEpochMilli());
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.util.List;

import org.jboss.logging.Logger;
import org.keycloak.common.util.DurationConverter;
import org.keycloak.models.KeycloakSession;

class RunWorkflowTask extends WorkflowTransactionalTask {
Expand Down Expand Up @@ -34,7 +35,7 @@ public void run(KeycloakSession session) {
WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class);

for (WorkflowStep step : stepsToRun) {
if (step.getAfter() > 0) {
if (DurationConverter.isPositiveDuration(step.getAfter())) {
// If a step has a time defined, schedule it and stop processing the other steps of workflow
log.debugf("Scheduling step %s to run in %d ms for resource %s (execution id: %s)",
step.getProviderId(), step.getAfter(), resourceId, executionId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public void run(KeycloakSession session) {
WorkflowStep firstStep = workflow.getSteps().findFirst().orElseThrow(() -> new WorkflowInvalidStateException("No steps found for workflow " + workflow.getName()));
log.debugf("Scheduling first step '%s' of workflow '%s' for resource %s based on on event %s with notBefore %d",
firstStep.getProviderId(), workflow.getName(), event.getResourceId(), event.getOperation(), workflow.getNotBefore());
Long originalAfter = firstStep.getAfter();
String originalAfter = firstStep.getAfter();
try {
firstStep.setAfter(workflow.getNotBefore());
WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,27 @@
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_ERROR;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_NAME;

import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;

import org.keycloak.common.util.DurationConverter;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelValidationException;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
import org.keycloak.utils.StringUtil;

public class Workflow {

private final RealmModel realm;
private final KeycloakSession session;
private MultivaluedHashMap<String, String> config;
private String id;
private Long notBefore;
private String notBefore;

public Workflow(KeycloakSession session, ComponentModel c) {
this.session = session;
Expand Down Expand Up @@ -72,11 +75,11 @@ public boolean isEnabled() {
return config != null && Boolean.parseBoolean(config.getFirstOrDefault(CONFIG_ENABLED, "true"));
}

public Long getNotBefore() {
public String getNotBefore() {
return notBefore;
}

public void setNotBefore(Long notBefore) {
public void setNotBefore(String notBefore) {
this.notBefore = notBefore;
}

Expand Down Expand Up @@ -133,22 +136,33 @@ private void addStep(WorkflowStep step) {
}

private WorkflowStep toModel(WorkflowStepRepresentation rep) {
WorkflowStep step = new WorkflowStep(rep.getUses(), rep.getConfig());
validateStep(step);
return step;
validateStep(rep);
return new WorkflowStep(rep.getUses(), rep.getConfig());
}

private void validateStep(WorkflowStep step) throws ModelValidationException {
if (step.getAfter() < 0) {
throw new ModelValidationException("Step 'after' time condition cannot be negative.");
private void validateStep(WorkflowStepRepresentation step) throws ModelValidationException {

// validate the step rep has 'uses' defined
if (StringUtil.isBlank(step.getUses())) {
throw new ModelValidationException("Step 'uses' cannot be null or empty.");
}

// validate the after time, if present
try {
Duration duration = DurationConverter.parseDuration(step.getAfter());
if (duration != null && duration.isNegative()) { // duration can only be null if the config is not set
throw new ModelValidationException("Step 'after' configuration cannot be negative.");
}
} catch (IllegalArgumentException e) {
throw new ModelValidationException("Step 'after' configuration is not valid: " + step.getAfter());
}

// verify the step does have valid provider
WorkflowStepProviderFactory<WorkflowStepProvider> factory = (WorkflowStepProviderFactory<WorkflowStepProvider>) session
.getKeycloakSessionFactory().getProviderFactory(WorkflowStepProvider.class, step.getProviderId());
.getKeycloakSessionFactory().getProviderFactory(WorkflowStepProvider.class, step.getUses());

if (factory == null) {
throw new WorkflowInvalidStateException("Step not found: " + step.getProviderId());
throw new WorkflowInvalidStateException("Step not found: " + step.getUses());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,12 @@ public int getPriority() {
}
}

public void setAfter(Long ms) {
setConfig(CONFIG_AFTER, String.valueOf(ms));
public void setAfter(String after) {
setConfig(CONFIG_AFTER, after);
}

public Long getAfter() {
return Long.valueOf(getConfig().getFirstOrDefault(CONFIG_AFTER, "0"));
public String getAfter() {
return getConfig().getFirst(CONFIG_AFTER);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_AFTER;

import org.jboss.logging.Logger;
import org.keycloak.common.util.DurationConverter;
import org.keycloak.component.ComponentModel;
import org.keycloak.email.EmailException;
import org.keycloak.email.EmailTemplateProvider;
Expand Down Expand Up @@ -119,21 +120,21 @@ private Map<String, Object> getBodyAttributes() {
}

private String getNextStepType() {
Map<ComponentModel, Long> nextStepMap = getNextNonNotificationStep();
Map<ComponentModel, Duration> nextStepMap = getNextNonNotificationStep();
return nextStepMap.isEmpty() ? "unknown-step" : nextStepMap.keySet().iterator().next().getProviderId();
}

private int calculateDaysUntilNextStep() {
Map<ComponentModel, Long> nextStepMap = getNextNonNotificationStep();
Map<ComponentModel, Duration> nextStepMap = getNextNonNotificationStep();
if (nextStepMap.isEmpty()) {
return 0;
}
Long timeToNextStep = nextStepMap.values().iterator().next();
return Math.toIntExact(Duration.ofMillis(timeToNextStep).toDays());
Duration timeToNextStep = nextStepMap.values().iterator().next();
return Math.toIntExact(timeToNextStep.toDays());
}

private Map<ComponentModel, Long> getNextNonNotificationStep() {
long timeToNextNonNotificationStep = 0L;
private Map<ComponentModel, Duration> getNextNonNotificationStep() {
Duration timeToNextNonNotificationStep = Duration.ZERO;

RealmModel realm = session.getContext().getRealm();
ComponentModel workflowModel = realm.getComponent(stepModel.getParentId());
Expand All @@ -150,7 +151,8 @@ private Map<ComponentModel, Long> getNextNonNotificationStep() {
boolean foundCurrent = false;
for (ComponentModel step : steps) {
if (foundCurrent) {
timeToNextNonNotificationStep += step.get(CONFIG_AFTER, 0L);
Duration duration = DurationConverter.parseDuration(step.get(CONFIG_AFTER, "0"));
timeToNextNonNotificationStep = timeToNextNonNotificationStep.plus(duration != null ? duration : Duration.ZERO);
if (!step.getProviderId().equals("notify-user")) {
// we found the next non-notification action, accumulate its time and break
return Map.of(step, timeToNextNonNotificationStep);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,14 @@ public WorkflowRepresentation toRepresentation() {
*
* @param type the resource type
* @param resourceId the resource id
* @param notBefore optional notBefore time in milliseconds to schedule the first workflow step,
* it overrides the first workflow step time configuration (after).
* @param notBefore optional value representing the time to schedule the first workflow step, overriding the first
* step time configuration (after). The value is either an integer representing the seconds from now,
* an integer followed by 'ms' representing milliseconds from now, or an ISO-8601 date string.
*/
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Path("bind/{type}/{resourceId}")
public void bind(@PathParam("type") ResourceType type, @PathParam("resourceId") String resourceId, Long notBefore) {
public void bind(@PathParam("type") ResourceType type, @PathParam("resourceId") String resourceId, String notBefore) {
Object resource = provider.getResourceTypeSelector(type).resolveResource(resourceId);

if (resource == null) {
Expand Down
Loading
Loading