NRI OpenStandia Advent Calendar 2020の2日目は、KeycloakのToken Exchangeに対して機能追加した話を紹介します。具体的には、アクセストークンからSAML2トークンへの変換に対応させました。
Token Exchangeとは?
最初にToken Exchangeについて簡単に触れておきます。これは長らくドラフトでしたが、RFC 8693 となったOAuth2のアクセストークンなどを他のトークンに交換する標準仕様です。他のトークンと書いているのは、仕様上はOAuth2アクセストークンに限らず、OpenID ConnectのIDトークンやSAML1、SAML2トークン(アサーション)も規定されています。
代表的なユースケースとしては、アクセストークンで保護されているリソースサーバがバックエンドのAPIを呼び出すときに、バックエンドAPI用のトークンに交換した上でアクセスするといった、いわゆるマイクロサービスの認証認可で使われるケースがあります。 Delegation Patterns for OAuth 2.0で詳しく説明されています。
※ 出所: Delegation Patterns for OAuth 2.0
Keycloakでの対応状況
2020年11月時点の最新バージョン(Keycloak 11.0.3)では、Token Exchangeは Technology Preview 扱いで、デフォルトでは無効になっています。有効にするには、Keycloak起動時に -Dkeycloak.profile=preview または -Dkeycloak.profile.feature.token_exchange=enabled オプションを渡す必要があります。
また、RFC 8693をフル実装しているわけではなくて部分的かつルーズな実装になっています。Keycloak本家ドキュメントには以下のように記載があります。
Token exchange in Keycloak is a very loose implementation of the OAuth Token Exchange specification at the IETF. We have extended it a little, ignored some of it, and loosely interpreted other parts of the specification. It is a simple grant type invocation on a realm’s OpenID Connect token endpoint.
Keycloakのバージョン〜10までは、ドキュメントでは特に記載されていないですが、実はSAML2トークンは未対応で、IDトークンとアクセストークンのみが対象でした。
SAML2対応
というわけでまずはメーリングリストで提案し、その後チケット作成、プルリクエストを起こして対応を進めました。
- https://groups.google.com/g/keycloak-dev/c/nnlNtuGFH5w/discussion
- https://issues.redhat.com/browse/KEYCLOAK-14113
- https://github.com/keycloak/keycloak/pull/7064/commits/e88442431b8742e4bd359f44e5b994ae0751c974
プルリクエストのレビューの中で、既存の作りで仕様準拠していない箇所がいくつか見つかりましたが(ルーズな実装なので)、一旦そこは置いておいてSAML2対応の部分だけ先にマージされました。リリースノートには特に書かれていませんが、 Keycloak 11.0.0 から本機能を使うことができます。
使い方
実際にKeycloakを動かして使い方を紹介します。最初にKeycloakを -Dkeycloak.profile=preview オプション付きで起動します。Dockerを使うとさくっと起動できます。
docker run --rm \
-p 8080:8080 \
-e KEYCLOAK_USER=admin \
-e KEYCLOAK_PASSWORD=admin \
jboss/keycloak:11.0.3 -Dkeycloak.profile=preview
今回は master レルム内に、交換元のトークンを発行するOpenID Connectのクライアントと交換先のトークンを発行するSAMLクライアントを設定します。
OpenID Connectクライアントの作成
まず、交換元となるOpenID Connectのクライアントを作成します。openid-startingという名前で新規にクライアントを作成します。Client Protocolはopenid-connectを選択します。
Settingsタブにて、Access Typeをconfidentialにしておきます。また、動作確認を簡単に行うためにDirect Access Grants EnabledのみをONにしてSaveをクリックして保存します1。
Credentialsタブを開き、Secretを控えておきます。動作確認の際に利用します。
以上で交換元となるOpenID Connectのクライント登録は完了です。
SAMLクライアントの作成
次に、交換先となるSAMLのクライアントを作成します。今度はClient Protocolをsamlにして作成します。
Settingsタブにて、Assertion Consumer Service POST Binding URLを設定します。SAML Assertionの生成時にこれが必要になるので、テスト用に適当に設定してSaveをクリックして保存します2。
ここから、先ほど作成した openid-starting クライアントから saml-target クライアントに対してのToken Exchangeの権限を設定していきます。デフォルトではToken Exchangeは許可されていないので、クライアント間でトークンを交換することができません。openid-startingクライアントで発行されたトークンを交換できるように許可する設定を行う必要があります。
Permissionsタブをクリックします。Permissions EnabledをONに変更すると一覧が表示されるので、token-exchangeのリンク箇所またはEditボタンをクリックします。
次にApply Policyの右側にあるCreate Policy...プルダウンを開き、Clientを選択します。
するとAdd Client Policyのページに遷移するので、Nameに適当な名前を入力し、Clientsにて交換元となるクライアントIDのopenid-startingを選択します。すると下記キャプチャのような状態になります。これでSaveをクリックして保存します。
保存すると自動的に元の画面に戻り、Apply Policyに作成したポリシーが設定済みになります。そのままSaveをクリックして保存します。
以上でSAMLクライアントの設定は完了です。
動作確認
では動作確認してみます。流れとしては以下のようになります。
-
openid-startingクライアントに対して、Resource Owner Password Credentialsのフローを使用してアクセストークン取得。 - 取得したアクセストークンを利用してToken Exchangeを行い
saml-targetのSAML Assertionを取得。
curlコマンドを使って以下のようなスクリプトを実行すれば試すことできます。なお、アクセストークン取り出しのためにjqコマンド、Base64デコードのためにopensslコマンド、XML整形のためにxmllintコマンドを利用していますので、試す際には適宜インストールしてください。
# !/bin/bash
REALM=master
BASE_URL=http://localhost:8080/auth/realms/$REALM
TOKEN_ENDPOINT=$BASE_URL/protocol/openid-connect/token
CLIENT_ID=openid-starting
CLIENT_SECRET=dd168f6b-368e-4b2a-94c6-e97740ea3692
USER_ID=admin
USER_PASSWORD=admin
TARGET_CLIENT_ID=saml-target
# https://gist.github.com/alvis/89007e96f7958f2686036d4276d28e47
function base64url_decode {
INPUT=$(if [ -z "$1" ]; then echo -n $(cat -); else echo -n "$1"; fi)
MOD=$(($(echo -n "$INPUT" | wc -c) % 4))
PADDING=$(if [ $MOD -eq 2 ]; then echo -n '=='; elif [ $MOD -eq 3 ]; then echo -n '=' ; fi)
echo -n "$INPUT$PADDING" |
sed s/-/+/g |
sed s/_/\\//g |
openssl base64 -d -A
}
RES=`curl -s -X POST \
--url $TOKEN_ENDPOINT \
--header 'content-type: application/x-www-form-urlencoded' \
-d grant_type=password \
-d username=$USER_ID \
-d password=$USER_PASSWORD \
-d scope=openid \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRET`
ACCESS_TOKEN=`echo $RES | jq -r .access_token`
echo "========== ACCESS_TOKEN =========="
echo "$ACCESS_TOKEN"
echo "=================================="
RES=`curl -s -X POST \
--url $TOKEN_ENDPOINT \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=$ACCESS_TOKEN" \
--data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
--data-urlencode "requested_token_type=urn:ietf:params:oauth:token-type:saml2" \
-d "audience=$TARGET_CLIENT_ID"`
SAML_ASSERTION=`echo $RES | jq -r .access_token`
DECODED=`base64url_decode $SAML_ASSERTION | xmllint --format -`
echo "========== SAML_ASSERTION =========="
echo "$DECODED"
echo "===================================="
Token Exchangeを行っているのは以下の箇所です。requested_token_type=urn:ietf:params:oauth:token-type:saml2を送っているところがポイントで、要求するトークンタイプをsaml2にしています。
RES=`curl -s -X POST \
--url $TOKEN_ENDPOINT \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=$ACCESS_TOKEN" \
--data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
--data-urlencode "requested_token_type=urn:ietf:params:oauth:token-type:saml2" \
-d "audience=$TARGET_CLIENT_ID"`
なお、Token Exchangeでは通常のOAuth2のトークンレスポンスと同じ形式で結果が返ってきます。SAML2アサーションであっても、access_tokenクレームの中にBase64 URLエンコードされた形で格納されます。というわけで上記スクリプトではjqコマンドでまず取り出し、Base64 URLデコードを行ってその後XML整形して表示しています。
以下、実行結果例です。
========== ACCESS_TOKEN ==========
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJtXzU0RkdEOWIyQnlPNDNmNWNwTU9USUE2WG5LUzhYN0c5TXNabjBDb2FBIn0.eyJleHAiOjE2MDY3NDM2MDgsImlhdCI6MTYwNjc0MzU0OCwianRpIjoiZTkyNDMyZTAtNjU5Zi00NmJmLWE1MWEtMThjMjcwYTc3OTI5IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL2F1dGgvcmVhbG1zL21hc3RlciIsImF1ZCI6WyJtYXN0ZXItcmVhbG0iLCJhY2NvdW50Il0sInN1YiI6IjRhOGM5ZjUyLTk1YWItNGVkNC1hZTZlLTI0ZTMzZTlkNDQzNCIsInR5cCI6IkJlYXJlciIsImF6cCI6Im9wZW5pZC1zdGFydGluZyIsInNlc3Npb25fc3RhdGUiOiJkN2ZiYmZhOS1mMjk2LTQ3N2EtYWQ4My00MGQ2ZmIxMTMyNjMiLCJhY3IiOiIxIiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImNyZWF0ZS1yZWFsbSIsIm9mZmxpbmVfYWNjZXNzIiwiYWRtaW4iLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7Im1hc3Rlci1yZWFsbSI6eyJyb2xlcyI6WyJ2aWV3LWlkZW50aXR5LXByb3ZpZGVycyIsInZpZXctcmVhbG0iLCJtYW5hZ2UtaWRlbnRpdHktcHJvdmlkZXJzIiwiaW1wZXJzb25hdGlvbiIsImNyZWF0ZS1jbGllbnQiLCJtYW5hZ2UtdXNlcnMiLCJxdWVyeS1yZWFsbXMiLCJ2aWV3LWF1dGhvcml6YXRpb24iLCJxdWVyeS1jbGllbnRzIiwicXVlcnktdXNlcnMiLCJtYW5hZ2UtZXZlbnRzIiwibWFuYWdlLXJlYWxtIiwidmlldy1ldmVudHMiLCJ2aWV3LXVzZXJzIiwidmlldy1jbGllbnRzIiwibWFuYWdlLWF1dGhvcml6YXRpb24iLCJtYW5hZ2UtY2xpZW50cyIsInF1ZXJ5LWdyb3VwcyJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoiYWRtaW4ifQ.Yg3-E4QBqGfR20WCKyOdY73g1H1eB2YrKpiw18WA39F6_zFT1B7fo1GqtyoTvLo5rBlnCBOpjphsmvyVc2u0ynkT2WOjDekJ06vvpgT5pe5jOGq7lIZI04ndwItcgFTe6NxIUmSkIntBuASGcomv6aSsMr_WKNao_yTCkpolFL2pEE081Lriw2LAQE1ZkQxW6LqM7LUuHPbxhxnZ0Wfvyw3CZa7MpMQpaOGecidGyq14SlefXthylKr0nc1V24bE06HgIPB4yRoXXTgwXgpZpN-GhjNcJVursot06rRhAbF_BuLJ8GZ3-fokSaAzaOM1dp0JMITSmtUn80fl0grtKg
==================================
========== SAML_ASSERTION ==========
<?xml version="1.0"?>
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns="urn:oasis:names:tc:SAML:2.0:assertion" ID="ID_f57a81ef-aaba-4c13-8836-a0e7232780eb" IssueInstant="2020-11-30T13:39:08.433Z" Version="2.0">
<saml:Issuer>http://localhost:8080/auth/realms/master</saml:Issuer>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">admin</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData NotOnOrAfter="2020-11-30T13:40:06.433Z" Recipient="https://saml.example.com"/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="2020-11-30T13:39:06.433Z" NotOnOrAfter="2020-11-30T13:40:06.433Z">
<saml:AudienceRestriction>
<saml:Audience>saml-target</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="2020-11-30T13:39:08.433Z" SessionIndex="d7fbbfa9-f296-477a-ad83-40d6fb113263::ea43e348-7245-476b-b2ce-5db70d9e479f" SessionNotOnOrAfter="2020-11-30T23:39:08.433Z">
<saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement>
<saml:Attribute Name="Role" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
<saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">manage-realm</saml:AttributeValue>
</saml:Attribute>
...
</saml:AttributeStatement>
</saml:Assertion>
====================================
このように、アクセストークンをSAMLアサーションに交換することができました。
使い所
SAML2トークンへの交換のニーズはそんなにあるような気はしませんが、開発者にとっては意外と身近なユースケース(AWSとの連携)があります。これについては明日3日目の記事で紹介したいと思います。








