KeycloakのIdentity Brokeringを拡張してみよう (Discordを題材に)

10日目の今日は、KeycloakのIdentity Brokeringを拡張し、標準では対応していない外部IdP(アイデンティティー・プロバイダー)と認証連携する方法について紹介します。ちょうど先月、DiscordアカウントでログインできるようにKeycloakを拡張してみましたので、これを題材に今回紹介します！。

なお、動作確認しているKeycloakのバージョンは4.6.0.Finalですが、恐らく4系であれば互換性はあり動作すると思います。


# はじめに

KeycloakのIdentity Brokeringについて簡単に説明しておきます。続いて、今回題材として利用しているDiscordについても軽く説明しておきます。


## Identity Brokeringについて

Keycloakが提供する機能の1つに、外部のIdPを利用してKeycloakにログインする機能があります。これは、KeycloakのWebサイトやドキュメント等では、 **Identity Brokering** とか **Social Login** と書かれていたりする機能になります。Social Loginと書かれているだけあって、Keycloakでは標準でTwitterやGoogle、Facebookなどのメジャーなソーシャルネットワークのアカウントとの認証連携が可能になっています。

仕組みとしては、外部IdPとKeycloakとの間でOpenID Connect(OIDC)、OAuth、SAMLといったIdP側が提供するプロトコルを使用し、Keycloak側がSP(サービス・プロバイダー)として動作することで、IdP側のアカウントを利用してKeycloakにログイン可能になります。一旦Keycloakにログインすると、Keycloak側で管理する認証トークンが発行されますので、Keycloakがセキュリティ保護しているアプリケーションに対してそのままログインができるようになります。詳しくは、過去に＠ITで解説記事をOpenStandiaチームで書いていますので、是非そちらを参照してみてください。

* [Keycloak超入門（5）Keycloakで外部アイデンティティー・プロバイダーと連携してみよう](http://www.atmarkit.co.jp/ait/articles/1804/14/news004.html)


また、Keycloakオフィシャルドキュメントの1つ、Server Administration GuideにもIdentity Brokeringの章がありますので、そちらも参照してみてください。標準で対応している最新のソーシャルネットワーク一覧もここに書かれています(2018年12月時点では、11種類あります)。

* [英語版: Server Administration Guide / 12. Identity Brokering](https://www.keycloak.org/docs/latest/server_admin/index.html#_identity_broker)
* [日本語版: Server Administration Guide / 12. アイデンティティー・ブローカリング](https://keycloak-documentation.openstandia.jp/master/ja_JP/server_admin/index.html#_identity_broker)


## Discordとは

Discordとは、ゲーマー向けのVoIP基盤やチャット機能を提供しているサービスです。ゲーマー向けのサービスなのですが、Slackライクにチャットを使えるということもあり、OSSコミュニティのコミュニケーションツールとして使われていたりもします。たとえば、[React.js](https://reactjs.org/)のコミュニティでは元々Slackを使用していましたが、[ユーザーが増えすぎて追い出されてしまいDiscordに移動したという話](https://reactjs.org/blog/2015/10/19/reactiflux-is-moving-to-discord.html)が2015年に話題に挙がりました。

日本では、オンライン勉強会・コミュニティである[インフラ勉強会](https://wp.infra-workshop.tech/)などでDiscordが活用されています。なお、今回Discordとの認証連携を実装してみた背景として、このインフラ勉強会用のDiscordサーバにて下記投稿がされたことがきっかけだったりします。

> @everyone  全体へのメンション失礼します。ぜひ協力してください。
【募集】おはようございます。keycloakにおけるアイデンティティ プロバイダーjarの作成をしてみたい方、したい方、できる方を募集しています。
> 詳細としては、Discordの認証がkeycloakで実装がされていないため、実装していただきたきたいと思っています。

インフラ勉強会では現在、[WordPress](https://github.com/WordPress/WordPress)と[Growi](https://github.com/weseek/growi)をコミュニティ内で利用されているのですが、ここのアカウントを統合かつDiscordアカウントでSSOできるようにしたい、ということでした。私自身、インフラ勉強会にはこれまで視聴側でのみ参加していましたが、これならばと思い今回お手伝いさせていただくことになりました。なお、[こちらの#ssoと油田botチャンネル](https://discordapp.com/channels/394100660083359754/464073245264576512)にてディスカッションしながら認証基盤を絶賛構築中です。また、同チャンネルにて、認証・認可とかSSOまわりで何か疑問・質問など聞きたいことがありましたら、是非お気軽につぶやいてもらえればと思います。


# Keycloak標準のIdentity Brokering設定だとなぜ駄目なのか？

KeycloakのIdentity Brokeringには、特定のソーシャルネットワークとの連携以外に、[SAML v2.0 アイデンティティー・プロバイダー](https://keycloak-documentation.openstandia.jp/master/ja_JP/server_admin/index.html#saml-v2-0%E3%82%A2%E3%82%A4%E3%83%87%E3%83%B3%E3%83%86%E3%82%A3%E3%83%86%E3%82%A3%E3%83%BC-%E3%83%97%E3%83%AD%E3%83%90%E3%82%A4%E3%83%80%E3%83%BC)と[OpenID Connect v1.0 アイデンティティー・プロバイダー](https://keycloak-documentation.openstandia.jp/master/ja_JP/server_admin/index.html#openid-connect-v1-0%E3%82%A2%E3%82%A4%E3%83%87%E3%83%B3%E3%83%86%E3%82%A3%E3%83%86%E3%82%A3%E3%83%BC-%E3%83%97%E3%83%AD%E3%83%90%E3%82%A4%E3%83%80%E3%83%BC)向けの連携設定も標準で提供されています。これは、特定のIdPではなく、SAML 2.0やOIDCに対応した任意のIdPと認証連携するための汎用的な機能になります。DiscordがSAMLに対応していることには期待していませんでしたが、OIDCの方はコンシューマ向けサイトでは普及してきていますし、Discordもこれでいけるんじゃないかなぁ？と最初思っていました。

しかし[Discordのドキュメント](https://discordapp.com/developers/docs/topics/oauth2)を見ると、どうやらOAuth2のみに対応のようです。とはいえOIDCはOAuth2をベースにしているんだから、Keycloak側のオプション設定でなんとかなるんじゃないかなぁ？と期待しつつ、ここでKeycloak側のソースをちょっと読みながら動作確認もしてみると、、、 **あぁこりゃ駄目だ・・・** ということが分かりました。以下、その駄目な点ですが、

* 認可リクエストの際にはscopeパラメータに必ずopenidが含まれように実装されているのですが([4.6.0.Finalでの実装箇所](https://github.com/keycloak/keycloak/blob/4.6.0.Final/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java#L87))、Discord側ではサポートしていないscopeパラメータが指定されると、下記のようなエラーを返す仕様になっていました。
  ![image.png](https://qiita-image-store.s3.amazonaws.com/0/35233/7090f89b-5c08-baad-f2df-a713bf1a041d.png)

* たとえscopeパラメータから `openid` を消して認可リクエストを投げたとしても、Discord側は通りますが、Keycloak側で認可リクエストのレスポンスをDiscordから受け取った際に、IDトークンが必須でチェックされていました([4.6.0.Finalでの実装箇所](https://github.com/keycloak/keycloak/blob/4.6.0.Final/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java#L348))。オプションでスキップなどできるのを期待していましたがありませんでした。DiscordはIDトークンを返してはくれないので、これでは必ず下記のようなエラー画面となります。
  ![image.png](https://qiita-image-store.s3.amazonaws.com/0/35233/557f1fee-b290-bf85-15b8-a0088987c0d5.png)

エラーログには下記のように、 `No token from server` とIDトークンが取得できないエラーが出ています。

```text:IDトークンが取得できない時のエラーログ
14:27:19,192 ERROR [org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider] (default task-8) Failed to make identity provider oauth callback: org.keycloak.broker.provider.IdentityBrokerException: No token from server.
        at org.keycloak.broker.oidc.OIDCIdentityProvider.validateToken(OIDCIdentityProvider.java:463)
        at org.keycloak.broker.oidc.OIDCIdentityProvider.validateToken(OIDCIdentityProvider.java:458)
        at org.keycloak.broker.oidc.OIDCIdentityProvider.getFederatedIdentity(OIDCIdentityProvider.java:348)
        at org.keycloak.broker.oidc.AbstractOAuth2IdentityProvider$Endpoint.authResponse(AbstractOAuth2IdentityProvider.java:422)
        ...
```

というわけで標準で用意されている設定では、OAuth2だけでなくそれなりにOIDCまで実装している外部IdPでないと、残念ながら認証連携できないことが分かりました :cry:

よって、Discordと認証連携したい場合は、Identity BrokeringのSPIを拡張して独自に作り込む必要があります。


# というわけで作りました

とりあえずプロトをサクッと作ってみました。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/35233/d2635d8d-0770-4641-0835-115b1f2947b0.png)

[ソースはこちら](https://github.com/wadahiro/keycloak-discord)。アドオン形式で開発しており、ビルド済みモジュール(EARファイル)も[GitHubのリリースページ](https://github.com/wadahiro/keycloak-discord/releases)に置いていますので、自分でビルドせずともKeycloakにデプロイすることで簡単に試せます。実装について詳細はソース読んでくださいだと怒られそうなんで、以降でもうちょっと説明しておきます。


# Discord側のAPI仕様の確認

外部IdPであるDiscordと認証連携するにあたって、[Discord側のドキュメント](https://discordapp.com/developers/docs/topics/oauth2)を見てAPI仕様をまず確認します。これをみると、利用可能なOAuth2のscopeが定義されています。今回のように、認証連携目的でAPIをコールするには[^1]、以下のscopeを使えば良さそうです。

* `identify` : `/users/@me` という認証したユーザーの属性情報を返すためのAPIアクセスを許可するために必要です。このAPIがUserInfoエンドポイント相当ですので、認証連携の際にユーザー属性を取得してユーザー特定するために、このscopeは必須となります。
* `email` : `/users/@me` のレスポンスに `email` 属性を含めるには必要となります。Keycloak的にはメールアドレスは必須ではありませんが使用するケースの方が多いかと思います。今回の実装ではデフォルトscopeとしてハードコードしてはいますが、ユースケースに応じてKeycloakの管理コンソールから設定で指定するscopeを変えられるようにはなっています。


これらのscopeを指定してDiscordのアクセストークンを取得し、 `/users/@me` を呼び出すと以下のようなユーザー属性が返ります。なお、各項目の詳細については、[DiscordのAPIドキュメントのUser Object](https://discordapp.com/developers/docs/resources/user#user-object)に記載されています。

```json:Discordのユーザー属性レスポンス例
{
  "id": "80351110224678912",
  "username": "Nelly",
  "discriminator": "1337",
  "avatar": "8342729096ea3675442027381ff50dfe",
  "verified": true,
  "email": "nelly@discordapp.com",
  "flags": 64,
  "premium_type": 1
}
```

これらの属性を利用してKeycloak側に存在するアカウントとマッピング(または、初回認証連携時にアカウントを動的に作成)することで外部IdP-Keycloak間で認証連携することが可能になります。


# Identity Brokering SPIの実装

外部IdP側のAPI仕様が分かったところで、Keycloak側の拡張機能開発について説明していきます。

今回のようにソーシャルログイン先を追加するには、Identity BrokeringのSPIである[SocialIdentityProviderインタフェース](https://github.com/keycloak/keycloak/blob/4.6.0.Final/server-spi-private/src/main/java/org/keycloak/broker/social/SocialIdentityProvider.java)を実装する必要があります。なお、注意点として、このインタフェースは2018年12月時点では プライベートなAPI扱いです。作成した拡張モジュールをKeycloakにデプロイすると、以下のようなWARNログが出力されることからも分かります[^2]。

>15:49:40,902 WARN  [org.jboss.as.dependency.private] (MSC service thread 1-1) WFLYSRV0018: Deployment "deployment.keycloak-discord-ear-0.1.0.ear" is using a private module ("org.keycloak.keycloak-server-spi-private") which may be changed or removed in future versions without notice.

**プライベートなAPIは、今後のKeycloakのバージョンアップで事前予告やマイグレーション方法の案内なしに変わる可能性があります。** よって、バージョアップの際に突然動作しなくなる恐れがありますので注意してください。また、 **Red Hat社によるKeycloakの商用サポート版であるRed Hat SSOにおいては、プライベートなAPIを使用した場合はそのままではサポートを受けられない恐れがありますので、Red Hat SSOユーザーの方はその点も留意してください。** 何か問題発生時には、問い合わせの前に切り分けをご自身で実施し、拡張した部分が影響していないことを説明しないといけない場合があります[^3]。

さて、SocialIdentityProviderインタフェースの実装について話を戻しますが、これをすべて実装するのはちょっと大変そうです。幸いなことに、[AbstractOAuth2IdentityProvider](https://github.com/keycloak/keycloak/blob/4.6.0.Final/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java)というOAuth2向けの親クラスがありますので、これを継承することでほとんどコーディングせずに開発することができます[^4]。便利なことに、この親クラスにてOAuth2の認可コードフローでアクセストークンの取得までは行ってくれますので、後は取得したアクセストークンを使用して、

* 必要なユーザー属性を外部IdP側のAPIをコールして取得する
* 取得したユーザー属性から、BrokeredIdentityContextオブジェクトを作成する

が主な実装内容になります。AbstractOAuth2IdentityProviderクラスでは空実装である `doGetFederatedIdentity(String accessToken)` をオーバーライドして上記2点を実装する必要があります。

加えて、 `extractIdentityFromProfile(EventBuilder event, JsonNode profile)` メソッドも実装しておくとよいでしょう。こちらは通常のブラウザー利用時の認証連携では使用されませんが、Token Exchangeという機能を使用する場合に呼び出されます。この機能を使用すると、DiscordのアクセストークンをKeycloakのIDトークン/アクセストークンに交換することができます。ブラウザベースではないアプリケーションで認証連携が必要な場合にはこちらの仕組みを使うとよいでしょう。Token Exchangeに関しての詳細は、下記のKeycloakドキュメントを参照してください。

* [英語版: Securing Applications and Services Guide / 7. Token Exchange](https://www.keycloak.org/docs/latest/securing_apps/index.html#_token-exchange)
* [日本語版: Securing Applications and Services Guide / 7. トークンの交換](https://keycloak-documentation.openstandia.jp/master/ja_JP/securing_apps/index.html#_token-exchange)


それでは実装の詳細を見ていきましょう。


## ファクトリークラスの実装

短いので全コード載せています。SocialIdentityProviderインタフェースに対応するファクトリークラスのため、SocialIdentityProviderFactoryインタフェースを実装する必要があります。AbstractIdentityProviderFactoryクラスを継承すると少し実装コードを減らせます。 `create` メソッドでは後述するDiscordIdentityProviderのインスタンスを作成して返すようにします。

```java:ファクトリークラスの実装
public class DiscordIdentityProviderFactory extends AbstractIdentityProviderFactory<DiscordIdentityProvider>
        implements SocialIdentityProviderFactory<DiscordIdentityProvider> {

    public static final String PROVIDER_ID = "discord";

    @Override
    public String getName() {
        return "Discord";
    }

    @Override
    public DiscordIdentityProvider create(KeycloakSession session, IdentityProviderModel model) {
        return new DiscordIdentityProvider(session, new OAuth2IdentityProviderConfig(model));
    }

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

また、このファクトリークラスをソーシャルログイン拡張用としてKeycloakに認識させるために、クラス名を `META-INF/services/org.keycloak.broker.social.SocialIdentityProviderFactory` に書いておきます。

```text:META-INF/services/org.keycloak.broker.social.SocialIdentityProviderFactory
org.keycloak.social.discord.DiscordIdentityProviderFactory
```


## プロバイダークラスの実装

ここからがメイン処理を含むプロバイダークラスの実装内容になります。

### クラス定義

SocialIdentityProviderインタフェースを実装しつつ、AbstractOAuth2IdentityProviderクラスを継承します。設定画面にIdP固有の設定項目を追加する必要がなければ、型パラメータにはOAuth2用に予め用意されているOAuth2IdentityProviderConfigを使用しておきます。

```java:クラス定義
public class DiscordIdentityProvider extends AbstractOAuth2IdentityProvider<OAuth2IdentityProviderConfig>
        implements SocialIdentityProvider<OAuth2IdentityProviderConfig> {
```

:warning: [GitHubにある最新ソース](https://github.com/wadahiro/keycloak-discord/blob/master/ejb/src/main/java/org/keycloak/social/discord/DiscordIdentityProvider.java#L44-L45)では、設定画面にDiscord用に項目を追加したバージョンとなっているため、独自のConfigクラスを設定しています。

### コンストラクタ

コンストラクタで、外部IdP固有の各種エンドポイントURL設定をしておきます。Discordの場合は[ドキュメント](https://discordapp.com/developers/docs/topics/oauth2)に記載がありますのでそれを指定します。

```java:コンストラクタ
    public static final String AUTH_URL = "https://discordapp.com/api/oauth2/authorize";
    public static final String TOKEN_URL = "https://discordapp.com/api/oauth2/token";
    public static final String PROFILE_URL = "https://discordapp.com/api/users/@me";

    public DiscordIdentityProvider(KeycloakSession session, OAuth2IdentityProviderConfig config) {
        super(session, config);
        config.setAuthorizationUrl(AUTH_URL);
        config.setTokenUrl(TOKEN_URL);
        config.setUserInfoUrl(PROFILE_URL);
    }
```

### デフォルトスコープの実装

`getDefaultScopes()` をオーバーライドしてデフォルトで指定するOAuth2のscopeを設定します。Discordの場合は前述のとおり、 `identify` と `email` を指定します。

```java:デフォルトスコープの実装
    public static final String DEFAULT_SCOPE = "identify email";
    
    @Override
    protected String getDefaultScopes() {
        return DEFAULT_SCOPE;
    }
```

### 外部IdP側のユーザー属性取得の実装

引数で外部IdP側のアクセストークンが渡ってきますので、これで外部IdPからユーザー情報を取得します。HTTPアクセス用のクラスとしてSimpleHttpというものがKeycloakに用意されていますので、これを利用します。そしてHTTPレスポンスを表すJsonNodeオブジェクトをここでBrokeredIdentityContextオブジェクトに変換してもよいのですが、後述のToken Exchange用の実装と共通化可能なので、そちらへ変換処理は委譲します。

```java:外部IdP側のユーザー属性取得の実装
    @Override
    protected BrokeredIdentityContext doGetFederatedIdentity(String accessToken) {
        JsonNode profile = null;
        try {
            profile = SimpleHttp.doGet(PROFILE_URL, session).header("Authorization", "Bearer " + accessToken).asJson();
        } catch (Exception e) {
            throw new IdentityBrokerException("Could not obtain user profile from discord.", e);
        }

        return extractIdentityFromProfile(null, profile);
    }
```


### Token Exchange用の実装

Token Exchangeを利用可能にするには下記のような実装が必要となります。前述の `doGetFederatedIdentity` メソッドから `extractIdentityFromProfile` メソッドがJSONを引数に呼び出され、BrokeredIdentityContextオブジェクトに変換して返しています。Token Exchangeで呼び出される際は、 `doGetFederatedIdentity` メソッドは経由せずに `extractIdentityFromProfile` メソッドが呼ばれます。

```java:TokenExchange用の実装
    public static final String PROFILE_URL = "https://discordapp.com/api/users/@me";
    
    @Override
    protected boolean supportsExternalExchange() {
        return true;
    }

    @Override
    protected String getProfileEndpointForValidation(EventBuilder event) {
        return PROFILE_URL;
    }

    @Override
    protected BrokeredIdentityContext extractIdentityFromProfile(EventBuilder event, JsonNode profile) {
        BrokeredIdentityContext user = new BrokeredIdentityContext(getJsonProperty(profile, "id"));

        user.setUsername(getJsonProperty(profile, "username") + "#" + getJsonProperty(profile, "discriminator"));
        user.setEmail(getJsonProperty(profile, "email"));
        user.setIdpConfig(getConfig());
        user.setIdp(this);

        AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias());

        return user;
    }
```


### BrokeredIdentityContextオブジェクトの作成について補足

BrokeredIdentityContextオブジェクトの作成は下記のようなコードを書いていました。

```java:BrokeredIdentityContextオブジェクトの作成
        BrokeredIdentityContext user = new BrokeredIdentityContext(getJsonProperty(profile, "id"));

        user.setUsername(getJsonProperty(profile, "username") + "#" + getJsonProperty(profile, "discriminator"));
        user.setEmail(getJsonProperty(profile, "email"));
        user.setIdpConfig(getConfig());
        user.setIdp(this);

        AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias());

        return user;
```

このBrokeredIdentityContextオブジェクトがリンクする外部IdP(今回だとDiscord)のID情報を表しており、これをSPIの実装からKeycloak側に渡すことで外部IdP-Keycloak間のアカウントリンキングが行われます。BrokeredIdentityContextオブジェクトの生成にはコンストラクタで外部IdP側のユニークなユーザーIDを渡す必要がありますが、Discordの場合は先で例示したJSON内に `id` 属性で内部ユーザーIDがありますので、これを利用しています。

一方、 `BrokeredIdentityContext#setUsername(String)` には何を渡せばよいのでしょうか？ まだKeycloak側に対応するユーザーアカウントが存在しない場合に、初回認証連携の際にアカウント作成を行うことができるのですが、ここで渡した値がKeycloak側の `username` 属性のデフォルト値として採用されます。そのため、Keycloak側の `username` にマッピングされても良さそうな値を設定しておくとよいでしょう。ただし、Discordの場合には1点注意事項があります。前述のとおり、Discordが返すユーザー属性は以下のようなJSONなのですが、

```json:Discordのユーザー属性レスポンス例
{
  "id": "80351110224678912",
  "username": "Nelly",
  "discriminator": "1337",
  "avatar": "8342729096ea3675442027381ff50dfe",
  "verified": true,
  "email": "nelly@discordapp.com",
  "flags": 64,
  "premium_type": 1
}
```

これだけを見ると、`username` を単純に `BrokeredIdentityContext#setUsername(String)` で設定したくなるところです。しかし[DiscordのAPIドキュメントのUser Object](https://discordapp.com/developers/docs/resources/user#user-object)をよく読むと、 **`the user's username, not unique across the platform`** とあり実はユニークではありません。Discordユーザーの方ならご存知かと思いますが、Discordでは一意なユーザー名として `discriminator` を連結した `Nelly#1337` のような名前が使われています。そのまま単純マッピングしてしまうと、 `username` が被るため[^5]初回認証連携時にアカウント作成がスムーズにできない、という事象になりかねません。というわけで、今回は `username` と `discriminator` をDiscord側にならい `#` で連結した値を設定しています。

そして最後に、 **`AbstractJsonUserAttributeMapper.storeUserProfileForMapper` メソッドを忘れずに呼び出してください**。 これを呼び出しておくことで、Keycloakの管理コンソールから行うIdentiy BrokeringのMapper設定にて、取得したIdP側のユーザー属性をKeycloak側のユーザー属性にマッピングができるようになるためです。要件に合わせて柔軟にマッピングをカスタマイズできるように、ここで必ず呼び出しておきましょう。


## 設定画面用HTMLテンプレートの作成

独自の外部IdP連携機能を追加する場合、Keycloak管理コンソールの設定画面用のHTMLテンプレートを作成する必要があります。今回はプロバイダークラスはAbstractOAuth2IdentityProviderを継承して作成していますので、同クラスを継承しつつConfigクラスにOAuth2IdentityProviderConfigを使用している他のソーシャルログイン用のHTMLテンプレートファイルを[ここから](https://github.com/keycloak/keycloak/tree/4.6.0.Final/themes/src/main/resources/theme/base/admin/resources/partials)コピーし、Discord用に修正するのが手っ取り早いです。

なお、外部IdP固有の設定項目を追加したい場合はHTMLテンプレートをカスタマイズすればよいですが、追加分は `*-ext.html` というファイルを別途作成し、それをインクルードするという書き方でKeycloak内では標準化されているようなので、それにならうとよいでしょう。今回のDiscord用のソースでも、GitHub上にあるソースでは独自設定項目を追加していますので以下を参照してください。

* [Discord用のHTMLテンプレート](https://github.com/wadahiro/keycloak-discord/blob/0.1.0/ejb/src/main/resources/theme/discord/admin/resources/partials/realm-identity-provider-discord.html)
  * [インクルード箇所](https://github.com/wadahiro/keycloak-discord/blob/0.1.0/ejb/src/main/resources/theme/discord/admin/resources/partials/realm-identity-provider-discord.html#L35)
* [Discord用の独自設定項目のHTMLテンプレート](https://github.com/wadahiro/keycloak-discord/blob/0.1.0/ejb/src/main/resources/theme/discord/admin/resources/partials/realm-identity-provider-discord-ext.html)

また、テンプレートファイルのデプロイ方法には2つあります。

* HTMLテンプレートのファイルをKeycloak本体のthemesディレクトリ配下に配置する。他のソーシャルログイン用のテンプレートと同ディレクトリに配置します。
* 独自テーマとして作成し、JARに格納してデプロイし、テーマ設定で適用する。作成したDiscord連携用クラスと同じJARに格納でもよいです。

今回作成したモジュールでは、デプロイ簡易化のため後者の方法を採用しています。独自テーマの作成方法についての詳細は、下記のKeycloakドキュメントを参照してください。

* [英語版: Server Developer Guide / 3.4. Creating a theme](https://www.keycloak.org/docs/latest/server_development/index.html#creating-a-theme)
* [日本語版: Server Developer Guide / 3.4. テーマの作成](https://keycloak-documentation.openstandia.jp/master/ja_JP/server_development/index.html#%E3%83%86%E3%83%BC%E3%83%9E%E3%81%AE%E4%BD%9C%E6%88%90)


# 動作確認

作成した拡張部品を利用して動作確認をしてみます。

## Discordへのアプリケーション登録

Discordにログインした状態で [Discord Developer Portal](https://discordapp.com/developers/applications/#top) にアクセスし、アプリケーションの登録を行います。適当な名前でアプリケーションを登録すると自動的に `CLIENT ID` と `CLIENT SECRET` が発行されます。これをコピーし、後述するKeycloak管理コンソールの設定画面で使用します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/35233/a3511bc4-3fc1-e5a4-bed2-3530ef7caddb.png)

また、画面左の `OAuth2` メニューを開き、許可するリダイレクトURIを設定して `Save Changes` を押して保存します。リダイレクトURIは、後述のKeycloak管理コンソールの設定画面に表示されている `Redirect URI` をコピーして貼り付ければOKです。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/35233/ea405842-c91a-05d3-bf80-d904ce941f19.png)


## Keycloakへのアドオンのデプロイ

Discord連携のソースを `mvn package` でビルドすると、 `ear/target` ディレクトリ配下にそのままデプロイ可能なEARファイルが生成されます。これを `<Keycloakインストール先>/standalone/deployments/` ディレクトリ配下にコピーするだけです。

## Keycloak管理コンソールでの設定

Keycloakの管理コンソールに管理者アカウントでログインし、以下の設定を行います。

* テーマの設定変更: `master` レルムのテーマ設定にて、 `Admin Console Theme` に `discord` を設定して保存します。**保存後、テーマのHTMLを読み直すために管理コンソールから一度ログアウトして再ログインしておいてください。**
  ![image.png](https://qiita-image-store.s3.amazonaws.com/0/35233/a9a343b5-df0e-e510-4fc5-f49d50371a36.png)
* 再ログイン後、Discordと認証連携したいレルムに変更します(例として、 `test` レルムを使用しています)。
* 画面左の `Identity Providers` メニューから新規にプロバイダーの追加を行います。新たに `Discord` が選べるようになっていますのでこれを選択します。
  ![image.png](https://qiita-image-store.s3.amazonaws.com/0/35233/631fac2b-3376-d207-d930-53ed458b21f4.png)
* 前述のDiscord側で登録したアプリケーションの `CLIENT ID` と `CLIENT SECRET` をここで入力して保存します。**また、この画面で表示されている `Redirect URI` がDiscord側に設定する許可するリダイレクトURIになります**。
  ![image.png](https://qiita-image-store.s3.amazonaws.com/0/35233/9a113632-d53a-83d1-a3ed-ead0f92c8121.png)

## Discordアカウントによるソーシャルログインの確認

簡易的な動作確認として、Keycloakが標準で備えている **アカウント管理** アプリケーションを利用してDiscordアカウントでソーシャルログインしてみます。

* **<レルムURL>/account** にアクセスします。ローカルホストでKeycloakを動作かつ `test` レルムを使用している場合は、 http://localhost:8080/auth/realms/test/account にアクセスします。
* Keycloakログイン画面に **Discord** ボタンが表示されるようになっていますので、これをクリックします。
  ![image.png](https://qiita-image-store.s3.amazonaws.com/0/35233/8ba912ba-4f07-7438-124a-1e68190260dc.png)
* Discordにリダイレクトされます。Discordに未ログイン状態の場合はログイン画面が表示されますのでDiscordに登録したメールアドレス/パスワードでログインします。<br/>
  ![image.png](https://qiita-image-store.s3.amazonaws.com/0/35233/c3269a0a-c4a5-807b-2703-8e921083db14.png)
* DiscordのOAuth2の同意画面が表示されます。内容を確認の上、`認証` ボタンをクリックして処理を進めます。<br/>
  ![image.png](https://qiita-image-store.s3.amazonaws.com/0/35233/1454612f-220d-bc19-e8c2-3f6ce1e7ad52.png)
* Keycloakに再びリダイレクトで戻り、初回ログインの場合(Keycloakにまだ存在しないユーザーの場合)は、下記のようなアカウント情報入力画面がKeycloakのデフォルト設定では表示されます。`Username` と `Email` ではDiscord側から連携されたユーザー情報を元に自動設定済みになっています。ログインを進めるためには `First name` と `Last name` を入力して `Submit` をクリックします。<br/>
  ![image.png](https://qiita-image-store.s3.amazonaws.com/0/35233/79152af3-8e7c-b5ba-4158-3624ca524e98.png)
* ログイン完了となり、無事にログインユーザー自身のアカウント管理画面にリダイレクトされて表示されます。
  ![image.png](https://qiita-image-store.s3.amazonaws.com/0/35233/e7920fe5-df5a-c4e9-f99d-e9e8fc733818.png)

# まとめ

以上のとおり、Discordを題材にIdentity Brokeringの拡張方法を紹介しました。外部IdPがOIDCに対応しているなら設定のみで連携できる可能性は高いですが、DiscordのようにOAuth2までの実装しかないといったケースでも、今回のように少しカスタム実装を追加するだけで簡単に認証連携することができます。

また、IdP側がOAuth2でもない独自の認証方式を取っているようなケースであっても、SocialIdentityProviderインタフェースでその認証方式に対応した処理を実装すれば連携は可能となります。たとえば、社内にSAMLやOIDCに対応していないレガシーな認証基盤が既にあるような状況で、でも新しいアプリケーション(特に最近だとクラウドサービス)はSAMLやOIDCでないと認証連携できなくて、SSOできなくて困るケースがあるかもしれません。この場合、今回のようにIdentity Brokeringを拡張することでレガシー認証基盤とKeycloakを連携させ、KeycloakをハブとしてSAMLやOIDCに対応したアプリケーションをSSOさせることが可能になるかと思います。こうすることで、レガシーな認証基盤を残しつつ、少しずつアプリケーションを新しい認証基盤へと移行していくことができるかと思います。

# 最後に

今回紹介したDiscord連携のモジュールはアドオン形式で一旦は開発してしまいましたが、これをベースにKeycloak本体側にプルリクエストを是非出したいと思います :muscle: 


[^1]: 認証目的なら、ユーザーを一意に特定可能であるユーザー情報を返すAPIを呼ぶことができればOKです。
[^2]: Red Hatのナレッジサイト: [Why is RH-SSO logging a "This SPI is internal and may change without notice" warning?](https://access.redhat.com/solutions/2536951) にて、 `This message is logged because the KeyCloak SPIs are not all currently supported in RH-SSO available releases` と述べられており、Red Hat SSOではまだサポートされていないSPIを拡張した場合に本ログが出力されます。
[^3]: Red Hatのナレッジサイト: [Are Keycloak SPIs all supported in RH-SSO ?](https://access.redhat.com/solutions/2486391) にて、 `If something wrong happens, Red Hat Support may ask those customers to (temporarily) remove the custom module to prove that an issue is not relevant to the custom spi implemented module.` と述べられており、ケースによってはご自身での問題切り分けが必要になります。
[^4]: Keycloakが標準で提供しているGitHubなどとのソーシャルログイン機能も、このクラスを継承して作られています。
[^5]: Keycloakでは同一レルム内では、`username` はユニークである必要があります。一方、`email` はレルム設定によってはユニークでなくてもOKに変更できます(デフォルトだとユニーク)。