DPoP 채택 가이드

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

승인 코드 플로우 변경사항

기존 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) 키 쌍을 생성하고 공개 키 좌표 (xy)를 jwk 매개변수에 포함합니다. RSA 키 쌍도 가능하지만 계산 비용이 높으므로 권장하지 않습니다.

다음은 JOSE 헤더의 예입니다.

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

증명 페이로드를 구성하려면 4개의 값이 필요합니다.

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

다른 두 클레임(iatjti)은 모든 요청에 대해 생성해야 합니다. iat의 값은 발급된 타임스탬프이며 요청마다 변경됩니다. JWT ID (jti) 클레임의 값은 교환 유형에 따라 다릅니다. 승인 코드가 액세스 토큰 및 갱신 토큰으로 교환되면 jti의 값은 승인 코드의 Base-64 및 URL로 인코딩된 SHA256 해시입니다(예: jti = BASE64URL(SHA-256(authorization_code))).

다음은 페이로드 본문의 예입니다.

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

JOSE 헤더와 페이로드 본문은 토큰 요청의 DPoP HTTP 헤더에서 직접 사용할 수 있도록 JWT (RFC7519)로 인코딩됩니다.

$ 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의 승인 서버에서 생성된 nonce는 모든 후속 토큰 요청에 포함되어야 합니다. nonce 값은 한 번만 사용되며 누락되거나, 잘못되었거나, 만료되었거나, 재사용된 nonce 값은 HTTP 400 응답으로 거부됩니다. 이 경우 재시도에 사용할 새 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-ilon1gwad9NF6HFOdXTzrBNck",
  "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: 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"

만료되었거나, 잘못되었거나, 재사용된 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",
  "challenge": "AO4t07IXuovyCbtLEr6VVFZQ_Kb78MMOXTt6-CyZpsJeF62HZ3P_EW55XbWqYcU76Jg"
}

다음 요청에서 사용할 DPoP-Nonce 값을 저장합니다.

자세한 내용과 권장사항은 웹 서버 애플리케이션용 OAuth 2.0 사용권장사항 을 참고하세요.