Guía de adopción de DPoP

En esta guía, se detalla cómo implementar DPoP (demostración de prueba de posesión) en tus integraciones de OAuth 2.0 con la plataforma de OAuth de Google. DPoP (definido en RFC 9449) protege tus aplicaciones contra el robo de tokens y los ataques de repetición vinculando criptográficamente los tokens a un par de claves asimétricas generadas por el cliente.

Cambios en el flujo de código de autorización

Agregar DPoP a un flujo de código de autorización de OAuth 2.0 existente requiere la generación y el almacenamiento de un par de claves, la construcción de un JWT de prueba de DPoP y la inclusión de la prueba como un encabezado HTTP cuando el código de autorización se intercambia por un token de actualización, como se muestra en los pasos 5 y 6 de la Figura 1.

Flujo de código de autorización con DPoP
Figura 1: La secuencia de eventos en el flujo de código de autorización con DPoP.

Solicitud de código de autorización

La solicitud de autorización se crea de forma normal. Por ejemplo:

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

El código de autorización que se devuelve como un parámetro de URI de redireccionamiento se usa en la construcción de una prueba de DPoP. El token de actualización se vincula a la prueba incluida como un encabezado HTTP en todas las solicitudes adicionales al extremo del token.

Las SPA puras del cliente sin secretos no pueden usar DPoP directamente debido al requisito de client_secret y a las limitaciones de CORS en el encabezado DPoP-Nonce. Para proteger las SPA, enruta el tráfico a través de un backend para frontend (BFF) que actúe como un cliente confidencial, habilite access_type=offline y use DPoP para vincular el token de actualización del lado del servidor.

Crea la prueba de DPoP

Una prueba contiene un encabezado JOSE y una carga útil.

Para construir el encabezado, genera un par de claves EC P-256 (ES256) y, luego, incluye las coordenadas de la clave pública (x y y) en el parámetro jwk. También se admite un par de claves RSA (RS256), aunque se recomienda ES256 debido al tamaño de firma más pequeño y al menor costo de procesamiento.

Este es un ejemplo de encabezado JOSE:

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

Para construir la carga útil de la prueba, se requieren cuatro reclamaciones (htm, htu, iat y jti), además de una reclamación nonce opcional.

Dos reclamaciones: htm: POST y htu: https://oauth2.googleapis.com/token son valores fijos y no cambian cuando se realiza una solicitud al extremo del token de Google.

Las otras dos reclamaciones, iat y jti, se deben generar para cada solicitud. El valor de iat es la marca de tiempo de emisión y cambia por solicitud. El valor de la reclamación del ID de JWT (jti) depende del tipo de intercambio. Cuando un código de autorización se intercambia por tokens de acceso y tokens de actualización, el valor de jti es el hash SHA256 codificado en Base-64 y URL del código de autorización, como jti = BASE64URL(SHA-256(authorization_code)). La reclamación nonce es opcional en el intercambio inicial de código de autorización y solo se necesita si tu cliente ya tiene un DPoP-Nonce válido de una solicitud anterior.

Este es un ejemplo de cuerpo de carga útil:

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

El encabezado JOSE y el cuerpo de la carga útil se codifican y firman como un JWT (RFC 7519) para su uso directo en el encabezado HTTP DPoP en la solicitud de token. Para ES256, la firma de JWS debe usar el formato IEEE P1363 sin procesar (R || S, 64 bytes para 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"

Se devuelve un token de actualización vinculado a DPoP junto con un encabezado HTTP DPoP-Nonce, por ejemplo:

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

Siempre almacena en caché y envía el DPoP-Nonce más reciente que devuelve el servidor de autorización de Google en las solicitudes de token posteriores. Los nonces vencen de forma periódica y, si a una prueba le falta una reclamación nonce obligatoria o contiene un nonce no válido o vencido, el servidor devuelve una respuesta HTTP 400 (use_dpop_nonce) con un encabezado DPoP-Nonce nuevo para usar en los reintentos.

Cambios en el flujo de actualización de tokens

Para actualizar un flujo de actualización de tokens de OAuth 2.0 existente, se requiere la generación y el envío de una prueba de DPoP como encabezado HTTP cuando se intercambia un token de actualización por tokens nuevos, como se muestra en los pasos del 2 al 5 de la figura 2.

Flujo de actualización de tokens con DPoP
Figura 2: Secuencia de eventos en el flujo de actualización de tokens con manejo de errores y reintentos.

Cómo compilar la prueba de DPoP

El método para construir una prueba de actualización de token difiere de la situación del código de autorización. El encabezado JOSE se construye de la misma manera que se describió anteriormente cuando se compila una solicitud de código de autorización. El cuerpo de la prueba se construye de manera similar, pero incluye una reclamación nonce y jti contiene una cadena aleatoria única.

Para construir el cuerpo de la carga útil, el valor del encabezado HTTP DPoP-Nonce que se devolvió antes debe incluirse en la reclamación nonce y la marca de tiempo de emisión (iat) se debe actualizar para cada solicitud. El ID de JWT (jti) es una cadena aleatoria única que se genera por solicitud, con la API de WebCrypto integrada crypto.getRandomValues(new Uint8Array(24)) y la codificación Base64URL de la cadena.

Este es un ejemplo de cuerpo de carga útil que contiene jti, nonce y iat:

{
  "jti": "o29CN8ZIY0l_K8iy5-ilon1guad9NF6HFOdXTzrBNck",
  "htm": "POST",
  "htu": "https://oauth2.googleapis.com/token",
  "nonce": "AN3XwJjZsjnb0ZuWkRlek8QU7wY-Zhf-5IP6tO0tORz0KgtDT1Bo8FX-w4nz3r5lnepI",
  "iat": 1784822025
}

El encabezado JOSE y el cuerpo de la carga útil se codifican como un JWT (RFC7519) para su uso directo en el encabezado HTTP de DPoP en la solicitud de token.

La prueba se agrega como un encabezado DPoP a la solicitud de actualización del token:

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

Cuando se usa un nonce vencido o no válido, o cuando se realiza la transición entre diferentes flujos de trabajo de OAuth (como pasar del intercambio de código de autorización inicial a una solicitud de actualización de token), el servidor de Google aplica el aislamiento del flujo de trabajo. Esto significa que el servidor rechaza de forma incondicional el nonce con un desafío HTTP 400 use_dpop_nonce para establecer un espacio de nombres de nonce nuevo para el flujo de trabajo nuevo.

Este es un ejemplo de respuesta 400 que requiere un reintento y una prueba nueva que se creará con el valor 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."
}

Si se ejecuta de forma correcta, se devuelven un nonce nuevo y un token de acceso de corta duración:

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

Guarda el valor de DPoP-Nonce para usarlo en la siguiente solicitud.

Consulta Usa OAuth 2.0 para aplicaciones de servidor web y Prácticas recomendadas para obtener más detalles y recomendaciones.