Keycloakを独自の外部ユーザストレージと連携してみた

# やること
Keycloakは標準機能としてLDAPやActive Directoryといった外部ユーザストレージとの連携を提供しています。
しかし、組織によっては独自の外部ユーザストレージと連携させたいという要件が発生することもあると思います。

そこで、本記事はKeycloak公式ドキュメントのServer DeveloperにあるUser Storage SPIの章に基づいて、User Storage SPIを利用して外部ストレージのユーザで認証する方法を解説します。

https://www.keycloak.org/docs/latest/server_development/index.html#_user-storage-spi

本記事ではKeycloak公式のクイックスタートのソースコードを使用しているため、動作確認したい方はこちらをご利用ください。

https://github.com/keycloak/keycloak-quickstarts

# 前提事項
今回使用した環境は以下の通りです。
- Keycloak 23.0.1
- OpenJDK 17
- Maven 3.8.8
- WSL（Ubuntu 22.04.1）

:::note warn
必ずOpenJDK 17以上、Maven 3.6.3以上をインストールしてください。
keycloak-quickstartsのソースコードがビルドできなくなります。
:::

Keycloakのインストールはこちらをご覧ください。

https://qiita.com/l_katayose/items/0e942a160fa65ed32b1d



# User Storage SPIの概要
Keycloakが標準機能として提供しているLDAPやActive Directoryといった外部ユーザストレージとの連携機能は、User Storage SPIによって実装されています。
User Storage SPIをカスタマイズすることで、Keycloakを独自の外部ストレージと連携することも可能です。
外部ストレージの仕様にもよりますが、複雑な条件のクエリを実行したり、ユーザに対してCRUD操作を実行したり、クレデンシャルを検証および管理したり、多くのユーザの一括更新を実行することも可能です。

:::note info
補足情報：
Keycloakでユーザを検索する場合、以下の順番でユーザを特定します。
1. ユーザキャッシュ
1. Keycloakのローカルデータベース
1. User Storage SPI Provider
（User Storage SPI Providerをループし、そのうちの一つがユーザを返すまでユーザクエリを実行する）
:::


## User Storage SPIの実装について

User Storage SPIを実装する場合、ProviderクラスとProvider Factoryを定義する必要があります。
Provider Factoryはトランザクション毎にProviderクラスインスタンスを作成します。

User Storage SPIのProviderクラスを実装するために使用されるSPIは以下の通りです。

| SPI |説明  |
|---|---|
| `org.keycloak.storage.UserStorageProvider` | User Storage SPIを実装するために必須のProviderインタフェース。トランザクションごとに作成され、トランザクションが完了すると`UserStorageProvider.close()`メソッドが呼び出される。 |
| `org.keycloak.storage.user.UserLookupProvider` | 外部ストレージからのユーザをログインさせるために必要。ほとんどのUser Storage SPIのProviderはこのインターフェイスを実装する。 |
| `org.keycloak.storage.user.UserQueryProvider` | 1つ以上のユーザを検索する複雑なクエリを定義する。管理コンソールでユーザを閲覧・管理する場合に実装する。|
| `org.keycloak.storage.user.UserRegistrationProvider` | ユーザの追加や削除をサポートする場合に実装する。 |
| `org.keycloak.storage.user.UserBulkUpdateProvider` | ユーザのバルク更新をサポートする場合に実装する。 |
| `org.keycloak.credential.CredentialInputValidator` | 1つ以上の異なるクレデンシャルタイプを検証する（例えばパスワード検証など）場合に実装する。 |
| `org.keycloak.credential.CredentialInputUpdater` | 1つ以上の異なるクレデンシャルタイプを更新する場合に実装する。 |

User Storage SPIのProvider Factoryを実装するために使用されるSPIは以下の通りです。
|SPI  |説明  |
|---|---|
| `org.keycloak.storage.UserStorageProviderFactory` | User Storage SPIを実装するために必須のProvider Factoryインタフェース。 |

各SPIが持つ主要なメソッドは、この後具体的なサンプルコードをもとに解説します。

### Model interface

前述したSPIで使用されるユーザは`org.keycloak.models.UserModel`インタフェースによって定義されます。
このインタフェースを実装することで、外部ストレージとKeycloakが使用するユーザモデルとの間のマッピングを行います。
```java:org.keycloak.models.UserModel
package org.keycloak.models;

public interface UserModel extends RoleMapperModel {
    String getId();

    String getUsername();
    void setUsername(String username);

    String getFirstName();
    void setFirstName(String firstName);

    String getLastName();
    void setLastName(String lastName);

    String getEmail();
    void setEmail(String email);
...
}
```
`UserModel`の実装は、ユーザ名、名前、Eメール、ロール、グループマッピング、その他の任意の属性などのユーザに関するメタデータの読み取りと更新の機能を提供します。

`UserModel`の重要なメソッドとして`getId()`があります。
ユーザIDのフォーマットは決まっており、以下の通りでなければなりません。
```shell
"f:" + component id + ":" + external id
```
「component id」は、`ComponentModel.getId()`から返されたIDです。`ComponentModel`は、Providerクラスを作成するときにパラメータとして渡されるので、そこから取得できます。「external id」は、Providerクラスが外部ストレージでユーザを見つけるために必要な情報です。多くの場合、ユーザー名かUIDが使用されます。
サンプルとして、ユーザIDは`f:332a234e31234:wburke`のような値をとります。

# 外部ストレージのユーザで認証してみる
では、Keycloak公式の[クイックスタート](https://github.com/keycloak/keycloak-quickstarts)にある[user-storage-simple](https://github.com/keycloak/keycloak-quickstarts/tree/latest/extension/user-storage-simple)のサンプルコードを使用して、外部ストレージのユーザで認証してみましょう。

このサンプルコードでは、User Storage SPIを使用して2つのUser Storage Providerを実装しています。
今回はwriteable-property-file providerを使用します。
writeable-property-file providerが実現する主な機能は以下の通りです。
- 外部ストレージユーザを読み込む。
- 外部ストレージユーザでログインする。
- 管理コンソールから外部ストレージユーザを検索する。
- 管理コンソールから外部ストレージユーザを追加・削除する。
- 管理コンソールから外部ストレージユーザのパスワードを更新する。
- 管理コンソールから外部ストレージユーザにロールや属性情報などを追加・削除する。

連携する外部ストレージには、ユーザ情報が記載されたプロパティファイルを使用します。
プロパティファイルには`<ユーザ名>=<パスワード>`の形式でユーザ情報が保存されています。
```shell: プロパティファイルのサンプル
user001=password001
user002=password002
user003=password003
```

## まずは動かしてみる
では、実際に動かして機能を確認してみましょう。
サンプルコードをビルドするだけで簡単に動作確認できます。

### パッケージ化とデプロイ
1. [keycloak-quickstarts](https://github.com/keycloak/keycloak-quickstarts/tree/latest)をクローンします。

1. `keycloak-quickstarts/extension/user-storage-simple`へ移動します。
    ```shell
    $ cd keycloak-quickstarts/extension/user-storage-simple
    ```

1. [user-storage-simpleのREADME](https://github.com/keycloak/keycloak-quickstarts/tree/latest/extension/user-storage-simple#build-and-deploy-the-quickstart)を参考に以下のコマンドでビルドします。
    ```shell
    $ mvn -Pextension clean install -DskipTests=true
    ```

1. ビルド後に作成されたjarファイル`keycloak-quickstarts/extension/user-storage-simple/target/user-storage-properties-example.jar`をKeycloakの`<KEYCLOAK_HOME>/providers`ディレクトリへコピーします。
    ```shell
    $ cp target/user-storage-properties-example.jar <KEYCLOAK_HOME>/providers/user-storage-properties-example.jar
    ```
    
    ```shell:jarファイルを配置したKeycloakディレクトリ構成
    keycloak-23.0.1/
    　├ providers/
    　│　├ README.md
    　│　└ user-storage-properties-example.ja
    　:
    　:
    ```
    
1. 外部ストレージであるプロパティファイル`keycloak-quickstarts/blob/latest/extension/user-storage-simple/src/main/resources/users.properties`を`example-users.properties`という名前で`<KEYCLOAK_HOME>/conf`ディレクトリにコピーします。
    ```shell
    $ cp src/main/resources/users.properties <KEYCLOAK_HOME>/conf/example-users.properties
    ```
    ここで、`example-users.properties`の中身は、ユーザ名：`tbrady`とパスワード：`superbowl`のユーザが記載された簡単なものになっています。
    ```shell: example-users.properties
    tbrady=superbowl
    ```
    
1. `<KEYCLOAK_HOME>/bin`ディレクトリへ移動し、Keycloakを起動します。
    ```shell
    $ cd <KEYCLOAK_HOME>/bin
    $ ./kc.sh start-dev
    ```

### 管理コンソールでの有効化
1. 管理コンソールの「User federation」→「Add writeable-property-file provider」を選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2812915/c864c39e-c520-a5d7-6648-b76638d2020f.png)

1. プロパティ設定画面に以下の設定を入力し、「Save」を押します。
    | パラメータ | 説明 | 設定値 |
    |---|---|---|
    | UI dispay name | Providerの表示名 | writeable-property-file |
    | Path | プロパティファイルのパス | ${jboss.server.config.dir}/example-users.properties |
    | Cache policy | Providerのキャッシュポリシー | DEFAULT |
    
    ※`${jboss.server.config.dir}`は`<KEYCLOAK_HOME>/conf`を指します。
    
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2812915/adcd64f7-1462-ab35-6c1f-fa30e3b6d0c2.png)
    ここで、`Path`パラメータに存在しないファイルのパスを入力すると設定に失敗します。

1. User federationのトップページへ戻ると、Providerが登録されたことが分かります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2812915/bc7113be-0ecb-fc03-f55b-47e22dac7e00.png)

### 動作確認
では、実際にどのような動作をするか確認してみましょう。

- 外部ストレージユーザでログインする。
ユーザ名：`tbrady`とパスワード：`superbowl`でアカウントコンソールへログインできました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2812915/88c91386-d42f-f27a-9a00-6ad8f4db1aac.png)

- 外部ストレージユーザを検索する。
ユーザを検索すると、プロパティファイルへ記載されていた`tbrady`ユーザが表示されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2812915/14565c0e-198d-5a35-8422-8f52587a4ca0.png)

- 外部ストレージユーザを追加・削除する。
管理コンソールの「Users」→「Add User」からユーザ`user001`を追加します。
ユーザ追加成功し、ユーザ詳細画面の「Federation link」にはwriteable-property-fileの記載があります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2812915/d2c34f15-dcb4-3afe-3163-b575bbb1e47a.png)

- 外部ストレージユーザのパスワードを更新する。
管理コンソールの「Users」→「user001」→「Credentials」からパスワードを`password`に更新できました。
また、外部ストレージ`<KEYCLOAK_HOME>/conf/example-users.properties`を見ると、ユーザが追加されていることが分かります。
    ```shell:example-users.properties
    user001=password
    tbrady=superbowl
    ```

- 外部ストレージユーザにロールや属性情報などを追加・削除する。
    管理コンソールの「Users」→「user001」→「Attributes」から属性を追加することができました。
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2812915/01fce668-6944-3fc8-fba1-df3c30303c38.png)
    ただし、外部ストレージ`<KEYCLOAK_HOME>/conf/example-users.properties`を見ると属性情報は保存されていません。
    ```shell:example-users.properties
    user001=password
    tbrady=superbowl
    ```
    なぜなら、今回使用している外部ストレージには属性情報を保存する機能が無いからです。
    では、どこに保存されているかというとKeycloakデータベースに保存されています。
    KeycloakはKeycloakデータベースを用いて外部ストレージを拡張し、ロールやEメール、姓名などの属性の追加・変更を可能にします。
    試しにKeycloakデータベース（今回はH2を使用）の中身を確認すると、FED_USER_ATTRIBUTEテーブルに外部ストレージのユーザの属性情報が保存されいます。
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2812915/48f30829-0ed2-51a7-6a37-cdd06a862b1a.png)

一通りwriteable-property-file providerの動作が分かったので、次はソースコードを見てどのように実装されているか確認しましょう。

## ソースコード解説

### PropertyFileUserStorageProvider
User Storage SPIのProviderクラスとして、`PropertyFileUserStorageProvider`を作成しています。

```java
public class PropertyFileUserStorageProvider implements
        UserStorageProvider,
        UserLookupProvider,
        CredentialInputValidator,
        CredentialInputUpdater,
        UserRegistrationProvider,
        UserQueryProvider {
...
}
```
`PropertyFileUserStorageProvider`が実装しているインタフェースの概要は[前述の表](#user-storage-spiの実装について)をご覧ください。

```java
    public static final String UNSET_PASSWORD="#$!-UNSET-PASSWORD";

    protected KeycloakSession session;
    protected Properties properties;
    protected ComponentModel model;
    // map of loaded users in this transaction
    protected Map<String, UserModel> loadedUsers = new HashMap<>();

    public PropertyFileUserStorageProvider(KeycloakSession session, ComponentModel model, Properties properties) {
        this.session = session;
        this.model = model;
        this.properties = properties;
    }
```
`UNSET_PASSWORD`は、`Properties`（プロパティファイル）に追加されるユーザのパスワードの初期値です。
これは、`Properties`はプロパティ値をnullにできないため使用されます。

コンストラクタでは、 `KeycloakSession`、`ComponentModel`、`Properties`へ値が代入されます。
また、`loadedUsers`を定義し、ユーザを見つけるたびにこのマップに保存し、同じトランザクション内でユーザ情報を再度作成しないで済むようにしています。

#### UserlookupProviderの実装
`UserlookupProvider`は、外部ストレージからのユーザをログインさせるために必要な機能を実装します。
```java
    @Override
    public UserModel getUserByUsername(RealmModel realm, String username) {
        UserModel adapter = loadedUsers.get(username);
        if (adapter == null) {
            String password = properties.getProperty(username);
            if (password != null) {
                adapter = createAdapter(realm, username);
                loadedUsers.put(username, adapter);
            }
        }
        return adapter;
    }
```
`getUserByUsername()`メソッドは、ユーザがログインする際にKeycloakログインページにより呼び出され、ユーザが存在するか確認します。
まず、`loadedUsers`を確認してユーザがすでにトランザクション内に読み込まれているかを確認します。
もし読み込まれていない場合、プロパティファイル内でユーザ名を検索します。
ユーザが存在した場合`createAdapter()`メソッドを呼び出して`userModel`を作成し、`loadedUsers`に格納します。

```java
    protected UserModel createAdapter(RealmModel realm, String username) {
        return new AbstractUserAdapterFederatedStorage(session, realm, model) {
            @Override
            public String getUsername() {
                return username;
            }

            @Override
            public void setUsername(String username) {
                String pw = (String)properties.remove(username);
                if (pw != null) {
                    properties.put(username, pw);
                    save();
                }
            }
        };
    }
```
`createAdapter()`メソッドは、`userModel`を作成します。
`org.keycloak.storage.adapter.AbstractUserAdapterFederatedStorage`を使用し、ユーザIDフォーマット（[参考](#model-interface)）に基づいたユーザIDを自動で生成します。

さらに、`org.keycloak.storage.adapter.AbstractUserAdapterFederatedStorage`の重要な役割として、Keycloakデータベースに外部ストレージユーザの追加情報を保存することで、外部ストレージを拡張します。
今回外部ストレージとして使用しているプロパティファイルではユーザ名とパスワードしか保存できないため、ログインはできますがロールやEメール、姓名などの属性の追加・変更はできません。
そこで、これらの情報をKeycloakデータベースで管理することで外部ストレージを拡張し、Keycloakが提供する様々な機能を利用することができるようにします。
[動作確認](#動作確認)で外部ストレージユーザに属性情報を追加した際に、外部ストレージではなくKeycloakデータベースに属性情報が保存されたのはこの実装によるものです。

```java
    @Override
    public UserModel getUserById(RealmModel realm, String id) {
        StorageId storageId = new StorageId(id);
        String username = storageId.getExternalId();
        return getUserByUsername(realm, username);
    }
```
`getUserById()`メソッドは、 `id`から取得したユーザの`UserModel`を返します。
`org.keycloak.storage.StorageId`を使って`id`パラメータを解析し、`StorageId.getExternalId()`メソッドを使用して `id`パラメータに埋め込まれたユーザ名を取得します。
そして、`getUserByUsername()`メソッドを使用して`UserModel`を返します。

```java
    @Override
    public UserModel getUserByEmail(RealmModel realm, String email) {
        return null;
    }
```
今回はユーザ属性情報としてEメールは持たないため、`getUserByEmail()`メソッドはnullを返します。

#### CredentialInputValidatorの実装
`CredentialInputValidator`は、1つ以上の異なるクレデンシャルタイプを検証します。

```java
    @Override
    public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
        String password = properties.getProperty(user.getUsername());
        return credentialType.equals(PasswordCredentialModel.TYPE) && password != null;
    }
```
`isConfiguredFor()`メソッドは、ユーザのクレデンシャルタイプとパスワードが設定されているかを確認します。

```java
    @Override
    public boolean supportsCredentialType(String credentialType) {
        return credentialType.equals(PasswordCredentialModel.TYPE);
    }
```
`supportsCredentialType()`メソッドは、ユーザのクレデンシャルタイプがサポートされているものか確認します。

```java
    @Override
    public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
        if (!supportsCredentialType(input.getType()) || !(input instanceof UserCredentialModel)) return false;

        UserCredentialModel cred = (UserCredentialModel)input;
        String password = properties.getProperty(user.getUsername());
        if (password == null || UNSET_PASSWORD.equals(password)) return false;
        return password.equals(cred.getValue());
    }
```
`isValid()`メソッドは、パスワードの有効性を検証します。
まず、ユーザがログイン時に入力したパスワードがサポートされているクレデンシャルタイプであること、かつ、それが`UserCredentialModel`のインスタンスであることを確認します。
そして入力したパスワードとプロパティファイルに格納されているパスワードの比較結果を返します。

#### CredentialInputUpdaterの実装
`CredentialInputUpdater`は、1つ以上の異なるクレデンシャルタイプを更新します。

```java
    @Override
    public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) {
        if (!(input instanceof UserCredentialModel)) return false;
        if (!input.getType().equals(PasswordCredentialModel.TYPE)) return false;
        UserCredentialModel cred = (UserCredentialModel)input;
        synchronized (properties) {
            properties.setProperty(user.getUsername(), cred.getValue());
            save();
        }
        return true;
    }
```
`updateCredential()`メソッドは、パスワードを更新します。
パスワードの更新は、ユーザの入力したパスワードを`properties.setProperty()`でユーザのパスワードとして反映し、`save()`でプロパティファイルへ保存することで実装します。

```java
    @Override
    public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) {
        if (!credentialType.equals(PasswordCredentialModel.TYPE)) return;
        synchronized (properties) {
            properties.setProperty(user.getUsername(), UNSET_PASSWORD);
            save();
        }

    }
```
`disableCredentialType()`メソッドは、クレデンシャルを無効にします。
クレデンシャルの無効化は、`properties.setProperty()`を使用してユーザのパスワードに`UNSET_PASSWORD`を設定し、`save()`でプロパティファイルへ保存することで実装します。

```java
    private static final Set<String> disableableTypes = new HashSet<>();

    static {
        disableableTypes.add(PasswordCredentialModel.TYPE);
    }
    
    @Override
    public Stream<String> getDisableableCredentialTypesStream(RealmModel realm, UserModel user) {
        return disableableTypes.stream();
    }
```
`getDisableableCredentialTypesStream()`メソッドは、無効化できるクレデンシャルタイプを返します。


#### UserRegistrationProviderの実装
`UserRegistrationProvider`は、ユーザの追加や削除をサポートします。

```java
    public void save() {
        String path = model.getConfig().getFirst("path");
        path = EnvUtil.replace(path);
        try {
            FileOutputStream fos = new FileOutputStream(path);
            properties.store(fos, "");
            fos.close();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
```
`save()`メソッドは、プロパティファイルをディスクに保存します。
`properties`の情報をプロパティファイルへ保存することで、プロパティファイルが最新のユーザ情報に更新されます。

```java
    @Override
    public UserModel addUser(RealmModel realm, String username) {
        synchronized (properties) {
            properties.setProperty(username, UNSET_PASSWORD);
            save();
        }
        return createAdapter(realm, username);
    }
```
`addUser()`メソッドは、ユーザを追加します。
ユーザを追加する際にプロパティのパスワードをnullに設定できないため、代わりに`UNSET_PASSWORD`を設定します。
メソッドの返り値をnullにすることで、ユーザの追加機能をオフにすることも可能です。


```java
    @Override
    public boolean removeUser(RealmModel realm, UserModel user) {
        synchronized (properties) {
            if (properties.remove(user.getUsername()) == null) return false;
            save();
            return true;
        }
    }
```
`removeUser()`メソッドは、ユーザを削除します。

#### UserQueryProviderの実装
`UserQueryProvider`は、1つ以上のユーザを検索する複雑なクエリを定義します。
管理コンソールでwriteable-property-file providerによって読み込まれたユーザを閲覧・管理するために実装します。

```java
    @Override
    public Stream<UserModel> searchForUserStream(RealmModel realm, String search, Integer firstResult,
            Integer maxResults) {
        Predicate<String> predicate = "*".equals(search) ? username -> true : username -> username.contains(search);
        return properties.keySet().stream()
                .map(String.class::cast)
                .filter(predicate)
                .skip(firstResult)
                .map(username -> getUserByUsername(realm, username))
                .limit(maxResults);
    }
```
`searchForUserStream()`は二つ実装されています。
一つ目の`searchForUserStream()`は、Stringパラメータの検索文字列（今回はユーザ名）からユーザを検索します。
検索文字列に`*`が入力された場合はすべてのユーザ、そうでない場合は`String.contains()`を使用して検索文字列と部分一致するユーザを返します。

外部ストレージがページ分割をサポートしていない場合でも、`firstResult`および`maxResults`パラメータを使用して実装可能です。

```java
    @Override
    public Stream<UserModel> searchForUserStream(RealmModel realm, Map<String, String> params, Integer firstResult,
            Integer maxResults) {
        // only support searching by username
        String usernameSearchString = params.get("username");
        if (usernameSearchString != null)
            return searchForUserStream(realm, usernameSearchString, firstResult, maxResults);

        // if we are not searching by username, return all users
        return searchForUserStream(realm, "*", firstResult, maxResults);
    }
```
二つ目の`searchForUserStream()`は、Mapパラメータを取り、姓、名、ユーザ名、およびEメールに基づいてユーザを検索できます。
Mapパラメータに`username`属性が含まれる場合はユーザ名で検索し、含まれない場合はすべてのユーザが返されます。

```java
    @Override
    public Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group, Integer firstResult, Integer maxResults) {
        // runtime automatically handles querying UserFederatedStorage
        return Stream.empty();
    }

    @Override
    public Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group) {
        // runtime automatically handles querying UserFederatedStorage
        return Stream.empty();
    }

    @Override
    public Stream<UserModel> searchForUserByUserAttributeStream(RealmModel realm, String attrName, String attrValue) {
        // runtime automatically handles querying UserFederatedStorage
        return Stream.empty();
    }
```
グループや属性は保存しないので、その他のメソッドは空のリストを返します。



### PropertyFileUserStorageProviderFactory
User Storage SPIのProvider Factoryとして、PropertyFileUserStorageProviderFactoryを作成します。

```java
public class PropertyFileUserStorageProviderFactory implements UserStorageProviderFactory<PropertyFileUserStorageProvider> {
```
`UserStorageProviderFactory`クラスを実装する際、テンプレートパラメーターとして具体的なProviderクラスの実装を渡す必要があります。
ここでは`PropertyFileUserStorageProvider`クラスを指定します。

```java
    private static final Logger logger = Logger.getLogger(PropertyFileUserStorageProviderFactory.class);

    public static final String PROVIDER_NAME = "writeable-property-file";

    protected static final List<ProviderConfigProperty> configMetadata;

    static {
        configMetadata = ProviderConfigurationBuilder.create()
                .property().name("path")
                .type(ProviderConfigProperty.STRING_TYPE)
                .label("Path")
                .defaultValue("${jboss.server.config.dir}/example-users.properties")
                .helpText("File path to properties file")
                .add().build();
    }
    
    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        return configMetadata;
    }
```
`configMetadata`は設定プロパティのリストを定義しています。
ここでは、`ProviderConfigurationBuilder`クラスを使用しString型の`path`という名前の変数を指定します。
管理コンソールのプロパティ設定ページでは、この設定変数は`Path`というラベルが付けられており、デフォルト値は`${jboss.server.config.dir}/example-users.properties`です。ヘルプテキストには`File path to properties file`が表示されます。
`getConfigProperties()`メソッドは、`configMetadata`に定義された設定プロパティのリストを返します。 

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2812915/adcd64f7-1462-ab35-6c1f-fa30e3b6d0c2.png)

```java
    @Override
    public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config) throws ComponentValidationException {
        String fp = config.getConfig().getFirst("path");
        if (fp == null) throw new ComponentValidationException("user property file does not exist");
        fp = EnvUtil.replace(fp);
        File file = new File(fp);
        if (!file.exists()) {
            throw new ComponentValidationException("user property file does not exist");
        }
    }
```
`validateConfiguration()`メソッドは、設定プロパティで指定されたプロパティファイルがディスク上に存在するか確認します。
有効なプロパティファイルを指定していない場合、このProviderのインスタンスを有効できなくなります。

`validateConfiguration()`メソッドでは、`ComponentModel`から設定変数を取得し、そのファイルがディスク上に存在するかどうかを確認します。 
`org.keycloak.common.util.EnvUtil.replace()`メソッドを使用して、`${}`が含まれる文字列がシステムプロパティ値で置換されます。

```java
    @Override
    public String getId() {
        return PROVIDER_NAME;
    }
```
`getId()`メソッドは、レルム用のUser Storage Providerを有効にする場合、Factoryを識別するための識別子や、管理コンソールへの表示名として設定されます。



```java
    @Override
    public PropertyFileUserStorageProvider create(KeycloakSession session, ComponentModel model) {
        String path = model.getConfig().getFirst("path");
        path = EnvUtil.replace(path);

        Properties props = new Properties();
        try {
            InputStream is = new FileInputStream(path);
            props.load(is);
            is.close();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        return new PropertyFileUserStorageProvider(session, model, props);
    }
```
`create()`メソッドは、Provider Factoryを作成するためのメソッドで、トランザクション毎に都度呼び出されます。

# まとめ
本記事ではUser Storage SPIを使用してKeycloakを外部ストレージと連携する方法を説明しました。
今回紹介したのはUser Storage SPIの機能の一部で、他にも外部ストレージユーザをKeycloakデータベースにインポートして使用する機能など色々あります。
より多くの情報を知りたい方は、Keycloak公式ドキュメントの[User Storage SPIの章](https://www.keycloak.org/docs/23.0.2/server_development/index.html#_user-storage-spi)を見ることをお勧めします。

# 参考資料
- [Keycloak Documentation](https://www.keycloak.org/archive/documentation-23.0.html)
- [NRI OpenStandia Keycloak日本語ドキュメント](https://keycloak-documentation.openstandia.jp/)
- [Keycloak クイックスタート](https://github.com/keycloak/keycloak-quickstarts/tree/latest)