AizuDemy

Tutorial DPoP Node.js: Cegah Pencurian Token OAuth 2.0 & Replay Attack

Tutorial DPoP Node.js: Cegah Pencurian Token OAuth 2.0 & Replay Attack
๐ŸŽง
Dengarkan Artikel Ini
Suara AI Otomatis โ€ข 8 mnt baca baca
โšก TL;DR

Poin Kunci Artikel Ini:

  • Import JWK client, verifikasi signature DPoP proof.
  • Client kirim request tanpa nonce atau pakai nonce lama.
  • Client kirim ulang request. Server terima request.
๐Ÿ“‹ Daftar Isi Materi Tutup โ–ด

Bearer token pada OAuth 2.0 bekerja seperti uang tunai. Siapa pun pemegang token bisa akses resource terlindungi tanpa bukti kepemilikan. Attacker curi access token lewat Cross-Site Scripting (XSS), Man-in-the-Middle (MitM), log leakage, atau browser storage leakage. Attacker lalu panggil API atas nama korban.

RFC 9449 perkenalkan DPoP (Demonstrating Proof-of-Possession at Application Layer). DPoP ubah bearer token jadi sender-constrained token. Access token terikat kriptografis ke pasangan kunci publik/privat client. Attacker curi token DPoP tetap tidak bisa pakai token tanpa private key client.

Vektor Serangan Bearer Token & Keterbatasan Keamanan

REST API konvensional pakai header Authorization skema Bearer:

Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6...

Mekanisme ini punya celah keamanan fatal:

  • XSS Exfiltration: Script jahat di client-side baca token dari memory/storage, kirim ke server attacker. Attacker pakai token dari mana saja.
  • API Gateway / Reverse Proxy Log Leakage: Token tercatat di access log HTTP server format plaintext. Internal user atau attacker punya akses log langsung pakai token.
  • Replay Attack: Attacker cegat HTTP request, kirim ulang request sama persis berulang kali selama token belum expired.
  • Token Misuse Across Services: Token untuk Service A dipakai akses Service B karena token tidak terikat ke URI target tertentu.

OAuth 2.1 wajibkan token binding seperti DPoP atau mTLS (Mutual TLS). DPoP berjalan penuh di Application Layer (HTTP). DPoP tidak butuh infrastruktur mTLS rumit pada load balancer atau reverse proxy.

Konsep Kriptografi & Spesifikasi RFC 9449

DPoP mewajibkan client buat header HTTP DPoP tiap request resource. Header berisi JWT umur pendek (30-60 detik), ditandatangani private key client.

1. Header DPoP Proof JWT

Header JWT mengandung parameter wajib:

  • typ: Wajib bernilai dpop+jwt.
  • alg: Algoritma asymmetric signature (misal ES256, EdDSA). RS256 dilarang untuk performa dan keamanan.
  • jwk: Public key client format JSON Web Key. Tidak boleh mengandung private key parameter.

2. Payload DPoP Proof JWT

Payload memuat claim validasi kontekstual:

  • jti (JWT ID): Identifier unik UUIDv4 cegah replay.
  • htm (HTTP Method): Method request (GET, POST, PUT, DELETE).
  • htu (HTTP URI): Target URI tanpa query string dan fragment.
  • iat (Issued At): Epoch timestamp pembuatan proof.
  • nonce: String acak dari server (opsional, untuk pengetatan replay).

3. Confirmation Claim (cnf) & JWK Thumbprint (jkt)

Authorization Server menghitung SHA-256 thumbprint dari JWK client (RFC 7638), dinamai jkt. Authorization Server selipkan jkt ke access token JWT pada claim cnf.jkt:

{
  "sub": "user_123",
  "iss": "https://auth.example.com",
  "cnf": {
    "jkt": "0ZcOCORWFuNu-EjG-jvWhaMqi9zOi-gJ728-kqZwep8"
  }
}

Resource Server verifikasi jkt dari header DPoP public key cocok dengan cnf.jkt di dalam Access Token. Jika cocok, client pemilik kunci sah.

Perbandingan Keamanan: Bearer vs DPoP vs mTLS

Fitur / KarakteristikOAuth 2.0 BearerOAuth 2.0 DPoP (RFC 9449)OAuth 2.0 mTLS (RFC 8705)
Token Binding LayerTidak AdaApplication Layer (HTTP Header)Transport Layer (TLS Handshake)
Kebutuhan InfrastrukturMinimalAplikasi (Node.js/Express) sajaSertifikat X.509, Root CA, Proxy Pass-Through
Proteksi XSS Token TheftTidak ProteksiProteksi Penuh (Private key tak bisa diekspor)Proteksi Penuh
Proteksi Log LeakageBocor = Kompromi TotalBocor = Token Tidak Bisa Dipakai AttackerBocor = Token Tidak Bisa Dipakai Attacker
Kompleksitas ClientRendahSedang (Sign JWT tiap request)Tinggi (Manajemen sertifikat TLS)

Praktik Implementasi DPoP di Node.js & Express.js

1. Instalasi Dependensi

npm install express jose ioredis

2. Client-Side DPoP Proof Generator

Client buat keypair EC P-256, lalu generate DPoP proof tiap pemanggilan API:

const { generateKeyPair, SignJWT, exportJWK } = require('jose');
const crypto = require('crypto');

let clientKeyPair = null;

async function getOrCreateKeyPair() {
  if (!clientKeyPair) {
    clientKeyPair = await generateKeyPair('ES256', { extractable: false });
  }
  return clientKeyPair;
}

async function createDPoPProof(httpMethod, httpUri, nonce = null) {
  const { privateKey, publicKey } = await getOrCreateKeyPair();
  const publicJwk = await exportJWK(publicKey);

  const jwt = new SignJWT({
    htm: httpMethod.toUpperCase(),
    htu: httpUri,
    nonce: nonce || undefined
  })
    .setProtectedHeader({
      typ: 'dpop+jwt',
      alg: 'ES256',
      jwk: publicJwk
    })
    .setIssuedAt()
    .setJti(crypto.randomUUID());

  return await jwt.sign(privateKey);
}

3. Middleware Verifikasi DPoP Server-Side

Urutan validasi ketat wajib diikuti. Kegagalan di satu tahap batalkan seluruh request.

Peringatan Keamanan Validasi Token: Jangan pernah melewatkan verifikasi claim htm dan htu. Abaikan salah satu claim membuat token rentan terhadap serangan Cross-Site Request Forgery (CSRF) dan Token Forwarding Attack antar endpoint.

  1. Periksa keberadaan header DPoP dan Authorization: DPoP <token>.
  2. Decode header DPoP, pastikan typ === 'dpop+jwt' dan jwk tersedia.
  3. Import JWK client, verifikasi signature DPoP proof.
  4. Validasi claim htm cocok dengan HTTP method request.
  5. Validasi claim htu cocok dengan URL endpoint tanpa query parameter.
  6. Cek timestamp iat, tolak jika selisih waktu melebihi 60 detik.
  7. Hitung SHA-256 JWK thumbprint dari header jwk.
  8. Verifikasi Access Token JWT, pastikan claim cnf.jkt persis sama dengan hasil thumbprint.
const crypto = require('crypto');
const { jwtVerify, importJWK, calculateJwkThumbprint } = require('jose');

// Public Key Auth Server untuk verifikasi Access Token
const AUTH_SERVER_PUBLIC_KEY = /* import Key Object */;

async function dpopValidationMiddleware(req, res, next) {
  try {
    const dpopHeaderValue = req.headers['dpop'];
    const authHeaderValue = req.headers['authorization'];

    if (!dpopHeaderValue) {
      return res.status(401).json({ error: 'invalid_dpop_proof', error_description: 'Header DPoP tidak ditemukan' });
    }

    if (!authHeaderValue || !authHeaderValue.startsWith('DPoP ')) {
      return res.status(401).json({ error: 'invalid_token', error_description: 'Skema Authorization harus DPoP' });
    }

    const accessToken = authHeaderValue.slice(5).trim();

    // Parse header JWT DPoP tanpa verifikasi untuk ambil public key
    const dpopParts = dpopHeaderValue.split('.');
    if (dpopParts.length !== 3) {
      return res.status(401).json({ error: 'invalid_dpop_proof', error_description: 'Format DPoP JWT malformed' });
    }

    const dpopHeader = JSON.parse(Buffer.from(dpopParts[0], 'base64url').toString());

    if (dpopHeader.typ !== 'dpop+jwt' || !dpopHeader.jwk) {
      return res.status(401).json({ error: 'invalid_dpop_proof', error_description: 'Header typ harus dpop+jwt dan jwk wajib ada' });
    }

    if (dpopHeader.jwk.d) {
      return res.status(401).json({ error: 'invalid_dpop_proof', error_description: 'JWK tidak boleh mengandung private key' });
    }

    // Import public key client dari JWK
    const clientPublicKey = await importJWK(dpopHeader.jwk, dpopHeader.alg);

    // Verifikasi signature DPoP Proof
    const { payload: dpopPayload } = await jwtVerify(dpopHeaderValue, clientPublicKey, {
      algorithms: ['ES256', 'EdDSA']
    });

    // Validasi claim HTM (HTTP Method)
    if (dpopPayload.htm !== req.method.toUpperCase()) {
      return res.status(401).json({ error: 'invalid_dpop_proof', error_description: 'Claim htm tidak cocok dengan request method' });
    }

    // Validasi claim HTU (HTTP URI) - Normalisasi hapus query string & fragment
    const requestFullUrl = `${req.protocol}://${req.get('host')}${req.baseUrl}${req.path}`;
    if (dpopPayload.htu !== requestFullUrl) {
      return res.status(401).json({ error: 'invalid_dpop_proof', error_description: 'Claim htu tidak cocok dengan request URI' });
    }

    // Validasi Window Expiry (Clock Skew Max 60 Detik)
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - dpopPayload.iat) > 60) {
      return res.status(401).json({ error: 'invalid_dpop_proof', error_description: 'DPoP proof kedaluwarsa atau clock skew terlalu besar' });
    }

    // Hitung JWK Thumbprint (jkt)
    const computedJkt = await calculateJwkThumbprint(dpopHeader.jwk, 'sha256');

    // Verifikasi Access Token JWT
    const { payload: tokenPayload } = await jwtVerify(accessToken, AUTH_SERVER_PUBLIC_KEY);

    // Validasi Token Binding (cnf.jkt)
    if (!tokenPayload.cnf || tokenPayload.cnf.jkt !== computedJkt) {
      return res.status(401).json({ error: 'unauthorized_client', error_description: 'Access token tidak terikat ke public key DPoP ini' });
    }

    req.dpop = dpopPayload;
    req.token = tokenPayload;
    req.clientJkt = computedJkt;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'invalid_dpop_proof', error_description: err.message });
  }
}

Mitigasi Replay Attack Lanjutan

Signature kriptografi mencegah pembajakan isi payload. Attacker masih bisa curi DPoP proof valid lalu re-send request dalam jendela 60 detik. Dua mekanisme cegah serangan ini:

1. Cache Tracking JTI dengan Redis

Simpan jti DPoP proof di Redis. Set TTL sesuai window expiry (60 detik). Jika jti sudah ada di Redis, tolak request.

const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

async function verifyJTIUniqueness(req, res, next) {
  const jti = req.dpop.jti;
  const key = `dpop:jti:${jti}`;
  
  // Atomic operation SETNX dengan TTL 60 detik
  const isSet = await redis.set(key, '1', 'EX', 60, 'NX');
  
  if (!isSet) {
    return res.status(401).json({
      error: 'invalid_dpop_proof',
      error_description: 'Replay attack terdeteksi: JTI sudah pernah digunakan'
    });
  }
  next();
}

2. Server-Provided Nonce Handshake (RFC 9449 Section 8)

Server paksa client pakai nonce buatan server. Alur kerja handshake nonce:

  1. Client kirim request tanpa nonce atau pakai nonce lama.
  2. Server tolak request dengan status HTTP 401. Server kirim header DPoP-Nonce: <nonce_baru> dan error use_dpop_nonce.
  3. Client buat DPoP proof baru memasukkan nonce dari server.
  4. Client kirim ulang request. Server terima request.
async function validateServerNonce(req, res, next) {
  const clientNonce = req.dpop.nonce;
  const expectedNonce = await redis.get(`dpop:nonce:${req.clientJkt}`);

  if (!clientNonce || clientNonce !== expectedNonce) {
    const newNonce = crypto.randomBytes(16).toString('hex');
    await redis.set(`dpop:nonce:${req.clientJkt}`, newNonce, 'EX', 300);
    
    res.setHeader('DPoP-Nonce', newNonce);
    return res.status(401).json({
      error: 'use_dpop_nonce',
      error_description: 'Server membutuhkan DPoP-Nonce terbaru'
    });
  }
  next();
}

Strategi Rotasi Kunci & Key Storage Client

1. Browser Environment

Gunakan Web Crypto API (window.crypto.subtle) untuk simpan private key. Set properti extractable: false. Simpan KeyPair di IndexedDB. Script XSS tidak bisa mengekspor private key keluar dari browser.

2. Mobile Environment (React Native / Native Node)

Simpan private key di Android Keystore atau iOS Keychain (Hardware-backed security module / Secure Enclave). Operasi penandatanganan JWT dilakukan di dalam enclave.

3. Rotasi Key Client

Client bisa ganti pasangan kunci kapan saja. Prosedur rotasi kunci:

  1. Client buat EC keypair baru.
  2. Client panggil endpoint /oauth/token pakai grant_type=refresh_token.
  3. Client sertakan DPoP proof ditandatangani private key baru.
  4. Authorization Server terbitkan Access Token baru terikat jkt kunci baru.
  5. Client hapus pasangan kunci lama.

Checklist Produksi DPoP Node.js

KomponenKriteria ValidasiTindakan Rekayasa
Algoritma KunciGunakan ES256 (P-256) atau Ed25519Tolak algoritma none, HS256, atau RS256 pada header DPoP.
URI NormalizationMatch exact scheme, host, port, pathHapus query parameter, hash fragment, dan trailing slash tidak konsisten.
Clock Skew ToleransiMaksimal 60 detikSinkronkan jam server pakai NTP (Network Time Protocol).
Reverse ProxyHeader Stripping & ForwardingPastikan Nginx / Cloudflare meneruskan header DPoP tanpa modifikasi.
Transport SecurityWajib TLS 1.3DPoP proteksi application layer, TLS proteksi confidentiality perantara network.

Kesimpulan

Penggunaan Bearer Token tanpa token binding kriptografis sangat berisiko untuk API modern. RFC 9449 DPoP pada Node.js mengikat access token secara eksklusif ke kunci privat milik client. Kebocoran token akibat XSS, log server, atau interception tidak bisa dimanfaatkan oleh attacker tanpa kunci privat client. Kombinasi DPoP middleware, pengecekan cnf.jkt, Redis JTI cache, dan server-provided nonce menghasilkan arsitektur API OAuth 2.1 dengan tingkat keamanan maksimal.

๐Ÿ“– Artikel Terkait