DPoP 採用指南

本指南將詳細說明如何將 DPoP (Demonstrating Proof-of-Possession) 導入 Google OAuth 平台與 OAuth 2.0 的整合。DPoP (定義於 RFC 9449) 會透過加密方式,將權杖繫結至用戶端產生的非對稱金鑰組,保護應用程式免受權杖竊取和重送攻擊。

授權碼流程異動

如要在現有的 OAuth 2.0 授權碼流程中加入 DPoP,您必須產生並儲存金鑰組、建構 DPoP 證明 JWT,並在授權碼交換更新權杖時,將證明納入 HTTP 標頭,如圖 1 的步驟 5 和 6 所示。

採用 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 標頭的形式加入。

純粹、無密鑰的用戶端 SPA 無法直接使用 DPoP,因為 DPoP-Nonce 標頭有 client_secret 需求和 CORS 限制。如要保護 SPA,請透過 Backend-for-Frontend (BFF) 轉送流量,該 BFF 會做為機密用戶端、啟用 access_type=offline,並利用 DPoP 繫結更新權杖伺服器端。

建構 DPoP 證明

證明包含 JOSE 標頭和酬載。

如要建構標頭,請產生 EC P-256 (ES256) 金鑰組,並在 jwk 參數中加入公開金鑰座標 (x 和 y)。系統也支援 RSA 金鑰組 (RS256),但建議使用 ES256,因為簽章大小較小,運算成本也較低。

以下是 JOSE 標頭範例:

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

如要建構證明酬載,必須提供四個憑證 (htm、htu、iat 和 jti),以及選用的 nonce 憑證。

htm: POST 和 htu: https://oauth2.googleapis.com/token 這兩個宣告是固定值,向 Google 的權杖端點提出要求時不會變更。

另外兩個宣告:iat 和 jti 必須為每個要求產生。iat 的值是核發時間戳記,會隨要求而變更。JWT ID (jti) 聲明的值取決於交換類型。將授權碼換成存取權杖和更新權杖時,jti 的值是授權碼的 Base-64 和網址編碼 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 標頭和酬載主體會編碼並簽署為 JWT (RFC 7519),直接用於權杖要求中的 DPoP HTTP 標頭。如果是 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 會定期過期,如果證明缺少必要的 nonce 宣告,或包含無效或過期的 Nonce,伺服器會傳回 HTTP 400 (use_dpop_nonce) 回應,並附上新的 DPoP-Nonce 標頭,供重試時使用。

權杖更新流程異動

如要更新現有的 OAuth 2.0 權杖更新流程,必須在交換更新權杖以取得新權杖時,產生並傳送 DPoP 證明做為 HTTP 標頭,如圖 2 的步驟 2 至 5 所示。

使用 DPoP 的權杖更新流程
圖 2. 權杖重新整理流程中的事件序列,包含錯誤處理和重試。

建立 DPoP 證明

建構權杖更新證明的方法與授權碼情境不同。JOSE 標頭的建構方式與先前建構授權碼要求時所述的方式相同。證明主體的建構方式類似,但包含 nonce 宣告,且 jti 包含不重複的隨機字串。

如要建構酬載主體,必須在 nonce 宣告中加入先前傳回的 DPoP-Nonce HTTP 標頭值,並更新每個要求的核發時間戳記 (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 標頭和酬載主體會編碼為 JWT (RFC7519),直接用於權杖要求中的 DPoP HTTP 標頭。

證明會以 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,並傳送 HTTP 400 use_dpop_nonce 驗證,為新工作流程建立新的 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 處理網路伺服器應用程式」和「最佳做法」。