Service Provider Interface (SPI) contracts and abstractions for the Keycloak identity and access management server enabling extensibility through custom providers
—
The validation framework provides extensible field validation for user input, form data, and configuration parameters. It supports built-in validators and custom validation logic.
Base interface for field validators.
public interface Validator {
/**
* Gets the validator ID.
*
* @return validator ID
*/
String getId();
/**
* Validates input value.
*
* @param input the input value to validate
* @param inputHint hint about the input context
* @param context validation context
* @param config validator configuration
* @return validation result
* @throws ValidationException if validation fails unexpectedly
*/
ValidationResult validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config)
throws ValidationException;
}Base interface for simple validators that validate individual values.
public interface SimpleValidator extends Validator {
/**
* Validates a single input value.
*
* @param input the input value
* @param inputHint hint about the input
* @param context validation context
* @param config validator configuration
* @return validation result
*/
ValidationResult validateValue(Object input, String inputHint, ValidationContext context, ValidatorConfig config);
@Override
default ValidationResult validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config)
throws ValidationException {
return validateValue(input, inputHint, context, config);
}
}Provides context for validation operations.
public interface ValidationContext extends AttributeContext {
/**
* Gets the Keycloak session.
*
* @return Keycloak session
*/
KeycloakSession getSession();
/**
* Gets the attributes being validated.
*
* @return attributes
*/
Attributes getAttributes();
/**
* Gets the current realm.
*
* @return realm model
*/
RealmModel getRealm();
/**
* Gets the current user (if available).
*
* @return user model or null
*/
UserModel getUser();
}Base context interface for attribute operations.
public interface AttributeContext {
/**
* Gets the Keycloak session.
*
* @return Keycloak session
*/
KeycloakSession getSession();
/**
* Gets the current realm.
*
* @return realm model
*/
RealmModel getRealm();
}Represents the result of a validation operation.
public class ValidationResult {
public static final ValidationResult VALID = new ValidationResult();
private final boolean valid;
private final Set<ValidationError> errors;
private ValidationResult() {
this.valid = true;
this.errors = Collections.emptySet();
}
private ValidationResult(Set<ValidationError> errors) {
this.valid = false;
this.errors = errors != null ? Collections.unmodifiableSet(errors) : Collections.emptySet();
}
/**
* Creates a valid result.
*
* @return valid validation result
*/
public static ValidationResult valid() {
return VALID;
}
/**
* Creates an invalid result with errors.
*
* @param errors validation errors
* @return invalid validation result
*/
public static ValidationResult invalid(ValidationError... errors) {
if (errors == null || errors.length == 0) {
return valid();
}
return new ValidationResult(Set.of(errors));
}
/**
* Creates an invalid result with error set.
*
* @param errors validation errors
* @return invalid validation result
*/
public static ValidationResult invalid(Set<ValidationError> errors) {
if (errors == null || errors.isEmpty()) {
return valid();
}
return new ValidationResult(errors);
}
/**
* Checks if validation was successful.
*
* @return true if valid
*/
public boolean isValid() {
return valid;
}
/**
* Gets validation errors.
*
* @return set of validation errors
*/
public Set<ValidationError> getErrors() {
return errors;
}
/**
* Combines this result with another result.
*
* @param other the other validation result
* @return combined result
*/
public ValidationResult combine(ValidationResult other) {
if (this.isValid() && other.isValid()) {
return valid();
}
Set<ValidationError> combinedErrors = new HashSet<>();
if (!this.isValid()) {
combinedErrors.addAll(this.errors);
}
if (!other.isValid()) {
combinedErrors.addAll(other.errors);
}
return invalid(combinedErrors);
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
ValidationResult that = (ValidationResult) obj;
return valid == that.valid && Objects.equals(errors, that.errors);
}
@Override
public int hashCode() {
return Objects.hash(valid, errors);
}
@Override
public String toString() {
return valid ? "ValidationResult{valid}" : "ValidationResult{invalid, errors=" + errors + "}";
}
}Represents a validation error.
public class ValidationError {
private final String message;
private final String messageKey;
private final Object[] messageParameters;
private final String inputHint;
public ValidationError(String message) {
this(message, null, null, null);
}
public ValidationError(String message, String inputHint) {
this(message, null, null, inputHint);
}
public ValidationError(String messageKey, Object[] messageParameters) {
this(null, messageKey, messageParameters, null);
}
public ValidationError(String messageKey, Object[] messageParameters, String inputHint) {
this(null, messageKey, messageParameters, inputHint);
}
private ValidationError(String message, String messageKey, Object[] messageParameters, String inputHint) {
this.message = message;
this.messageKey = messageKey;
this.messageParameters = messageParameters;
this.inputHint = inputHint;
}
public String getMessage() { return message; }
public String getMessageKey() { return messageKey; }
public Object[] getMessageParameters() { return messageParameters; }
public String getInputHint() { return inputHint; }
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
ValidationError that = (ValidationError) obj;
return Objects.equals(message, that.message) &&
Objects.equals(messageKey, that.messageKey) &&
Arrays.equals(messageParameters, that.messageParameters) &&
Objects.equals(inputHint, that.inputHint);
}
@Override
public int hashCode() {
int result = Objects.hash(message, messageKey, inputHint);
result = 31 * result + Arrays.hashCode(messageParameters);
return result;
}
@Override
public String toString() {
return "ValidationError{" +
"message='" + message + '\'' +
", messageKey='" + messageKey + '\'' +
", messageParameters=" + Arrays.toString(messageParameters) +
", inputHint='" + inputHint + '\'' +
'}';
}
}Configuration for validators.
public class ValidatorConfig {
private final Map<String, Object> config;
public ValidatorConfig() {
this.config = new HashMap<>();
}
public ValidatorConfig(Map<String, Object> config) {
this.config = config != null ? new HashMap<>(config) : new HashMap<>();
}
/**
* Gets a configuration value.
*
* @param key the configuration key
* @return configuration value or null
*/
public Object get(String key) {
return config.get(key);
}
/**
* Gets a configuration value with default.
*
* @param key the configuration key
* @param defaultValue default value if key not found
* @return configuration value or default
*/
@SuppressWarnings("unchecked")
public <T> T get(String key, T defaultValue) {
Object value = config.get(key);
return value != null ? (T) value : defaultValue;
}
/**
* Gets a string configuration value.
*
* @param key the configuration key
* @return string value or null
*/
public String getString(String key) {
Object value = config.get(key);
return value != null ? value.toString() : null;
}
/**
* Gets a string configuration value with default.
*
* @param key the configuration key
* @param defaultValue default value
* @return string value or default
*/
public String getString(String key, String defaultValue) {
String value = getString(key);
return value != null ? value : defaultValue;
}
/**
* Gets an integer configuration value.
*
* @param key the configuration key
* @param defaultValue default value
* @return integer value or default
*/
public int getInt(String key, int defaultValue) {
Object value = config.get(key);
if (value instanceof Number) {
return ((Number) value).intValue();
}
if (value instanceof String) {
try {
return Integer.parseInt((String) value);
} catch (NumberFormatException e) {
return defaultValue;
}
}
return defaultValue;
}
/**
* Gets a boolean configuration value.
*
* @param key the configuration key
* @param defaultValue default value
* @return boolean value or default
*/
public boolean getBoolean(String key, boolean defaultValue) {
Object value = config.get(key);
if (value instanceof Boolean) {
return (Boolean) value;
}
if (value instanceof String) {
return Boolean.parseBoolean((String) value);
}
return defaultValue;
}
/**
* Sets a configuration value.
*
* @param key the configuration key
* @param value the configuration value
*/
public void put(String key, Object value) {
if (value == null) {
config.remove(key);
} else {
config.put(key, value);
}
}
/**
* Gets all configuration as map.
*
* @return configuration map
*/
public Map<String, Object> asMap() {
return new HashMap<>(config);
}
}Factory for creating validator instances.
public interface ValidatorFactory extends ProviderFactory<Validator> {
/**
* Gets the validator ID.
*
* @return validator ID
*/
@Override
String getId();
/**
* Creates a validator instance.
*
* @param session Keycloak session
* @return validator instance
*/
@Override
Validator create(KeycloakSession session);
}SPI for the validation framework.
public class ValidatorSPI implements Spi {
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return "validator";
}
@Override
public Class<? extends Provider> getProviderClass() {
return Validator.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return ValidatorFactory.class;
}
}Abstract base for simple validators.
public abstract class AbstractSimpleValidator implements SimpleValidator {
@Override
public ValidationResult validateValue(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
if (input == null) {
return ValidationResult.valid();
}
return doValidate(input, inputHint, context, config);
}
/**
* Performs the actual validation logic.
*
* @param input the non-null input value
* @param inputHint hint about the input
* @param context validation context
* @param config validator configuration
* @return validation result
*/
protected abstract ValidationResult doValidate(Object input, String inputHint, ValidationContext context, ValidatorConfig config);
}Abstract base for string validators.
public abstract class AbstractStringValidator extends AbstractSimpleValidator {
@Override
protected ValidationResult doValidate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
String stringValue = input.toString();
if (stringValue.isEmpty() && ignoreEmptyValues()) {
return ValidationResult.valid();
}
return validateString(stringValue, inputHint, context, config);
}
/**
* Validates a string value.
*
* @param value the string value
* @param inputHint hint about the input
* @param context validation context
* @param config validator configuration
* @return validation result
*/
protected abstract ValidationResult validateString(String value, String inputHint, ValidationContext context, ValidatorConfig config);
/**
* Whether to ignore empty string values.
*
* @return true to ignore empty values
*/
protected boolean ignoreEmptyValues() {
return true;
}
}Utility class providing access to built-in validators.
public class Validators {
public static final String LENGTH = "length";
public static final String EMAIL = "email";
public static final String PATTERN = "pattern";
public static final String URI = "uri";
public static final String INTEGER = "integer";
public static final String DOUBLE = "double";
public static final String DATE = "date";
public static final String PHONE_NUMBER = "phone-number";
public static final String POSTAL_CODE = "postal-code";
/**
* Creates a length validator configuration.
*
* @param min minimum length
* @param max maximum length
* @return validator configuration
*/
public static ValidatorConfig length(int min, int max) {
ValidatorConfig config = new ValidatorConfig();
config.put("min", min);
config.put("max", max);
return config;
}
/**
* Creates a pattern validator configuration.
*
* @param pattern regular expression pattern
* @return validator configuration
*/
public static ValidatorConfig pattern(String pattern) {
ValidatorConfig config = new ValidatorConfig();
config.put("pattern", pattern);
return config;
}
/**
* Creates an email validator configuration.
*
* @return validator configuration
*/
public static ValidatorConfig email() {
return new ValidatorConfig();
}
/**
* Creates a URI validator configuration.
*
* @return validator configuration
*/
public static ValidatorConfig uri() {
return new ValidatorConfig();
}
/**
* Creates an integer validator configuration.
*
* @param min minimum value
* @param max maximum value
* @return validator configuration
*/
public static ValidatorConfig integer(int min, int max) {
ValidatorConfig config = new ValidatorConfig();
config.put("min", min);
config.put("max", max);
return config;
}
}// Custom phone number validator
public class PhoneNumberValidator extends AbstractStringValidator {
public static final String ID = "phone-number";
private static final Pattern PHONE_PATTERN = Pattern.compile("^\\+?[1-9]\\d{1,14}$");
@Override
public String getId() {
return ID;
}
@Override
protected ValidationResult validateString(String value, String inputHint, ValidationContext context, ValidatorConfig config) {
if (!PHONE_PATTERN.matcher(value).matches()) {
return ValidationResult.invalid(
new ValidationError("invalid-phone-number", new Object[]{value}, inputHint)
);
}
// Additional validation based on country
String country = config.getString("country");
if (country != null) {
return validateForCountry(value, country, inputHint);
}
return ValidationResult.valid();
}
private ValidationResult validateForCountry(String phone, String country, String inputHint) {
// Country-specific validation logic
switch (country.toUpperCase()) {
case "US":
if (!phone.matches("^\\+?1[2-9]\\d{9}$")) {
return ValidationResult.invalid(
new ValidationError("invalid-us-phone-number", new Object[]{phone}, inputHint)
);
}
break;
case "UK":
if (!phone.matches("^\\+?44[1-9]\\d{8,9}$")) {
return ValidationResult.invalid(
new ValidationError("invalid-uk-phone-number", new Object[]{phone}, inputHint)
);
}
break;
}
return ValidationResult.valid();
}
}
// Custom validator factory
public class PhoneNumberValidatorFactory implements ValidatorFactory {
@Override
public Validator create(KeycloakSession session) {
return new PhoneNumberValidator();
}
@Override
public String getId() {
return PhoneNumberValidator.ID;
}
@Override
public List<ProviderConfigProperty> getConfigMetadata() {
return Arrays.asList(
new ProviderConfigProperty("country", "Country Code", "ISO country code for validation",
ProviderConfigProperty.STRING_TYPE, null)
);
}
}// Validate user profile attributes
try (KeycloakSession session = sessionFactory.create()) {
RealmModel realm = session.realms().getRealmByName("myrealm");
UserModel user = session.users().getUserByUsername(realm, "john");
// Create validation context
ValidationContext context = new ValidationContext() {
@Override
public KeycloakSession getSession() { return session; }
@Override
public RealmModel getRealm() { return realm; }
@Override
public UserModel getUser() { return user; }
@Override
public Attributes getAttributes() {
// Return user attributes
Map<String, List<String>> attrs = user.getAttributes();
return new AttributesImpl(attrs);
}
};
// Validate email
Validator emailValidator = session.getProvider(Validator.class, Validators.EMAIL);
ValidatorConfig emailConfig = Validators.email();
String email = user.getEmail();
ValidationResult emailResult = emailValidator.validate(email, "email", context, emailConfig);
if (!emailResult.isValid()) {
System.out.println("Email validation failed: " + emailResult.getErrors());
}
// Validate phone number with custom validator
Validator phoneValidator = session.getProvider(Validator.class, PhoneNumberValidator.ID);
ValidatorConfig phoneConfig = new ValidatorConfig();
phoneConfig.put("country", "US");
String phone = user.getFirstAttribute("phoneNumber");
ValidationResult phoneResult = phoneValidator.validate(phone, "phoneNumber", context, phoneConfig);
if (!phoneResult.isValid()) {
System.out.println("Phone validation failed: " + phoneResult.getErrors());
}
// Combine validation results
ValidationResult combinedResult = emailResult.combine(phoneResult);
if (!combinedResult.isValid()) {
System.out.println("Overall validation failed with " + combinedResult.getErrors().size() + " errors");
}
}// Validate form input during user registration
public class RegistrationFormValidator {
private final KeycloakSession session;
private final RealmModel realm;
public RegistrationFormValidator(KeycloakSession session, RealmModel realm) {
this.session = session;
this.realm = realm;
}
public ValidationResult validateRegistrationForm(MultivaluedMap<String, String> formData) {
ValidationContext context = createValidationContext();
ValidationResult result = ValidationResult.valid();
// Validate username
String username = formData.getFirst("username");
result = result.combine(validateUsername(username, context));
// Validate email
String email = formData.getFirst("email");
result = result.combine(validateEmail(email, context));
// Validate password
String password = formData.getFirst("password");
result = result.combine(validatePassword(password, context));
// Validate confirm password
String confirmPassword = formData.getFirst("confirmPassword");
result = result.combine(validatePasswordConfirmation(password, confirmPassword, context));
// Validate first name
String firstName = formData.getFirst("firstName");
result = result.combine(validateFirstName(firstName, context));
// Validate last name
String lastName = formData.getFirst("lastName");
result = result.combine(validateLastName(lastName, context));
return result;
}
private ValidationResult validateUsername(String username, ValidationContext context) {
if (username == null || username.trim().isEmpty()) {
return ValidationResult.invalid(new ValidationError("username-required", null, "username"));
}
// Check length
Validator lengthValidator = session.getProvider(Validator.class, Validators.LENGTH);
ValidatorConfig lengthConfig = Validators.length(3, 50);
ValidationResult lengthResult = lengthValidator.validate(username, "username", context, lengthConfig);
if (!lengthResult.isValid()) {
return lengthResult;
}
// Check pattern (alphanumeric and underscore only)
Validator patternValidator = session.getProvider(Validator.class, Validators.PATTERN);
ValidatorConfig patternConfig = Validators.pattern("^[a-zA-Z0-9_]+$");
ValidationResult patternResult = patternValidator.validate(username, "username", context, patternConfig);
if (!patternResult.isValid()) {
return ValidationResult.invalid(new ValidationError("username-invalid-characters", null, "username"));
}
// Check uniqueness
UserModel existingUser = session.users().getUserByUsername(realm, username);
if (existingUser != null) {
return ValidationResult.invalid(new ValidationError("username-exists", null, "username"));
}
return ValidationResult.valid();
}
private ValidationResult validateEmail(String email, ValidationContext context) {
if (email == null || email.trim().isEmpty()) {
return ValidationResult.invalid(new ValidationError("email-required", null, "email"));
}
Validator emailValidator = session.getProvider(Validator.class, Validators.EMAIL);
ValidatorConfig emailConfig = Validators.email();
ValidationResult emailResult = emailValidator.validate(email, "email", context, emailConfig);
if (!emailResult.isValid()) {
return emailResult;
}
// Check uniqueness
UserModel existingUser = session.users().getUserByEmail(realm, email);
if (existingUser != null) {
return ValidationResult.invalid(new ValidationError("email-exists", null, "email"));
}
return ValidationResult.valid();
}
private ValidationResult validatePassword(String password, ValidationContext context) {
if (password == null || password.isEmpty()) {
return ValidationResult.invalid(new ValidationError("password-required", null, "password"));
}
// Use realm password policy
PasswordPolicy policy = realm.getPasswordPolicy();
if (policy != null) {
try {
policy.validate(realm.getName(), null, password);
} catch (PolicyError e) {
return ValidationResult.invalid(new ValidationError(e.getMessage(), null, "password"));
}
}
return ValidationResult.valid();
}
private ValidationResult validatePasswordConfirmation(String password, String confirmPassword, ValidationContext context) {
if (confirmPassword == null || confirmPassword.isEmpty()) {
return ValidationResult.invalid(new ValidationError("password-confirmation-required", null, "confirmPassword"));
}
if (!Objects.equals(password, confirmPassword)) {
return ValidationResult.invalid(new ValidationError("passwords-do-not-match", null, "confirmPassword"));
}
return ValidationResult.valid();
}
private ValidationResult validateFirstName(String firstName, ValidationContext context) {
if (firstName == null || firstName.trim().isEmpty()) {
return ValidationResult.invalid(new ValidationError("first-name-required", null, "firstName"));
}
Validator lengthValidator = session.getProvider(Validator.class, Validators.LENGTH);
ValidatorConfig lengthConfig = Validators.length(1, 100);
return lengthValidator.validate(firstName.trim(), "firstName", context, lengthConfig);
}
private ValidationResult validateLastName(String lastName, ValidationContext context) {
if (lastName == null || lastName.trim().isEmpty()) {
return ValidationResult.invalid(new ValidationError("last-name-required", null, "lastName"));
}
Validator lengthValidator = session.getProvider(Validator.class, Validators.LENGTH);
ValidatorConfig lengthConfig = Validators.length(1, 100);
return lengthValidator.validate(lastName.trim(), "lastName", context, lengthConfig);
}
private ValidationContext createValidationContext() {
return new ValidationContext() {
@Override
public KeycloakSession getSession() { return session; }
@Override
public RealmModel getRealm() { return realm; }
@Override
public UserModel getUser() { return null; }
@Override
public Attributes getAttributes() { return null; }
};
}
}Install with Tessl CLI
npx tessl i tessl/maven-org-keycloak--keycloak-server-spi