Panduan Penerapan DPoP

Panduan ini menjelaskan cara menerapkan DPoP (Demonstrating Proof-of-Possession) dalam integrasi OAuth 2.0 Anda dengan Platform OAuth Google. DPoP (didefinisikan dalam RFC 9449) mengamankan aplikasi Anda dari pencurian token dan serangan replay dengan mengikat token ke pasangan kunci asimetris yang dihasilkan klien secara kriptografis.

Perubahan alur Kode Otorisasi

Menambahkan DPoP ke alur Kode Otorisasi OAuth 2.0 yang ada memerlukan pembuatan dan penyimpanan pasangan kunci, pembuatan JWT bukti DPoP, dan menyertakan bukti sebagai header HTTP saat kode otorisasi ditukar dengan token refresh seperti yang ditunjukkan pada langkah 5 dan 6 Gambar 1.

Alur Kode Otorisasi dengan DPoP
Gambar 1. Urutan peristiwa dalam alur Kode Otorisasi menggunakan DPoP.

Permintaan kode otorisasi

Permintaan otorisasi dibuat secara normal. Contoh:

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

Kode otorisasi yang ditampilkan sebagai parameter URI pengalihan digunakan dalam pembuatan bukti DPoP. Token refresh terikat dengan bukti yang disertakan sebagai header HTTP dalam semua permintaan lebih lanjut ke endpoint token.

SPA sisi klien murni tanpa rahasia tidak dapat menggunakan DPoP secara langsung karena persyaratan client_secret dan batasan CORS pada header DPoP-Nonce. Untuk mengamankan SPA, arahkan traffic melalui Backend-for-Frontend (BFF) yang bertindak sebagai Klien Rahasia, mengaktifkan access_type=offline, dan menggunakan DPoP untuk mengikat token refresh di sisi server.

Buat bukti DPoP

Bukti berisi header JOSE dan payload.

Untuk membuat header, buat pasangan kunci EC P-256 (ES256) dan sertakan koordinat kunci publik (x dan y) dalam parameter jwk. Pasangan kunci RSA juga memungkinkan, tetapi tidak direkomendasikan karena biaya komputasi yang lebih tinggi.

Berikut adalah contoh header JOSE:

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

Untuk membuat payload bukti, diperlukan empat nilai.

Dua klaim: htm: POST dan htu: https://oauth2.googleapis.com/token adalah nilai tetap dan tidak berubah saat membuat permintaan ke endpoint token Google.

Dua klaim lainnya: iat dan jti harus dibuat untuk setiap permintaan. Nilai iat adalah stempel waktu dikeluarkan dan berubah per permintaan. Nilai klaim ID JWT (jti) bergantung pada jenis pertukaran. Saat kode otorisasi ditukar dengan token akses dan refresh, nilai jti adalah hash SHA256 berenkode Base-64 dan URL dari kode otorisasi, seperti jti = BASE64URL(SHA-256(authorization_code)).

Berikut contoh isi payload:

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

Header JOSE dan isi payload dienkode sebagai JWT (RFC7519) untuk penggunaan langsung di header HTTP DPoP dalam permintaan token:

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

Token refresh yang terikat DPoP ditampilkan bersama dengan header HTTP DPoP-Nonce, misalnya:

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

Nonce yang dibuat oleh server otorisasi Google harus disertakan dalam setiap permintaan token berikutnya. Perhatikan bahwa nilai nonce hanya digunakan satu kali dan nilai nonce yang tidak ada, tidak valid, sudah habis masa berlakunya, atau digunakan kembali akan ditolak dengan respons HTTP 400. Dalam hal ini, nonce baru ditampilkan untuk digunakan dalam percobaan ulang.

Perubahan alur refresh token

Memperbarui alur Penggantian Token OAuth 2.0 yang ada memerlukan pembuatan dan pengiriman bukti DPoP sebagai header HTTP saat token refresh ditukar dengan token baru seperti yang ditunjukkan pada langkah 2-5 Gambar 2.

Alur Pembaruan Token dengan DPoP
Gambar 2. Urutan peristiwa dalam alur Pengambilan Token dengan penanganan error dan percobaan ulang.

Membangun bukti DPoP

Metode untuk membuat bukti untuk refresh token berbeda dengan skenario kode otorisasi. Header JOSE dibuat dengan cara yang sama seperti yang dijelaskan sebelumnya saat membuat permintaan kode otorisasi. Isi bukti dibuat dengan cara yang sama, tetapi menyertakan klaim nonce dan jti berisi string acak unik.

Untuk membuat isi payload, nilai header HTTP DPoP-Nonce yang ditampilkan sebelumnya harus disertakan dalam klaim nonce dan stempel waktu dikeluarkan (iat) diperbarui untuk setiap permintaan. ID JWT (jti) adalah string acak unik yang dibuat per permintaan, menggunakan WebCrypto API bawaan crypto.getRandomValues(new Uint8Array(24)) dan mengenkode string Base64URL.

Berikut adalah contoh isi payload yang berisi jti, nonce, dan iat:

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

Header JOSE dan isi payload dienkode sebagai JWT (RFC7519) untuk penggunaan langsung di header HTTP DPoP dalam permintaan token.

Bukti ditambahkan sebagai header DPoP ke permintaan refresh token:

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

Jika nonce yang sudah habis masa berlakunya, salah, atau digunakan ulang digunakan atau saat melakukan transisi di antara alur kerja OAuth yang berbeda (seperti berpindah dari Pertukaran Kode Otorisasi awal ke permintaan Perpanjangan Token), server Google akan menerapkan isolasi alur kerja. Artinya, server menolak nonce tanpa syarat dengan tantangan HTTP 400 use_dpop_nonce untuk membuat namespace nonce baru bagi alur kerja baru.

Berikut contoh respons 400 yang memerlukan percobaan ulang dan bukti baru yang akan dibuat menggunakan nilai 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."
}

Jika berhasil, nonce baru dan token akses yang berlaku singkat akan ditampilkan:

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

Simpan nilai DPoP-Nonce untuk digunakan dalam permintaan berikutnya.

Lihat Menggunakan OAuth 2.0 untuk Aplikasi Server Web dan Praktik Terbaik untuk mengetahui detail dan rekomendasi tambahan.