KeycloakでSAMLログインをテストしたい

# 前振り
私が担当したシステムでは、Keycloakの`Identity Broker`機能を使用し、外部Identity Provider(以下外部IdP)を経由したログインを実施しています。
Identity Broker機能とは、KeycloakがService Provider(以下SP)となることで、`OpenID Connect`、`SAML`、`OAuth`といった各種プロトコルに対応した外部IdPのアカウント情報を利用して、Keycloakと連携しているアプリケーションにSSOするという機能です。
本記事では、これらプロトコルのうち、SAMLを題材としています。

今回、このシステムを改修し、KeycloakがSAMLレスポンスを受け取った後のコールバックメソッドをカスタムして独自の処理を実行することになりました。
具体的には、以下図の`No.8`の箇所、外部IdPからレスポンスを受け取った後の処理です。
ここでは、レスポンスの内容を基にKeycloakの内部的な認証処理を実施しています。
このタイミングでユーザーの認証情報に対し、特定の属性をアタッチするよう修正を加えました。
:information_source: 各フローの具体的内容は[こちらのドキュメント](https://keycloak-documentation.openstandia.jp/master/ja_JP/server_admin/index.html#_identity_broker)をご確認ください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531974/b9309b80-02a3-17af-ee6e-ce33e197eb3c.png)


# 本題
さて、本改修の動作確認をローカル環境で実施するにあたり、SAMLレスポンスを返してくれる外部IdPが必要です。
本番環境では、ADFSなどの外部IdPを使用していますが、ローカルでの動作確認用に同等のものを用意するのは難しいです。
そこで、今回は簡単に用意できるSAMLのIdPについて考えてみます。

## 前提条件
- SP(Keycloak)にSAMLでログインできること
- SP上(Keycloak上)の既存のユーザー「`test@example.com`」とアカウントリンクできること
  - アカウントリンクは、SPのユーザー名 = IdPのユーザー名でのマッピングとする
  - JITプロビジョニングは行わない。SP、IdPに同名のユーザーが存在することを前提とする
    - 本システムでは、SPに同名のユーザーが存在しない場合
    エラーとしているため

## 備考
- SP(Keycloak)のレルムは`main`レルムとする
- Keycloakのバージョンは、本記事公開時点で最新の`11.0.3`を使用している

# 解その1 Keycloak(同一インスタンス上)を使う
Keycloak自身を外部IdPとみなす方法です。
Keycloakでは、`レルム`という単位でアカウント情報、セッション情報、連携先アプリケーションの情報、認証方法などを管理しています。
レルムが異なると、同一のインスタンスにおいても別のIdPとみなすことが可能です。

:information_source: 一応、同一のレルムでもIdPとして設定可能ですが、既にログイン済みである旨の警告メッセージが表示されてしまいます。
![](image-ki8l6p1e.png)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531974/dbf1d04c-f74c-e382-56e7-18d82e96d42d.png)

## 手順

### 1.1 外部IdP用レルム作成(外部IdP)
まずは別レルムを作成します。
今回は`test`レルムと名付けました。
![](image-ki8lbeim.png)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531974/90d11cbd-6edb-d8cc-ed48-7f75c0e29194.png)

### 1.2 matadata取得(外部IdP)
レルム作成後、以下のURLからIdPのmetadataを取得します。
`https://<Keycloak-host>/auth/realms/test/protocol/saml/descriptor`  

ブラウザから取得するより、curlで取得するほうが容易です。

```bash
$ curl https://<Keycloak-host>/auth/realms/test/protocol/saml/descriptor > metadata.xml
```

### 1.3 外部IdPの定義作成(SP)
SPとする`main`レルムの作業となります。
手順1.2で取得したmetadata.xmlをSPにインポートし、外部IdPの定義を作成します。
インポート手順ですが、Identity Providersに`SAML`を選択し、`metadata.xml`をインポートします。
![](image-ki8lxbh9.png)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531974/7792298b-fcc3-c192-2d1b-704b96bc9ad4.png)


すると、metadata.xmlの定義内容で設定が作成されます。
![](image-ki8lyzt5.png)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531974/6d869f69-d9a7-5127-0715-d3ff9b071224.png)

#### 1.3.1 外部IdP定義の設定について

ここで、以下の設定を確認し、必要に応じて変更します。 

前提条件で述べた通り、SPのユーザー名とIdPのユーザー名でマッピングを行いたいので`NameID Policy Format`には、`Unspecified`を設定します。

|設定名|設定内容|
|---|---|
|NameID Policy Format|`Unspecified`|

次に、以下はSAMLリクエストの署名と検証です。
この設定は、本番環境の外部IdPと設定を揃えたい場合には設定が必要となります。
(ローカル環境での動作確認では、必ずしも必要な設定ではありません)

|設定名|意味|`ON`となるケース|
|---|---|---|
|Want Assertions Signed|SAMLリクエストの署名有無|外部IdP側の設定によっては、リクエストの発行を信頼済みのSPにのみ許可するケースがあり、その場合、この設定は`ON`にする必要があります。|
|Want Assertions Encrypted|SAMLリクエストの暗号化|何等かの秘匿データをSAMLリクエストに含める場合、この設定は`ON`にする必要があります。|
|Validate Signature|SAMLレスポンスの署名検証|レスポンスの署名検証を行わないと、アサーションの改ざんを検知できなくなり容易になりすましログインが可能となるため、この設定は`ON`となっているべきです。|

これらの設定が完了したら、保存します。

### 1.4 matadata取得(SP)
exportタブから、SPのmetadata(`keycloak.txt`)をダウンロードします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531974/d6dad521-b847-1f63-4a45-084ca0043bae.png)

SP側の準備はこれで完了です！

### 1.5 クライアント(SPの定義)作成(外部IdP)
`test`レルムに戻り、手順1.4でダウンロードしたSPのmetadata(`keycloak.txt`)をインポートして、クライアントを作成します。  
これが、IdPに対するSPの定義となります。
![](image-ki8mzhll.png)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531974/1f44b78a-41ca-6848-f856-53935db62e3f.png)

### 1.6 SPに連携するユーザーを作成(外部IdP)
`test`レルム上にユーザーの作成と、パスワードの設定を実施します。
前提条件で述べた通り、SPのユーザー名と同一となる`test@example.com`とします。  
![](image-ki8n2z3j.png)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531974/0f2a9985-9da1-c976-c061-1327e34d93d1.png)

これでIdP側の準備も完了です！

## ログインしてみる
ログイン先は、Keycloakのユーザー用管理コンソールとします。  

まず、`https://<Keycloak-host>/auth/realms/main/account`にアクセスし、画面右側の外部IdP用ログインリンクを選択します。
![](image-ki8nq4xd.png)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531974/77c7a4c1-52bf-7fc0-a5d4-a2d2ce2eeb6f.png)

外部IdPである`test`レルムのログイン画面にリダイレクトするので、`test@example.com`でログインします。
![](image-ki8nexto.png)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531974/52d47162-c8fa-b6d8-c39d-e77cb4b35133.png)

`test@example.com`としてSPへのログインが成功し、連携先アプリケーション(管理コンソール)にリダイレクトしました。
![](image-ki8ng7ss.png)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531974/aab347ec-a120-94df-8afe-df2b287ada74.png)

アカウントリンクも問題ありません。
![](image-ki8nhemt.png)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531974/6865eb97-ae53-17a1-abaa-8b6d2bd08399.png)

## 思うところ
ここまでの手順で、1つのKeycloakインスタンスで外部IdPを使用したSP(Keycloak)へのログインの動作確認が可能となりました。
なのですが、この環境には以下の制約が存在します。

- ユーザーをSP、外部IdPのそれぞれに都度作成しなければならない
  - 手間なので、片方のみの作成としたい
  - ただし、[ユーザー・ストレージ・フェデレーション機能](https://keycloak-documentation.openstandia.jp/master/ja_JP/server_admin/index.html#_user-storage-federation)を使用しているのであれば、SP、外部IdPで同一の設定を使用すればよいのでその限りではない
- KeycloakデフォルトのBrowser Flowでは、ログイン時、ログインIDとパスワードを入力する必要がある
  - 入力の手間を減らすため、パスワードは不要としたい
  - これについては、外部IdP側で以下のような認証フローを作成、Browser Flowに設定すれば、ログインID単独でログイン可能ではある
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531974/07288bfb-b9f0-a066-cd87-bdeb3caca2ac.png)

そこで、前提条件を満たせつつ、IdP側のユーザ管理が不要で、かつ使いやすいモジュールを探したところ、[saml-idp](https://github.com/mcguinness/saml-idp)を発見しました。

# 解その2 saml-idpを使う
saml-idpは、nodeで書かれたSAML IdPとして動作するモジュールです。
Dockerfileがあらかじめ用意されているため、導入が容易で、設定もシンプルです。

## 手順
本手順では、予め用意されたDockerfileを使用して、saml-idpを構築します。

### 2.1 クローン
まずは`git clone`します。  

```bash
# git clone https://github.com/mcguinness/saml-idp
Cloning into 'saml-idp'...
remote: Enumerating objects: 12, done.
remote: Counting objects: 100% (12/12), done.
remote: Compressing objects: 100% (9/9), done.
remote: Total 481 (delta 4), reused 6 (delta 3), pack-reused 469
Receiving objects: 100% (481/481), 1.43 MiB | 1.37 MiB/s, done.
Resolving deltas: 100% (249/249), done.
```

このようなファイル構成となっています。

```bash
# cd saml-idp/
# ll -rt
合計 112
-rw-r--r-- 1 root root  1448  7月  2 12:25 config.js
drwxr-xr-x 2 root root    20  7月  2 12:25 bin
-rw-r--r-- 1 root root 26281  7月  2 12:25 app.js
-rw-r--r-- 1 root root 10597  7月  2 12:25 README.md
-rw-r--r-- 1 root root  1082  7月  2 12:25 LICENSE
-rw-r--r-- 1 root root   473  7月  2 12:25 Dockerfile
drwxr-xr-x 4 root root    29  7月  2 12:25 public
-rw-r--r-- 1 root root   919  7月  2 12:25 package.json
-rw-r--r-- 1 root root 41871  7月  2 12:25 package-lock.json
drwxr-xr-x 2 root root    36  7月  2 12:25 lib
-rw-r--r-- 1 root root  1184  7月  2 12:25 idp-public-cert.pem
-rw-r--r-- 1 root root  1679  7月  2 12:25 idp-private-key.pem
-rw-r--r-- 1 root root    65  7月  2 12:25 docker-compose.yml
drwxr-xr-x 2 root root   101  7月  2 12:25 views
drwxr-xr-x 2 root root   108  7月  2 12:25 test
```

### 2.2 設定修正
必要に応じて`config.js`を修正します。
今回修正したのは以下の設定です。

|設定名|設定値|備考|
|---|---|---|
|userName|`test@example.com`|SP(Keycloak)上のユーザーを設定|
|nameIdFormat|`urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified`|SPのユーザー名とIdPのユーザー名でマッピングを行いたいので`Unspecified`とする|

```js
/**
 * User Profile
 */
var profile = {
  userName: 'test@example.com',
  nameIdFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified',
  firstName: 'Saml',
  lastName: 'Jackson',
  displayName: 'saml jackson',
  email: 'saml.jackson@example.com',
  mobilePhone: '+1-415-555-5141',
  groups: 'Simple IdP Users, West Coast Users, Cloud Users'
}
```

### 2.3 Dockerfileの修正、ビルド
Dockerfileを修正し、nodeのバージョンを固定並びに実行時引数を追加、修正します。

```Dockerfile
FROM node:12.18.1-buster

ADD ./package.json package.json
RUN npm install

EXPOSE 7000 7000

# ADD ./node_modules node_modules
ADD ./lib lib
ADD ./views views
ADD ./app.js app.js
ADD ./config.js config.js
ADD ./idp-public-cert.pem idp-public-cert.pem
ADD ./idp-private-key.pem idp-private-key.pem
ADD ./public public

ENTRYPOINT [ "node",  "app.js", "--acs", "https://<Keycloak-host>/auth/realms/main/broker/saml/endpoint", "--aud", "https://<Keycloak-host>/auth/realms/main", "--host", "0.0.0.0"]
```

ビルドします。

```bash
docker build . -t saml-idp
```

### 2.4 起動
docker-composeで起動します。  
デフォルトの設定では、コンテナ側のポートは7000を使用しています。

```yaml
  saml-idp:
    container_name: saml-idp
    image: saml-idp
    ports:
      - 7000:7000
    network_mode: bridge
    extra_hosts:
        - keycloak.path.local:192.168.2.13
        - hoka.nanika.areba.path.local:192.168.2.14
```

```bash
# docker-compose up -d saml-idp
```

以下のログが出力されていれば起動完了です。

```bash
# docker logs saml-idp
-- 省略 --
IdP server ready at
  http://f6cdd171af63:7000
```

### 2.5 リバースプロキシ作成
saml-idpにアクセスするためのリバースプロキシを設定します。  
以下はApacheの場合の設定例となります。

```
<VirtualHost _default_:443>
ServerName test.saml.local

RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-Port 443

SSLEngine on
SSLProxyEngine On
SSLCertificateFile /root/certs/server.crt
SSLCertificateKeyFile /root/certs/server.key

ProxyPreserveHost On

ProxyPass / http://localhost:7000/
ProxyPassReverse / http://localhost:7000/
</VirtualHost>
```

### 2.6 metadata(外部IdP)取得
以下のURLからmetadataを取得します。
`https://<saml-idp-host>/metadata`  

```bash
$ curl https://<saml-idp-host>/metadata > metadata.xml
```

これで、saml-idp側の準備は完了です。
なお、SP(Keycloak)のmetadataをsaml-idpにインポートする必要はありません。

### 2.7 外部IdPの定義作成(SP)
SP(Keycloak)側で外部IdP(saml-idp)の定義を作成します。
設定については、手順1.5と同様に`metadata.xml`をインポートして外部IdP定義を作成すればよいので省略します。

## ログインしてみる
ログイン先は、Keycloakのユーザー用管理コンソールとします。

`https://<Keycloak-host>/auth/realms/main/account`にアクセスし、画面右側の外部IdP用ログインリンクを選択します。
![](image-ki8rucf0.png)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531974/5b0a57da-3a94-6fbd-7293-5ce9e6db1d10.png)

外部IdPであるsaml-idpにリダイレクトするので、右下の`Sign in`を押してログインします。
![](image-ki8s42e7.png)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531974/d7026a49-8daa-5d2d-acdf-38f47e194cf5.png)

SPにログインが成功し、連携先アプリケーション(管理コンソール)にリダイレクトしました。
![](image-ki8s51k1.png)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531974/e7993dd3-e17b-69d5-f509-c745f9ac02f8.png)

アカウントリンクも問題ありません。
![](image-ki8s5mjm.png)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531974/2cb622ca-5aa7-c61a-2d5a-8d0c359687e6.png)

なお、`Subject NameID`を変更すると、別ユーザーとしてログインすることができます。
![](image-ki8s8f34.png)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531974/a5851731-23a1-c04c-9560-b5897cede1b8.png)

# 終わりに
SAMLのテスト用IdPは、今回紹介した二つに限らず、数多く存在します。
例えば、テスト用IdPの[オンラインツール](https://samltest.id/)が存在します。
自身のユースケースに沿ったテストツールを探してみると面白いかもしれません。