Keycloakのプロバイダを作ってみる

ネットレックスが開発しているあるサービスでは認証・認可にKeycloakを使っています。
ユーザ作成時に招待メールを送りたいという要件が出てきたのでKeycloakのプロバイダで対応してみました。

## Keycloak
[Keycloak](https://www.keycloak.org/)はオープンソースのアイデンティ管理、アクセス管理のプロダクトです。
CNCF(Cloud Native Computing Foundation)のIncubatingプロジェクトとして開発が進められています。

## プロバイダ
Keycloakにはプロバイダを実装できるService Provider Interface(SPI)が用意されていて独自のプロバイダを実装することで機能を拡張することができます。
今回作ったプロバイダは、ユーザ作成時のイベントをトリガとして作成されたユーザにメールを送信するものになります。
プロバイダにはProviderFactoryおよびProviderインタフェースの実装とサービス構成ファイルの作成が必要です。

### ProviderFactory
ProviderFactoryでは以下のように、プロバイダIDと生成したProviderインスタンスを返却するように実装します。
AutoServiceアノテーションでサービス構成ファイルを作成します。

```RegisterListenerFactory.java
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 com.google.auto.service.AutoService;

@AutoService(EventListenerProviderFactory.class)
public class RegisterListenerFactory implements EventListenerProviderFactory {

    private static final String ID = "register-listener";

    @Override
    public EventListenerProvider create(KeycloakSession session) {
        return new RegisterListener(session);
    }

    @Override
    public void init(Config.Scope config) {
        // NOOP
    }

    @Override
    public void postInit(KeycloakSessionFactory factory) {
        // NOOP
    }

    @Override
    public void close() {
        // NOOP
    }

    @Override
    public String getId() {
        return ID;
    }
}
```

### Provider
ProviderではonEventのイベントハンドラでリソースタイプ、オペレーションタイプを見てユーザ作成時にメールを送るように実装します。

```RegisterListener.java
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.keycloak.email.EmailException;
import org.keycloak.email.EmailTemplateProvider;
import org.keycloak.events.Event;
import org.keycloak.events.EventListenerProvider;
import org.keycloak.events.admin.AdminEvent;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;

import lombok.RequiredArgsConstructor;
import lombok.extern.jbosslog.JBossLog;

@RequiredArgsConstructor
@JBossLog
public class RegisterListener implements EventListenerProvider {

    private final KeycloakSession session;

    @Override
    public void onEvent(Event event) {
        // NOOP
    }

    @Override
    public void onEvent(AdminEvent event, boolean includeRepresentation) {
        if (event.getResourceType() == ResourceType.USER && event.getOperationType() == OperationType.CREATE) {
            trySendWelcomeEmail(event.getRealmId(), event.getResourcePath().substring("users/".length()));
        }
    }

    private void trySendWelcomeEmail(String realmId, String userId) {
        RealmModel realm = session.realms().getRealm(realmId);
        UserModel user = session.users().getUserById(realm, userId);
        if (user.getEmail() == null) {
            log.warnf("Could not send welcome email due to missing email. realm=%s user=%s", realm.getId(), user.getUsername());
            return;
        }

        Map<String, Object> mailBodyAttributes = new HashMap<>();
        mailBodyAttributes.put("username", user.getUsername());
        List<Object> subjectParams = List.of(user.getUsername());

        try {
            EmailTemplateProvider emailProvider = session.getProvider(EmailTemplateProvider.class);
            emailProvider.setRealm(realm);
            emailProvider.setUser(user);
            emailProvider.send("welcomeEmailSubject", subjectParams, "welcome-email.ftl", mailBodyAttributes);
        } catch (EmailException eex) {
            log.errorf(eex, "Failed to send welcome email. realm=%s user=%s", realm.getId(), user.getUsername());
            eex.printStackTrace();
        }
    }

    @Override
    public void close() {
        // NOOP
    }
}
```

### テンプレートなど
メールの件名や内容にはテーマのテンプレートを使うことができます。

```resources/META-INF/keycloak-theme.json
{
  "themes": [
    {
      "name": "sample",
      "types": [
        "email"
      ]
    }
  ]
}
```

```resources/theme/sample/theme.properties
parent=base
```

```resources/theme/sample/email/html/welcome-email.ftl
<html>
<body>
${kcSanitize(msg("welcomeEmailBody", username))?no_esc}
</body>
</html>
```

```resources/theme/sample/email/text/welcome-email.ftl
<#ftl output_format="plainText">
${msg("welcomeEmailBody", username)}
```

```resources/theme/sample/email/messages/messages_ja.properties
# encoding: UTF-8

welcomeEmailBody={0} のアカウントが作成されました。
welcomeEmailSubject={0} アカウント作成
```

## デプロイ
ビルドして生成されたjarファイルをKeycloakサーバの$KEYCLOAK_HOME/providersディレクトリに配置しKeycloakを再起動します。
Keycloakの管理コンソールに独自プロバイダのプロバイダIDが表示されるので選択して設定を保存します。

![スクリーンショット 2024-05-31 17.31.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3485943/d64e9a57-d86a-dd04-3f25-c76e9183f532.png)

### テーマ設定

ここまでの設定だけではメールテンプレートがみつからないといったエラーになります。
Keycloakの管理コンソールでjarに含まれているテーマを指定する必要があります。

![スクリーンショット 2024-05-31 19.44.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3485943/57ed655a-00d9-f57b-e91b-289b6132e0fd.png)


以上でテンプレートの内容でメール送信できるようになります。

認証・認可というセキュリティ的にも重要な機能を自前で開発する時代は終わったと思っています。
そのような中オープンでスタンダードなだけでなくカスタマイズ可能なKeycloakは有用な選択肢の一つになっています。

## 参考情報
* https://www.keycloak.org/docs/latest/server_development/index.html
* https://github.com/thomasdarimont/keycloak-extension-playground