Keycloak 23.0.3 で DPoP を試す

1. 概要


shiba チームの中村です。少し前の記事で Keycloak でクライアントポリシーを設定した後で Financial-grade API Security Profile 1.0 - Part 1: Advance の動きを確認しました。

FAPI Advance では アクセストークンの正当な所有者であることの証明(Proof of Possession = PoP)の方法としてMTLSの挙動を確認したのですが、今回の記事ではその他のPoPの仕様として DPoP(OAuth 2.0 Demonstrating Proof of Possession) を試していきます。

1.1 本記事内での略称について


本資料では以降の資料において、下記のように名称を省略して表記します。

名称略称
OAuth 2.0 Demonstrating Proof of PossessionDPoP

1.2 当記事で説明する内容


今回の記事では主に下記の部分について解説していきます。

  • Keycloak を用いての DPoP の挙動

1.3 当記事で説明しない内容


  • FAPI に関する詳細な説明
  • MTLS に関する詳細な説明 ※ 前回の記事をご確認ください。

2. 今回の検証環境


先月のKeycloakのリリース (23.0.0)でついに DPoPのプレビューでの対応が発表されました。

DPoP preview support Keycloak has preview for support for OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer (DPoP). Thanks to Takashi Norimatsu and Dmitry Telegin for their contributions.

公式ドキュメントにもあるように、--features=preview または --features=dpop をサーバー起動時に指定する必要があります。

今回はKeycloakのドキュメントにもあるように、KC_FEATURES の環境変数を用いてdocker 環境で有効化します。

      KC_FEATURES: dpop

上記の機能(feature)を有効化する指定を含んだ docker-compose.yml は下記のようになります。

services:
  keycloak:
    container_name: keycloak
    image: quay.io/keycloak/keycloak:23.0.3
    entrypoint: ["/opt/keycloak/bin/kc.sh","start-dev"]
    ports:
      - "8088:8080"
    environment:
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: password
      KC_FEATURES: dpop

では下記のコマンドでコンテナを動かしておきましょう。 $ docker-compose up

2.1 検証で使うデータの作成

検証で使う、レルム、クライアント、ユーザーは下記のようなデータを用いて検証していきます。Keycloakの管理画面を開いて各種データを作成してください。

レルムのデータ

レルム名は下記の値とします。

レルムの作り方については公式ドキュメントをご参照ください。

フィールド
Realm namesample-realm

ユーザーのデータ

ユーザーのデータについては下記のデータで作成します。

ユーザーの作り方については公式ドキュメントをご参照ください。

フィールド
Usernamecl-taro

また、ユーザーの Credentials については今回も下記のように作成します。

フィールド
Passwordpassword
TemporaryOff

クライアントのデータ

下記のようにClient IDとValid redirect URIsのみを入力し、 それ以外の値はデフォルト値のままとします。

クライアントの作り方については公式ドキュメントをご参照ください。

フィールド
Client TypeOpenID Connect (defaultの値)
Client IDtest-client
Valid redirect URIshttps://client.example.com/test

3 DPoPの有効化

KC_FEATURES で DPoP自体を有効化し、クライアントを作成しましたがデフォルトではDPoPを強制させる動きはしません。なので次の順で検証を行っていきます。

  1. DPoPの設定前: トークンリクエスト
  2. DPoPの設定後: DPoPの情報を含まずにトークンリクエスト
  3. DPoPの設定後: DPoPの情報を含んでトークンリクエスト

3.1 DPoPの設定前: トークンリクエスト

まずは何も設定していない状態で試してみましょう。まずは認可リクエストを送信します。

ログイン画面が表示されるので、2.2 で作成したユーザーで Sign in します。

フィールド
Usernamecl-taro
Passwordpassword

下記のように redirect URIs に登録したURIに遷移します。認可コードを確認しましょう。

https://client.example.com/test?state=abcdefghijk&session_state=51d9a57d-fd9c-4c94-bdb6-99605a7f8f64&iss=http%3A%2F%2Flocalhost%3A8088%2Frealms%2Fsample-realm&code=fb03522f-7ef5-47ba-8cb0-a91457a1b6e8.51d9a57d-fd9c-4c94-bdb6-99605a7f8f64.4ae28041-1079-4e8d-9e7f-ad0c4841c994

取得した認可コードを元に、DPoPを含まないトークンリクエストを作成して送ってみます。

curl -i -X POST \
-H "Content-Type:application/x-www-form-urlencoded" \
-d "client_id=test-client" \
-d "grant_type=authorization_code" \
-d "code=fb03522f-7ef5-47ba-8cb0-a91457a1b6e8.51d9a57d-fd9c-4c94-bdb6-99605a7f8f64.4ae28041-1079-4e8d-9e7f-ad0c4841c994" \
-d "redirect_uri=https://client.example.com/test" \
'http://localhost:8088/realms/sample-realm/protocol/openid-connect/token'

下記のようにアクセストークンやリフレッシュトークンなどが取得できます。

{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJqckFjalZVSU1Tb1RMVEpOZXdVZklSaUt0NzU5enZUYmp2RDdBejFvdUZjIn0.eyJleHAiOjE3MDMwMzAxNjYsImlhdCI6MTcwMzAyOTg2NiwiYXV0aF90aW1lIjoxNzAzMDI4OTIwLCJqdGkiOiI2MDViZTRkNi1lODUzLTQ0YzktYmZmYy1iMGQ3NTY0MDJlNjMiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODgvcmVhbG1zL3NhbXBsZS1yZWFsbSIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiIxZDMzZjc5Yy01NTU5LTQxZGYtOTZkZS03ZGJlYjIzM2YxM2YiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJ0ZXN0LWNsaWVudCIsIm5vbmNlIjoiYWJjZGVmZ2hpamsiLCJzZXNzaW9uX3N0YXRlIjoiNTFkOWE1N2QtZmQ5Yy00Yzk0LWJkYjYtOTk2MDVhN2Y4ZjY0IiwiYWNyIjoiMCIsImFsbG93ZWQtb3JpZ2lucyI6WyJodHRwczovL2NsaWVudC5leGFtcGxlLmNvbSJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcy1zYW1wbGUtcmVhbG0iLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCIsInNpZCI6IjUxZDlhNTdkLWZkOWMtNGM5NC1iZGI2LTk5NjA1YTdmOGY2NCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoiY2wtdGFybyJ9.MC5Y-43UQ-qce_pSQTDFmcSvim6Qe-WZKGntzaOQxAPgzcCuevri6fdZMfYgmV7xY8R8oCnE2oAZEiHv0OSgAWrsElohLoRhQXVb8zdX6X3FrSXu7f2GAqV0Tb7CpDV5GtoEgROIRDrczQxQQu3uPvAAR47KCe4dJB2VzQbBhHo_TPASxS3gmmnMtMuJnGwAZ_AQ-2Qsc9GnYSedvKEYpGlteeOySRD_jbyUZhlFLyoCUWCSMknbrRxDm1G4XtoLmGgYjIK-UiLlShw6m3rk9NJPiyvzEXkiedjUPfDQ6veY1OB37mBnJmMosYRSPjFUulf2M43WnB9MToYF7NP_Gw","expires_in":300,"refresh_expires_in":1777,"refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJkODAyNDZlZS1lMmM0LTQwNWEtODZjMC01OTc5NDJmY2M5ZmYifQ.eyJleHAiOjE3MDMwMzE2NDMsImlhdCI6MTcwMzAyOTg2NiwianRpIjoiOGQ3ODc3ZWYtZWZmZC00YzExLTk1MDctZDNiMWUyZDE2YzBjIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDg4L3JlYWxtcy9zYW1wbGUtcmVhbG0iLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjgwODgvcmVhbG1zL3NhbXBsZS1yZWFsbSIsInN1YiI6IjFkMzNmNzljLTU1NTktNDFkZi05NmRlLTdkYmViMjMzZjEzZiIsInR5cCI6IlJlZnJlc2giLCJhenAiOiJ0ZXN0LWNsaWVudCIsIm5vbmNlIjoiYWJjZGVmZ2hpamsiLCJzZXNzaW9uX3N0YXRlIjoiNTFkOWE1N2QtZmQ5Yy00Yzk0LWJkYjYtOTk2MDVhN2Y4ZjY0Iiwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCIsInNpZCI6IjUxZDlhNTdkLWZkOWMtNGM5NC1iZGI2LTk5NjA1YTdmOGY2NCJ9.b33RyYHQ_c5rXnCBsPWt_M61RcfDc136DfCyXodQRzc","token_type":"Bearer","id_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJqckFjalZVSU1Tb1RMVEpOZXdVZklSaUt0NzU5enZUYmp2RDdBejFvdUZjIn0.eyJleHAiOjE3MDMwMzAxNjYsImlhdCI6MTcwMzAyOTg2NiwiYXV0aF90aW1lIjoxNzAzMDI4OTIwLCJqdGkiOiI2NWM2NWE5MC03NjBjLTQ2NTEtOGQxZi05MTIwNGNmM2FhNzMiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODgvcmVhbG1zL3NhbXBsZS1yZWFsbSIsImF1ZCI6InRlc3QtY2xpZW50Iiwic3ViIjoiMWQzM2Y3OWMtNTU1OS00MWRmLTk2ZGUtN2RiZWIyMzNmMTNmIiwidHlwIjoiSUQiLCJhenAiOiJ0ZXN0LWNsaWVudCIsIm5vbmNlIjoiYWJjZGVmZ2hpamsiLCJzZXNzaW9uX3N0YXRlIjoiNTFkOWE1N2QtZmQ5Yy00Yzk0LWJkYjYtOTk2MDVhN2Y4ZjY0IiwiYXRfaGFzaCI6Ijh2V1RmdUkwQzIwNG1xVldQcXhZT1EiLCJhY3IiOiIwIiwic2lkIjoiNTFkOWE1N2QtZmQ5Yy00Yzk0LWJkYjYtOTk2MDVhN2Y4ZjY0IiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJjbC10YXJvIn0.KtBj2401Folm1RokN_JsBaNhabQbGIVnPaO5Hrn5QOeD3-AqrQzWgRp4NeTmus3Mq7sq5XyL8GltE8YoRnbZ6nOQ0GJ95zMGlrN62Fqp-En-YTmpmFCIJFao-U-mGdVASKOR4KSYlVVEQcM3I6tl9RSTM50a3IR3QPf0cDdoetmnhZMbya31-L-ajEGw9VIGyEHUrX9V4QgecQkuPBWCTwD9g7HEErvIk8LtU-k3ktmtJRXtizr7ccqJyNsEfdflxTmE5B8yMcC-AIuuRs52qnIVJVbUt9cWD5vj8EfUz_eqgop_JlL9jpQUO4ho4wyxQSwPvKxGb-7bqjhsTlI-jA","not-before-policy":0,"session_state":"51d9a57d-fd9c-4c94-bdb6-99605a7f8f64","scope":"openid profile email"}

3.2 クライアントにDPoPの設定を行う

では次にクライアントにDPoPを有効化した状態を見ていくために、DPoPの設定を行っていきます。管理画面より、2.2で作成した test-clientの設定画面に移動します。

設定画面より Advanced タブに移動し、下記のように、"Advanced settings" の項目へ移動します。その中に "OAuth 2.0 DPoP Bound Access Tokens Enabled" があるので、これを "Off" から "On" に変更し "Save" を押してください。

また、公式ドキュメントにもあるように、 "OAuth 2.0 DPoP Bound Access Tokens Enabled" の設定が "Off" のままでも DPoP のJWTを含んだ情報は処理されます。 ただし "On" に設定することで DPoPのバインディングを強制することができます。

If the switch OAuth 2.0 DPoP Bound Access Tokens Enabled is off, the client can still send DPoP proof in the token request. In that case, Keycloak will verify DPoP proof and will add the thumbprint to the token. But if the switch is off, DPoP binding is not enforced by the Keycloak server for this client. It is recommended to have this switch on if you want to make sure that particular client always uses DPoP binding.

イメージとしては下記のようになります。。

設定値DPoPを含んだリクエストDPoPを含んでいないリクエスト
On処理されるDPoPがないのでエラーになる
Off処理される処理される

3.3 DPoPの設定後: DPoPの情報を含まずにトークンリクエスト

ではDPoPを強制する設定を行ったので、まずはDPoPを含まずに認可リクエストを送信してみます。

下記のようにレスポンスが返ってきました。

https://client.example.com/test?state=abcdefghijk&session_state=038267e1-46ac-4e63-b2d0-036c89718264&iss=http%3A%2F%2Flocalhost%3A8088%2Frealms%2Fsample-realm&code=1ebfb4ee-5fd7-47db-88fa-7bc5fd165d21.038267e1-46ac-4e63-b2d0-036c89718264.4ae28041-1079-4e8d-9e7f-ad0c4841c994

取得した認可コードを元に、DPoPを含まないトークンリクエストを送ってみます。

curl -i -X POST \
-H "Content-Type:application/x-www-form-urlencoded" \
-d "client_id=test-client" \
-d "grant_type=authorization_code" \
-d "code=1ebfb4ee-5fd7-47db-88fa-7bc5fd165d21.038267e1-46ac-4e63-b2d0-036c89718264.4ae28041-1079-4e8d-9e7f-ad0c4841c994" \
-d "redirect_uri=https://client.example.com/test" \
'http://localhost:8088/realms/sample-realm/protocol/openid-connect/token'

DPoPの設定が入っているので、下記のようなDPoP proofが見つからないというエラーレスポンスが帰ってきます。

{"error":"invalid_dpop_proof","error_description":"DPoP proof is missing"}

次の章からDPoPの情報を含んでリクエストを行いたいのですが、その前にDPoPについて理解しておくために簡単に説明します。

3.4 What is DPoP?

DPoPはRFC9449: OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer (DPoP) に記載されている拡張機能で、トークンを発行する際にアクセストークンと公開鍵を紐づけ、アクセストークンを使用する際にクライアントが対応する秘密鍵を所有していることを証明することを要求することにより、漏洩または盗難されたアクセストークンが使用されることを防ぐことを目的とした仕様です。

RFC9449: OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer (DPoP)に記載されている下記の図をベースに説明すると、おおまかに下記のような流れになります。

  • A: クライアントはトークンリクエスト時に DPoPヘッダーを含めて送信。
  • B: 認可サーバーはDPoPヘッダーに含まれる署名の検証、及びJWTの確認などを行い、
    問題がなければ、DPoPの公開鍵を紐づけたアクセストークンを返す
  • C: クライアントはDPoPヘッダーと、アクセストークンを用いてリソースへアクセス。
  • D: リソースサーバーはDPoPヘッダーに含まれる署名の検証、及びJWTの確認、
    Authroizationヘッダーで渡されるアクセストークンハッシュ値の確認などを行い、
    問題なければリソースを返す。
+--------+                                          +---------------+
|        |--(A)-- Token Request ------------------->|               |
| Client |        (DPoP Proof)                      | Authorization |
|        |                                          |     Server    |
|        |<-(B)-- DPoP-Bound Access Token ----------|               |
|        |        (token_type=DPoP)                 +---------------+
|        |
|        |
|        |                                          +---------------+
|        |--(C)-- DPoP-Bound Access Token --------->|               |
|        |        (DPoP Proof)                      |    Resource   |
|        |                                          |     Server    |
|        |<-(D)-- Protected Resource ---------------|               |
|        |                                          +---------------+
+--------+

Dの流れについては詳細は RFC9449 の 4.3. Checking DPoP Proofs をご確認ください。

3.5 DPoP含めた検証の確認

ではDPoPを含めて検証を進めていきます。まずは再度認可リクエストを送信してみます。

下記のようにレスポンスが返ってきました。 

次にトークンリクエストを行うのですが、DPoPはDPoP Proofという概念を導入しており、クライアントが用意した秘密鍵で署名されたJWT(=JWS)をヘッダーでHTTPリクエストと一緒に送信します。

RFC9449: OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer (DPoP)でいうAの箇所になります。

+--------+                                          +---------------+
|        |--(A)-- Token Request ------------------->|               |
| Client |        (DPoP Proof)                      | Authorization |
|        |                                          |     Server    |
|        |<-(B)-- DPoP-Bound Access Token ----------|               |
|        |        (token_type=DPoP)                 +---------------+
|        |
|        |
|        |                                          +---------------+
|        |--(C)-- DPoP-Bound Access Token --------->|               |
|        |        (DPoP Proof)                      |    Resource   |
|        |                                          |     Server    |
|        |<-(D)-- Protected Resource ---------------|               |
|        |                                          +---------------+
+--------+

3.6 JWSに使う鍵情報の作成

次に、鍵情報と JWKS を作成する必要があるのですが、今回は mkjwk.org というサービスを使ってみます。まずは画面右上から日本語に言語を変更してください。

該当ページを開けたら、タブが "RSA" であることを確認して、パラメーターは下記を指定した後に、"生成する" を押してください。

フィールド名
鍵のサイズ2048
鍵の用途署名
鍵のアルゴリズムPS256: RSASSA-PSS using SHA-256 and MGF1 with SHA-256
鍵のIDtest ※デフォルトのSpecifyの状態のまま test を指定してください
Show X.509Yes

下記のように 公開鍵・秘密鍵・ JWKS が生成されます。

今回の検証での公開鍵は、一例として下記のものを使います。

-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCt6ctahD+z5YbE
Ca3Q+i+A/kHzLEc20bz3/E26DsdspMjFYuc7TSo9+KQTXljzQL2D7yMFnVkaak7x
7CXC9YKHIk04g1+RKnMMmmrcAp5HjWVaWrKLnPO6OTvChneCOC251X7WV21NMtWz
grZZUroAtqATfm12l6wm0Nmd/G85q+n14zdPMUGYKcNYze807m3VtCW/KYo/3EOk
vsUIIz/zGAah86k/jaasKOWOxEBQ1pw0tjlvS/Z5Ohoc62L6GfJjgFVWZ3bvYb6M
IKGYA/9YTJ1xkDoVPTqUnPKeqKKlghvB6fX6/u/BS8kOMFyEPKGquzz6a3NRyZQZ
xxm43eLbAgMBAAECggEADlRtRRAQj6oh6JVDlSyILYDFvluvony1rVlErkf5dqI9
SMZVdzVsfZ63JkFn4uM6ulxB7nyAkwSfbJ+gnOfN0YSqCDYK8dMll6xBkc9Fa2/j
IgbHH2nXS0jb+7NItZobamyE/vRFNU8y/I+QptuCJ/zkQ7bvHg5xSOF8jN/36jB0
zdkWkgb+xE5/c4nq7so3rIxdAvrDEq619/3KUgX6bMZ6+YQxc32MOjPU5XMk19SI
WiXNIvjgnjqjif1d9nUA+OdU+BJ896KCFhqx/bILQL2w7b5qcypa202RMxLRrDit
R6ryy7IVKKEhGP7UG62E/GoNwGrKHJUa42kDgIpSgQKBgQD7wWy4YOai+HuzFIXN
9WSiFt50c55RxSC7zXwULn4Kme9WOjv7Fl83oq/51ln/trIIzTYwnNqVik257eQW
NWuLjKorJKjv1iwpSryieMKJBSefU8WJu3ST0hEr7SK/9m3YL/AZH/kf4vtlDLCV
IKhYhmu0T287Wzr8IIBLotEYeQKBgQCw2GcdsVidVFZGyJQN31dtXk+9dmkt+zeE
fTKm/46fB/Rc9+Ae0NyG8TJr2WigaelnF/F3h3GWc4J6gy5dp1ZwzJUId+7VgSa1
17rXqR1jOtPAg3bdB0G6l6trpA7zy8qd0U0u1v+0Dc123jtFLyoPyDI6bGG9ff1c
yOz43mbo8wKBgHeqDfJp5NbMA4gwlhU6shW1hxGVL0iwyYla98JkAvcpCjFTRtVn
YeUBCGJZt7ercF5spHhg5ik0bxOUdtjzlWXke7I8H6y4gY8y/gzAF7+nWpkJ6Zg5
KQVmUVEuy1ixWq7qwlY+81xruJDkgj6wIjTJ2AIBy95L0/KpxDGvd3IJAoGAPg4h
2PyDYOnYQIxfz6JglmMyzgQAEn+F0rrwDEO+8zUiXYEppwaZa8y1abznhurDWUbA
l7XyeN3dmknv+jMfFQPlBAy5xTfFsqeZy9VvF4PsDDDVg+fo+6X/JA10pb4MQmbQ
k7AkGDWDtMN0vuk29ETGw1OG1KyiPG3RId9A81sCgYA+YkaG+Cpl5ebaKxjI+Fp/
mnVOJ529vNFDXT/79gz/orFEMkGiSzq1PKe644y3kZfNW5IIBmMTLP0E+qn2WYJJ
3M5jTZqsGpjQvNzAC71OPPy+JKUCziLBwD5phgUgQVzRf9nZVAHOvXmTLPKSnuyG
sSRJ2eGWuE0rLcAOQbMdsg==
-----END PRIVATE KEY-----

今回の検証での秘密鍵は、一例として下記のものを使います。

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArenLWoQ/s+WGxAmt0Pov
gP5B8yxHNtG89/xNug7HbKTIxWLnO00qPfikE15Y80C9g+8jBZ1ZGmpO8ewlwvWC
hyJNOINfkSpzDJpq3AKeR41lWlqyi5zzujk7woZ3gjgtudV+1ldtTTLVs4K2WVK6
ALagE35tdpesJtDZnfxvOavp9eM3TzFBmCnDWM3vNO5t1bQlvymKP9xDpL7FCCM/
8xgGofOpP42mrCjljsRAUNacNLY5b0v2eToaHOti+hnyY4BVVmd272G+jCChmAP/
WEydcZA6FT06lJzynqiipYIbwen1+v7vwUvJDjBchDyhqrs8+mtzUcmUGccZuN3i
2wIDAQAB
-----END PUBLIC KEY-----

3.7 DPoPのJWTの作成

今回は Ruby で DPoPで使う JWS を作成していきます。Ruby の実行環境については Docker で適当な Ruby コンテナを作成し、 irb (Ruby コンソール)で作業していきます。

まずはコンテナを立ち上げて接続します。イメージはdocker公式のものを使っていきます。

$ docker run -it --rm ruby:3.2.2 bash

コマンドを実行すると、コマンドを受け付ける状態になっていると思いますので引き続きこの環境で作業を続けます。

JWSの作成には JSON::JWT という gem を使っていきます。下記のコマンドで gem を install しましょう。

$ gem install json-jwt

grep コマンドで gem が存在することを確認します。

$ gem list | grep json-jwt

ではここで irb ( Ruby コンソール)を開いていきます。

$ irb

以降 irb 内のプログラムは > 記号の後ろに続けて記載していきます。 では JWTを作成していくためまずは gem を読み込みます。

> require 'json/jwt'
=> true

次に秘密鍵を変数に保存します。

private_key = OpenSSL::PKey::RSA.new <<-PEM
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCt6ctahD+z5YbE
Ca3Q+i+A/kHzLEc20bz3/E26DsdspMjFYuc7TSo9+KQTXljzQL2D7yMFnVkaak7x
7CXC9YKHIk04g1+RKnMMmmrcAp5HjWVaWrKLnPO6OTvChneCOC251X7WV21NMtWz
grZZUroAtqATfm12l6wm0Nmd/G85q+n14zdPMUGYKcNYze807m3VtCW/KYo/3EOk
vsUIIz/zGAah86k/jaasKOWOxEBQ1pw0tjlvS/Z5Ohoc62L6GfJjgFVWZ3bvYb6M
IKGYA/9YTJ1xkDoVPTqUnPKeqKKlghvB6fX6/u/BS8kOMFyEPKGquzz6a3NRyZQZ
xxm43eLbAgMBAAECggEADlRtRRAQj6oh6JVDlSyILYDFvluvony1rVlErkf5dqI9
SMZVdzVsfZ63JkFn4uM6ulxB7nyAkwSfbJ+gnOfN0YSqCDYK8dMll6xBkc9Fa2/j
IgbHH2nXS0jb+7NItZobamyE/vRFNU8y/I+QptuCJ/zkQ7bvHg5xSOF8jN/36jB0
zdkWkgb+xE5/c4nq7so3rIxdAvrDEq619/3KUgX6bMZ6+YQxc32MOjPU5XMk19SI
WiXNIvjgnjqjif1d9nUA+OdU+BJ896KCFhqx/bILQL2w7b5qcypa202RMxLRrDit
R6ryy7IVKKEhGP7UG62E/GoNwGrKHJUa42kDgIpSgQKBgQD7wWy4YOai+HuzFIXN
9WSiFt50c55RxSC7zXwULn4Kme9WOjv7Fl83oq/51ln/trIIzTYwnNqVik257eQW
NWuLjKorJKjv1iwpSryieMKJBSefU8WJu3ST0hEr7SK/9m3YL/AZH/kf4vtlDLCV
IKhYhmu0T287Wzr8IIBLotEYeQKBgQCw2GcdsVidVFZGyJQN31dtXk+9dmkt+zeE
fTKm/46fB/Rc9+Ae0NyG8TJr2WigaelnF/F3h3GWc4J6gy5dp1ZwzJUId+7VgSa1
17rXqR1jOtPAg3bdB0G6l6trpA7zy8qd0U0u1v+0Dc123jtFLyoPyDI6bGG9ff1c
yOz43mbo8wKBgHeqDfJp5NbMA4gwlhU6shW1hxGVL0iwyYla98JkAvcpCjFTRtVn
YeUBCGJZt7ercF5spHhg5ik0bxOUdtjzlWXke7I8H6y4gY8y/gzAF7+nWpkJ6Zg5
KQVmUVEuy1ixWq7qwlY+81xruJDkgj6wIjTJ2AIBy95L0/KpxDGvd3IJAoGAPg4h
2PyDYOnYQIxfz6JglmMyzgQAEn+F0rrwDEO+8zUiXYEppwaZa8y1abznhurDWUbA
l7XyeN3dmknv+jMfFQPlBAy5xTfFsqeZy9VvF4PsDDDVg+fo+6X/JA10pb4MQmbQ
k7AkGDWDtMN0vuk29ETGw1OG1KyiPG3RId9A81sCgYA+YkaG+Cpl5ebaKxjI+Fp/
mnVOJ529vNFDXT/79gz/orFEMkGiSzq1PKe644y3kZfNW5IIBmMTLP0E+qn2WYJJ
3M5jTZqsGpjQvNzAC71OPPy+JKUCziLBwD5phgUgQVzRf9nZVAHOvXmTLPKSnuyG
sSRJ2eGWuE0rLcAOQbMdsg==
-----END PRIVATE KEY-----
PEM
=> #<OpenSSL::PKey::RSA:0x00007f07b1a12b78 oid=rsaEncryption>

そして公開鍵も変数に保存します。

> public_key = OpenSSL::PKey::RSA.new <<-PEM
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArenLWoQ/s+WGxAmt0Pov
gP5B8yxHNtG89/xNug7HbKTIxWLnO00qPfikE15Y80C9g+8jBZ1ZGmpO8ewlwvWC
hyJNOINfkSpzDJpq3AKeR41lWlqyi5zzujk7woZ3gjgtudV+1ldtTTLVs4K2WVK6
ALagE35tdpesJtDZnfxvOavp9eM3TzFBmCnDWM3vNO5t1bQlvymKP9xDpL7FCCM/
8xgGofOpP42mrCjljsRAUNacNLY5b0v2eToaHOti+hnyY4BVVmd272G+jCChmAP/
WEydcZA6FT06lJzynqiipYIbwen1+v7vwUvJDjBchDyhqrs8+mtzUcmUGccZuN3i
2wIDAQAB
-----END PUBLIC KEY-----
PEM
=> #<OpenSSL::PKey::RSA:0x00007f07b191e668 oid=rsaEncryption>

次にペイロード用の変数を準備します。

> payload = {
  'jti': SecureRandom.uuid,
  'htm': 'POST',
  'htu': 'http://localhost:8088/realms/sample-realm/protocol/openid-connect/token',
  'iat': Time.now.to_i,
}
=> 
{:jti=>"89ced3ef-06d2-4c25-8dd0-9a9eec78f284",

ペイロードの値は下記のような値になります。

フィールド
jti一意な識別子
htmDPoP Proofを送信する際のリクエストのHTTPメソッドの値
htuDPoP Proofを送信する際のリクエストのHTTP ターゲット URI
iatDPoP ProofのJWTの作成タイムスタンプ

ではペイロードをJWT化しましょう。

> jwt = JSON::JWT.new payload
=>
{"jti"=>"89ced3ef-06d2-4c25-8dd0-9a9eec78f284",
...

また、JWTのヘッダーをDPoPの指定のもに変更します。

> jwt.header[:typ] = "dpop+jwt"
=> "dpop+jwt"

JWTのheader部には JWK を含める必要があるためJWKを作成し、JWTのheaderの jwk パラメーターとして設定します。

> jwk = JSON::JWK.new(public_key, kid:'test')
=>
{"kty"=>:RSA,
...

> jwt.header[:jwk] = jwk
=>
{"kty"=>:RSA,
...

JWTに署名して、JWS を作成します。

> signed_jwt = jwt.sign(private_key, :PS256)
=>
{"jti"=>"89ced3ef-06d2-4c25-8dd0-9a9eec78f284",
...

作成した JWS を確認してみましょう。

> signed_jwt.to_s
=> "eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IlBTMjU2IiwiandrIjp7Imt0eSI6IlJTQSIsImUiOiJBUUFCIiwibiI6InJlbkxXb1Ffcy1XR3hBbXQwUG92Z1A1Qjh5eEhOdEc4OV94TnVnN0hiS1RJeFdMbk8wMHFQZmlrRTE1WTgwQzlnLThqQloxWkdtcE84ZXdsd3ZXQ2h5Sk5PSU5ma1NwekRKcHEzQUtlUjQxbFdscXlpNXp6dWprN3dvWjNnamd0dWRWLTFsZHRUVExWczRLMldWSzZBTGFnRTM1dGRwZXNKdERabmZ4dk9hdnA5ZU0zVHpGQm1DbkRXTTN2Tk81dDFiUWx2eW1LUDl4RHBMN0ZDQ01fOHhnR29mT3BQNDJtckNqbGpzUkFVTmFjTkxZNWIwdjJlVG9hSE90aS1obnlZNEJWVm1kMjcyRy1qQ0NobUFQX1dFeWRjWkE2RlQwNmxKenlucWlpcFlJYndlbjEtdjd2d1V2SkRqQmNoRHlocXJzOC1tdHpVY21VR2NjWnVOM2kydyIsImtpZCI6InRlc3QifX0.eyJqdGkiOiI4OWNlZDNlZi0wNmQyLTRjMjUtOGRkMC05YTllZWM3OGYyODQiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cDovL2xvY2FsaG9zdDo4MDg4L3JlYWxtcy9zYW1wbGUtcmVhbG0vcHJvdG9jb2wvb3BlbmlkLWNvbm5lY3QvdG9rZW4iLCJpYXQiOjE3MDMxNDc3MzR9.XCrth5RcAlyg0LMa0y8Bh3GSnkhsut3l3KIeTEmEaGUA-kCMBNp0t-NEdAjWUSx7EIiqzvsh3Z_RmUMo1AZggIHZlwx6Mhb0_-PsDgCjmZpBBJYXcpSHf3-HNmcMgoXVGHUQYJ2PfSthnswXkv7N2f0qRbngoPC4fUvW1kKj-3jV3OT0HqphvAqP_GKv4budGWmRbR9pJ5n-j-nxCvitptpGNL4v1FNeNTdixiSay7wnYPb0HTzo46CFVx_JX-MknYunudYQmNEE9HQWjLB9do8_zD3sEy_DRKrciRjZ4ZJx6vJSVt-Ot6oz9fiAav6EDVwJgYJ8iZSi9sWpHloTeg"

環境ごとに値は変わりますが、上記のような JWS 形式の文字列を取得できるので、これをヘッダーで指定していきます。

3.8 DPoP Proof と共にトークンリクエスト

DPoPを用いてトークンリクエストを行うために、再度認可エンドポイントより認可コードを再取得しておきます。

下記のようにレスポンスが返ってきたものとします。

上記で取得した code の箇所の認可コードと、直近のステップで作成した DPoP Proof をセットしてトークンリクエストを行います。

curl -i -X POST \
-H "Content-Type:application/x-www-form-urlencoded" \
-H "DPoP:eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IlBTMjU2IiwiandrIjp7Imt0eSI6IlJTQSIsImUiOiJBUUFCIiwibiI6InJlbkxXb1Ffcy1XR3hBbXQwUG92Z1A1Qjh5eEhOdEc4OV94TnVnN0hiS1RJeFdMbk8wMHFQZmlrRTE1WTgwQzlnLThqQloxWkdtcE84ZXdsd3ZXQ2h5Sk5PSU5ma1NwekRKcHEzQUtlUjQxbFdscXlpNXp6dWprN3dvWjNnamd0dWRWLTFsZHRUVExWczRLMldWSzZBTGFnRTM1dGRwZXNKdERabmZ4dk9hdnA5ZU0zVHpGQm1DbkRXTTN2Tk81dDFiUWx2eW1LUDl4RHBMN0ZDQ01fOHhnR29mT3BQNDJtckNqbGpzUkFVTmFjTkxZNWIwdjJlVG9hSE90aS1obnlZNEJWVm1kMjcyRy1qQ0NobUFQX1dFeWRjWkE2RlQwNmxKenlucWlpcFlJYndlbjEtdjd2d1V2SkRqQmNoRHlocXJzOC1tdHpVY21VR2NjWnVOM2kydyIsImtpZCI6InRlc3QifX0.eyJqdGkiOiI2ZjI3MjQ5NS1hZDQ1LTQ5MzItYmJjMi1kMzE2NjY0NTA3ZDEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cDovL2xvY2FsaG9zdDo4MDg4L3JlYWxtcy9zYW1wbGUtcmVhbG0vcHJvdG9jb2wvb3BlbmlkLWNvbm5lY3QvdG9rZW4iLCJpYXQiOjE3MDMxNTM1NTB9.RmtUCHCM_TJVTB9ms_CVY5J6XNCySjyZTDt_jk5QNA1u1oV6JtRxsXJEAzRDAQpE7KLyftKHwQi9c05vWFcaSRQhxUEvoxaVpM_ACbNF9BYDjHmd0cC0C7oXpUSTI5vB70EYetJGPqWFNJexB7rRHtU5IOPU-5MN6HcNo2oFJXfVFtgZA3edgmArHJp8037ciQ-FUAYudEv71yNC_gO3d1wb4YsE79_obk6xP8yQvITfROntzUSrlv3OxhO_Y98XKOxkUra-3XArmSqcmSjX1_mH7yeUXXW9i2fG8cY21CsayKaMrDiqcbblISoC1nkql3NKWjko2ULB060pknCOwg" \
-d "client_id=test-client" \
-d "grant_type=authorization_code" \
-d "code=8116bbc5-de56-4ec7-ba93-f2af2206855d.7e04b333-e7a1-48e6-a38b-b2bcf1f70000.4ae28041-1079-4e8d-9e7f-ad0c4841c994" \
-d "redirect_uri=https://client.example.com/test" \
'http://localhost:8088/realms/sample-realm/protocol/openid-connect/token'

ですが、多くの場合は下記のようなエラーが帰ってきているかと思います。

{"error":"invalid_dpop_proof","error_description":"DPoP proof is not active"}%

これはなぜかというと、Keycloak のDPoP Proofのバリデーションの処理の1つの、iat のフィールドを見てDPoP Proof が古すぎるか、新しすぎるとエラーにするという処理があります。DPoP ProofのJWTを作成する際に、今回のように手動で作成している場合は多くの場合ひっかかってしまうためです。

実際のKeycloakの実装箇所では下記のように実装されています。

        public boolean test(DPoP t) throws DPoPVerificationException {
            long time = Time.currentTime();
            Long iat = t.getIat();

            if (!(iat <= time + clockSkew && iat > time - lifetime)) {
                throw new DPoPVerificationException(t, "DPoP proof is not active");
            }
            return true;
        }

基本的には認可リクエストのレスポンスの認可コードを取得して、アクセストークンをリクエストする処理はクライアント側が機械的に行うので問題ありませんが、今回は検証のため手動でやっているので下記のように少しイレギュラーな対応で無理やり進めます。

  1. 認可リクエストを送信
  2. 認可コードを取得
  3. アクセストークンをリクエストする雛形(CURL文)を作っておく
  4. DPoP Proof を作成
  5. DPoP Proof をCURL文に埋め込んで送信

上記の4の手順で、4から5の作業でかかる時間を考慮して、iat の値は現在時間 + 15秒など変更してみましょう。具体的には下記のように irb で入力し、JWTを作成してCURLに埋め込んで送信してみましょう。

payload = {
  'jti': SecureRandom.uuid,
  'htm': 'POST',
  'htu': 'http://localhost:8088/realms/sample-realm/protocol/openid-connect/token',
  'iat': (Time.now + 15).to_i,
}

jwt = JSON::JWT.new payload
jwt.header[:typ] = "dpop+jwt"
jwt.header[:jwk] = jwk
signed_jwt = jwt.sign(private_key, :PS256)
signed_jwt.to_s

下記のようにアクセストークンが取得できれば処理としては成功です。

{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJqckFjalZVSU1Tb1RMVEpOZXdVZklSaUt0NzU5enZUYmp2RDdBejFvdUZjIn0.eyJleHAiOjE3MDMxNTM4NTEsImlhdCI6MTcwMzE1MzU1MSwiYXV0aF90aW1lIjoxNzAzMTUzMDgxLCJqdGkiOiI3ZjRjOTU1MS0wNjM4LTQ1OWItOWM0OS1hYTM3NTdmZDE3OTYiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODgvcmVhbG1zL3NhbXBsZS1yZWFsbSIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiIxZDMzZjc5Yy01NTU5LTQxZGYtOTZkZS03ZGJlYjIzM2YxM2YiLCJ0eXAiOiJEUG9QIiwiYXpwIjoidGVzdC1jbGllbnQiLCJub25jZSI6ImFiY2RlZmdoaWprIiwic2Vzc2lvbl9zdGF0ZSI6IjdlMDRiMzMzLWU3YTEtNDhlNi1hMzhiLWIyYmNmMWY3MDAwMCIsImFjciI6IjAiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cHM6Ly9jbGllbnQuZXhhbXBsZS5jb20iXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtc2FtcGxlLXJlYWxtIiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sImNuZiI6eyJqa3QiOiJIUHlBVEZRYkFOZ0ZMRVNLb1hmWm1hYWVPYlg3UGRMeF82WE9Nb2kxUkZnIn0sInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwiLCJzaWQiOiI3ZTA0YjMzMy1lN2ExLTQ4ZTYtYTM4Yi1iMmJjZjFmNzAwMDAiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJlZF91c2VybmFtZSI6ImNsLXRhcm8ifQ.vpcMgEnKzh3zmmxDFEwVl_C3mXNPm42jCOu87XYMDRlA5BwHFTS5NUnkkyspkZV8CcYPLrVonXtYwZ_zm58wjObndw1Oka_xif6WZEmqB_Lzsrl3SsPbVtY4QfUR24IhGC42IuZ3MFZkFhdHAz1YpTU3XLRGy8xzrlWsjkM35Yk5PDoIOYFXdHd1ZGfxQsMlA4p4mTn40WuufBouDuI7Hp8laD1J4Fh4tcr_6RjDfzxxQj8Yz1gvFWuzNwdHtxPjX_U4Q3LR2RczHRuF9HL57QzLAhBqjYmqkt13cHEg0U3RgD0TZCHCNQ0GsRfELVn05Z1vQAkbwDVqFP5FADTxYA","expires_in":300,"refresh_expires_in":1750,"refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJkODAyNDZlZS1lMmM0LTQwNWEtODZjMC01OTc5NDJmY2M5ZmYifQ.eyJleHAiOjE3MDMxNTUzMDEsImlhdCI6MTcwMzE1MzU1MSwianRpIjoiNGYwNzhiNTUtNjY5ZC00NTIzLWJjMmItY2I0N2Q3ZWE0MzMwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDg4L3JlYWxtcy9zYW1wbGUtcmVhbG0iLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjgwODgvcmVhbG1zL3NhbXBsZS1yZWFsbSIsInN1YiI6IjFkMzNmNzljLTU1NTktNDFkZi05NmRlLTdkYmViMjMzZjEzZiIsInR5cCI6IlJlZnJlc2giLCJhenAiOiJ0ZXN0LWNsaWVudCIsIm5vbmNlIjoiYWJjZGVmZ2hpamsiLCJzZXNzaW9uX3N0YXRlIjoiN2UwNGIzMzMtZTdhMS00OGU2LWEzOGItYjJiY2YxZjcwMDAwIiwiY25mIjp7ImprdCI6IkhQeUFURlFiQU5nRkxFU0tvWGZabWFhZU9iWDdQZEx4XzZYT01vaTFSRmcifSwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCIsInNpZCI6IjdlMDRiMzMzLWU3YTEtNDhlNi1hMzhiLWIyYmNmMWY3MDAwMCJ9.Xblbkc-rHhb85k5vqXLsO_zvLf6CYXerY15tdNpQkUs","token_type":"DPoP","id_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJqckFjalZVSU1Tb1RMVEpOZXdVZklSaUt0NzU5enZUYmp2RDdBejFvdUZjIn0.eyJleHAiOjE3MDMxNTM4NTEsImlhdCI6MTcwMzE1MzU1MSwiYXV0aF90aW1lIjoxNzAzMTUzMDgxLCJqdGkiOiI3OWE2OTUyZS1lMzM1LTQ3ZDEtYmU3YS05NzdjMmM1ZmUwNDkiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODgvcmVhbG1zL3NhbXBsZS1yZWFsbSIsImF1ZCI6InRlc3QtY2xpZW50Iiwic3ViIjoiMWQzM2Y3OWMtNTU1OS00MWRmLTk2ZGUtN2RiZWIyMzNmMTNmIiwidHlwIjoiSUQiLCJhenAiOiJ0ZXN0LWNsaWVudCIsIm5vbmNlIjoiYWJjZGVmZ2hpamsiLCJzZXNzaW9uX3N0YXRlIjoiN2UwNGIzMzMtZTdhMS00OGU2LWEzOGItYjJiY2YxZjcwMDAwIiwiYXRfaGFzaCI6IndxNHV4ZFV4bFMzOXh5d0NuaUI3aEEiLCJhY3IiOiIwIiwic2lkIjoiN2UwNGIzMzMtZTdhMS00OGU2LWEzOGItYjJiY2YxZjcwMDAwIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJjbC10YXJvIn0.C--V8ip1OL2YRwTPLEfTZOsnm825Mqgjd4l1aY49ZEvFaNvhqA_QbNR8URn7KPvg2OxD53-ExpTJXzgL63S6Tr3O14La0n_Aglp6YSoXeNJKckboHEjWzIBHUt2gX7KSaJcIWwFdy6diit350nSqx8Sd56Laj_AjtUMdokMG1LJr9iyDEoJLYK3ahOtOUQc3AbpbjEjRg72vBXfxGjz842T21AcMMlC_HPdcI2412oGfDK8e-bBMG_I9-G5WBn4ahr8x3HEh0GK5-KFfLXUn1px-dL9McdGFxwnFvSHPGC3OHMMvRvoT2vGHWd5-4qGPFVNM0nxM19dUNFwKDyzm1w","not-before-policy":0,"session_state":"7e04b333-e7a1-48e6-a38b-b2bcf1f70000","scope":"openid profile email"}

3.9 リソースサーバーへのリクエスト

ではせっかくなので、取得したアクセストークンを用いてリソースサーバーへのリクエストを行うことを想像してみます。

RFC9449: OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer (DPoP)でいうCの箇所になります。

+--------+                                          +---------------+
|        |--(A)-- Token Request ------------------->|               |
| Client |        (DPoP Proof)                      | Authorization |
|        |                                          |     Server    |
|        |<-(B)-- DPoP-Bound Access Token ----------|               |
|        |        (token_type=DPoP)                 +---------------+
|        |
|        |
|        |                                          +---------------+
|        |--(C)-- DPoP-Bound Access Token --------->|               |
|        |        (DPoP Proof)                      |    Resource   |
|        |                                          |     Server    |
|        |<-(D)-- Protected Resource ---------------|               |
|        |                                          +---------------+
+--------+

このときのリクエストについてですが、保護されたリソースにアクセストークンの提示とともにDPoP Proof を渡す場合は ath クレームを含む必要があります。

フィールド
athアクセストークンの値のASCIIエンコーディングのSHA-256ハッシュをbase64urlエンコーディングした値

まだirbを開き続けている場合には下記のように payload を作成してください。

> access_token = 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJqckFjalZVSU1Tb1RMVEpOZXdVZklSaUt0NzU5enZUYmp2RDdBejFvdUZjIn0.eyJleHAiOjE3MDMxNTM4NTEsImlhdCI6MTcwMzE1MzU1MSwiYXV0aF90aW1lIjoxNzAzMTUzMDgxLCJqdGkiOiI3ZjRjOTU1MS0wNjM4LTQ1OWItOWM0OS1hYTM3NTdmZDE3OTYiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODgvcmVhbG1zL3NhbXBsZS1yZWFsbSIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiIxZDMzZjc5Yy01NTU5LTQxZGYtOTZkZS03ZGJlYjIzM2YxM2YiLCJ0eXAiOiJEUG9QIiwiYXpwIjoidGVzdC1jbGllbnQiLCJub25jZSI6ImFiY2RlZmdoaWprIiwic2Vzc2lvbl9zdGF0ZSI6IjdlMDRiMzMzLWU3YTEtNDhlNi1hMzhiLWIyYmNmMWY3MDAwMCIsImFjciI6IjAiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cHM6Ly9jbGllbnQuZXhhbXBsZS5jb20iXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtc2FtcGxlLXJlYWxtIiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sImNuZiI6eyJqa3QiOiJIUHlBVEZRYkFOZ0ZMRVNLb1hmWm1hYWVPYlg3UGRMeF82WE9Nb2kxUkZnIn0sInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwiLCJzaWQiOiI3ZTA0YjMzMy1lN2ExLTQ4ZTYtYTM4Yi1iMmJjZjFmNzAwMDAiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJlZF91c2VybmFtZSI6ImNsLXRhcm8ifQ.vpcMgEnKzh3zmmxDFEwVl_C3mXNPm42jCOu87XYMDRlA5BwHFTS5NUnkkyspkZV8CcYPLrVonXtYwZ_zm58wjObndw1Oka_xif6WZEmqB_Lzsrl3SsPbVtY4QfUR24IhGC42IuZ3MFZkFhdHAz1YpTU3XLRGy8xzrlWsjkM35Yk5PDoIOYFXdHd1ZGfxQsMlA4p4mTn40WuufBouDuI7Hp8laD1J4Fh4tcr_6RjDfzxxQj8Yz1gvFWuzNwdHtxPjX_U4Q3LR2RczHRuF9HL57QzLAhBqjYmqkt13cHEg0U3RgD0TZCHCNQ0GsRfELVn05Z1vQAkbwDVqFP5FADTxYA'
=> "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJqckFjalZVSU1Tb1RMVEpOZXdVZklSaUt0NzU5enZUYmp2RDdBejFvdUZjIn0.eyJleHAiOjE3MDMxNTM...

payload = {
  'jti': SecureRandom.uuid,
  'htm': 'GET',
  'htu': 'http://localhost:8088/user/1',
  'iat': Time.now.to_i,
  'ath': Base64.urlsafe_encode64(Digest::SHA256.digest(access_token)).delete('=')
}
=>
{:jti=>"56f995a3-19a7-4b65-9ccd-e640ad4ccfb2",
...

その後、トークンリクエストの再と同じようにJWSを作成していきます。

> jwt = JSON::JWT.new payload
=>
{"jti"=>"asdfghjklzxcvbnm",
...

> jwt.header[:typ] = "dpop+jwt"
=> "dpop+jwt"

> jwk = JSON::JWK.new(public_key, kid:'test')
=>
{"kty"=>:RSA,
...

> jwt.header[:jwk] = jwk
=>
{"kty"=>:RSA,
...

> signed_jwt = jwt.sign(private_key, :PS256)
=>
{"jti"=>"56f995a3-19a7-4b65-9ccd-e640ad4ccfb2",
...

> signed_jwt.to_s
=> "eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IlBTMjU2IiwiandrIjp7Imt0eSI6IlJTQSIsImUiOiJBUUFCIiwibiI6InJlbkxXb1Ffcy1XR3hBbXQwUG92Z1A1Qjh5eEhOdEc4OV94TnVnN0hiS1RJeFdMbk8wMHFQZmlrRTE1WTgwQzlnLThqQloxWkdtcE84ZXdsd3ZXQ2h5Sk5PSU5ma1NwekRKcHEzQUtlUjQxbFdscXlpNXp6dWprN3dvWjNnamd0dWRWLTFsZHRUVExWczRLMldWSzZBTGFnRTM1dGRwZXNKdERabmZ4dk9hdnA5ZU0zVHpGQm1DbkRXTTN2Tk81dDFiUWx2eW1LUDl4RHBMN0ZDQ01fOHhnR29mT3BQNDJtckNqbGpzUkFVTmFjTkxZNWIwdjJlVG9hSE90aS1obnlZNEJWVm1kMjcyRy1qQ0NobUFQX1dFeWRjWkE2RlQwNmxKenlucWlpcFlJYndlbjEtdjd2d1V2SkRqQmNoRHlocXJzOC1tdHpVY21VR2NjWnVOM2kydyIsImtpZCI6InRlc3QifX0.eyJqdGkiOiI1NmY5OTVhMy0xOWE3LTRiNjUtOWNjZC1lNjQwYWQ0Y2NmYjIiLCJodG0iOiJHRVQiLCJodHUiOiJodHRwOi8vbG9jYWxob3N0OjgwODgvdXNlci8xIiwiaWF0IjoxNzAzMTUzOTgwLCJhdGgiOiJ3cTR1eGRVeGxTMzl4eXdDbmlCN2hLMUljOXlqMmJqU0JXTzlJUHd5RzVFIn0.OC2VbnjR397eRX0LTuHGGZUMnADwqXmcFN7r09D8i4US9JAf4KpViYqPpPxW_0AzFfQb81ycxZOVhxp5vwd5Ittrr9x2bof4fWIN3zDhzRwd_OavS7hhTDF7NWRXSMDxlO6ui6HTzfOtWR4-SrhtB6ZAWqAeXQQ7GoNk6NWFHyhQpE-jhJOKZ0uYqdYrdKQYzqhjziZXCXRFMj6Up4hHPHg5WU7p0HMPwva2FEvO97xfqfn4rM4bd6Fj8qh_K81_b9EvMNT6bQr93W7lGxObiVT5Xz9C3W53dh4ngiTNijYXxLBpSMaznGbhyZyz448vxyRJldE92I47xg803rRt8Q"

また、 DPoP Proof とともに、 Authorization ヘッダーを用いて バインドしたアクセストークンを指定する必要があるので、サンプルとしては下記のようになります。

curl -i \
-H "Authorization:DPoP eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJqckFjalZVSU1Tb1RMVEpOZXdVZklSaUt0NzU5enZUYmp2RDdBejFvdUZjIn0.eyJleHAiOjE3MDMxNTM4NTEsImlhdCI6MTcwMzE1MzU1MSwiYXV0aF90aW1lIjoxNzAzMTUzMDgxLCJqdGkiOiI3ZjRjOTU1MS0wNjM4LTQ1OWItOWM0OS1hYTM3NTdmZDE3OTYiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODgvcmVhbG1zL3NhbXBsZS1yZWFsbSIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiIxZDMzZjc5Yy01NTU5LTQxZGYtOTZkZS03ZGJlYjIzM2YxM2YiLCJ0eXAiOiJEUG9QIiwiYXpwIjoidGVzdC1jbGllbnQiLCJub25jZSI6ImFiY2RlZmdoaWprIiwic2Vzc2lvbl9zdGF0ZSI6IjdlMDRiMzMzLWU3YTEtNDhlNi1hMzhiLWIyYmNmMWY3MDAwMCIsImFjciI6IjAiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cHM6Ly9jbGllbnQuZXhhbXBsZS5jb20iXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtc2FtcGxlLXJlYWxtIiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sImNuZiI6eyJqa3QiOiJIUHlBVEZRYkFOZ0ZMRVNLb1hmWm1hYWVPYlg3UGRMeF82WE9Nb2kxUkZnIn0sInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwiLCJzaWQiOiI3ZTA0YjMzMy1lN2ExLTQ4ZTYtYTM4Yi1iMmJjZjFmNzAwMDAiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJlZF91c2VybmFtZSI6ImNsLXRhcm8ifQ.vpcMgEnKzh3zmmxDFEwVl_C3mXNPm42jCOu87XYMDRlA5BwHFTS5NUnkkyspkZV8CcYPLrVonXtYwZ_zm58wjObndw1Oka_xif6WZEmqB_Lzsrl3SsPbVtY4QfUR24IhGC42IuZ3MFZkFhdHAz1YpTU3XLRGy8xzrlWsjkM35Yk5PDoIOYFXdHd1ZGfxQsMlA4p4mTn40WuufBouDuI7Hp8laD1J4Fh4tcr_6RjDfzxxQj8Yz1gvFWuzNwdHtxPjX_U4Q3LR2RczHRuF9HL57QzLAhBqjYmqkt13cHEg0U3RgD0TZCHCNQ0GsRfELVn05Z1vQAkbwDVqFP5FADTxYA" \
-H "DPoP:eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IlBTMjU2IiwiandrIjp7Imt0eSI6IlJTQSIsImUiOiJBUUFCIiwibiI6InJlbkxXb1Ffcy1XR3hBbXQwUG92Z1A1Qjh5eEhOdEc4OV94TnVnN0hiS1RJeFdMbk8wMHFQZmlrRTE1WTgwQzlnLThqQloxWkdtcE84ZXdsd3ZXQ2h5Sk5PSU5ma1NwekRKcHEzQUtlUjQxbFdscXlpNXp6dWprN3dvWjNnamd0dWRWLTFsZHRUVExWczRLMldWSzZBTGFnRTM1dGRwZXNKdERabmZ4dk9hdnA5ZU0zVHpGQm1DbkRXTTN2Tk81dDFiUWx2eW1LUDl4RHBMN0ZDQ01fOHhnR29mT3BQNDJtckNqbGpzUkFVTmFjTkxZNWIwdjJlVG9hSE90aS1obnlZNEJWVm1kMjcyRy1qQ0NobUFQX1dFeWRjWkE2RlQwNmxKenlucWlpcFlJYndlbjEtdjd2d1V2SkRqQmNoRHlocXJzOC1tdHpVY21VR2NjWnVOM2kydyIsImtpZCI6InRlc3QifX0.eyJqdGkiOiI1NmY5OTVhMy0xOWE3LTRiNjUtOWNjZC1lNjQwYWQ0Y2NmYjIiLCJodG0iOiJHRVQiLCJodHUiOiJodHRwOi8vbG9jYWxob3N0OjgwODgvdXNlci8xIiwiaWF0IjoxNzAzMTUzOTgwLCJhdGgiOiJ3cTR1eGRVeGxTMzl4eXdDbmlCN2hLMUljOXlqMmJqU0JXTzlJUHd5RzVFIn0.OC2VbnjR397eRX0LTuHGGZUMnADwqXmcFN7r09D8i4US9JAf4KpViYqPpPxW_0AzFfQb81ycxZOVhxp5vwd5Ittrr9x2bof4fWIN3zDhzRwd_OavS7hhTDF7NWRXSMDxlO6ui6HTzfOtWR4-SrhtB6ZAWqAeXQQ7GoNk6NWFHyhQpE-jhJOKZ0uYqdYrdKQYzqhjziZXCXRFMj6Up4hHPHg5WU7p0HMPwva2FEvO97xfqfn4rM4bd6Fj8qh_K81_b9EvMNT6bQr93W7lGxObiVT5Xz9C3W53dh4ngiTNijYXxLBpSMaznGbhyZyz448vxyRJldE92I47xg803rRt8Q" \
'http://localhost:8088/user/1'

4. あとがき

今回は KeycloakでのDPoP実装を試してみました。過去のFAPI1 Advancedの記事で検証したMTLSでは対応が難しいケースなど、DPoPによってある程度のケースを補完できるのではないかと思います。

また、KeycloakのFAPI 2ドラフト仕様への対応も確認されているので、いずれどこかでご紹介できればと思います。

新規CTA