Keycloakのサポート対応事例
Javaアダプターでトークン更新処理が競合してエラーが発生する問題を解消
KeycloakのJavaアダプターには、アクセストークンの有効期限が切れる前(もしくは切れた後)に内部処理でリフレッシュトークンを利用してトークン更新を行う機能があります。
ある顧客環境ではセキュリティの要件により、Keycloakでリフレッシュトークンの再利用不可(Revoke Refresh Token: ON、Refresh Token Max Reuse: 0)に設定していました。この設定の場合、発行されたリフレッシュトークンは1回だけ利用可能で、同じ値の再利用は不可となります。このような環境でJavaアダプターのトークン更新処理が競合してしまい、リフレッシュトークンの再利用不可のエラーが発生するという問い合わせがあり、調査を行いました。
再現確認および調査
顧客環境では、Keycloak 6.0.1(およびJavaサーブレットフィルターアダプター)が使われていました。まずは、その当時の最新版であるKeycloak 9.0.0でも再現するかどうか、シンプルなSPAを作成し再現確認を行います。
Keycloakでは下記のようにリフレッシュトークンの再利用不可の設定および、アクセストークンの有効期限を最小(1分)に設定します。

Javaサーブレットフィルターアダプターの適用されているSPAでは、Ajaxにより指定した回数のリクエストを同時に投げられるボタンをいくか用意しておき、非同期で複数のリクエストが行われる状況を再現できるようにしておきます(カッコ内の数字が同時に投げるリクエスト数を表しています)。

問題発生時のcatalina.out
下記はこのアプリケーションにログインしてから、数分経過後(アクセストークンの有効期限切れの後)に "ajaxPosting(3)"ボタンを押下した場合のcatalina.outログの一部抜粋です。
03-Mar-2020 20:15:35.743 DEBUG [http-nio-8080-exec-1] org.keycloak.adapters.RefreshableKeycloakSecurityContext.refreshExpiredToken Token Verification succeeded!
03-Mar-2020 20:15:35.794 ERROR [http-nio-8080-exec-10] org.keycloak.adapters.RefreshableKeycloakSecurityContext.refreshExpiredToken Refresh token failure status: 400 {"error":"invalid_grant","error_description":"Maximum allowed refresh token reuse exceeded"}
03-Mar-2020 20:15:35.957 ERROR [http-nio-8080-exec-2] org.keycloak.adapters.RefreshableKeycloakSecurityContext.refreshExpiredToken Refresh token failure status: 400 {"error":"invalid_grant","error_description":"Maximum allowed refresh token reuse exceeded"}
最初のリクエストに対応するスレッド(http-nio-8080-exec-1)では、リフレッシュトークンは成功しますが、後続の2リクエストに対応するスレッド(http-nio-8080-exec-10、http-nio-8080-exec-2)でもトークン更新処理が実行されてしまい、リフレッシュトークンの再利用不可のエラー("Maximum allowed refresh token reuse exceeded")が発生していることが分かります。
そこで、Javaサーブレットフィルターアダプター側のトークン更新箇所のソースコード調査を進めたところ、下記の「★★★」の箇所のメソッド呼び出しに排他制御がかかっておらず、同タイミングで複数のリクエストがあると、同じリフレッシュトークンでトークン更新処理を複数のスレッドで競合して呼び出してしまうことが分かりました。
OIDCFilterSessionStore#checkCurrentToken メソッド内
@Override
public void checkCurrentToken() {
HttpSession httpSession = request.getSession(false);
if (httpSession == null) return;
SerializableKeycloakAccount account = (SerializableKeycloakAccount)httpSession.getAttribute(KeycloakAccount.class.getName());
if (account == null) {
return;
}
RefreshableKeycloakSecurityContext session = account.getKeycloakSecurityContext();
if (session == null) return;
// just in case session got serialized
if (session.getDeployment() == null) session.setCurrentRequestInfo(deployment, this);
if (session.isActive() && !session.getDeployment().isAlwaysRefreshToken()) return;
// FYI: A refresh requires same scope, so same roles will be set. Otherwise, refresh will fail and token will
// not be updated
boolean success = session.refreshExpiredToken(false); <=== ★★★
if (success && session.isActive()) return;
// Refresh failed, so user is already logged out from keycloak. Cleanup and expire our session
//log.fine("Cleanup and expire session " + httpSession.getId() + " after failed refresh");
cleanSession(httpSession);
httpSession.invalidate();
}
暫定的にこのメソッド内のsessionインスタンスの利用される範囲をsynchronizedブロックで排他制御を加えたところ、問題が再現しなくなることが確認できました。つまり、Javaアダプターのリフレッシュトークン処理ではスレッドの排他制御に関して、潜在的なバグがあることが判明しました。
まとめ
上記のような調査結果から、以下の3つの条件を満たした場合に、問題の事象が再現することが確認できました。
- Keycloak 10.0.0より前のJavaアダプターを利用
- Keycloakのセキュリティ設定で、リフレッシュトークンの再利用不可(Revoke Refresh Token: ON"、Refresh Token Max Reuse: 0)の設定を利用
- アクセストークンの有効期限が切れた状態で、アプリケーションのAjaxなどから、Javaアダプターに同時に複数のリクエストが発生する
直接の原因は、Javaアダプター内のトークン更新処理に排他制御がかかっていなことに起因したものでした。当該問題に関しては、Keycloakのコミュニティに報告を行い、バグとして修正されました。 実際のやりとりは以下の通りです。
- KEYCLOAK-13187 The problem that refresh token conflicts when "Revoke Refresh Token" is ON
- [KEYCLOAK-13187] - Concurrency issue when refreshing tokens and updating security context state #6881
Keycloak 10.0.0以上でこの問題は修正されています。