トークン失効 — introspection、revocation、拒否リスト
目標
Keycloakが発行したトークンをRFC 7009で取り消し、RFC 7662のintrospectionでその結果を確認したうえで、リソースサーバーでローカル検証・introspection・jti拒否リストの3方式が、取り消されたトークンにどう反応するかを、実際のリクエストで証明します。
なぜ重要なのか
署名されたJWTはリソースサーバーが単独で検証するので、発行者でセッションを終わらせても、APIはその事実を知らず、トークンは期限切れまで通ります。RFC 7009も、自己完結型トークンの即時の取り消しには別途の連携が必要で、代替は短い寿命だと書いています。そのため設計者は3つの中から選びます。毎回発行者に尋ねるintrospection(即時、その代わり往復と可用性の結合)、リソースサーバーの拒否リスト(即時、その代わり状態を持つストア)、短いTTL(単純、その代わり最大寿命の分だけ遅れる)です。このラボは、3方式を1つのサーバーに並べ、取り消されたトークン1つがそれぞれで200になるか401になるか、そしてその代償が何ミリ秒かを、自分で測らせます。
ステップ
- Keycloakが停止していれば
lab-start-keycloakで起動し、/root/st/revoke/wait.shでhttp://127.0.0.1:8080/realms/labhubが200になるまで待ってください(最大240秒)。かかった時間を/root/st/revoke/ready.txtにready_seconds=<정수>の形で(プレースホルダーは整数です)書いてください。 /opt/fixtures/kc/realm-info.envをsourceして、パブリッククライアントweb-appのパスワードグラントでユーザーdev1のトークンを受け取り、レスポンス本文のJSON全体を/root/st/revoke/token.jsonに保存してください。/root/st/revoke/introspect.sh <토큰>(プレースホルダーはトークンです)が、introspectionエンドポイントをapi-svcの資格で呼び、レスポンスのJSONをそのまま出力するようにしてください。秘密はenvから読みます。新しいトークンはactive=true、偽物はactive=falseです。/root/st/revoke/revoke.sh <refresh 토큰>(プレースホルダーはrefreshトークンです)が、RFC 7009の取り消しエンドポイントにclient_id=web-app、token、token_type_hint=refresh_tokenを送るようにし、取り消しの前後の結果を/root/st/revoke/revoke.txtにbefore=true、after=false、refresh_after=400と書いてください。/root/st/revoke/api.pyを127.0.0.1:8307で起動してください(/health、JWKSだけで検証する/local/me、リクエストごとにintrospectionする/introspect/me)。/root/st/revoke/bench.pyで2つの経路を10回以上測り、/root/st/revoke/latency.txtにn、local_ms、introspect_ms、access_ttl、max_exposure_sを書いてください。api.pyにGET /guarded/me(拒否リストrevoked:jti:<jti>を確認)とPOST /logout(jtiを残りの寿命の分だけ拒否リストに載せ、refreshをKeycloakで取り消す)を追加して、もう一度起動してください。/root/st/revoke/e2e.shで全体の流れを試し、/root/st/revoke/e2e.outに7行を残してください。
参考
- レルムのURL・クライアント・ユーザー・秘密は、
lab-envまたはcat /opt/fixtures/kc/realm-info.envで見られます。スクリプトには値を書かず、sourceしてください。 - 取り消しエンドポイントは、無効なトークンにも200を返します。効いたかどうかは、introspectionで確認します。
- よくある失敗1: アクセストークンだけを取り消すケースです。このKeycloakではrefreshが生きていて、新しいアクセストークンが出続けます。ログアウトではrefreshを取り消す必要があります。
- よくある失敗2: 拒否リストの項目にTTLを付けないケースです。期限切れのトークンまで永遠に溜まります。残りの寿命の分だけ覚えておけば足ります。
Keycloakを起動し、レルムの準備を待つ
Keycloakが停止していればlab-start-keycloakで起動し、/root/st/revoke/wait.shでhttp://127.0.0.1:8080/realms/labhubが200になるまで待ってください(最大240秒)。かかった時間を/root/st/revoke/ready.txtにready_seconds=<정수>の形で(プレースホルダーは整数です)書いてください。
KeycloakはJVMなので、ポートが開いた後もレルムの読み込みまで時間がかかります。固定のsleepではなく、whileループでステータスコードをポーリングしてください。起動しているかはlab-statusで見られます。
web-appでdev1のトークンの組を受け取る
/opt/fixtures/kc/realm-info.envをsourceして、パブリッククライアントweb-appのパスワードグラント(grant_type=password、ユーザーdev1)でトークンを受け取り、レスポンス本文のJSON全体を/root/st/revoke/token.jsonに保存してください。access_token・refresh_token・expires_inが含まれている必要があります。
トークンエンドポイントは、envのKC_TOKEN_URLです。パブリッククライアントは秘密なしでclient_idだけを送ります。パスワードに特殊文字があるので、--data-urlencodeを使ってください。
introspectionでactiveを確認する
/root/st/revoke/introspect.sh <토큰>(プレースホルダーはトークンです)が、RFC 7662のintrospectionエンドポイント(/realms/labhub/protocol/openid-connect/token/introspect)を機密クライアントapi-svcの資格で呼び、レスポンスのJSONをそのまま出力するようにしてください。秘密はファイルに書かず、realm-info.envから読みます。新しいトークンはactive=true、偽の文字列はactive=falseになる必要があります。
introspectionは呼び出し元の認証が必須です(トークンのスキャン防止)。curl -uでBasic認証を渡し、tokenをformでPOSTします。無効なトークンには、active以外の情報が何も来ないのが正常です。
RFC 7009でrefreshトークンを取り消す
/root/st/revoke/revoke.sh <refresh 토큰>(プレースホルダーはrefreshトークンです)が、RFC 7009の取り消しエンドポイント(/realms/labhub/protocol/openid-connect/revoke)にclient_id=web-app、token、token_type_hint=refresh_tokenを送るようにしてください。新しいトークンの組で、取り消しの前後のaccessトークンのintrospectionと、取り消し後のrefreshグラントの結果を、/root/st/revoke/revoke.txtにbefore=true、after=false、refresh_after=400と書いてください。
取り消しエンドポイントは、成功でも無効なトークンでも200を返すので、レスポンスコードだけでは効いたかどうかわかりません。結果はintrospectionで確認してください。パブリッククライアントでも、client_idを抜かすと拒否されます。
ローカル検証とintrospectionの比較: 取り消しとレイテンシ
/root/st/revoke/api.pyを127.0.0.1:8307で起動してください。GET /healthは200、GET /local/meはBearerトークンをJWKSだけで検証(署名・exp・iss・aud=lab-api)し、GET /introspect/meはリクエストごとにintrospectionのactiveで判定します(どちらも成功は200、失敗は401)。そして/root/st/revoke/bench.pyで2つの経路を同じトークンでN回以上(10以上)測り、/root/st/revoke/latency.txtにn=、local_ms=、introspect_ms=、access_ttl=(トークンのexp-iat)、max_exposure_s=(ローカル検証だけのとき、取り消されたトークンが生きられる最大秒数)を書いてください。
PyJWTのPyJWKClientがJWKSを取得してキャッシュします。ローカルの経路は発行者に尋ねないので、取り消されたトークンも期限切れまで200が出ます。このステップではそれが正しい結果で、その期間の長さがそのままトークンの寿命です。introspectionの結果をキャッシュすると、取り消しの反映が遅れます。
jti拒否リストとログアウト
/root/st/revoke/api.pyに、GET /guarded/me(ローカル検証の後、Redisのキーrevoked:jti:<jti>があれば401)と、POST /logout(Bearerトークンのjtiをrevoked:jti:<jti>として、トークンの残りの寿命の分だけTTLを付けて保存し、form本文のrefresh_tokenをRFC 7009でKeycloakから取り消してから200を返す)を追加し、もう一度起動してください。ログアウトの後、同じトークンで/guarded/meが401になる必要があります。
TTLは、expから現在時刻を引いた値です。期限切れのトークンは署名検証でどのみち拒否されるので、その後は覚えておく理由がありません。拒否リストだけではKeycloak側のセッションが生きていて、refreshで新しいトークンを受け取れてしまうので、取り消しのリクエストも一緒に送ってください。コードを直したら、サーバーを停止してからもう一度起動する必要があります。
取り消しの戦略を1つの流れで証明する
/root/st/revoke/e2e.shで、新しいトークンを受け取って、introspection→/guarded/me→POST /logout→/guarded/me・/local/me→introspection→refreshグラント、の順に試し、/root/st/revoke/e2e.outにintrospect_before=true、guarded_before=200、logout=200、guarded_after=401、local_after=200、introspect_after=false、refresh_after=400を1行ずつ残してください。
前のステップのintrospect.shとサーバーをそのまま使います。採点ツールは、ファイルだけを見るのではなく、e2e.shをもう一度新しく実行して、同じ7行が出るかを見ます。local_afterが200なのは、短いTTLが埋める期間です。