概要
KeycloakのIDトークン・アクセストークンにユーザーのカスタム属性を追加する方法を調べた。
ユーザーエンティティへの属性の追加
まずはKeycloak上のユーザーエンティティに属性を追加する方法を調べる。今回はユーザーのブログのURL(=blogUrl)という属性を追加してみる。
Keycloakの管理画面のメニューから「Realm settings -> User profile」でユーザーの属性を確認できる。デフォルトでは以下の4つの属性が登録されている。
- username
- email
- firstName
- lastName

「Create attribute」から属性を追加する。1回開いてみたら
- Display name
- Attribute group
- Permission
- Validations
あたりを設定をどのようにしたらいいか分からなかったので、参考にするためにfirstNameの設定を確認してみた。すると以下のように設定されていた。

真似して入力してみる。「Validations」はいったん空とする。

以下のようにblogUrlを登録できた。

実際にユーザーを作成してblogUrlを追加してみる。メニューの「Users > Add user」から登録する。



ここまででユーザーエンティティへの属性の追加方法は検証できた。
IDトークン・アクセストークンを取得する事前準備(検証方法に興味がない人は飛ばしてください)
IDトークン・アクセストークンを取得するためには、OIDCのフローをトークンリクエストまで実行する必要があるので、その準備をする。
新規追加したユーザーにデフォルトではパスワードが設定されていない。OIDCのフローを実行ためには設定が必要。「Users > Credentials > Set password」から設定する。

クライアント(Relying Party)も作成する必要がある。メニューの「Clients > Create client」から作成する。


「Client authentication」を有効にする。

「Valid redirect URIs」はOIDCのフローに必要なのでhttp://localhost/callback/登録する。

「Clients > Credentials」からClient Secretを確認して控えておく。

IDトークン・アクセストークンを取得する(初期設定ではブログのURLが含まれないことの確認なので結論だけ知りたい人は読み飛ばしてください)
IDトークン・アクセストークンを取得するためにOIDCのフローをトークンリクエストまで実行する。
まずはブラウザに以下のURLを入力する。
http://localhost:18080/realms/master/protocol/openid-connect/auth?response_type=code&scope=openid%20profile&client_id=testclient&redirect_uri=http://localhost/callback/

ログインに成功すると以下のURLにリダイレクトされるので、クエリストリングのcodeをコピる。

以下のcurlコマンドを実行する。
curl -X POST -d "grant_type=authorization_code" -d "code={コピったコード}" -d "redirect_uri=http://localhost/callback/" -d "client_id=testclient" -d "client_secret=***" "http://localhost:18080/realms/master/protocol/openid-connect/token"
成功すると以下のようなaccess_tokenとid_tokenを含むJSONが得られる。
{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJhVm1weTBiUEotY2JPUENSQnBzNWJYSG01NW5lcXIxUUM4QUJETjRxVDA0In0.eyJleHAiOjE3Mzg2MDIyNTgsImlhdCI6MTczODYwMjE5OCwiYXV0aF90aW1lIjoxNzM4NjAyMTcyLCJqdGkiOiI0YzExNTIyOC03YWM2LTQzZTktOGM4Yy1mNmYzMWI0NDI2ZmIiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjE4MDgwL3JlYWxtcy9tYXN0ZXIiLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiZDRlZWE1OGMtMTFkNS00ODQ1LTkwNmMtNmE1NzgzZWYxNGUxIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidGVzdGNsaWVudCIsInNpZCI6IjI2ZTUwZmMwLTkzYjAtNDlkZS04MTFlLThhMGNiNmRmOGIyMSIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cDovL2xvY2FsaG9zdCJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcy1tYXN0ZXIiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgZW1haWwgcHJvZmlsZSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6IuWkqumDjiDlsbHnlLAiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJkZWxoaTA5IiwiZ2l2ZW5fbmFtZSI6IuWkqumDjiIsImZhbWlseV9uYW1lIjoi5bGx55SwIiwiZW1haWwiOiJkZWxoaTA5QGV4YW1wbGUuY29tIn0.gedpNdUYNSMq7Pbj6OGUMjFqoC17Ab1MPyLXfyKlLjD2o4mzS30WxeLQzEFYpAeU2Bd--Pcc-o2nejFn3SMlh5_K5HcfCLkq72cw6-_OyqWcPaNBs9d9fXiL0t9tazhJf44kUSaUzbXgp1QOHf7s0Nf4TmhkwwyafpTVEH6pycVDLJ4Ftv8Qwbucub3otGJeVSDtqjodThijulKyZfJmVKPtdImrFejonPeK89lJuxGcGk4mTCHl-Xu_PkBu4l73lw1tKLEMBktM7RPJ7lMGykhjNNSwBFDzzHUBLX_k-VwrOERr7oAPJy48sitgo_zgWo4zFtcCjalMIdB8Y3u6kQ","expires_in":60,"refresh_expires_in":1800,"refresh_token":"eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2MWQxZTI2Mi1jMjYyLTQzN2MtODJkOS1lN2UwMTA0MDQwM2EifQ.eyJleHAiOjE3Mzg2MDM5OTgsImlhdCI6MTczODYwMjE5OCwianRpIjoiMTkzYTUxYzYtZWQ3Yi00NGIzLTg5ZGMtOGYwMDhjNTZlMTdkIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDoxODA4MC9yZWFsbXMvbWFzdGVyIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDoxODA4MC9yZWFsbXMvbWFzdGVyIiwic3ViIjoiZDRlZWE1OGMtMTFkNS00ODQ1LTkwNmMtNmE1NzgzZWYxNGUxIiwidHlwIjoiUmVmcmVzaCIsImF6cCI6InRlc3RjbGllbnQiLCJzaWQiOiIyNmU1MGZjMC05M2IwLTQ5ZGUtODExZS04YTBjYjZkZjhiMjEiLCJzY29wZSI6Im9wZW5pZCBlbWFpbCBhY3Igd2ViLW9yaWdpbnMgcHJvZmlsZSByb2xlcyBiYXNpYyJ9.mgkiF-kO8U-9kQT3GLcmt0qvpt2Qcx9mkF6d2ia4VrVE1pMdmkTHQTsjXAuPPurHYvAwFTWz6QDl_1K0-kCNTg","token_type":"Bearer","id_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJhVm1weTBiUEotY2JPUENSQnBzNWJYSG01NW5lcXIxUUM4QUJETjRxVDA0In0.eyJleHAiOjE3Mzg2MDIyNTgsImlhdCI6MTczODYwMjE5OCwiYXV0aF90aW1lIjoxNzM4NjAyMTcyLCJqdGkiOiI1ZmUxMDM1MS1jNGRkLTQxZGItOGIwMi1kOTA5NWRiMDg4MDAiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjE4MDgwL3JlYWxtcy9tYXN0ZXIiLCJhdWQiOiJ0ZXN0Y2xpZW50Iiwic3ViIjoiZDRlZWE1OGMtMTFkNS00ODQ1LTkwNmMtNmE1NzgzZWYxNGUxIiwidHlwIjoiSUQiLCJhenAiOiJ0ZXN0Y2xpZW50Iiwic2lkIjoiMjZlNTBmYzAtOTNiMC00OWRlLTgxMWUtOGEwY2I2ZGY4YjIxIiwiYXRfaGFzaCI6IkNvUXotYnF3cE1PNExWUmRGWmZlV0EiLCJhY3IiOiIxIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoi5aSq6YOOIOWxseeUsCIsInByZWZlcnJlZF91c2VybmFtZSI6ImRlbGhpMDkiLCJnaXZlbl9uYW1lIjoi5aSq6YOOIiwiZmFtaWx5X25hbWUiOiLlsbHnlLAiLCJlbWFpbCI6ImRlbGhpMDlAZXhhbXBsZS5jb20ifQ.VeGrAQqjHsZNbmr3u41mA_aU5xoSn5ZcXCbsQLzinJSXs1nl8GQ0soosbDPr6ZcmXVxNYzuAvG-h-Xae2F2b5UJ-YOLNHFP_xV7BG1LUydqm3cEBaRztbWAwr_WHoLm0tA5wtRvohDHLVhm19A05V-ADLc2HQ4SZRR_BcLrKMxncqjurA58KHxJpy0gT1AFmN2GWVYnSr6VkUmFyLJiObnyP3ccJlauiyJljjRfCxBNkAR4JotU57kH4HBQnopt3fS6bIaoOSm0f09vtXpBSMVvSWu9RvTBUNmeTYt2lPPhqMW9HaqGgrc6Amu3rTy3OAb1LFCwaPIgffQRoNQ_4rg","not-before-policy":0,"session_state":"26e50fc0-93b0-49de-811e-8a0cb6df8b21","scope":"openid email profile"}
IDトークンとアクセストークンはJWTだからただのbase64された文字列なので、以下のコマンドでデコードできる。
$ echo "トークン"|cut -d "." -f2 | base64 -d
実際に実行してみる。
echo "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJhVm1weTBiUEotY2JPUENSQnBzNWJYSG01NW5lcXIxUUM4QUJETjRxVDA0In0.eyJleHAiOjE3Mzg2MDIyNTgsImlhdCI6MTczODYwMjE5OCwiYXV0aF90aW1lIjoxNzM4NjAyMTcyLCJqdGkiOiI0YzExNTIyOC03YWM2LTQzZTktOGM4Yy1mNmYzMWI0NDI2ZmIiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjE4MDgwL3JlYWxtcy9tYXN0ZXIiLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiZDRlZWE1OGMtMTFkNS00ODQ1LTkwNmMtNmE1NzgzZWYxNGUxIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidGVzdGNsaWVudCIsInNpZCI6IjI2ZTUwZmMwLTkzYjAtNDlkZS04MTFlLThhMGNiNmRmOGIyMSIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cDovL2xvY2FsaG9zdCJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcy1tYXN0ZXIiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgZW1haWwgcHJvZmlsZSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6IuWkqumDjiDlsbHnlLAiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJkZWxoaTA5IiwiZ2l2ZW5fbmFtZSI6IuWkqumDjiIsImZhbWlseV9uYW1lIjoi5bGx55SwIiwiZW1haWwiOiJkZWxoaTA5QGV4YW1wbGUuY29tIn0.gedpNdUYNSMq7Pbj6OGUMjFqoC17Ab1MPyLXfyKlLjD2o4mzS30WxeLQzEFYpAeU2Bd--Pcc-o2nejFn3SMlh5_K5HcfCLkq72cw6-_OyqWcPaNBs9d9fXiL0t9tazhJf44kUSaUzbXgp1QOHf7s0Nf4TmhkwwyafpTVEH6pycVDLJ4Ftv8Qwbucub3otGJeVSDtqjodThijulKyZfJmVKPtdImrFejonPeK89lJuxGcGk4mTCHl-Xu_PkBu4l73lw1tKLEMBktM7RPJ7lMGykhjNNSwBFDzzHUBLX_k-VwrOERr7oAPJy48sitgo_zgWo4zFtcCjalMIdB8Y3u6kQ"|cut -d "." -f2 | base64 -d
{"exp":1738602258,"iat":1738602198,"auth_time":1738602172,"jti":"4c115228-7ac6-43e9-8c8c-f6f31b4426fb","iss":"http://localhost:18080/realms/master","aud":"account","sub":"d4eea58c-11d5-4845-906c-6a5783ef14e1","typ":"Bearer","azp":"testclient","sid":"26e50fc0-93b0-49de-811e-8a0cb6df8b21","acr":"1","allowed-origins":["http://localhost"],"realm_access":{"roles":["default-roles-master","offline_access","uma_authorization"]},"resource_access":{"account":{"roles":["manage-account","manage-account-links","view-profile"]}},"scope":"openid email profile","email_verified":false,"name":"太郎 山田","preferred_username":"delhi09","given_name":"太郎","family_name":"山田","email":"delhi09@example.com
blogUrlは含まれていないことが分かる。
トークンに属性を追加する
Keycloakの管理画面の「Client scopes」というメニューを開く。emailやprofileなど、認可リクエストのscopeに使用可能なパラメータが定義されている。

今回はprofileをscopeに渡した時にblogUrlも含まれてほしいので、profileを選択する。

メニューの「Mappers」を開くとprofileに紐づく属性が定義されているっぽい。

例えばトークンにすでに属性として含まれているgiven_nameの詳細を開くと、以下のように定義されている。

- Add to ID token
- Add to access token
という項目があり、これが有効になっているからトークンに属性が含まれていると考えられる。
同じようにblog_urlを追加するとよさそうである。
「Add mapper」を押すと「From predefined mappers」と「By configuration」という選択肢が出てくる。「By configuration」を選択する。

「User Attribute」を選択する。

以下のようにmapperをblogという名前で作成する。

「Add to ID token」と「Add to access token」はデフォルトで有効になっていたのでそのまま作成する。
profileにblogが登録された。

先の検証手順と同じようにトークンエンドポイントを叩いてトークンを取得すると、以下のようにIDトークン・アクセストークンともにblog_urlを追加できた。
echo "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJhVm1weTBiUEotY2JPUENSQnBzNWJYSG01NW5lcXIxUUM4QUJETjRxVDA0In0.eyJleHAiOjE3Mzg2MDQ4MzUsImlhdCI6MTczODYwNDc3NSwiYXV0aF90aW1lIjoxNzM4NjA0NzUyLCJqdGkiOiJlNTFkMTVlNC1jYTNjLTRlYzYtODAyZC02OGEyNWYwMTdjM2EiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjE4MDgwL3JlYWxtcy9tYXN0ZXIiLCJhdWQiOiJ0ZXN0Y2xpZW50Iiwic3ViIjoiZDRlZWE1OGMtMTFkNS00ODQ1LTkwNmMtNmE1NzgzZWYxNGUxIiwidHlwIjoiSUQiLCJhenAiOiJ0ZXN0Y2xpZW50Iiwic2lkIjoiYzIwZWU0YTktYjBjNy00NjYwLTlmYjEtNWQ5NGE1ZDZhMGE0IiwiYXRfaGFzaCI6IlhnanQ1MENyZkNCWEtTSmRjcVduNFEiLCJhY3IiOiIxIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJibG9nX3VybCI6Imh0dHBzOi8va2FtYXRpbWFydS5oYXRlbmFibG9nLmNvbS8iLCJuYW1lIjoi5aSq6YOOIOWxseeUsCIsInByZWZlcnJlZF91c2VybmFtZSI6ImRlbGhpMDkiLCJnaXZlbl9uYW1lIjoi5aSq6YOOIiwiZmFtaWx5X25hbWUiOiLlsbHnlLAiLCJlbWFpbCI6ImRlbGhpMDlAZXhhbXBsZS5jb20ifQ.KfvLl9vEnYctPZADTIJ7JjzlLX0RHXxzmwQ1g5WFbx4ZezMKSCQvIj-DlQHcrQQYVAcjAR9-WOt_O2wMiPCyQD9jp0EHklfFdWAMkIKw3-3dKO5NqElijgLGFP70xDh5g70e2BYRBNbrTcMpcZdS5R1APR7XESK4HC4of0uJfEVqzWlirvTXEv2arJAqzutyeHHeptS6-GjitkUi17X_pOPK_j9apUEwc644FH9ZmWVIkiiKKSRvcVbqS_Ca_P0BJitVvj7OphONTAUxgGp2mVH-kZL1uWP19nctfrkIMIQszxPcR1e6KbqNV1hg4Kvb_jCOaV4eok910vQOS00JQA"|cut -d "." -f2 | base64 -d
{"exp":1738604835,"iat":1738604775,"auth_time":1738604752,"jti":"e51d15e4-ca3c-4ec6-802d-68a25f017c3a","iss":"http://localhost:18080/realms/master","aud":"testclient","sub":"d4eea58c-11d5-4845-906c-6a5783ef14e1","typ":"ID","azp":"testclient","sid":"c20ee4a9-b0c7-4660-9fb1-5d94a5d6a0a4","at_hash":"Xgjt50CrfCBXKSJdcqWn4Q","acr":"1","email_verified":false,"blog_url":"https://kamatimaru.hatenablog.com/","name":"太郎 山田","preferred_username":"delhi09","given_name":"太郎","family_name":"山田","email":"delhi09@example.com"
echo "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJhVm1weTBiUEotY2JPUENSQnBzNWJYSG01NW5lcXIxUUM4QUJETjRxVDA0In0.eyJleHAiOjE3Mzg2MDQ4MzUsImlhdCI6MTczODYwNDc3NSwiYXV0aF90aW1lIjoxNzM4NjA0NzUyLCJqdGkiOiIwNWFlMWE3MC1kOWNlLTRjYTItODY4Zi00OWU5ZjQ4NTIxY2MiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjE4MDgwL3JlYWxtcy9tYXN0ZXIiLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiZDRlZWE1OGMtMTFkNS00ODQ1LTkwNmMtNmE1NzgzZWYxNGUxIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidGVzdGNsaWVudCIsInNpZCI6ImMyMGVlNGE5LWIwYzctNDY2MC05ZmIxLTVkOTRhNWQ2YTBhNCIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cDovL2xvY2FsaG9zdCJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcy1tYXN0ZXIiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgZW1haWwgcHJvZmlsZSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwiYmxvZ191cmwiOiJodHRwczovL2thbWF0aW1hcnUuaGF0ZW5hYmxvZy5jb20vIiwibmFtZSI6IuWkqumDjiDlsbHnlLAiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJkZWxoaTA5IiwiZ2l2ZW5fbmFtZSI6IuWkqumDjiIsImZhbWlseV9uYW1lIjoi5bGx55SwIiwiZW1haWwiOiJkZWxoaTA5QGV4YW1wbGUuY29tIn0.YdhN0_IUtQi4abe8uz3rfnwP5R4xCKs6AIT-2kT0Z-SEFK2pzx3R3zGGl_BuSVscc2V946Bwyt_QVgNdKJv0cK6oH4oocqkSbIJUkGJUWb-gzggXmrdY2c6CjYZrE-zXf_meJjVpU-jTh9LPF3fvzyrLpTIv1yWZkUkx8ESpWLMtGHTcC3HZe4S4Dl4VhhhFWSM4zUqnBdkaWbesqNysbWGTHNBjV8mLZG8hEKd5X3jpmaF5pImihVJDfagj2_cRarog9rFvHd5HbAcyN8DV_ksKJXz9U_2L5PY18W1gBZT57IpGa-l09RNFPyrrQiKkm7SIuDuGBGizApr_kNDuuQ"|cut -d "." -f2 | base64 -d
{"exp":1738604835,"iat":1738604775,"auth_time":1738604752,"jti":"05ae1a70-d9ce-4ca2-868f-49e9f48521cc","iss":"http://localhost:18080/realms/master","aud":"account","sub":"d4eea58c-11d5-4845-906c-6a5783ef14e1","typ":"Bearer","azp":"testclient","sid":"c20ee4a9-b0c7-4660-9fb1-5d94a5d6a0a4","acr":"1","allowed-origins":["http://localhost"],"realm_access":{"roles":["default-roles-master","offline_access","uma_authorization"]},"resource_access":{"account":{"roles":["manage-account","manage-account-links","view-profile"]}},"scope":"openid email profile","email_verified":false,"blog_url":"https://kamatimaru.hatenablog.com/","name":"太郎 山田","preferred_username":"delhi09","given_name":"太郎","family_name":"山田","email":"delhi09@example.com