このガイドでは、Google の OAuth プラットフォームとの OAuth 2.0 統合で DPoP(Demonstrating Proof-of-Possession)を実装する方法について説明します。DPoP(RFC 9449 で定義)は、トークンをクライアントが生成した非対称鍵ペアに暗号的にバインドすることで、トークンの盗難やリプレイ攻撃からアプリケーションを保護します。
認可コードフローの変更
既存の OAuth 2.0 認可コードフローに DPoP を追加するには、鍵ペアの生成と保存、DPoP 証明 JWT の作成、認可コードを更新トークンと交換する際の HTTP ヘッダーとしての証明の追加が必要です(図 1 のステップ 5 と 6 を参照)。
認証コードのリクエスト
承認リクエストは通常どおり作成されます。次に例を示します。
$ 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 ヘッダーとして含まれる証明にバインドされます。
純粋なシークレットレスのクライアントサイド SPA は、client_secret 要件と DPoP-Nonce ヘッダーの CORS 制限により、DPoP を直接使用できません。SPA を保護するには、機密クライアントとして機能する Backend-for-Frontend(BFF)を介してトラフィックをルーティングし、access_type=offline を有効にして、DPoP を使用して更新トークンをサーバーサイドにバインドします。
DPoP 署名を作成する
証明には JOSE ヘッダーとペイロードが含まれます。
ヘッダーを作成するには、EC P-256(ES256)鍵ペアを生成し、公開鍵の座標(x と y)を jwk パラメータに含めます。RSA 鍵ペア(RS256)もサポートされていますが、署名サイズが小さく、計算コストが低いため、ES256 をおすすめします。
JOSE ヘッダーの例を次に示します。
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "VC91y9ZYdfSWaDv8JaI6gx5ifOw2rn3YdqkAB51Uu6E",
"y": "ikPjOtea4k7fWPVrRYwaA4Ww6iVY3pOOICotHwwGV3o"
}
}
証明ペイロードを作成するには、4 つのクレーム(htm、htu、iat、jti)と、オプションの nonce クレームが必要です。
2 つのクレーム: htm: POST と htu: https://oauth2.googleapis.com/token は固定値であり、Google のトークン エンドポイントにリクエストを送信しても変更されません。
他の 2 つのクレーム(iat と jti)は、リクエストごとに生成する必要があります。iat の値は発行時のタイムスタンプであり、リクエストごとに変更されます。JWT ID(jti)クレームの値は、交換のタイプによって異なります。認可コードがアクセス トークンと更新トークンに交換される場合、jti の値は、認可コードの Base-64 および URL エンコードされた SHA256 ハッシュ(jti =
BASE64URL(SHA-256(authorization_code)) など)です。nonce クレームは、最初の認証コード交換では省略可能です。クライアントが以前のリクエストから有効な DPoP-Nonce をすでに保持している場合にのみ必要です。
ペイロード本文の例を次に示します。
{
"jti": "o29CN8LIY0l_N8iy5-ilon1guad9NFQHFOdXTzrBNck",
"htm": "POST",
"htu": "https://oauth2.googleapis.com/token",
"iat": 1784822025
}
JOSE ヘッダーとペイロード本文は、トークン リクエストの DPoP HTTP ヘッダーで直接使用するために、JWT(RFC 7519)としてエンコードされ、署名されます。ES256 の場合、JWS 署名では未加工の IEEE P1363 形式(R || S、P-256 の場合は 64 バイト)を使用する必要があります。
$ curl -X POST https://oauth2.googleapis.com/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIiwiY3J2IjoiUC0yNTYiLCJ4IjoiVkM5MXk5WllkZlNXYUR2OEphSTZneDVpZk93MnJuM1lkcWtBQjUxVXU2RSIsInkiOiJpa1BqT3RlYTRrN2ZXUFZyUll3YUE0V3c2aVZZM3BPT0lDb3RId3dHVjNvIn19.eyJqdGkiOiJvMjlDTjhMSVkwbF9OOGl5NS1pbG9uMWd1YWQ5TkZRSEZPZFhUenJCTmNrIiwiaHRtIjoiUE9TVCIsImh0dSI6Imh0dHBzOi8vb2F1dGgyLmdvb2dsZWFwaXMuY29tL3Rva2VuIiwiaWF0IjoxNzg0ODIyMDI1fQ.OSdQCmqTng_uZmGK5UXf8hcEMtoOu7ucmYtl5mx4901RXnj6fJRJQmIeTqfhprRBTG_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 の認証サーバーから返された最新の DPoP-Nonce を常にキャッシュに保存して送信します。ノンスは定期的に期限切れになります。証明に必要な nonce クレームがない場合、または無効なノンスや期限切れのノンスが含まれている場合、サーバーは HTTP 400(use_dpop_nonce)レスポンスを返します。このレスポンスには、再試行で使用する新しい DPoP-Nonce ヘッダーが含まれています。
トークン更新フローの変更
既存の OAuth 2.0 トークン更新フローを更新するには、図 2 のステップ 2 ~ 5 に示すように、更新トークンを新しいトークンと交換するときに、DPoP 証明を生成して HTTP ヘッダーとして送信する必要があります。
DPoP 認証の構築
トークン更新の証明を作成する方法は、認証コードのシナリオとは異なります。JOSE ヘッダーは、認可コード リクエストの作成時に前述した方法と同じ方法で作成されます。証明本文も同様に作成されますが、nonce クレームが含まれ、jti には一意のランダムな文字列が含まれます。
ペイロード本文を作成するには、以前に返された DPoP-Nonce HTTP ヘッダー値を nonce クレームに含め、リクエストごとに発行タイムスタンプ(iat)を更新する必要があります。JWT ID(jti)は、組み込みの WebCrypto API crypto.getRandomValues(new Uint8Array(24)) を使用してリクエストごとに生成され、Base64URL でエンコードされた一意のランダムな文字列です。
jti、nonce、iat を含むペイロード本文の例を次に示します。
{
"jti": "o29CN8ZIY0l_K8iy5-ilon1guad9NF6HFOdXTzrBNck",
"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: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIiwiY3J2IjoiUC0yNTYiLCJ4IjoiVkM5MXk5WllkZlNXYUR2OEphSTZneDVpZk93MnJuM1lkcWtBQjUxVXU2RSIsInkiOiJpa1BqT3RlYTRrN2ZXUFZyUll3YUE0V3c2aVZZM3BPT0lDb3RId3dHVjNvIn19.eyJqdGkiOiJvMjlDTjhaSVkwbF9LOGl5NS1pbG9uMWd1YWQ5TkY2SEZPZFhUenJCTmNrIiwiaHRtIjoiUE9TVCIsImh0dSI6Imh0dHBzOi8vb2F1dGgyLmdvb2dsZWFwaXMuY29tL3Rva2VuIiwibm9uY2UiOiJBTjNYd0pqWnNqbmIwWnVXa1JsZWs4UVU3d1ktWmhmLTVJUDZ0TzB0T1J6MEtndERUMUJvOEZYLXc0bnozcjVsbmVwSSIsImlhdCI6MTc4NDgyMjAyNX0.ObT0BejZz2yi8atNSStsQH-T2atr8OSOL4JD2ZJlU_VxbiqkCE7Dt9RKyUT_3kj7q1vCvVggwnWJNX3P3vO1mw" \
--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"
期限切れまたは無効なノンスが使用されている場合、または異なる OAuth ワークフロー間で移行する場合(最初の認証コード交換からトークン更新リクエストに移行する場合など)、Google のサーバーはワークフローの分離を適用します。つまり、サーバーは HTTP 400 use_dpop_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."
}
成功すると、新しいノンスと有効期間の短いアクセス トークンが返されます。
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"
}
次のリクエストで使用するために、DPoP-Nonce の値を保存します。
詳細と推奨事項については、ウェブサーバー アプリケーションに OAuth 2.0 を使用するとベスト プラクティスをご覧ください。