DPoP 採用指南

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

授權碼流程變更

如要將 DPoP 新增至現有的 OAuth 2.0 授權碼流程,必須產生及儲存金鑰組、建構 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 標頭形式傳送至權杖端點的驗證。

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

建構 DPoP 驗證

證明包含 JOSE 標頭和酬載。

如要建構標頭,請產生 EC P-256 (ES256) 金鑰組,並在 jwk 參數中加入公開金鑰座標 (xy)。您也可以使用 RSA 金鑰組,但由於運算成本較高,因此不建議使用。

以下是 JOSE 標頭範例:

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

如要建構驗證酬載,需要四個值。

htm: POSThtu: https://oauth2.googleapis.com/token 這兩項聲明是固定值,向 Google 權杖端點提出要求時不會變更。

另外兩項聲明:iatjti 必須為每項要求產生。iat 的值是簽發時間戳記,且每個要求都會變更。JWT ID (jti) 憑證附加資訊的值取決於交易類型。授權碼換成存取和更新權杖時,jti 的值是授權碼的 Base64 和網址編碼 SHA256 雜湊,例如 jti = BASE64URL(SHA-256(authorization_code))

以下是酬載主體的範例:

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

JOSE 標頭和酬載主體會編碼為 JWT (RFC7519),以便直接在權杖要求的 DPoP HTTP 標頭中使用:

$ 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 授權伺服器產生的隨機值必須包含在後續的每個權杖要求中。請注意,隨機值只能使用一次,如果隨機值遺失、無效、過期或重複使用,系統會拒絕要求,並傳回 HTTP 400 回應。在這種情況下,系統會傳回新的隨機值,供重試時使用。

權杖更新流程變更

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

使用 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 標頭和酬載主體會編碼為 JWT (RFC7519),以便直接在權杖要求的 DPoP HTTP 標頭中使用。

驗證碼會以 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"

如果使用過期、不正確或重複使用的隨機值,或是在不同 OAuth 工作流程之間轉換 (例如從初始授權碼交換移至權杖重新整理要求),Google 伺服器會強制執行工作流程隔離。也就是說,伺服器會透過 HTTP 400 use_dpop_nonce 質詢無條件拒絕 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."
}

成功後,系統會傳回新的隨機值和短期存取權杖:

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 處理網路伺服器應用程式」和「最佳做法」。