Skip to content

필사 모드: Keycloak SPI Extension Development — From Custom Authenticators to EventListeners

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

Introduction

The real power of Keycloak is not "lots of built-in features" but "everything can be swapped out." Almost every internal behavior — authentication steps, event handling, user storage, even REST APIs — is abstracted behind an SPI (Service Provider Interface), letting you fill the gaps the standard features leave with Java code.

As of 2026, demand for SPI development is actually growing. Integrating with internal legacy systems (logging in with users from a legacy DB), meeting country-specific regulations (SMS identity verification), streaming audit logs in real time (Kafka), and automating identity issuance for AI agents are requirements that standard configuration alone cannot solve. This article covers the following.

- The SPI architecture: the relationship between Provider and ProviderFactory

- Setting up the development environment (Java 17+, Maven)

- A complete custom Authenticator implementation — OTP via an internal SMS gateway

- Forcing phone number registration with a RequiredAction

- Streaming audit logs to Kafka with an EventListener

- Building custom REST endpoints with RealmResourceProvider

- Integrating a legacy DB with the User Storage SPI

- Deployment, Testcontainers integration tests, and managing upgrade compatibility

The SPI Architecture — Provider and ProviderFactory

Every extension point in Keycloak follows the same three-layer pattern.

+------------------------------------------------------------+

| Spi (extension point definition) |

| e.g., AuthenticatorSpi, EventListenerSpi |

| |

| +------------------------------------------------------+ |

| | ProviderFactory (singleton, one per server lifetime) | |

| | - init(Config.Scope): load configuration | |

| | - create(KeycloakSession): create Provider instance | |

| | - getId(): unique identifier | |

| | | |

| | +------------------------------------------------+ | |

| | | Provider (created per request/transaction) | | |

| | | - the actual business logic | | |

| | | - close(): cleanup at end of request | | |

| | +------------------------------------------------+ | |

| +------------------------------------------------------+ |

+------------------------------------------------------------+

The key rules are as follows.

- The ProviderFactory is a singleton created once at server startup. Do heavy initialization (connection pools, HTTP clients) here.

- The Provider is created per KeycloakSession (roughly per HTTP request). It should be stateless or hold only session-scoped state.

- Implementation registration follows the Java ServiceLoader convention: create a file named after the fully qualified interface in the META-INF/services directory and list the implementation class.

Setting Up the Development Environment

Keycloak 26.x requires Java 17 or later and also runs on 21. Maven dependencies use the provided scope, so that classes the server already ships are not duplicated in your JAR.

<!-- For the Kafka EventListener (compile scope: must ship in the JAR) -->

Keep in mind that interfaces in spi-private can change even in minor versions. We return to this in the compatibility management section below.

A Custom Authenticator — OTP via an Internal SMS Gateway

This is the most in-demand extension. Let's build an Authenticator that sends a 6-digit code through an internal SMS gateway after password authentication and verifies it.

1. Implementing the Authenticator

package com.example.iam.sms;

public class SmsOtpAuthenticator implements Authenticator {

private static final String OTP_NOTE = "sms-otp-code";

private static final String PHONE_ATTR = "phoneNumber";

@Override

public void authenticate(AuthenticationFlowContext context) {

UserModel user = context.getUser();

String phone = user.getFirstAttribute(PHONE_ATTR);

if (phone == null || phone.isBlank()) {

// No phone number registered -> route to a RequiredAction

user.addRequiredAction("register-phone-number");

context.attempted();

return;

}

String code = generateCode();

// auth notes are temporary storage valid only for this auth session

context.getAuthenticationSession().setAuthNote(OTP_NOTE, code);

SmsGateway gateway = SmsGateway.fromConfig(

context.getAuthenticatorConfig());

gateway.send(phone, "Verification code: " + code + " (enter within 5 min)");

context.challenge(

context.form()

.setAttribute("phoneHint", maskPhone(phone))

.createForm("sms-otp.ftl"));

}

@Override

public void action(AuthenticationFlowContext context) {

String expected = context.getAuthenticationSession()

.getAuthNote(OTP_NOTE);

String submitted = context.getHttpRequest()

.getDecodedFormParameters()

.getFirst("otp");

if (expected != null && expected.equals(submitted)) {

context.success();

return;

}

context.failureChallenge(

AuthenticationFlowError.INVALID_CREDENTIALS,

context.form()

.setError("invalidOtpCode")

.createForm("sms-otp.ftl"));

}

@Override

public boolean requiresUser() {

return true; // must come after password authentication

}

@Override

public boolean configuredFor(KeycloakSession session,

RealmModel realm, UserModel user) {

return user.getFirstAttribute(PHONE_ATTR) != null;

}

@Override

public void setRequiredActions(KeycloakSession session,

RealmModel realm, UserModel user) {

user.addRequiredAction("register-phone-number");

}

@Override

public void close() { }

private String generateCode() {

return String.format("%06d", new SecureRandom().nextInt(1_000_000));

}

private String maskPhone(String phone) {

int len = phone.length();

return phone.substring(0, 3) + "****" + phone.substring(len - 2);

}

}

2. Implementing the AuthenticatorFactory

package com.example.iam.sms;

public class SmsOtpAuthenticatorFactory implements AuthenticatorFactory {

public static final String PROVIDER_ID = "sms-otp-authenticator";

private static final SmsOtpAuthenticator SINGLETON =

new SmsOtpAuthenticator();

@Override

public String getId() { return PROVIDER_ID; }

@Override

public String getDisplayType() { return "SMS OTP (internal gateway)"; }

@Override

public Authenticator create(KeycloakSession session) {

return SINGLETON; // stateless, so the singleton can be reused

}

@Override

public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {

return new AuthenticationExecutionModel.Requirement[] {

AuthenticationExecutionModel.Requirement.REQUIRED,

AuthenticationExecutionModel.Requirement.ALTERNATIVE,

AuthenticationExecutionModel.Requirement.CONDITIONAL,

AuthenticationExecutionModel.Requirement.DISABLED

};

}

@Override

public boolean isConfigurable() { return true; }

@Override

public List<ProviderConfigProperty> getConfigProperties() {

ProviderConfigProperty url = new ProviderConfigProperty(

"gateway.url", "SMS Gateway URL",

"Internal SMS gateway endpoint",

ProviderConfigProperty.STRING_TYPE,

"https://sms.internal.example.com/v1/send");

return List.of(url);

}

@Override

public boolean isUserSetupAllowed() { return true; }

@Override

public String getHelpText() {

return "Sends a 6-digit OTP via the internal SMS gateway.";

}

@Override

public void init(Config.Scope config) { }

@Override

public void postInit(KeycloakSessionFactory factory) { }

@Override

public void close() { }

}

3. ServiceLoader registration

File path:

src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory

com.example.iam.sms.SmsOtpAuthenticatorFactory

Afterwards, duplicate the browser flow in the admin console and add the SMS OTP execution after the Username Password Form to put it into effect. We covered the mechanics of the authentication flow engine in the architecture article, so we skip them here.

RequiredAction — Forcing Phone Number Registration

The Authenticator above adds a RequiredAction called register-phone-number when no phone number exists. A RequiredAction is "something the user must do before continuing, even though login succeeded."

package com.example.iam.sms;

public class RegisterPhoneRequiredAction implements RequiredActionProvider {

public static final String PROVIDER_ID = "register-phone-number";

@Override

public void evaluateTriggers(RequiredActionContext context) {

// Evaluate auto-trigger conditions on each login (unused here)

}

@Override

public void requiredActionChallenge(RequiredActionContext context) {

context.challenge(

context.form().createForm("register-phone.ftl"));

}

@Override

public void processAction(RequiredActionContext context) {

String phone = context.getHttpRequest()

.getDecodedFormParameters()

.getFirst("phoneNumber");

if (phone == null || !phone.matches("^\\+?[0-9]{10,15}$")) {

context.challenge(

context.form().setError("invalidPhone")

.createForm("register-phone.ftl"));

return;

}

context.getUser().setSingleAttribute("phoneNumber", phone);

context.success();

}

@Override

public void close() { }

}

The Factory and the ServiceLoader registration (org.keycloak.authentication.RequiredActionFactory) follow the same pattern as the Authenticator, so we omit them.

EventListener — Streaming Audit Logs to Kafka

The most common security-audit requirement is "ship every login/admin event to the SIEM in real time." We implement it with the EventListener SPI.

package com.example.iam.audit;

public class KafkaEventListener implements EventListenerProvider {

private final KafkaProducer<String, String> producer;

private final String topic;

public KafkaEventListener(KafkaProducer<String, String> producer,

String topic) {

this.producer = producer;

this.topic = topic;

}

@Override

public void onEvent(Event event) {

// LOGIN, LOGIN_ERROR, LOGOUT, TOKEN_REFRESH ...

send(event.getRealmId(), toJson(event));

}

@Override

public void onEvent(AdminEvent adminEvent, boolean includeRepresentation) {

// Change history from the admin console / Admin API

send(adminEvent.getRealmId(), toJson(adminEvent));

}

private void send(String key, String value) {

// Async send: it is critical not to block the authentication path

producer.send(new ProducerRecord<>(topic, key, value), (md, ex) -> {

if (ex != null) {

// A Kafka outage must not become a login outage: log only

org.jboss.logging.Logger

.getLogger(KafkaEventListener.class)

.errorf(ex, "audit event publish failed: key=%s", key);

}

});

}

private String toJson(Object o) {

try {

return JsonSerialization.writeValueAsString(o);

} catch (Exception e) {

throw new RuntimeException(e);

}

}

@Override

public void close() {

// Per-request close: the Factory owns the producer, so do not close here

}

}

The point is to manage the KafkaProducer as a singleton in the Factory.

package com.example.iam.audit;

public class KafkaEventListenerFactory

implements EventListenerProviderFactory {

private KafkaProducer<String, String> producer;

private String topic;

@Override

public String getId() { return "kafka-audit"; }

@Override

public void init(Config.Scope config) {

// Loaded from spi-events-listener-kafka-audit-* options

String bootstrap = config.get("bootstrapServers",

"kafka.internal:9092");

topic = config.get("topic", "keycloak-audit");

Properties props = new Properties();

props.put("bootstrap.servers", bootstrap);

props.put("key.serializer",

"org.apache.kafka.common.serialization.StringSerializer");

props.put("value.serializer",

"org.apache.kafka.common.serialization.StringSerializer");

props.put("acks", "1");

props.put("linger.ms", "20");

producer = new KafkaProducer<>(props);

}

@Override

public EventListenerProvider create(KeycloakSession session) {

return new KafkaEventListener(producer, topic);

}

@Override

public void postInit(KeycloakSessionFactory factory) { }

@Override

public void close() {

if (producer != null) producer.close();

}

}

Configuration values are injected via kc.sh options.

bin/kc.sh start --optimized \

--spi-events-listener-kafka-audit-bootstrap-servers=kafka.internal:9092 \

--spi-events-listener-kafka-audit-topic=keycloak-audit

To activate it, add kafka-audit to Event listeners on the Events tab under Realm settings in the admin console.

A Custom REST Endpoint — RealmResourceProvider

Functionality missing from the Admin API (e.g., optimized bulk user lookups, health reports in an internal format) can be exposed as a REST API under a realm subpath.

package com.example.iam.rest;

public class StatsResourceProvider implements RealmResourceProvider {

private final KeycloakSession session;

public StatsResourceProvider(KeycloakSession session) {

this.session = session;

}

@Override

public Object getResource() { return this; }

@GET

@Path("summary")

@Produces(MediaType.APPLICATION_JSON)

public Map<String, Object> summary() {

// Token validation: 401 without a Bearer token

AuthenticationManager.AuthResult auth =

new AppAuthManager.BearerTokenAuthenticator(session)

.authenticate();

if (auth == null) {

throw new jakarta.ws.rs.NotAuthorizedException("Bearer");

}

long userCount = session.users()

.getUsersCount(session.getContext().getRealm());

return Map.of(

"realm", session.getContext().getRealm().getName(),

"users", userCount

);

}

@Override

public void close() { }

}

If the Factory getId is stats, the final URL of this endpoint is:

GET /realms/production/stats/summary

Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

Custom REST is as dangerous as it is powerful. You must implement token validation and role checks yourself — omit them and you have an unauthenticated public API.

The User Storage SPI — Integrating a Legacy DB

This extension point lets an existing user database (e.g., an old membership table) serve as a Keycloak user source without migration. The core interface is UserStorageProvider, composed with per-capability interfaces.

package com.example.iam.legacy;

public class LegacyDbStorageProvider implements UserStorageProvider,

UserLookupProvider, CredentialInputValidator {

private final KeycloakSession session;

private final ComponentModel model;

private final LegacyUserDao dao; // JDBC access layer

public LegacyDbStorageProvider(KeycloakSession session,

ComponentModel model,

LegacyUserDao dao) {

this.session = session;

this.model = model;

this.dao = dao;

}

@Override

public UserModel getUserByUsername(RealmModel realm, String username) {

LegacyUser u = dao.findByUsername(username);

return u == null ? null

: new LegacyUserAdapter(session, realm, model, u);

}

@Override

public UserModel getUserById(RealmModel realm, String id) {

String externalId = StorageId.externalId(id);

LegacyUser u = dao.findById(externalId);

return u == null ? null

: new LegacyUserAdapter(session, realm, model, u);

}

@Override

public UserModel getUserByEmail(RealmModel realm, String email) {

LegacyUser u = dao.findByEmail(email);

return u == null ? null

: new LegacyUserAdapter(session, realm, model, u);

}

@Override

public boolean supportsCredentialType(String type) {

return PasswordCredentialModel.TYPE.equals(type);

}

@Override

public boolean isConfiguredFor(RealmModel realm, UserModel user,

String type) {

return supportsCredentialType(type);

}

@Override

public boolean isValid(RealmModel realm, UserModel user,

CredentialInput input) {

if (!supportsCredentialType(input.getType())) return false;

// Delegate verification of legacy hashes (bcrypt, etc.)

return dao.verifyPassword(

StorageId.externalId(user.getId()),

input.getChallengeResponse());

}

@Override

public void close() { }

}

Three practical tips.

- Decide early between import mode (copying users into the Keycloak DB) and non-import mode (always querying externally). Long term, we recommend a migration path of importing and then retiring the legacy system.

- If you re-hash with the standard Keycloak hash at the first login where the legacy password verifies (updating the credential), you get gradual migration for free.

- Put timeouts and a circuit breaker in the DAO layer so an external DB outage does not cascade into a total login outage.

How This Differs from Theme Customization

If all you want is to change how the login screen looks, use themes, not SPIs.

| Requirement | Mechanism |

| --- | --- |

| Change logo/colors/CSS | Theme (FreeMarker templates + CSS) |

| Add a field to the login form + validation logic | Authenticator SPI + theme template |

| Add localized messages | Theme messages properties |

| Add/modify authentication steps themselves | Authenticator SPI |

| Customize registration fields | User Profile configuration (often no code needed) |

As with sms-otp.ftl in the SMS OTP example above, SPIs and themes are often used together. Templates live under the login folder of the theme directory.

Deployment — The providers Directory and Build

Place the extension JAR in the providers directory and re-run the build.

FROM quay.io/keycloak/keycloak:26.6 AS builder

COPY target/keycloak-extensions-1.0.0.jar /opt/keycloak/providers/

External dependencies like kafka-clients too (unnecessary with a shaded JAR)

COPY target/dependency/kafka-clients-3.9.0.jar /opt/keycloak/providers/

ENV KC_DB=postgres

ENV KC_HEALTH_ENABLED=true

RUN /opt/keycloak/bin/kc.sh build

FROM quay.io/keycloak/keycloak:26.6

COPY --from=builder /opt/keycloak/ /opt/keycloak/

ENTRYPOINT ["/opt/keycloak/bin/kc.sh", "start", "--optimized"]

Verify via the startup logs or Provider info in the admin console.

Check registered providers

bin/kc.sh show-config

kubectl logs keycloak-0 | grep -i "sms-otp"

Integration Testing with Testcontainers

Unit tests alone are not enough for SPIs. Integration tests that load the JAR into a real Keycloak container are essential. We use the community-standard testcontainers-keycloak by dasniko.

package com.example.iam;

@Testcontainers

class SmsOtpAuthenticatorIT {

@Container

static KeycloakContainer keycloak =

new KeycloakContainer("quay.io/keycloak/keycloak:26.6")

.withProviderClassesFrom("target/classes")

.withRealmImportFile("test-realm.json");

@Test

void authenticatorFactoryIsRegistered() {

Keycloak admin = keycloak.getKeycloakAdminClient();

var info = admin.serverInfo().getInfo();

var authenticators = info.getProviders()

.get("authenticator").getProviders();

assertThat(authenticators)

.containsKey("sms-otp-authenticator");

}

@Test

void loginWithoutPhoneTriggersRequiredAction() {

// Pre-define a flow containing sms-otp and a user without a

// phone number in test-realm.json, then simulate the OAuth

// code flow with RestAssured

// (omitted for space — see the full example in the repository)

}

}

Running a Keycloak version matrix in CI (26.5, 26.6, etc.) catches upgrade-compatibility regressions early.

Managing Upgrade Compatibility

The biggest risk in SPI development is internal API changes during Keycloak upgrades. Practical rules of thumb:

1. Depend on the public SPI (keycloak-server-spi) and minimize use of the private SPI. Private interfaces can break even in minor versions.

2. Pin keycloak.version in the pom to the server version, and handle server upgrades and extension builds in one pipeline.

3. Always read the deprecation section of the release notes before upgrading. Changes like the legacy console theme removal in 26 and the jakarta namespace transition (22) have historically broken extension code.

4. Keep a canary job that runs the Testcontainers ITs against the next version image ahead of time.

5. Do not pack too much responsibility into the extension JAR. Keep logic like Kafka publishing thin where possible; enrich and transform downstream.

Upgrade procedure (recommended)

1. Review the next version release notes / deprecations

2. Bump keycloak.version and compile -> detect API breakage

3. Run the Testcontainers ITs against the new image

4. Deploy the new image + extension JAR to staging

5. Apply to production: rolling for 26.6+ patch versions, recreate otherwise

Conclusion

SPIs are what turn Keycloak from a "product" into a "platform." Master the single Provider/ProviderFactory pattern and you can extend Authenticators, EventListeners, REST resources, and User Storage with the same structure. But remember that extension code runs in the middle of the authentication path — isolating external system failures (timeouts, async) and managing version compatibility matter as much as writing the code. Build on the examples in this article, and always go to production accompanied by integration tests.

References

- [Keycloak Server Development Guide](https://www.keycloak.org/docs/latest/server_development/index.html)

- [Keycloak Documentation](https://www.keycloak.org/documentation)

- [Keycloak Release Notes](https://www.keycloak.org/docs/latest/release_notes/index.html)

- [Keycloak 26.6.0 Release Announcement](https://www.keycloak.org/2026/04/keycloak-2660-released)

- [Keycloak Server Configuration Guide (providers)](https://www.keycloak.org/server/configuration-provider)

- [testcontainers-keycloak (GitHub)](https://github.com/dasniko/testcontainers-keycloak)

- [Testcontainers Documentation](https://java.testcontainers.org/)

- [Apache Kafka Documentation](https://kafka.apache.org/documentation/)

- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)

- [OAuth 2.0 (RFC 6749)](https://datatracker.ietf.org/doc/html/rfc6749)

- [JWT (RFC 7519)](https://datatracker.ietf.org/doc/html/rfc7519)

- [Keycloak GitHub Repository](https://github.com/keycloak/keycloak)

현재 단락 (1/430)

The real power of Keycloak is not "lots of built-in features" but "everything can be swapped out." A...

작성 글자: 0원문 글자: 18,682작성 단락: 0/430