במדריך הזה מוסבר איך להטמיע DPoP (הוכחת בעלות) בשילובי OAuth 2.0 עם פלטפורמת OAuth של Google. DPoP (מוגדר ב-RFC 9449) מגן על האפליקציות מפני גניבת טוקנים והתקפות של הפעלה מחדש, על ידי קישור קריפטוגרפי של טוקנים לזוג מפתחות אסימטרי שנוצר על ידי הלקוח.
שינויים בתהליך ההרשאה באמצעות קוד
כדי להוסיף 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 ומטען ייעודי (payload).
כדי ליצור את הכותרת, יוצרים צמד מפתחות 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"
}
}
כדי ליצור את המטען הייעודי (payload) של ההוכחה, נדרשות ארבע הצהרות (htm, htu, iat ו-jti), וגם הצהרת nonce אופציונלית.
שתי תביעות: htm: POST ו-htu: https://oauth2.googleapis.com/token הן ערכים קבועים שלא משתנים כששולחים בקשה לנקודת הקצה (endpoint) של האסימון של Google.
שתי התביעות האחרות: iat ו-jti חייבות להיווצר בכל בקשה. הערך של iat הוא חותמת הזמן של ההנפקה, והוא משתנה בכל בקשה. הערך של בקשת מזהה ה-JWT (jti) תלוי בסוג ההחלפה. כשקוד הרשאה מוחלף בטוקנים של גישה וטוקן רענון, הערך של jti הוא גיבוב SHA256 של קוד ההרשאה בקידוד Base-64 ו-URL, למשל jti =
BASE64URL(SHA-256(authorization_code)). הצהרת nonce היא אופציונלית בהחלפה הראשונית של קוד ההרשאה, ונדרשת רק אם ללקוח כבר יש DPoP-Nonce חוקי מבקשה קודמת.
זו דוגמה לגוף של מטען ייעודי (payload):
{
"jti": "o29CN8LIY0l_N8iy5-ilon1guad9NFQHFOdXTzrBNck",
"htm": "POST",
"htu": "https://oauth2.googleapis.com/token",
"iat": 1784822025
}
הכותרת של JOSE וגוף המטען הייעודי (payload) מקודדים ונחתמים כ-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 מכיל מחרוזת אקראית ייחודית.
כדי ליצור את גוף המטען הייעודי (payload), צריך לכלול את הערך של כותרת ה-HTTP מסוג DPoP-Nonce שהוחזר קודם לכן בטענת הנכוֹנוּת (claim) מסוג nonce, ולעדכן את חותמת הזמן של ההנפקה (iat) בכל בקשה. מזהה ה-JWT (jti) הוא מחרוזת אקראית ייחודית שנוצרת לכל בקשה, באמצעות WebCrypto API המובנה crypto.getRandomValues(new Uint8Array(24)) וקידוד Base64URL של המחרוזת.
זו דוגמה לגוף מטען ייעודי (payload) שמכיל את jti, nonce ו-iat:
{
"jti": "o29CN8ZIY0l_K8iy5-ilon1guad9NF6HFOdXTzrBNck",
"htm": "POST",
"htu": "https://oauth2.googleapis.com/token",
"nonce": "AN3XwJjZsjnb0ZuWkRlek8QU7wY-Zhf-5IP6tO0tORz0KgtDT1Bo8FX-w4nz3r5lnepI",
"iat": 1784822025
}
הכותרת של JOSE וגוף המטען הייעודי (payload) מקודדים כ-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 אוכף בידוד של תהליך העבודה. כלומר, השרת דוחה את המספר החד-פעמי ללא תנאי באמצעות אתגר HTTP 400 use_dpop_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 לאפליקציות אינטרנט ושיטות מומלצות.