Skip to content

필사 모드: Keycloak SPI 拡張開発 — カスタム Authenticator から EventListener まで

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

はじめに

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 に重複して含めないためです。

<!-- Kafka EventListener 用 (JAR に含める必要があるため compile スコープ) -->

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

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

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

1. 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()) {

// 電話番号未登録 -> 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;

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;

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;

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;

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;

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;

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;

@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 まで同じ構造で拡張できます。ただし拡張コードは認証経路の真ん中で実行されるため、外部システム障害の隔離(タイムアウト、非同期化)とバージョン互換性管理がコードを書くことと同じくらい重要です。本記事の例をもとに、必ず統合テストとともに本番環境へ進んでください。

参考資料

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

- [Keycloak 公式ドキュメント](https://www.keycloak.org/documentation)

- [Keycloak リリースノート](https://www.keycloak.org/docs/latest/release_notes/index.html)

- [Keycloak 26.6.0 リリース告知](https://www.keycloak.org/2026/04/keycloak-2660-released)

- [Keycloak サーバー設定ガイド (providers)](https://www.keycloak.org/server/configuration-provider)

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

- [Testcontainers 公式ドキュメント](https://java.testcontainers.org/)

- [Apache Kafka 公式ドキュメント](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 リポジトリ](https://github.com/keycloak/keycloak)

현재 단락 (1/430)

Keycloak の本当の力は「組み込み機能が多い」ことではなく「すべてを差し替えられる」ことにあります。認証ステップ、イベント処理、ユーザーストレージ、REST API まで、ほぼすべての内部動作が...

작성 글자: 0원문 글자: 15,604작성 단락: 0/430