Service Provider Interface (SPI) contracts and abstractions for the Keycloak identity and access management server enabling extensibility through custom providers
—
The component framework provides a configuration-driven approach to managing provider instances in Keycloak. It enables dynamic configuration of providers with validation, lifecycle management, and hierarchical organization.
Factory interface for component-based providers with configuration support.
public interface ComponentFactory<T extends Provider> extends ProviderFactory<T> {
/**
* Creates a provider instance with the given component configuration.
*
* @param session the Keycloak session
* @param model the component model with configuration
* @return provider instance
*/
T create(KeycloakSession session, ComponentModel model);
/**
* Validates the component configuration.
*
* @param session the Keycloak session
* @param realm the realm
* @param model the component model to validate
* @throws ComponentValidationException if validation fails
*/
void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model)
throws ComponentValidationException;
/**
* Called when a component instance is created.
*
* @param session the Keycloak session
* @param realm the realm
* @param model the component model
*/
void onCreate(KeycloakSession session, RealmModel realm, ComponentModel model);
/**
* Called when a component configuration is updated.
*
* @param session the Keycloak session
* @param realm the realm
* @param oldModel the previous component model
* @param newModel the updated component model
*/
void onUpdate(KeycloakSession session, RealmModel realm, ComponentModel oldModel, ComponentModel newModel);
/**
* Gets configuration properties for this component type.
*
* @return list of configuration properties
*/
@Override
default List<ProviderConfigProperty> getConfigMetadata() {
return Collections.emptyList();
}
/**
* Gets configuration properties specific to the component context.
*
* @param session the Keycloak session
* @param realm the realm
* @return list of configuration properties
*/
default List<ProviderConfigProperty> getConfigProperties(KeycloakSession session, RealmModel realm) {
return getConfigMetadata();
}
}Factory for components that can have sub-components.
public interface SubComponentFactory<T extends Provider, S extends Provider> extends ComponentFactory<T> {
/**
* Gets the provider class for sub-components.
*
* @return sub-component provider class
*/
Class<S> getSubComponentClass();
/**
* Gets supported sub-component types.
*
* @param session the Keycloak session
* @param realm the realm
* @param model the parent component model
* @return list of supported sub-component types
*/
List<String> getSubComponentTypes(KeycloakSession session, RealmModel realm, ComponentModel model);
/**
* Gets configuration properties for a sub-component type.
*
* @param session the Keycloak session
* @param realm the realm
* @param parent the parent component model
* @param subType the sub-component type
* @return list of configuration properties
*/
List<ProviderConfigProperty> getSubComponentConfigProperties(KeycloakSession session, RealmModel realm,
ComponentModel parent, String subType);
}Model representing a component configuration.
public class ComponentModel {
private String id;
private String name;
private String providerId;
private String providerType;
private String parentId;
private String subType;
private MultivaluedHashMap<String, String> config;
// Constructors
public ComponentModel() {
this.config = new MultivaluedHashMap<>();
}
public ComponentModel(ComponentModel copy) {
this.id = copy.id;
this.name = copy.name;
this.providerId = copy.providerId;
this.providerType = copy.providerType;
this.parentId = copy.parentId;
this.subType = copy.subType;
this.config = new MultivaluedHashMap<>(copy.config);
}
public ComponentModel(String id, String name, String parentId, String providerId, String providerType) {
this();
this.id = id;
this.name = name;
this.parentId = parentId;
this.providerId = providerId;
this.providerType = providerType;
}
// Getters and setters
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getProviderId() { return providerId; }
public void setProviderId(String providerId) { this.providerId = providerId; }
public String getProviderType() { return providerType; }
public void setProviderType(String providerType) { this.providerType = providerType; }
public String getParentId() { return parentId; }
public void setParentId(String parentId) { this.parentId = parentId; }
public String getSubType() { return subType; }
public void setSubType(String subType) { this.subType = subType; }
public MultivaluedHashMap<String, String> getConfig() { return config; }
public void setConfig(MultivaluedHashMap<String, String> config) { this.config = config; }
// Configuration helper methods
public String get(String key) {
List<String> values = config.get(key);
return values != null && !values.isEmpty() ? values.get(0) : null;
}
public String get(String key, String defaultValue) {
String value = get(key);
return value != null ? value : defaultValue;
}
public List<String> getList(String key) {
List<String> values = config.get(key);
return values != null ? values : Collections.emptyList();
}
public int get(String key, int defaultValue) {
String value = get(key);
if (value == null) return defaultValue;
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
return defaultValue;
}
}
public long get(String key, long defaultValue) {
String value = get(key);
if (value == null) return defaultValue;
try {
return Long.parseLong(value);
} catch (NumberFormatException e) {
return defaultValue;
}
}
public boolean get(String key, boolean defaultValue) {
String value = get(key);
return value != null ? Boolean.parseBoolean(value) : defaultValue;
}
public void put(String key, String value) {
if (value == null) {
config.remove(key);
} else {
config.putSingle(key, value);
}
}
public void put(String key, List<String> values) {
if (values == null || values.isEmpty()) {
config.remove(key);
} else {
config.put(key, values);
}
}
public void put(String key, int value) {
put(key, String.valueOf(value));
}
public void put(String key, long value) {
put(key, String.valueOf(value));
}
public void put(String key, boolean value) {
put(key, String.valueOf(value));
}
public boolean contains(String key) {
return config.containsKey(key);
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
ComponentModel that = (ComponentModel) obj;
return Objects.equals(id, that.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
@Override
public String toString() {
return String.format("ComponentModel{id='%s', name='%s', providerId='%s', providerType='%s'}",
id, name, providerId, providerType);
}
}Interface for components that require configuration.
public interface ConfiguredComponent {
/**
* Gets the component model configuration.
*
* @return component model
*/
ComponentModel getModel();
}Component model with priority ordering.
public class PrioritizedComponentModel extends ComponentModel {
private int priority;
public PrioritizedComponentModel() {
super();
}
public PrioritizedComponentModel(ComponentModel copy) {
super(copy);
if (copy instanceof PrioritizedComponentModel) {
this.priority = ((PrioritizedComponentModel) copy).priority;
}
}
public PrioritizedComponentModel(ComponentModel copy, int priority) {
super(copy);
this.priority = priority;
}
public int getPriority() { return priority; }
public void setPriority(int priority) { this.priority = priority; }
@Override
public String toString() {
return String.format("PrioritizedComponentModel{id='%s', name='%s', providerId='%s', priority=%d}",
getId(), getName(), getProviderId(), priority);
}
}Component model that stores configuration as JSON.
public class JsonConfigComponentModel extends ComponentModel {
private Map<String, Object> jsonConfig;
public JsonConfigComponentModel() {
super();
this.jsonConfig = new HashMap<>();
}
public JsonConfigComponentModel(ComponentModel copy) {
super(copy);
this.jsonConfig = new HashMap<>();
}
public Map<String, Object> getJsonConfig() { return jsonConfig; }
public void setJsonConfig(Map<String, Object> jsonConfig) { this.jsonConfig = jsonConfig; }
public <T> T getJsonConfig(String key, Class<T> type) {
Object value = jsonConfig.get(key);
return type.isInstance(value) ? type.cast(value) : null;
}
public void putJsonConfig(String key, Object value) {
if (value == null) {
jsonConfig.remove(key);
} else {
jsonConfig.put(key, value);
}
}
}Exception thrown during component validation.
public class ComponentValidationException extends Exception {
private final Object[] parameters;
public ComponentValidationException(String message) {
super(message);
this.parameters = null;
}
public ComponentValidationException(String message, Object... parameters) {
super(message);
this.parameters = parameters;
}
public ComponentValidationException(String message, Throwable cause) {
super(message, cause);
this.parameters = null;
}
public ComponentValidationException(String message, Throwable cause, Object... parameters) {
super(message, cause);
this.parameters = parameters;
}
public Object[] getParameters() { return parameters; }
public String getLocalizedMessage() {
if (parameters != null && parameters.length > 0) {
return String.format(getMessage(), parameters);
}
return getMessage();
}
}public class EmailProviderFactory implements ComponentFactory<EmailProvider> {
public static final String PROVIDER_ID = "smtp-email";
@Override
public EmailProvider create(KeycloakSession session, ComponentModel model) {
return new SmtpEmailProvider(session, model);
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public List<ProviderConfigProperty> getConfigMetadata() {
return Arrays.asList(
new ProviderConfigProperty("host", "SMTP Host", "SMTP server hostname",
ProviderConfigProperty.STRING_TYPE, "localhost"),
new ProviderConfigProperty("port", "SMTP Port", "SMTP server port",
ProviderConfigProperty.STRING_TYPE, "587"),
new ProviderConfigProperty("username", "Username", "SMTP username",
ProviderConfigProperty.STRING_TYPE, null),
new ProviderConfigProperty("password", "Password", "SMTP password",
ProviderConfigProperty.PASSWORD, null),
new ProviderConfigProperty("tls", "Enable TLS", "Enable TLS encryption",
ProviderConfigProperty.BOOLEAN_TYPE, true),
new ProviderConfigProperty("from", "From Address", "Email from address",
ProviderConfigProperty.STRING_TYPE, "noreply@example.com")
);
}
@Override
public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model)
throws ComponentValidationException {
String host = model.get("host");
if (host == null || host.trim().isEmpty()) {
throw new ComponentValidationException("SMTP host is required");
}
String port = model.get("port");
if (port != null && !port.isEmpty()) {
try {
int portNum = Integer.parseInt(port);
if (portNum < 1 || portNum > 65535) {
throw new ComponentValidationException("Port must be between 1 and 65535");
}
} catch (NumberFormatException e) {
throw new ComponentValidationException("Invalid port number: " + port);
}
}
String fromAddress = model.get("from");
if (fromAddress == null || !fromAddress.contains("@")) {
throw new ComponentValidationException("Valid from address is required");
}
// Test SMTP connection
try {
testSmtpConnection(model);
} catch (Exception e) {
throw new ComponentValidationException("Failed to connect to SMTP server: " + e.getMessage(), e);
}
}
@Override
public void onCreate(KeycloakSession session, RealmModel realm, ComponentModel model) {
// Log component creation
logger.info("Created email provider component: " + model.getName());
}
@Override
public void onUpdate(KeycloakSession session, RealmModel realm, ComponentModel oldModel, ComponentModel newModel) {
// Handle configuration changes
if (!Objects.equals(oldModel.get("host"), newModel.get("host"))) {
logger.info("Email provider host changed from {} to {}",
oldModel.get("host"), newModel.get("host"));
}
}
private void testSmtpConnection(ComponentModel model) throws Exception {
// Implementation to test SMTP connection
Properties props = new Properties();
props.put("mail.smtp.host", model.get("host"));
props.put("mail.smtp.port", model.get("port", "587"));
props.put("mail.smtp.starttls.enable", model.get("tls", true));
String username = model.get("username");
String password = model.get("password");
Session mailSession;
if (username != null && password != null) {
props.put("mail.smtp.auth", "true");
mailSession = Session.getInstance(props, new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(username, password);
}
});
} else {
mailSession = Session.getInstance(props);
}
Transport transport = mailSession.getTransport("smtp");
transport.connect();
transport.close();
}
}public class LdapProviderFactory implements SubComponentFactory<UserStorageProvider, UserStorageProviderModel> {
public static final String PROVIDER_ID = "ldap";
@Override
public UserStorageProvider create(KeycloakSession session, ComponentModel model) {
return new LdapUserStorageProvider(session, model);
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public Class<UserStorageProviderModel> getSubComponentClass() {
return UserStorageProviderModel.class;
}
@Override
public List<String> getSubComponentTypes(KeycloakSession session, RealmModel realm, ComponentModel model) {
return Arrays.asList("attribute-mapper", "group-mapper", "role-mapper");
}
@Override
public List<ProviderConfigProperty> getSubComponentConfigProperties(KeycloakSession session, RealmModel realm,
ComponentModel parent, String subType) {
switch (subType) {
case "attribute-mapper":
return getAttributeMapperProperties();
case "group-mapper":
return getGroupMapperProperties();
case "role-mapper":
return getRoleMapperProperties();
default:
return Collections.emptyList();
}
}
@Override
public List<ProviderConfigProperty> getConfigMetadata() {
return Arrays.asList(
new ProviderConfigProperty("connectionUrl", "Connection URL", "LDAP connection URL",
ProviderConfigProperty.STRING_TYPE, "ldap://localhost:389"),
new ProviderConfigProperty("bindDn", "Bind DN", "DN of LDAP admin user",
ProviderConfigProperty.STRING_TYPE, null),
new ProviderConfigProperty("bindCredential", "Bind Credential", "Password of LDAP admin user",
ProviderConfigProperty.PASSWORD, null),
new ProviderConfigProperty("usersDn", "Users DN", "DN where users are stored",
ProviderConfigProperty.STRING_TYPE, "ou=users,dc=example,dc=com"),
new ProviderConfigProperty("usernameAttribute", "Username Attribute", "LDAP attribute for username",
ProviderConfigProperty.STRING_TYPE, "uid")
);
}
private List<ProviderConfigProperty> getAttributeMapperProperties() {
return Arrays.asList(
new ProviderConfigProperty("ldap.attribute", "LDAP Attribute", "LDAP attribute name",
ProviderConfigProperty.STRING_TYPE, null),
new ProviderConfigProperty("user.model.attribute", "User Model Attribute", "User model attribute name",
ProviderConfigProperty.STRING_TYPE, null),
new ProviderConfigProperty("read.only", "Read Only", "Whether attribute is read-only",
ProviderConfigProperty.BOOLEAN_TYPE, false)
);
}
private List<ProviderConfigProperty> getGroupMapperProperties() {
return Arrays.asList(
new ProviderConfigProperty("groups.dn", "Groups DN", "DN where groups are stored",
ProviderConfigProperty.STRING_TYPE, "ou=groups,dc=example,dc=com"),
new ProviderConfigProperty("group.name.attribute", "Group Name Attribute", "LDAP attribute for group name",
ProviderConfigProperty.STRING_TYPE, "cn"),
new ProviderConfigProperty("membership.attribute", "Membership Attribute", "LDAP attribute for membership",
ProviderConfigProperty.STRING_TYPE, "member")
);
}
private List<ProviderConfigProperty> getRoleMapperProperties() {
return Arrays.asList(
new ProviderConfigProperty("roles.dn", "Roles DN", "DN where roles are stored",
ProviderConfigProperty.STRING_TYPE, "ou=roles,dc=example,dc=com"),
new ProviderConfigProperty("role.name.attribute", "Role Name Attribute", "LDAP attribute for role name",
ProviderConfigProperty.STRING_TYPE, "cn"),
new ProviderConfigProperty("use.realm.roles.mapping", "Use Realm Roles", "Map to realm roles",
ProviderConfigProperty.BOOLEAN_TYPE, true)
);
}
}// Create and configure a component
try (KeycloakSession session = sessionFactory.create()) {
RealmModel realm = session.realms().getRealmByName("myrealm");
// Create email provider component
ComponentModel emailComponent = new ComponentModel();
emailComponent.setName("SMTP Email Provider");
emailComponent.setProviderId("smtp-email");
emailComponent.setProviderType("email");
emailComponent.setParentId(realm.getId());
// Configure SMTP settings
emailComponent.put("host", "smtp.gmail.com");
emailComponent.put("port", "587");
emailComponent.put("username", "myapp@gmail.com");
emailComponent.put("password", "app-password");
emailComponent.put("tls", true);
emailComponent.put("from", "myapp@gmail.com");
// Add the component to the realm
realm.addComponent(emailComponent);
// Get the email provider instance
EmailProvider emailProvider = session.getProvider(EmailProvider.class, emailComponent.getId());
// Use the provider
emailProvider.send("test@example.com", "Test Subject", "Test message");
}// Working with component hierarchies
try (KeycloakSession session = sessionFactory.create()) {
RealmModel realm = session.realms().getRealmByName("myrealm");
// Create parent component (LDAP provider)
ComponentModel ldapComponent = new ComponentModel();
ldapComponent.setName("LDAP User Storage");
ldapComponent.setProviderId("ldap");
ldapComponent.setProviderType("org.keycloak.storage.UserStorageProvider");
ldapComponent.setParentId(realm.getId());
// Configure LDAP settings
ldapComponent.put("connectionUrl", "ldap://ldap.example.com:389");
ldapComponent.put("bindDn", "cn=admin,dc=example,dc=com");
ldapComponent.put("bindCredential", "admin123");
ldapComponent.put("usersDn", "ou=users,dc=example,dc=com");
realm.addComponent(ldapComponent);
// Create sub-component (attribute mapper)
ComponentModel attributeMapper = new ComponentModel();
attributeMapper.setName("Email Attribute Mapper");
attributeMapper.setProviderId("user-attribute-ldap-mapper");
attributeMapper.setProviderType("org.keycloak.storage.ldap.mappers.LDAPStorageMapper");
attributeMapper.setParentId(ldapComponent.getId());
attributeMapper.setSubType("attribute-mapper");
// Configure attribute mapping
attributeMapper.put("ldap.attribute", "mail");
attributeMapper.put("user.model.attribute", "email");
attributeMapper.put("read.only", false);
realm.addComponent(attributeMapper);
// Get all sub-components of the LDAP provider
Stream<ComponentModel> subComponents = realm.getComponentsStream(ldapComponent.getId());
subComponents.forEach(comp -> {
System.out.println("Sub-component: " + comp.getName() + " (" + comp.getSubType() + ")");
});
}// Custom component with validation
public class CustomComponentFactory implements ComponentFactory<CustomProvider> {
@Override
public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model)
throws ComponentValidationException {
// Validate required fields
validateRequired(model, "apiKey", "API Key is required");
validateRequired(model, "endpoint", "Endpoint URL is required");
// Validate URL format
String endpoint = model.get("endpoint");
try {
new URL(endpoint);
} catch (MalformedURLException e) {
throw new ComponentValidationException("Invalid endpoint URL: " + endpoint);
}
// Validate numeric fields
validateNumeric(model, "timeout", 1, 300, "Timeout must be between 1 and 300 seconds");
validateNumeric(model, "maxRetries", 0, 10, "Max retries must be between 0 and 10");
// Validate enum values
String protocol = model.get("protocol");
if (protocol != null && !Arrays.asList("http", "https").contains(protocol.toLowerCase())) {
throw new ComponentValidationException("Protocol must be 'http' or 'https'");
}
// Custom business logic validation
validateBusinessRules(session, realm, model);
}
private void validateRequired(ComponentModel model, String key, String errorMessage)
throws ComponentValidationException {
String value = model.get(key);
if (value == null || value.trim().isEmpty()) {
throw new ComponentValidationException(errorMessage);
}
}
private void validateNumeric(ComponentModel model, String key, int min, int max, String errorMessage)
throws ComponentValidationException {
String value = model.get(key);
if (value != null && !value.isEmpty()) {
try {
int intValue = Integer.parseInt(value);
if (intValue < min || intValue > max) {
throw new ComponentValidationException(errorMessage);
}
} catch (NumberFormatException e) {
throw new ComponentValidationException("Invalid number format for " + key + ": " + value);
}
}
}
private void validateBusinessRules(KeycloakSession session, RealmModel realm, ComponentModel model)
throws ComponentValidationException {
// Custom validation logic specific to your component
String apiKey = model.get("apiKey");
if (apiKey != null && apiKey.length() < 32) {
throw new ComponentValidationException("API key must be at least 32 characters long");
}
// Check for conflicts with existing components
realm.getComponentsStream(realm.getId(), "custom-provider")
.filter(comp -> !comp.getId().equals(model.getId()))
.filter(comp -> comp.get("endpoint").equals(model.get("endpoint")))
.findFirst()
.ifPresent(existing -> {
throw new RuntimeException(new ComponentValidationException(
"Another component is already using this endpoint: " + existing.getName()));
});
}
}Install with Tessl CLI
npx tessl i tessl/maven-org-keycloak--keycloak-server-spi