Skip to content
Published on

Keycloak SPI 拡張開発 — カスタム Authenticator から EventListener まで

Authors

はじめに

Keycloak の本当の力は「組み込み機能が多い」ことではなく「すべてを差し替えられる」ことにあります。認証ステップ、イベント処理、ユーザーストレージ、REST API まで、ほぼすべての内部動作が SPI(Service Provider Interface)として抽象化されており、標準機能では足りない要件を Java コードで埋めることができます。

2026 年時点で SPI 開発の需要はむしろ増えています。社内レガシーシステムとの統合(レガシー DB のユーザーでログイン)、国ごとの規制対応(SMS 本人認証)、監査ログのリアルタイムストリーミング(Kafka)、AI エージェントのアイデンティティ発行の自動化といった要求は、標準設定だけでは解決できないからです。本記事では以下を扱います。

  • SPI アーキテクチャ: Provider と ProviderFactory の関係
  • 開発環境の構成(Java 17+、Maven)
  • カスタム Authenticator の完全実装 — 社内 SMS ゲートウェイベースの OTP
  • RequiredAction による電話番号登録の強制
  • EventListener で監査ログを Kafka にストリーミング
  • RealmResourceProvider でカスタム REST エンドポイントを作る
  • User Storage SPI でレガシー DB を連携
  • デプロイ、Testcontainers 統合テスト、バージョンアップグレード互換性管理

SPI アーキテクチャ — Provider と ProviderFactory

Keycloak のすべての拡張ポイントは同一の三層パターンに従います。

+------------------------------------------------------------+
|  Spi (拡張ポイント定義)                                      |
|   例: AuthenticatorSpi, EventListenerSpi                    |
|                                                            |
|  +------------------------------------------------------+  |
|  |  ProviderFactory (シングルトン、サーバー寿命中 1 個)      |  |
|  |   - init(Config.Scope): 設定のロード                   |  |
|  |   - create(KeycloakSession): Provider インスタンス生成  |  |
|  |   - getId(): 一意の識別子                              |  |
|  |                                                      |  |
|  |  +------------------------------------------------+  |  |
|  |  |  Provider (リクエスト/トランザクション単位で生成)   |  |  |
|  |  |   - 実際のビジネスロジック                        |  |  |
|  |  |   - close(): リクエスト終了時のクリーンアップ      |  |  |
|  |  +------------------------------------------------+  |  |
|  +------------------------------------------------------+  |
+------------------------------------------------------------+

核心となるルールは次のとおりです。

  • ProviderFactory はサーバー起動時に一度だけ生成されるシングルトンです。重い初期化(コネクションプール、HTTP クライアント)はここで行います。
  • Provider は KeycloakSession 単位(おおよそ HTTP リクエスト単位)で生成されます。状態を持たないか、セッションスコープの状態だけを持つべきです。
  • 実装の登録は Java の ServiceLoader 規約に従います。META-INF/services ディレクトリにインターフェースの完全修飾名のファイルを作り、実装クラスを記述します。

開発環境の構成

Keycloak 26.x は Java 17 以上を要求し、21 でも動作します。Maven 依存関係は provided スコープにします。サーバーが既に持っているクラスを JAR に重複して含めないためです。

<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example.iam</groupId>
  <artifactId>keycloak-extensions</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>

  <properties>
    <keycloak.version>26.6.2</keycloak.version>
    <maven.compiler.release>17</maven.compiler.release>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.keycloak</groupId>
      <artifactId>keycloak-server-spi</artifactId>
      <version>${keycloak.version}</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>org.keycloak</groupId>
      <artifactId>keycloak-server-spi-private</artifactId>
      <version>${keycloak.version}</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>org.keycloak</groupId>
      <artifactId>keycloak-services</artifactId>
      <version>${keycloak.version}</version>
      <scope>provided</scope>
    </dependency>
    <!-- Kafka EventListener 用 (JAR に含める必要があるため compile スコープ) -->
    <dependency>
      <groupId>org.apache.kafka</groupId>
      <artifactId>kafka-clients</artifactId>
      <version>3.9.0</version>
    </dependency>
  </dependencies>
</project>

spi-private にあるインターフェースはマイナーバージョンでも変わる可能性があることを覚えておいてください。後述の互換性管理の節で再び扱います。

カスタム Authenticator — 社内 SMS ゲートウェイ OTP

最も需要の多い拡張です。パスワード認証の後、社内 SMS ゲートウェイで 6 桁のコードを送信して検証する Authenticator を作ってみましょう。

1. Authenticator の実装

package com.example.iam.sms;

import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;

import java.security.SecureRandom;

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()) {
            // 電話番号未登録 -> RequiredAction へ誘導
            user.addRequiredAction("register-phone-number");
            context.attempted();
            return;
        }

        String code = generateCode();
        // auth note は今回の認証セッションでのみ有効な一時ストレージ
        context.getAuthenticationSession().setAuthNote(OTP_NOTE, code);

        SmsGateway gateway = SmsGateway.fromConfig(
            context.getAuthenticatorConfig());
        gateway.send(phone, "認証コード: " + code + " (5 分以内に入力)");

        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; // パスワード認証の後に来る必要がある
    }

    @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. AuthenticatorFactory の実装

package com.example.iam.sms;

import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;

import java.util.List;

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; // 状態がないためシングルトンを再利用可能
    }

    @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",
            "社内 SMS ゲートウェイのエンドポイント",
            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 登録

# ファイルパス:
# src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory
com.example.iam.sms.SmsOtpAuthenticatorFactory

その後、管理コンソールで browser flow を複製し、Username Password Form の後に SMS OTP execution を追加すれば適用されます。認証フローエンジンの動作原理はアーキテクチャ編で扱ったため省略します。

RequiredAction — 電話番号登録の強制

上記の Authenticator は電話番号がなければ register-phone-number という RequiredAction を追加します。RequiredAction は「ログインには成功したが、続行する前にユーザーが必ずしなければならないこと」です。

package com.example.iam.sms;

import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.RequiredActionProvider;

public class RegisterPhoneRequiredAction implements RequiredActionProvider {

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

    @Override
    public void evaluateTriggers(RequiredActionContext context) {
        // ログインごとの自動トリガー条件評価 (ここでは未使用)
    }

    @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() { }
}

Factory と ServiceLoader 登録(org.keycloak.authentication.RequiredActionFactory)は Authenticator と同じパターンのため省略します。

EventListener — 監査ログを Kafka へ

セキュリティ監査要件で最もよくあるのが「すべてのログイン/管理イベントをリアルタイムで SIEM に送れ」というものです。EventListener SPI で実装します。

package com.example.iam.audit;

import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.keycloak.events.Event;
import org.keycloak.events.EventListenerProvider;
import org.keycloak.events.admin.AdminEvent;
import org.keycloak.util.JsonSerialization;

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) {
        // 管理コンソール/Admin API の変更履歴
        send(adminEvent.getRealmId(), toJson(adminEvent));
    }

    private void send(String key, String value) {
        // 非同期送信: 認証経路をブロックしないことが重要
        producer.send(new ProducerRecord<>(topic, key, value), (md, ex) -> {
            if (ex != null) {
                // Kafka 障害がログイン障害になってはいけない: ログのみ残す
                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() {
        // リクエスト単位の close: producer は Factory が所有するためここでは閉じない
    }
}

KafkaProducer を Factory でシングルトンとして管理するのがポイントです。

package com.example.iam.audit;

import org.apache.kafka.clients.producer.KafkaProducer;
import org.keycloak.Config;
import org.keycloak.events.EventListenerProvider;
import org.keycloak.events.EventListenerProviderFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;

import java.util.Properties;

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) {
        // spi-events-listener-kafka-audit-* オプションからロード
        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();
    }
}

設定値は kc.sh オプションで注入します。

bin/kc.sh start --optimized \
  --spi-events-listener-kafka-audit-bootstrap-servers=kafka.internal:9092 \
  --spi-events-listener-kafka-audit-topic=keycloak-audit

有効化は管理コンソールの Realm settings の Events タブにある Event listeners に kafka-audit を追加すれば完了です。

カスタム REST エンドポイント — RealmResourceProvider

Admin API にない機能(例: ユーザー一括照会の最適化、社内フォーマットのヘルスレポート)を realm 配下のパスの REST API として公開できます。

package com.example.iam.rest;

import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.keycloak.models.KeycloakSession;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.resource.RealmResourceProvider;

import java.util.Map;

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() {
        // トークン検証: Bearer トークンがなければ 401
        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() { }
}

Factory の getId が stats なら、このエンドポイントの最終的な URL は次のようになります。

GET /realms/production/stats/summary
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

カスタム REST は強力なぶん危険です。必ずトークン検証と role 検査を自分で実装しなければならず、抜けると認証なしの公開 API になってしまいます。

User Storage SPI — レガシー DB の連携

既存のユーザー DB(例: 古い会員テーブル)をマイグレーションなしに Keycloak のユーザーソースとして使う拡張ポイントです。中核インターフェースは UserStorageProvider で、機能ごとの capability インターフェースを組み合わせます。

package com.example.iam.legacy;

import org.keycloak.component.ComponentModel;
import org.keycloak.credential.CredentialInput;
import org.keycloak.credential.CredentialInputValidator;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.storage.StorageId;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.user.UserLookupProvider;

public class LegacyDbStorageProvider implements UserStorageProvider,
        UserLookupProvider, CredentialInputValidator {

    private final KeycloakSession session;
    private final ComponentModel model;
    private final LegacyUserDao dao; // JDBC アクセス層

    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;
        // レガシーハッシュ (bcrypt など) の検証を委譲
        return dao.verifyPassword(
            StorageId.externalId(user.getId()),
            input.getChallengeResponse());
    }

    @Override
    public void close() { }
}

実務 Tips を三つ挙げます。

  • import モード(Keycloak DB へユーザーをコピー)vs 非 import モード(常に外部照会)を初期に決める必要があります。長期的には import 後にレガシーを廃棄するマイグレーション経路を推奨します。
  • レガシーパスワードハッシュが検証される初回ログイン時に Keycloak 標準ハッシュで再ハッシュして保存すれば(credential 更新)、漸進的なマイグレーションになります。
  • 外部 DB の障害がログイン全体の障害に波及しないよう、タイムアウトとサーキットブレーカーを DAO 層に入れるべきです。

テーマカスタマイズとの区別

ログイン画面の見た目を変えたいだけなら、SPI ではなくテーマを使うべきです。

要件手段
ロゴ/色/CSS の変更テーマ (FreeMarker テンプレート + CSS)
ログインフォームにフィールド追加 + 検証ロジックAuthenticator SPI + テーマテンプレート
多言語メッセージの追加テーマの messages プロパティ
認証ステップ自体の追加/変更Authenticator SPI
会員登録項目のカスタムUser Profile 設定 (コード不要な場合が多い)

上記 SMS OTP の例の sms-otp.ftl のように、SPI とテーマは一緒に使われることが多いです。テンプレートはテーマディレクトリの login 配下に置きます。

デプロイ — providers ディレクトリとビルド

拡張 JAR は providers ディレクトリに入れ、build を再実行します。

FROM quay.io/keycloak/keycloak:26.6 AS builder
COPY target/keycloak-extensions-1.0.0.jar /opt/keycloak/providers/
# kafka-clients のような外部依存も一緒に (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"]

確認は起動ログまたは管理コンソールの Provider info で行います。

# 登録済み provider の確認
bin/kc.sh show-config
kubectl logs keycloak-0 | grep -i "sms-otp"

Testcontainers 統合テスト

SPI は単体テストだけでは不十分です。実際の Keycloak コンテナに JAR を載せて検証する統合テストが必須です。コミュニティ標準である dasniko の testcontainers-keycloak を使用します。

package com.example.iam;

import dasniko.testcontainers.keycloak.KeycloakContainer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeAll;
import org.keycloak.admin.client.Keycloak;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import static org.assertj.core.api.Assertions.assertThat;

@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() {
        // test-realm.json に sms-otp を含む flow と
        // 電話番号のないユーザーを事前に定義しておき、
        // OAuth コードフローを RestAssured でシミュレーション
        // (紙面の都合で省略 — リポジトリの完全な例を参照)
    }
}

CI で Keycloak バージョンをマトリクスで回せば(26.5、26.6 など)、アップグレード互換性の回帰を早期に捕捉できます。

バージョンアップグレード互換性管理

SPI 開発の最大のリスクは、Keycloak アップグレード時の内部 API 変更です。実践的な鉄則を整理します。

  1. 公開 SPI(keycloak-server-spi)に依存し、private SPI の使用は最小限にします。private はマイナーバージョンでも壊れる可能性があります。
  2. pom の keycloak.version をサーバーバージョンと固定し、サーバーアップグレードと拡張ビルドを一つのパイプラインで処理します。
  3. リリースノートの deprecation セクションをアップグレード前に必ず読みます。例えば 26 でのレガシーコンソールテーマ削除、jakarta 名前空間への移行(22)のような変化が拡張コードを壊してきました。
  4. Testcontainers 統合テストを次期バージョンのイメージで事前に回すカナリアジョブを設けます。
  5. 拡張 JAR にあまり多くの責務を詰め込みません。Kafka 送信のようなロジックはできるだけ薄く保ち、加工/補強はダウンストリームで行います。
アップグレード手順 (推奨)
1. 次期バージョンのリリースノート / deprecation を確認
2. keycloak.version を上げてコンパイル -> API の破壊を検出
3. Testcontainers IT を新イメージで実行
4. ステージングに新イメージ + 拡張 JAR をデプロイ
5. 26.6+ のパッチバージョンなら rolling、それ以外は recreate で本番適用

おわりに

SPI は Keycloak を「製品」から「プラットフォーム」へと変える仕組みです。Provider/ProviderFactory パターンを一つ身につければ、Authenticator、EventListener、REST リソース、User Storage まで同じ構造で拡張できます。ただし拡張コードは認証経路の真ん中で実行されるため、外部システム障害の隔離(タイムアウト、非同期化)とバージョン互換性管理がコードを書くことと同じくらい重要です。本記事の例をもとに、必ず統合テストとともに本番環境へ進んでください。

参考資料