client credentials とトークン交換(STS を自分で立てる)
目標
Keycloakでclient credentialsによりサービストークンを受け取って確認し、Keycloak 26.0.7にない標準のトークン交換(RFC 8693)をローカルのSTSで自分で立てて、ユーザートークンを下流専用の委任トークンに交換したうえで、下流がaudとactを検査するか、偽造トークンが拒否されるかを、実際のリクエストで証明します。
なぜ重要なのか
サービスがユーザーの代わりに別のサービスを呼ぶとき、受け取ったユーザートークンをそのまま渡すと、下流が受け付けるにはaudの検査を切る必要があり、誰が代わりに呼んだのかもわからず、権限も減りません。1か所から漏れたトークンが、チェーン全体の鍵になります。サービス自身の名前のトークン(client credentials)だけを使うと、今度は誰のための要求なのかが失われます。トークン交換は、ユーザーの身元をつなぎつつ、audienceを下流に絞り、actに行為者を残して、この2つを同時に解決します。このときSTSがsubject_tokenを検証しないと、偽造トークンを自分の署名でロンダリングする装置になり、下流がaudとactを見なければ、交換をした意味がありません。Keycloakは26.2から標準の交換を正式にサポートするので、バージョンが足りないとき何を自分でやる必要があるかも、このラボで見ます。
ステップ
- Keycloakが停止していれば
lab-start-keycloakで起動し、/root/st/exchange/wait.shでhttp://127.0.0.1:8080/realms/labhubが200になるまで最大240秒待ってから、/root/st/exchange/ready.txtにready_seconds=<정수>(プレースホルダーは整数です)を書いてください。 - 機密クライアント
api-svcでclient credentialsのトークンを受け取って/root/st/exchange/svc_token.txtに保存し、署名を検証してから/root/st/exchange/svc_claims.txtにazp=、aud=、username=、roles=、refresh_token=を書いてください。 - Keycloakにtoken-exchangeグラントでリクエストした結果を
/root/st/exchange/kc_exchange.txtにkc_exchange_error=、token_exchange_advertised=の形で書き、ローカルSTSの/root/st/exchange/sts.pyを127.0.0.1:8308で起動してください(/health、/jwks、署名鍵/root/st/exchange/sts_key.pem)。 - dev1のトークンをsubject_token、api-svcのトークンをactor_tokenとして、
audience=orders-apiの交換を受け、/root/st/exchange/exchanged_token.txtに置いて、検証した値を/root/st/exchange/exchanged.txtに書いてください。ヘルパーは/root/st/exchange/tokens.shとして作ります。 - 下流サービスの
/root/st/exchange/downstream.pyを127.0.0.1:8309で起動し、4種類のトークンでGET /ordersを呼んだステータスコードを/root/st/exchange/downstream.txtに書いてください。 - 偽造・改ざん・署名なしのsubject_token、actor_tokenの欠落、知らないaudience、正常な対照群をSTSに送り、
/root/st/exchange/reject.txtに書いてください。 /root/st/exchange/e2e.shで全体の流れを試し、/root/st/exchange/e2e.outに7行を残してください。
参考
- レルムの情報(トークンURL、クライアント、ユーザー、秘密)は、
cat /opt/fixtures/kc/realm-info.envで見られます。秘密をファイルやコードに書き写さないでください。 - トークン種別の識別子は
urn:ietf:params:oauth:token-type:access_token、グラントはurn:ietf:params:oauth:grant-type:token-exchangeです。 - このイメージにはpython-multipartがありません。STSは
http.serverで組み、formはurllib.parse.parse_qsで読みます。 - よくある失敗1: STSが
jwt.decode(..., options={"verify_signature": False})でsubject_tokenを読むだけにするケースです。ステップ6の偽造トークンがすべて通ります。 - よくある失敗2: 下流が署名だけを見て、audとactを見ないケースです。Keycloakの元のトークンや、別の下流向けのトークンまで受け付けてしまいます。
Keycloakの準備ができるまで待つ
Keycloakが停止していればlab-start-keycloakで起動し、/root/st/exchange/wait.shでhttp://127.0.0.1:8080/realms/labhubが200になるまで最大240秒待ってから、かかった時間を/root/st/exchange/ready.txtにready_seconds=<정수>の形で(プレースホルダーは整数です)書いてください。
KeycloakはJVMなので、40–90秒かかります。固定のsleepではなく、ステータスコードをポーリングするループを使ってください。lab-statusで何が起動しているかを見られます。
client credentialsでサービストークンを受け取る
機密クライアントapi-svcでgrant_type=client_credentialsのトークンを受け取って/root/st/exchange/svc_token.txtの1行目に保存し、レルムのJWKSで署名を検証してから、/root/st/exchange/svc_claims.txtにazp=、aud=(カンマでつなぐ)、username=、roles=、refresh_token=present|absentを書いてください。クライアントの秘密は、/opt/fixtures/kc/realm-info.envのKC_CONFIDENTIAL_SECRETから読み、ファイルに書き写さないでください。
client credentialsは、ユーザーなしでサービス自身の名前で受け取るトークンです(RFC 6749 4.4)。トークンエンドポイントは、realm-info.envのKC_TOKEN_URLです。誰のトークンかは、azpとpreferred_usernameが示します。レスポンスのJSONにrefresh_tokenがあったかも確認してください。
Keycloakの限界を確認し、ローカルSTSを起動する
Keycloakにgrant_type=urn:ietf:params:oauth:grant-type:token-exchangeでリクエストして返ってきたerrorの値をkc_exchange_error=、discoveryドキュメントのgrant_types_supportedにそのグラントがあるかをtoken_exchange_advertised=yes|noとして、/root/st/exchange/kc_exchange.txtに書いてください。その後、ローカルSTSの/root/st/exchange/sts.pyを127.0.0.1:8308で起動します。GET /healthは{"ok":true}、GET /jwksはkid付きのRSA公開鍵を返し、署名用の秘密鍵は/root/st/exchange/sts_key.pemに置きます。POST /tokenはRFC 8693の交換を処理します(ステップ4・6で採点)。
このPodのKeycloakは26.0.7で、標準のトークン交換(V2)は26.2からです。discoveryのアドレスは、発行者の後ろに/.well-known/openid-configurationを付けたものです。STSはsubject_tokenをKeycloakのJWKSで検証する必要があり(PyJWTのPyJWKClient)、form本文はparse_qsで読みます。
ユーザートークンをorders-api専用のトークンに交換する
dev1のユーザートークン(web-app、passwordグラント)をsubject_token、api-svcのclient credentialsトークンをactor_tokenとして、STSにPOST /token(audience=orders-api)して、交換トークンを受け取ってください。新しいトークンは、元のユーザーのsubを引き継ぎ、actに行為者のsub・client_idを入れ、rolesは元のユーザーのロールのうちその下流に必要なものだけを入れ、寿命は元のトークン(900秒)より短い必要があります。トークンを/root/st/exchange/exchanged_token.txtに置き、STSの公開鍵で検証して、/root/st/exchange/exchanged.txtにissued_token_type=、iss=、aud=、sub_matches=yes|no、act_client_id=、roles=、ttl=を書いてください。発行・交換のヘルパーは/root/st/exchange/tokens.shとして作り、以降のステップで使います。
RFC 8693では、subject_tokenとsubject_token_typeは必須で、レスポンスには必ずissued_token_typeが必要です。トークン種別の識別子は、urn:ietf:params:oauth:token-type:access_tokenです。curlは--data-urlencodeで値を渡してください。
下流にaudとactを検査させる
下流サービスの/root/st/exchange/downstream.pyを127.0.0.1:8309で起動してください。GET /ordersは、STSが署名していてaud=orders-apiで、act.client_idがapi-svcのトークンにだけ200と{"user":..., "actor":...}を返し、残りはすべて401を返します。4つのリクエストのステータスコードを、/root/st/exchange/downstream.txtにexchanged=(正常な交換トークン)、keycloak_direct=(Keycloakの元のユーザートークン)、other_aud=(billing-api向けの交換トークン)、no_act=(STSの鍵で署名したがactがないトークン)の形で書いてください。
PyJWTのjwt.decodeにaudienceとissuerを渡すと、audとissの検査も一緒に行います。actはPyJWTが知らないクレームなので、自分で確認する必要があります。RFC 8693は、アクセス制御には最も外側のactだけを使うよう述べています。
偽造subject_tokenと知らないaudienceを拒否する
STSが、検証に失敗するリクエストを拒否するかを試して、/root/st/exchange/reject.txtに書いてください。tampered=(署名はそのままで、subだけを変えたトークン)、forged_key=(別のRSA鍵で署名し、kidだけKeycloakのものに似せたトークン)、alg_none=(署名なしのトークン)、no_actor=(actor_tokenなし)にはステータスコードを、bad_audience=にはaudience=admin-apiのときのerrorの値を、control=には正常なリクエストのステータスコードを書きます。
RFC 8693は、subject_tokenが無効ならinvalid_request、要求されたaudienceでは発行できないならinvalid_targetを使うよう述べています。署名検証なしでデコードだけを行うSTSや、algorithmsを固定していないSTSが、ここで露呈します。採点ツールは、レルムの鍵で署名された期限切れのトークンや、audが違うトークンも送ります。
全体の流れを一度に証明する
/root/st/exchange/e2e.shで全体の流れを一度に試し、/root/st/exchange/e2e.outにkeycloak_exchange=(Keycloakの交換拒否のerror)、exchange=、act=、downstream=、direct_keycloak=、forged_subject=(署名を壊したsubject_tokenのステータスコード)、bad_audience=の7行を残してください。
前のステップを、tokens.shヘルパーでつなげれば足ります。すべての値はファイルに手で書かず、リクエストの結果として得てください。採点ツールは、ファイルに加えて、交換→下流の流れをもう一度直接確認します。