DPoP 導入ガイド

このガイドでは、Google の OAuth プラットフォームとの OAuth 2.0 統合で DPoP(Demonstrating Proof-of-Possession)を実装する方法について詳しく説明します。DPoP(RFC 9449 で定義)は、トークンをクライアント生成の非対称鍵ペアに暗号的にバインドすることで、トークンの盗難やリプレイ攻撃からアプリケーションを保護します。

認可コードフローの変更

既存の OAuth 2.0 認可コードフローに DPoP を追加するには、鍵ペアの生成と保存、DPoP 証明 JWT の構築、図 1 のステップ 5 と 6 に示すように、認可コードが更新トークンと交換されるときに証明を HTTP ヘッダーとして含める必要があります。

DPoP を使用した認可コードフロー
図 1. DPoP を使用した認可コードフローのイベント シーケンス。

認証コードのリクエスト

承認リクエストは正常に作成されます。次に例を示します。

$ curl -G "https://accounts.google.com/o/oauth2/v2/auth" \
  --data-urlencode "client_id=YOUR_CLIENT_ID.apps.googleusercontent.com" \
  --data-urlencode "redirect_uri=http://127.0.0.1:8080" \
  --data-urlencode "response_type=code" \
  --data-urlencode "scope=calendar.readonly" \
  --data-urlencode "state=AI1Bvapj7E5SDmtW4gohcA" \
  --data-urlencode "code_challenge=PO4pPROl-31Wy9fVZ7uTW9Ga6CrjrSKsf4AAtx_JNM8" \
  --data-urlencode "code_challenge_method=S256" \
  --data-urlencode "nonce=PrMfmSNAvJFPQ7GnlEKUaw" \
  --data-urlencode "access_type=offline" \
  --data-urlencode "prompt=consent"

リダイレクト URI パラメータとして返された認証コードは、DPoP 証明の構築に使用されます。更新トークンは、トークン エンドポイントに対する以降のすべてのリクエストで HTTP ヘッダーとして含まれる証明にバインドされます。

client_secret 要件と DPoP-Nonce ヘッダーの CORS 制限により、シークレットレスの純粋なクライアントサイド SPA は DPoP を直接使用できません。SPA を保護するには、機密クライアントとして機能し、access_type=offline を有効にして、DPoP を使用して更新トークンをサーバーサイドでバインドする Backend-for-Frontend(BFF)を介してトラフィックをルーティングします。

DPoP 証明を構築する

証明には JOSE ヘッダーとペイロードが含まれます。

ヘッダーを構築するには、EC P-256(ES256)鍵ペアを生成し、公開鍵座標(xy)を jwk パラメータに含めます。RSA 鍵ペアも使用できますが、計算コストが高くなるため推奨されません。

これは JOSE ヘッダーの例です。

{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": {
    "kty": "EC",
    "crv": "P-256",
    "x": "VC91y9ZYdfSWaDv8JaI6gx5ifOw2rn3YdqkAB51Uu6E",
    "y": "ikPjOtea4k7fWPVrRYwaA4Ww6iVY3pOOICotHwwGV3o"
  }
}

証明ペイロードを構築するには、4 つの値が必要です。

2 つのクレーム: htm: POSThtu: https://oauth2.googleapis.com/token は固定値であり、Google のトークン エンドポイントにリクエストを行うときに変更されません。

他の 2 つのクレーム(iatjti)は、リクエストごとに生成する必要があります。iat の値は発行時のタイムスタンプで、リクエストごとに変化します。JWT ID(jti)クレームの値は、交換のタイプによって異なります。認可コードがアクセス トークンと更新トークンに交換される場合、jti の値は、認証コードの Base-64 および URL エンコードされた SHA256 ハッシュ(jti = BASE64URL(SHA-256(authorization_code)) など)になります。

ペイロード本文の例を次に示します。

{
  "jti": "o29CN8LIY0l_N8iy5-ilon1guad9NFQHFOdXTzrBNck",
  "htm": "POST",
  "htu": "https://oauth2.googleapis.com/token",
  "iat": 1784822025
}

JOSE ヘッダーとペイロード本文は、トークン リクエストの DPoP HTTP ヘッダーで直接使用するために JWT(RFC7519)としてエンコードされます。

$ curl -X POST https://oauth2.googleapis.com/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6\
       IkVDIiwiY3J2IjoiUC0yNTYiLCJ4IjoiVkM5MXk5WllkZlNXYUR2OEphSTZneDVpZ\
       k93MnJuM1lkcWtBQjUxVXU2RSIsInkiOiJpa1BqT3RlYTRrN2ZXUFZyUll3YUE0V3\
       c2aVZZM3BPT0lDb3RId3dHVjNvIn19.eyJqdGkiOiJvMjlDTjhMSVkwbF9OOGl5NS\
       1pbG9uMWd1YWQ5TkZRSEZPZFhUenJCTmNrIiwiaHRtIjoiUE9TVCIsImh0dSI6Imh\
       0dHBzOi8vb2F1dGgyLmdvb2dsZWFwaXMuY29tL3Rva2VuIiwiaWF0IjoxNzg0ODIy\
       MDI1fQ.OSdQCmqTng_uZmGK5UXf8hcEMtoOu7ucmYtl5mx4901RXnj6fJRJQmIeTq\
       fhprRBTG_RSJv2fPcWDqvQbDW7YA" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=4/0AXEQxIDNpLD-qpSIvjHb2Hl10uS_2sk2GBRpO8UJQ78YZF3hZ9LB9kTA1xYLD4xisi4C5w" \
  --data-urlencode "redirect_uri=http://127.0.0.1:8080" \
  --data-urlencode "client_id=YOUR_CLIENT_ID.apps.googleusercontent.com" \
  --data-urlencode "client_secret=YOUR_CLIENT_SECRET" \
  --data-urlencode "code_verifier=q8ZztyVv7HH8E2M-SEL8WaB-7CPs68rejN5UZ9OdYgo"

DPoP バインド更新トークンは、DPoP-Nonce HTTP ヘッダーとともに返されます。例:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
DPoP-Nonce: AN3XwJjZsjnb0ZuWkRlek8QU7wY-Zhf-5IP6tO0tORz0KgtDT1Bo8FX-w4nz3r5lnepI

{
  "access_token": "ya29.a0ARGnu0aebRL97B91dmvm14gTug5wpItFf9MVWq12Hja6yv09A_qxa4T73_z2gFbf32qR4RXispQ7vnOzv6gn0APLQrF51LVa6AOqCVPH2Tupocv8y0JHu4ByEbvgXEEhiHEU8Xa9_w3i-PKBPsKWiLi210RCZdqJjLXkcRrGnoPPjbGPzOPtm6KCJjPrNHG16caOWecaCgYKASESARASFQHGX2MiBn7ihbbk_n-buCbOfl2TDA0206",
  "expires_in": 3599,
  "refresh_token": "1//06dUPZ9FIBQm3CgYIARAAGAYSNwF-L9IrJwuIEKUA_zbBPU-xoCDGM0QrDu7-jv7cMQZ0kARPUK9WhwfFFfbOVEgXDQKmFh4w9GM",
  "scope": "https://www.googleapis.com/auth/calendar.readonly",
  "token_type": "Bearer"
}

Google の認証サーバーによって生成されたノンスは、後続のすべてのトークン リクエストに含める必要があります。nonce 値は 1 回のみ使用され、nonce 値がない、無効である、期限切れである、再利用されている場合は、HTTP 400 レスポンスで拒否されます。この場合、再試行で使用する新しいノンスが返されます。

トークン更新フローの変更

既存の OAuth 2.0 トークン更新フローを更新するには、図 2 のステップ 2 ~ 5 に示すように、更新トークンを新しいトークンと交換するときに、DPoP 証明を生成して HTTP ヘッダーとして送信する必要があります。

DPoP を使用したトークン更新フロー
図 2. エラー処理と再試行を含むトークン更新フローのイベント シーケンス。

DPoP 証明の構築

トークン更新の証明を構築する方法は、認可コードのシナリオとは異なります。JOSE ヘッダーは、認証コード リクエストの作成時に前述した方法で作成されます。証明の本文は同様に構築されますが、nonce クレームが含まれ、jti には一意のランダム文字列が含まれます。

ペイロードの本文を構築するには、以前に返された DPoP-Nonce HTTP ヘッダー値を nonce クレームに含め、発行済みタイムスタンプ(iat)をリクエストごとに更新する必要があります。JWT ID(jti)は、組み込みの WebCrypto API crypto.getRandomValues(new Uint8Array(24)) を使用してリクエストごとに生成され、文字列が Base64URL エンコードされた一意のランダム文字列です。

jtinonceiat を含むペイロード本文の例を次に示します。

{
  "jti": "o29CN8ZIY0l_K8iy5-ilon1gwad9NF6HFOdXTzrBNck",
  "htm": "POST",
  "htu": "https://oauth2.googleapis.com/token",
  "nonce": "AN3XwJjZsjnb0ZuWkRlek8QU7wY-Zhf-5IP6tO0tORz0KgtDT1Bo8FX-w4nz3r5lnepI",
  "iat": 1784822025
}

JOSE ヘッダーとペイロード本文は、トークン リクエストの DPoP HTTP ヘッダーで直接使用するために JWT(RFC7519)としてエンコードされます。

証明は、トークン更新リクエストの DPoP ヘッダーとして追加されます。

$ curl -X POST https://oauth2.googleapis.com/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6\
       IkVDIiwiY3J2IjoiUC0yNTYiLCJ4IjoiVkM5MXk5WllkZlNXYUR2OEphSTZneDVpZ\
       k93MnJuM1lkcWtBQjUxVXU2RSIsInkiOiJpa1BqT3RlYTRrN2ZXUFZyUll3YUE0V3\
       c2aVZZM3BPT0lDb3RId3dHVjNvIn19.eyJqdGkiOiJvMjlDTjhaSVkwbF9LOGl5NS\
       1pbG9uMWd1YWQ5TkY2SEZPZFhUenJCTmNrIiwiaHRtIjoiUE9TVCIsImh0dSI6Imh\
       0dHBzOi8vb2F1dGgyLmdvb2dsZWFwaXMuY29tL3Rva2VuIiwibm9uY2UiOiJBTjNY\
       d0pqWnNqbmIwWnVXa1JsZWs4UVU3d1ktWmhmLTVJUDZ0TzB0T1J6MEtndERUMUJvO\
       EZYLXc0bnozcjVsbmVwSSIsImlhdCI6MTc4NDgyMjAyNX0.MEQCIDm09AXo2c9sov\
       GrTUkrbEB_k9mra_Dkji-CQ9mSZVP1AiBxbiqkCE7Dt9RKyUT_3kj7q1vCvVggwnW\
       JNX3P3vO1mw" \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "refresh_token=1//06dUPZ9FIBQm3CgYIARAAGAYSNwF-L9IrJwuIEKUA_zbBPU-xoCDGM0QrDu7-jv7cMQZ0kARPUK9WhwfFFfbOVEgXDQKmFh4w9GM" \
  --data-urlencode "client_id=YOUR_CLIENT_ID.apps.googleusercontent.com" \
  --data-urlencode "client_secret=YOUR_CLIENT_SECRET"

期限切れ、誤った、または再利用された nonce が使用された場合、または異なる OAuth ワークフロー間を移行する場合(最初の認証コード交換からトークン更新リクエストに移行するなど)、Google のサーバーはワークフローの分離を強制します。つまり、サーバーは HTTP 400 use_dpop_nonce チャレンジで nonce を無条件に拒否し、新しいワークフローの新しい nonce 名前空間を確立します。

これは、再試行と DPoP-Nonce 値を使用して新しい証明を構築する必要がある 400 レスポンスの例です。

HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
DPoP-Nonce: AO4t07Kf85RJXmltUhiAiELLPPrJ4zOi66zWxU1uDZbhRcahFBYvT0WlcjSSXULXknSA

{
  "error": "use_dpop_nonce",
  "error_description": "New DPoP nonce issued due to invalid or expired challenge."
}

成功すると、新しい nonce と有効期間の短いアクセス トークンが返されます。

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
DPoP-Nonce: AO4t07IXuovyCbtLEr6VVFZQ_Kb78MMOXTt6-CyZpsJeF62HZ3P_EW55XbWqYcU76Jg=

{
  "access_token": "ya29.a0ARGnu0bDj9BAQYVbF5hi3vw-brBUZBZu1bnInk1hS7gueqEb6QPqUjDGb0MMj9A0QX5FRrJo3FDw-DEDtvVbRUdeCgjwsL_LVVFXz-p-MUyiFyRoufI4KC0Go9aq5cEjD_BWvOJLMSIY6_EnwnhqDgk0XxvzaaAxDnv8PXJAGev_UotcfApstqi0NCxbfi-6Kgull9QaCgYKAUQSARASFQHGX2MiZpMjRS6z4S0RjOkNxn2o1Q0206",
  "expires_in": 3599,
  "scope": "https://www.googleapis.com/auth/calendar.readonly",
  "token_type": "Bearer",
  "challenge": "AO4t07IXuovyCbtLEr6VVFZQ_Kb78MMOXTt6-CyZpsJeF62HZ3P_EW55XbWqYcU76Jg"
}

次のリクエストで使用するために DPoP-Nonce 値を保存します。

詳細と推奨事項については、ウェブサーバー アプリケーションでの OAuth 2.0 の使用ベスト プラクティスをご覧ください。