Keycloakのシングル・ログアウト(SLO)についてのまとめ

# 今日やること

以前に私が投稿したKeycloakの記事では、Keycloakアダプターやmod_auth_openidcを利用したアプリケーション保護の仕方について書きました。しかし、アプリケーション利用後のログアウトの仕組みについてはほとんど言及していなかったので、今回の投稿ではKeycloakのシングル・ログアウト(以降SLOと省略)の仕組みをまとめることにしました。
ここでいうSLOとは、OIDCで複数のRPにログインした状態で、Keycloakもしくは特定のRP起点のログアウト処理を行った際に、Keycloakを含め、ユーザーが利用していたすべてのRPからログアウトされる動作を指しています。

# KeycloakのSLOの仕組みについて

KeycloakのSLO処理は、大まかに以下のどちらかのパターンで処理されます。
主な違いはKeycloakのログアウト・エンドポイントにフロントチャネルからアクセスするか、バックチャネルからアクセスするかの違いです。
フロントチャネルとはブラウザを経由した通信のことで、バックチャネルとはブラウザを経由しないサーバー間の通信のことを指します。

## フロントチャネルSLOパターン
Keycloakのログアウト・エンドポイントにフロントチャネルからアクセスするパターンです。

1. ユーザーがRPのログアウト・エンドポイントにアクセスする(ここは省略可能な場合もある)
2. ユーザーがKeycloakのログアウト・エンドポイントにアクセス ＜＝ **★この呼び出しがフロントチャネル★**
    3. Keycloakが当該ユーザーのログイン済RPのリストを取得
    3. Keycloakが**ログイン済RP毎にRPのバックチャネル・ログアウトにアクセス**
4. ユーザーがログアウト完了後のURLに遷移

## バックチャネルSLOパターン
Keycloakのログアウト・エンドポイントにバックチャネルからアクセスするパターンです。

1. ユーザーがRPのログアウト・エンドポイント or ログアウト処理にアクセス
2. RPがKeycloakのログアウト・エンドポイントにアクセス ＜＝ **★この呼び出しがバックチャネル★**
    3. Keycloakが当該ユーザーのログイン済RPのリストを取得
    3. Keycloakが**ログイン済RP毎にRPのバックチャネル・ログアウトにアクセス**
4. ユーザーがログアウト完了後のURL or ログアウト完了画面に遷移

-----
SLOがどちらのパターンで動作するかは、利用するOIDCライブラリよって異なります。両者を見てわかるとおり、基本的には、**利用するRPのOIDCライブラリにバックチャネル・ログアウトがあれば、KeycloakでSLOが実現できます**。ただし、JavaScriptアダプターだけはバックチャネル・ログアウトがなくても、SLOができる別の機構が利用されているので、これは後半に補足します。

:information_source: OIDCのログアウトの仕様としては、

- [OpenID Connect Front-Channel Logout 1.0](https://openid.net/specs/openid-connect-frontchannel-1_0.html)
- [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html)

という2つが策定されておりますが、これらはまだドラフトの状態です。Keycloak 11においても、このログアウトの仕様が実装されたものにはなってはおらず、Keycloak独自のログアウト実装(RPに対しては独自のバックチャネル・ログアウトのみを利用)となっています。

以下に上記のOIDCログアウトに関連するissueもリンクしておきました。[KEYCLOAK-2940](https://issues.redhat.com/browse/KEYCLOAK-2940)をみると、バックチャネル・ログアウトに関しては、Keycloak 12以降からは仕様に準じたものに動作が変わるかもしれません。

- [KEYCLOAK-12133 OpenID Connect Logout](https://issues.redhat.com/browse/KEYCLOAK-12133)
    - [KEYCLOAK-2939 OpenID Connect Front-Channel Logout](https://issues.redhat.com/browse/KEYCLOAK-2939)[^frontchannel1]
    - [KEYCLOAK-2940 OpenID Connect Back-Channel Logout](https://issues.redhat.com/browse/KEYCLOAK-2940)


# OIDCライブラリごとのSLO対応有無

Keycloakのガイド上に記載がある以下の5つのOIDCライブラリでの、SLO動作を〇△×で記載しました。(△のものは条件付きとなります。後半で補足します)

| OIDCライブラリ | OIDCライブラリ自身の<br>フロントチャネル・ログアウトの有無 | OIDCライブラリ自身の<br>バックチャネル・ログアウトの有無 | SLO動作 | SLO処理パターン |
|:--|:-:|:-:|:-:|:-:|:--|
| Javaアダプター  | △  | 〇  | 〇  | バックチャネルSLO |
| JavaScriptアダプター  | ×  | ×  | 〇 | セッション・ステータスiframe(後述) |
| Node.jsアダプター  | 〇  | 〇  | 〇  | フロントチャネルSLO |
| Louketo Proxy<br>(旧Gatekeeper)[^gatekeeper]  | 〇  | ×  | △ | バックチャネルSLO<br>(もしくはフロントチャネルSLO) |
| mod_auth_openidc  | 〇  | ×  | △ | フロントチャネルSLO  |

OIDCライブラリ自身のバックチャネル・ログアウトがあるJavaアダプター、Node.jsアダプターは特に問題なく、KeycloakとのSLOが動作します。
JavaScriptアダプターにはバックチャネル・ログアウトはありませんが、別の仕組みがあるためSLO自体は可能です(これも後述します)。
一方、OIDCライブラリ自身のバックチャネル・ログアウトがないLouketo Proxy、mod_auth_openidcは、KeycloakとのSLO動作には制約があります。

# 動作確認したミドルウェアバージョン

2020年11月時点でリリースされている下記バージョンのミドルウェアで動作確認したので、念のため記載しておきます。

| ミドルウェア | バージョン |
|:--|:--|
| Keycloak  | 11.0.3 |
| Javaアダプター(Tomcatアダプター)  | 11.0.3 |
| JavaScriptアダプター  | 11.0.3 |
| Node.jsアダプター  | 11.0.3 |
| Louketo Proxy | 1.0.0 |
| mod_auth_openidc  | 2.4.4.1 |

# 各起点ごとのSLOシーケンスについて

まず、上記の5つのOIDCライブラリのRPにすべてログイン済の状態と仮定します。そこから、Keycloakもしくは、各RP(JavaScriptアダプターは除外)を起点とするログアウトを呼び出した場合どのようにSLO処理が動くかを具体的に説明していきます。

:warning: バックチャネル・ログアウトを利用するJavaアダプター／Node.jsアダプターでは、クライアント設定の「Admin URL」の設定が必須です。このURLは、Keycloakサーバー自身からアダプターに通信できる経路のURLを指定する必要があります。バックチャネル・ログアウトが正常に動作しない場合は、この設定がされているかどうか、通信が行えるURLになっているかどうかを確認ください。

参考： クライアント設定の「Admin URL」の例
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209129/19511bb4-0477-d97a-2fd7-8fb5df1296b5.png)



## Keycloak起点のSLO

Keycloak起点のフロントチャネル・ログアウトは、
`/auth/realms/{レルム}/protocol/openid-connect/logout?redirect_uri={ログアウト完了後の遷移先URL}`
で呼び出します。
この場合のSLOシーケンスは以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209129/b4146bea-2560-d8a9-aa2e-c74cc38a268e.png)

:information_source: この{ログアウト完了後の遷移先URL}で指定するURLは、Keycloak上のいずれかのクライアント設定の「Valid Redirect URIs」で許可されているURLでないと、Keycloakが「無効なリダイレクトURI」としてエラーを返します。これはオープンリダイレクトを抑止するための機構です。

### /k_logout について
シーケンス内の `/k_logout` とはなんぞやと思われるとおもいます。
これがKeycloak独自仕様のバックチャネル・ログアウトを行うためのURLです。実際は、`クライアント設定の「Admin URL」+ "k_logout"` というURLに対して、KeycloakからPOST送信が行われることになります。
このPOST送信のボディ部でJWTが送信されており、この中にアダプター側の特定のセッションをログアウトさせるために必要なアダプター側のセッションIDが含まれています。以下は送信されるJWTのペイロード部のサンプルです。

```:Javaアダプターの場合のJWTのペイロード部のサンプル
{
  "id": "edfd2bf0-1f2d-4875-a4b1-2752caa07ee1-1606363972255",
  "expiration": 1606364002,
  "resource": "kc-tomcat",
  "action": "LOGOUT",
  "adapterSessionIds": [
    "FC60BED115518DFB043EDDB77F0E0A8E"　＜＝＝ ★これがアダプター側のセッションID★
  ],
  "notBefore": 0,
  "keycloakSessionIds": [
    "ac04ef9d-7793-481c-a5c7-5750560e3c14"
  ]
}
```
このバックチャネル・ログアウトの仕組みは、あくまでKeycloak独自のものであるため、Keycloakコミュニティが開発しているJavaアダプターやNode.jsアダプターでしか使えません。Keycloakコミュニティで開発されていたLouketo Proxy(旧Gatekeeper)[^gatekeeper]で使えないのはちょっと残念ですね。

:information_source: Keycloak独自のバックチャネル通信としては、ログアウト以外にも以下のようなパスがあります。詳細はここでは割愛しますが、これらもKeycloak独自のものなので、JavaアダプターやNode.jsアダプターでしか使えません。

- /k_push_not_before
- /k_test_available
- /k_query_bearer_token
- /k_jwks

### このパターンでのSLO動作結果
| OIDCライブラリ | SLO動作 | 理由 |
|:--|:-:|:--|
| Javaアダプター  |  〇  | バックチャネル・ログアウトできるため |
| JavaScriptアダプター  | 〇  | ブラウザ側のKeycloakセッションCookieが消えるため | 
| Node.jsアダプター  | 〇  | バックチャネル・ログアウトできるため |
| Louketo Proxy | × |バックチャネル・ログアウトできないため |
| mod_auth_openidc  | × |バックチャネル・ログアウトできないため |


## Javaアダプター起点のSLO

Javaアダプターの場合、アダプター自身のフロントチャネル・ログアウトを行うエンドポイントがありません。
そのため、ガイドに記載のとおりに、Keycloak起点のログアウトを呼び出すか、独自のフロントチャネル・ログアウトの実装を行い、その中で、HttpServletRequest#logoutを呼び出すようにします。詳細は割愛しますが、このHttpServletRequest#logoutの要求は、最終的にJavaアダプター側のログアウト処理に委譲され、Keycloakのバックチャネル・ログアウトが行われます(Javaアダプターの種類によっても経由するクラスに差異はあります)。
HttpServletRequest#logoutを呼んだ場合のSLOシーケンスは以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209129/06cd4d68-aa58-7e06-6087-f143460b7b55.png)


### このパターンでのSLO動作結果
| OIDCライブラリ | SLO動作 | 理由 |
|:--|:-:|:--|
| Javaアダプター  |  〇  | バックチャネル・ログアウトできるため |
| JavaScriptアダプター  | 〇 | サーバー側のKeycloakセッションが消えるため | 
| Node.jsアダプター  | 〇  | バックチャネル・ログアウトできるため |
| Louketo Proxy | × |バックチャネル・ログアウトできないため |
| mod_auth_openidc  | × |バックチャネル・ログアウトできないため |


## JavaScriptアダプターのログアウトについて

JavaScriptアダプターのログアウト検知は少し特殊です。
JavaScriptアダプターには、アダプター自身のフロントチャネル／バックチャネル・ログアウトのどちらも存在しません。
JavaScriptアダプターではOIDCが成功すると、ブラウザ側のKeycloakセッションCookieを5秒間隔でチェックするためのセッション・ステータスiframe(/auth/realms/{レルム}/protocol/openid-connect/login-status-iframe.html)が自動的に生成されます。このiframeは通信を特に行っていませんが、JavaScriptでブラウザ側のKeycloakセッションCookieを一定間隔でチェックします。
そのため、いずれかを起点としたSLO処理により、ブラウザ側のKeycloakセッションCookieが削除された場合、JavaScriptアダプター側もそれを検知してログアウトされ、SLOが実現されるという動きになります。
JavaScriptアダプターでログアウト検知する際のシーケンスは以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209129/391a4ebc-efea-3592-fa20-8f7dd24d4a12.png)

:information_source: JavaScriptアダプターでは、RPとKeycloak間でCORSを利用したクロスドメイン間通信を行っているため、クライアント設定の「Web Origins」に自ドメインFQDNを正しく設定しておく必要があります。この設定がないと、OIDCによるトークンの取得や、セッション・ステータスiframeによるチェックが正しく動作しません。

参考： クライアント設定の「Web Origins」の例
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209129/37311a86-3998-acaf-ae7b-901c5c46f751.png)


:warning: SLO処理パターンがバックチャネルSLO(Javaアダプター／Louketo Proxy起点のSLO)の場合、ブラウザ側のKeycloakセッションCookieはその時点では削除されないため、アプリのつくり方によってはログアウト検知ができない／遅れる可能性があります。(ページ自体がロードされれば、JavaScriptアダプターは毎回OIDCを実施するため、その時点でログアウトは検知されます)

## Node.jsアダプター起点のSLO

Node.jsアダプターの場合、アダプター自身のフロントチャネル・ログアウトは、
`/logout?redirect_url={ログアウト完了後の遷移先URL}`
で呼び出します。
この場合のSLOシーケンスは以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209129/14bab847-25c3-c904-bd23-e5623b6d3424.png)

:information_source: この{ログアウト完了後の遷移先URL}は、Keycloakのフロントチャネル・ログアウトの際に、Keycloak上のいずれかのクライアント設定で「Valid Redirect URIs」として許可されたURLでないと、Keycloakが「無効なリダイレクトURI」としてエラーを返します。これはオープンリダイレクトを抑止するための機構です。

### このパターンでのSLO動作結果
| OIDCライブラリ | SLO動作 | 理由 |
|:--|:-:|:--|
| Javaアダプター  |  〇  | バックチャネル・ログアウトできるため |
| JavaScriptアダプター  | 〇 | ブラウザ側のKeycloakセッションCookie消えるため | 
| Node.jsアダプター  | 〇  | バックチャネル・ログアウトできるため |
| Louketo Proxy | × |バックチャネル・ログアウトできないため |
| mod_auth_openidc  | × |バックチャネル・ログアウトできないため |

## Louketo Proxy起点のSLO

Louketo Proxyの場合、ライブラリ自身のフロントチャネル・ログアウトは、
`/oauth/logout?redirect={ログアウト完了後の遷移先URL}`
で呼び出します。
この場合のSLOシーケンスは以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209129/d6908643-fe2b-8cc0-7e05-ff700f93e2d9.png)

:information_source: Keycloakのガイド上には記載はないですが、`enable-logout-redirect: true` という設定を利用すれば、Keycloakのフロントチャネル・ログアウト経由の処理に変更できます。`enable-logout-redirect`のデフォルトはfalseなので、ここではKeycloakのバックチャネル・ログアウト経由の動作を書いています。

:warning: Louket Proxyをデフォルトのバックチャネル・ログアウトのまま動かした場合、{ログアウト完了後の遷移先URL}に、自身のドメイン以外の自由なURLを設定できてしまうため、この動作は問題がありそうです。上述のKeycloakのフロントチャネル・ログアウト経由の設定にすれば、Keycloak側でリダイレクトURLのチェックがかかるので、この設定に変更して動かすことを推奨します。

### このパターンでのSLO動作結果
| OIDCライブラリ | SLO動作 | 理由 |
|:--|:-:|:--|
| Javaアダプター  |  〇  | バックチャネル・ログアウトできるため |
| JavaScriptアダプター  | 〇 | サーバー側のKeycloakセッションが消えるため | 
| Node.jsアダプター  | 〇  | バックチャネル・ログアウトできるため |
| Louketo Proxy | 〇 | 自身のフロントチャネル・ログアウトを呼び出しているため |
| mod_auth_openidc  | × |バックチャネル・ログアウトできないため |

## mod_auth_openidc起点のSLO

mod_auth_openidcの場合、ライブラリ自身のフロントチャネル・ログアウトは、
`{リダイレクトURI}?logout={ログアウト完了後の遷移先URL}`
で呼び出します。
この場合のSLOシーケンスは以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209129/01ffa1f7-4cc7-cb06-0fdc-3bd02711b6e6.png)

:information_source: この{ログアウト完了後の遷移先URL}に、自身のドメイン以外のURLを指定しようとすると、mod_auth_openidcがエラーを返します。これはオープンリダイレクトを抑止するための機構です。


### このパターンでのSLO動作結果
| OIDCライブラリ | SLO動作 | 理由 |
|:--|:-:|:--|
| Javaアダプター  |  〇  | バックチャネル・ログアウトできるため |
| JavaScriptアダプター  | 〇 | ブラウザ側のKeycloakセッションCookieが消えるため | 
| Node.jsアダプター  | 〇  | バックチャネル・ログアウトできるため |
| Louketo Proxy | × | バックチャネル・ログアウトできないため |
| mod_auth_openidc  | 〇 |自身のフロントチャネル・ログアウトを呼び出しているため |


# まとめ

以上、各起点ごとのSLO動作結果をまとめると、最終的に以下のようになります。

### SLO動作結果まとめ
| OIDCライブラリ＼SLO起点 | Keycloak | Javaアダプター | Node.jsアダプター | Louketo Proxy | mod_auth_openidc |
|:--|:-:|:-:|:-:|:-:|:-:|
| Javaアダプター  | 〇 | 〇 | 〇 | 〇 | 〇 | 
| JavaScriptアダプター | 〇 | 〇 | 〇 | 〇 | 〇 | 
| Node.jsアダプター  | 〇 | 〇 | 〇 | 〇 | 〇 | 
| Louketo Proxy | × | × | × | 〇 | × | 
| mod_auth_openidc  | × | × | × | × | 〇 |

表をみてわかるとおり、Javaアダプター、JavaScriptアダプター、Node.jsアダプターだけを利用している場合であれば、完全なSLOが実現可能です。ですが、リバースプロキシタイプであるLouketo Proxyおよび、mod_auth_openidcに関しては、自身が起点になる以外では、SLOが実現できません。今後、Keycloakで、OIDCフロントチャネル・ログアウトに対応するようになれば、上記2つもログアウトができるようになるかもしれません[^frontchannel2]。現状では、以下のような設定にすることにより、SLOに近い動きにはなります（ただし完全ではありません)。

- Louketo Proxy
    - このクライアントに発行されるアクセストークンの有効期限を可能な限り短くする(最小は1分)
- mod_auth_openidc
    - このクライアントに発行されるアクセストークンの有効期限を可能な限り短くする(最小は1分)
    - `OIDCRefreshAccessTokenBeforeExpiry` に `logout_on_error` のオプション(アクセストークンのリフレッシュに失敗したらエラーとする)を付ける

Keycloakと複数のOIDCライブラリを組み合わせてシステムを構成する場合には、これらのSLO動作についても理解していた方がよいでしょう。


# 参考資料
- [Javaアダプターのログアウト](https://keycloak-documentation.openstandia.jp/11.0.0/ja_JP/securing_apps/index.html#%E3%83%AD%E3%82%B0%E3%82%A2%E3%82%A6%E3%83%88)
- [JavaScriptアダプターのセッション・ステータスiframe](https://keycloak-documentation.openstandia.jp/11.0.0/ja_JP/securing_apps/index.html#%E3%82%BB%E3%83%83%E3%82%B7%E3%83%A7%E3%83%B3-%E3%82%B9%E3%83%86%E3%83%BC%E3%82%BF%E3%82%B9iframe)
- [Node.jsアダプターのログアウト](https://keycloak-documentation.openstandia.jp/11.0.0/ja_JP/securing_apps/index.html#%E8%BF%BD%E5%8A%A0%E3%81%AEurl)
- [Louketo Proxyのログアウト](https://keycloak-documentation.openstandia.jp/11.0.0/ja_JP/securing_apps/index.html#%E3%83%AD%E3%82%B0%E3%82%A2%E3%82%A6%E3%83%88-%E3%82%A8%E3%83%B3%E3%83%89%E3%83%9D%E3%82%A4%E3%83%B3%E3%83%88)
- [mod_auth_openidcのログアウト](https://github.com/zmartzone/mod_auth_openidc/wiki#9-how-do-i-logout-users)

[^gatekeeper]: 残念ながら、Louketo Proxy(旧Gatekeeper)は今後の開発は終了となったため、[OAuth2 Proxy](https://github.com/oauth2-proxy/oauth2-proxy)に移行することが推奨されています。(参考: https://github.com/louketo/louketo-proxy/issues/683)
[^frontchannel1]: iframeを利用したフロントチャネル・ログアウトを使うような場合には、RP側のセッションCookieのSameSite属性の影響を受けるので注意が必要です。Chrome 80以降ではSameSite属性のデフォルト値がLAXに変更となり、iframe経由では別ドメインにCookieの送信が行われなくなったためです。
[^frontchannel2]: KeycloakからのOIDCフロントチャネル・ログアウト対応については、2016年ごろから検討はされているようですが、実装されるまではまだ時間がかかりそうですね(参考: https://issues.redhat.com/browse/KEYCLOAK-2939)