คู่มือนี้จะอธิบายรายละเอียดวิธีใช้ DPoP (Demonstrating Proof-of-Possession) ในการผสานรวม OAuth 2.0 กับแพลตฟอร์ม OAuth ของ Google DPoP (ตามที่ระบุไว้ใน RFC 9449) จะช่วยปกป้องแอปพลิเคชันของคุณจากการโจรกรรมโทเค็นและการโจมตีแบบ Replay โดยการผูกโทเค็นกับคู่คีย์แบบไม่สมมาตรที่ไคลเอ็นต์สร้างขึ้นด้วยการเข้ารหัส
การเปลี่ยนแปลงขั้นตอนรหัสการให้สิทธิ์
การเพิ่ม DPoP ลงในขั้นตอนรหัสการให้สิทธิ์ OAuth 2.0 ที่มีอยู่ต้องมีการสร้างและจัดเก็บคู่คีย์ การสร้าง JWT หลักฐาน DPoP และการรวมหลักฐานเป็นส่วนหัว HTTP เมื่อมีการแลกเปลี่ยนรหัสการให้สิทธิ์เป็นโทเค็นการรีเฟรช ดังที่แสดงในขั้นตอนที่ 5 และ 6 ของรูปภาพที่ 1
คำขอรหัสการให้สิทธิ์
ระบบจะสร้างคำขอการให้สิทธิ์ตามปกติ เช่น
$ 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 ฝั่งไคลเอ็นต์แบบเพียวๆ ที่ไม่มีข้อมูลลับไม่สามารถใช้ DPoP โดยตรงได้เนื่องจากข้อกำหนด client_secret และข้อจำกัด CORS ในส่วนหัว DPoP-Nonce หากต้องการรักษาความปลอดภัยให้กับ 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 (jti) จะขึ้นอยู่กับประเภทของการแลกเปลี่ยน เมื่อมีการแลกเปลี่ยนรหัสการให้สิทธิ์เป็นโทเค็นเพื่อการเข้าถึงและโทเค็นการรีเฟรช ค่าของ jti จะเป็นแฮช SHA256 ที่เข้ารหัส Base-64 และ URL ของรหัสการให้สิทธิ์ เช่น jti =
BASE64URL(SHA-256(authorization_code)) การอ้างสิทธิ์ nonce เป็นตัวเลือกในการแลกเปลี่ยนรหัสการให้สิทธิ์ครั้งแรก และจำเป็นต้องใช้ก็ต่อเมื่อไคลเอ็นต์ของคุณมี DPoP-Nonce ที่ถูกต้องจากคำขอก่อนหน้านี้อยู่แล้ว
ตัวอย่างเนื้อหาเพย์โหลดมีดังนี้
{
"jti": "o29CN8LIY0l_N8iy5-ilon1guad9NFQHFOdXTzrBNck",
"htm": "POST",
"htu": "https://oauth2.googleapis.com/token",
"iat": 1784822025
}
ส่วนหัว JOSE และเนื้อหาเพย์โหลดจะได้รับการเข้ารหัสและลงนามเป็น JWT (RFC 7519) เพื่อใช้โดยตรงในส่วนหัว HTTP DPoP ในคำขอโทเค็น สำหรับ ES256 ลายเซ็น JWS ต้องใช้รูปแบบ IEEE P1363 ดิบ (R || S, 64 ไบต์สำหรับ P-256) ดังนี้
$ 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 พร้อมกับส่วนหัว HTTP DPoP-Nonce เช่น
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"
}
แคชและส่ง DPoP-Nonce ล่าสุดที่เซิร์ฟเวอร์การให้สิทธิ์ของ Google แสดงผลในคำขอโทเค็นครั้งต่อๆ ไปเสมอ Nonce จะหมดอายุเป็นระยะๆ และหากหลักฐานไม่มีการอ้างสิทธิ์ nonce ที่จำเป็น หรือมี Nonce ที่ไม่ถูกต้องหรือหมดอายุ เซิร์ฟเวอร์จะแสดงผลการตอบกลับ HTTP 400 (use_dpop_nonce) พร้อมส่วนหัว DPoP-Nonce ใหม่เพื่อใช้ในการลองอีกครั้ง
การเปลี่ยนแปลงขั้นตอนการรีเฟรชโทเค็น
การอัปเดตขั้นตอนการรีเฟรชโทเค็น OAuth 2.0 ที่มีอยู่ต้องมีการสร้างและส่งหลักฐาน DPoP เป็นส่วนหัว HTTP เมื่อมีการแลกเปลี่ยนโทเค็นการรีเฟรชเป็นโทเค็นใหม่ ดังที่แสดงในขั้นตอนที่ 2-5 ของรูปภาพที่ 2
การสร้างหลักฐาน DPoP
วิธีการสร้างหลักฐานสำหรับการรีเฟรชโทเค็นจะแตกต่างจากสถานการณ์ของรหัสการให้สิทธิ์ ส่วนหัว JOSE จะสร้างขึ้นในลักษณะเดียวกับที่อธิบายไว้ก่อนหน้านี้เมื่อสร้างคำขอรหัสการให้สิทธิ์ เนื้อหาของหลักฐานจะสร้างขึ้นในลักษณะเดียวกัน แต่รวมการอ้างสิทธิ์ nonce และ jti มีสตริงแบบสุ่มที่ไม่ซ้ำกัน
หากต้องการสร้างเนื้อหาเพย์โหลด ค่าส่วนหัว HTTP DPoP-Nonce ที่แสดงผลก่อนหน้านี้ต้องรวมอยู่ในคำกล่าวอ้าง nonce และการประทับเวลาที่ออก (iat) ที่อัปเดตสำหรับทุกคำขอ รหัส JWT (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 และเนื้อหาเพย์โหลดจะได้รับการเข้ารหัสเป็น JWT (RFC7519) เพื่อใช้โดยตรงในส่วนหัว HTTP ของ DPoP ในคำขอโทเค็น
ระบบจะเพิ่มหลักฐานเป็นส่วนหัว 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"
เมื่อมีการใช้ Nonce ที่หมดอายุหรือไม่ถูกต้อง หรือเมื่อเปลี่ยนระหว่างเวิร์กโฟลว์ OAuth ต่างๆ (เช่น การย้ายจากการแลกเปลี่ยนรหัสการให้สิทธิ์เริ่มต้นไปยังคำขอรีเฟรชโทเค็น) เซิร์ฟเวอร์ของ Google จะบังคับใช้การแยกเวิร์กโฟลว์ ซึ่งหมายความว่าเซิร์ฟเวอร์จะปฏิเสธ Nonce โดยไม่มีเงื่อนไขด้วยการตรวจสอบ 400 use_dpop_nonce ของ HTTP เพื่อสร้างเนมสเปซ Nonce ใหม่สำหรับเวิร์กโฟลว์ใหม่
ต่อไปนี้คือตัวอย่างการตอบกลับ 400 ที่ต้องลองอีกครั้งและต้องสร้างหลักฐานใหม่โดยใช้ค่า DPoP-Nonce
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"
}
บันทึกค่า DPoP-Nonce เพื่อใช้ในคำขอถัดไป
ดูรายละเอียดและคำแนะนำเพิ่มเติมได้ที่การใช้ OAuth 2.0 สำหรับแอปพลิเคชันเว็บเซิร์ฟเวอร์และแนวทางปฏิบัติแนะนำ