DPoP 채택 가이드

이 가이드에서는 Google의 OAuth 플랫폼과의 OAuth 2.0 통합에서 DPoP (Demonstrating Proof-of-Possession)를 구현하는 방법을 자세히 설명합니다. DPoP (RFC 9449에 정의됨)는 토큰을 클라이언트에서 생성된 비대칭 키 쌍에 암호화 방식으로 바인딩하여 토큰 도용 및 재생 공격으로부터 애플리케이션을 보호합니다.

승인 코드 플로우 변경사항

기존 OAuth 2.0 승인 코드 플로우에 DPoP를 추가하려면 키 쌍을 생성하고 저장하고, DPoP 증명 JWT를 구성하고, 그림 1의 5단계와 6단계에 표시된 것처럼 승인 코드가 갱신 토큰으로 교환될 때 증명을 HTTP 헤더로 포함해야 합니다.

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는 client_secret 요구사항과 DPoP-Nonce 헤더의 CORS 제한으로 인해 DPoP를 직접 사용할 수 없습니다. SPA를 보호하려면 기밀 클라이언트 역할을 하고, access_type=offline를 사용 설정하고, DPoP를 활용하여 갱신 토큰을 서버 측에 바인딩하는 BFF (Backend-for-Frontend)를 통해 트래픽을 라우팅합니다.

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"
  }
}

증명 페이로드를 구성하려면 4개의 클레임 (htm, htu, iat, jti)과 선택적 nonce 클레임이 필요합니다.

두 클레임 htm: POST 및 htu: https://oauth2.googleapis.com/token은 고정 값이며 Google의 토큰 엔드포인트에 요청할 때 변경되지 않습니다.

다른 두 클레임인 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는 주기적으로 만료되며, 증명에 필수 nonce 클레임이 누락되었거나 잘못되었거나 만료된 nonce가 포함된 경우 서버는 재시도에 사용할 새로운 DPoP-Nonce 헤더와 함께 HTTP 400 (use_dpop_nonce) 응답을 반환합니다.

토큰 갱신 흐름 변경사항

기존 OAuth 2.0 토큰 갱신 흐름을 업데이트하려면 그림 2의 2~5단계에 표시된 것처럼 갱신 토큰을 새 토큰으로 교환할 때 DPoP 증명을 생성하고 HTTP 헤더로 전송해야 합니다.

DPoP를 사용한 토큰 갱신 흐름
그림 2. 오류 처리 및 재시도가 포함된 토큰 새로고침 흐름의 이벤트 시퀀스

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"

만료되거나 잘못된 nonce가 사용되거나 다른 OAuth 워크플로 간에 전환할 때 (예: 초기 승인 코드 교환에서 토큰 새로고침 요청으로 이동) Google 서버는 워크플로 격리를 시행합니다. 즉, 서버는 새 워크플로에 대한 새로운 nonce 네임스페이스를 설정하기 위해 HTTP 400 use_dpop_nonce 챌린지를 사용하여 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."
}

성공하면 새 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 사용 및 권장사항을 참고하세요.