Masalah Keamanan Klasik: Eksploitasi Bearer Token

Bearer token (RFC 6750) bekerja layaknya uang tunai. Entitas mana pun yang memegang token tersebut memiliki wewenang penuh untuk mengakses API. Jika token bocor melalui cross-site scripting (XSS), logging proksi reverse proxy, mitigasi TLS termination, atau cache perantara, penyerang dapat langsung menggunakannya kembali (replay attack) dari server atau perangkat mana pun tanpa deteksi.

RFC 9449 menyelesaikan masalah ini dengan mekanisme Demonstrating Proof-of-Possession (DPoP). DPoP menerapkan prinsip sender-constraining: token autentikasi diikat ke pasangan kunci kriptografi asimetris milik klien. Setiap request HTTP wajib menyertakan bukti kepemilikan kunci berupa JWT sementara (DPoP Proof) yang ditandatangani oleh private key klien.

Anatomi DPoP Proof (RFC 9449)

DPoP Proof dikirim melalui header HTTP DPoP. JWT ini memiliki struktur spesifik yang harus divalidasi oleh resource server:

  • Header: Wajib memiliki typ: "dpop+jwt", algoritma asimetris (misal ES256), dan public key klien dalam format JWK (jwk).
  • Payload:
    • jti (JWT ID): Pengidentifikasi unik untuk mencegah penggunaan token yang sama berulang kali.
    • htm (HTTP Method): Metode request yang ditargetkan (misal GET, POST).
    • htu (HTTP URI): URI target tanpa komponen query atau fragment.
    • iat (Issued At): Timestamp saat token dibuat. Server memvalidasi rentang kedaluwarsa pendek (umumnya ±60 detik).
    • ath (Access Token Hash): SHA-256 hash dari access token jika DPoP diikat langsung ke access token.

Implementasi Verifikasi Middleware DPoP

Berikut implementasi verifikasi DPoP menggunakan standar WebCrypto API pada Node.js tanpa dependensi eksternal. Middleware memvalidasi signature publik JWK, binding method/URI, rentang waktu, dan menolak jti yang sudah pernah digunakan via in-memory cache.

const crypto = globalThis.crypto;

// Penyimpanan jti in-memory untuk cegah replay
// ponytail: Set in-memory terbatas pada single-instance; ganti Redis EXPIRE untuk lingkungan klaster/multi-node.
const replayCache = new Map();

function cleanExpiredJti(now) {
  for (const [jti, exp] of replayCache.entries()) {
    if (now > exp) replayCache.delete(jti);
  }
}

function base64UrlToUint8Array(base64Url) {
  const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
  const pad = base64.length % 4 === 0 ? '' : '='.repeat(4 - (base64.length % 4));
  return Uint8Array.from(atob(base64 + pad), c => c.charCodeAt(0));
}

async function verifyDPoP(dpopHeader, reqMethod, reqUrl, maxClockSkewSec = 60) {
  if (!dpopHeader) {
    return { valid: false, status: 401, error: 'Missing DPoP header' };
  }

  const parts = dpopHeader.split('.');
  if (parts.length !== 3) {
    return { valid: false, status: 401, error: 'Malformed DPoP token' };
  }

  let header, payload;
  try {
    header = JSON.parse(new TextDecoder().decode(base64UrlToUint8Array(parts[0])));
    payload = JSON.parse(new TextDecoder().decode(base64UrlToUint8Array(parts[1])));
  } catch {
    return { valid: false, status: 401, error: 'Invalid JSON in DPoP segment' };
  }

  if (header.typ !== 'dpop+jwt' || !header.jwk || header.alg !== 'ES256') {
    return { valid: false, status: 401, error: 'Invalid header metadata or unsupported algorithm' };
  }

  const now = Math.floor(Date.now() / 1000);
  cleanExpiredJti(now);

  // 1. Validasi timestamp iat
  if (!payload.iat || Math.abs(now - payload.iat) > maxClockSkewSec) {
    return { valid: false, status: 401, error: 'DPoP token expired or iat outside acceptable window' };
  }

  // 2. Validasi binding HTM dan HTU
  const targetHtu = reqUrl.split('?')[0];
  if (payload.htm !== reqMethod.toUpperCase() || payload.htu !== targetHtu) {
    return { valid: false, status: 401, error: 'HTM or HTU mismatch' };
  }

  // 3. Validasi Replay Attack via jti
  if (!payload.jti || replayCache.has(payload.jti)) {
    return { valid: false, status: 401, error: 'Replay detected: jti reused or missing' };
  }

  // 4. Verifikasi Signature ES256 menggunakan embedded JWK
  try {
    const key = await crypto.subtle.importKey(
      'jwk',
      header.jwk,
      { name: 'ECDSA', namedCurve: 'P-256' },
      false,
      ['verify']
    );

    const data = new TextEncoder().encode(`${parts[0]}.${parts[1]}`);
    const signature = base64UrlToUint8Array(parts[2]);

    const isValid = await crypto.subtle.verify(
      { name: 'ECDSA', hash: { name: 'SHA-256' } },
      key,
      signature,
      data
    );

    if (!isValid) {
      return { valid: false, status: 401, error: 'Invalid DPoP cryptographic signature' };
    }
  } catch (err) {
    return { valid: false, status: 401, error: `Key import/verification failed: ${err.message}` };
  }

  // Simpan jti ke cache replay sampai window kedaluwarsa habis
  replayCache.set(payload.jti, payload.iat + maxClockSkewSec);
  return { valid: true, payload };
}

[code] → skipped: [distributed Redis store, RS256/Ed25519 support, ath claim validation], add when [multi-node cluster / multiple key algs needed].

Pengujian Validasi dan Proteksi Replay

Script pengujian di bawah ini memverifikasi request valid, lalu memastikan request yang identik (replay) ditolak dengan status HTTP 401.

const assert = require('node:assert/strict');

function uint8ArrayToBase64Url(arr) {
  return Buffer.from(arr).toString('base64url');
}

async function createDPoPProof(keyPair, method, url, iatOffset = 0, customJti = null) {
  const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
  const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: publicJwk };
  const payload = {
    jti: customJti || crypto.randomUUID(),
    htm: method,
    htu: url,
    iat: Math.floor(Date.now() / 1000) + iatOffset,
  };

  const enc = new TextEncoder();
  const h64 = uint8ArrayToBase64Url(enc.encode(JSON.stringify(header)));
  const p64 = uint8ArrayToBase64Url(enc.encode(JSON.stringify(payload)));
  const data = enc.encode(`${h64}.${p64}`);

  const sig = await crypto.subtle.sign(
    { name: 'ECDSA', hash: { name: 'SHA-256' } },
    keyPair.privateKey,
    data
  );

  return `${h64}.${p64}.${uint8ArrayToBase64Url(new Uint8Array(sig))}`;
}

async function runTests() {
  const keyPair = await crypto.subtle.generateKey(
    { name: 'ECDSA', namedCurve: 'P-256' },
    true,
    ['sign', 'verify']
  );

  const endpoint = 'https://api.internal/wallet/transfer';

  // Test 1: Request DPoP sah
  const validProof = await createDPoPProof(keyPair, 'POST', endpoint);
  const res1 = await verifyDPoP(validProof, 'POST', endpoint);
  assert.equal(res1.valid, true, 'Request pertama harus valid');

  // Test 2: Replay Attack (menggunakan token yang sama untuk request kedua)
  const resReplay = await verifyDPoP(validProof, 'POST', endpoint);
  assert.equal(resReplay.valid, false, 'Replay request harus ditolak');
  assert.equal(resReplay.status, 401);
  assert.match(resReplay.error, /Replay detected/);

  // Test 3: HTTP Method Mismatch
  const methodProof = await createDPoPProof(keyPair, 'GET', endpoint);
  const resMethod = await verifyDPoP(methodProof, 'POST', endpoint);
  assert.equal(resMethod.valid, false, 'Method mismatch harus ditolak');

  // Test 4: Expired Timestamp
  const expiredProof = await createDPoPProof(keyPair, 'POST', endpoint, -120);
  const resExpired = await verifyDPoP(expiredProof, 'POST', endpoint);
  assert.equal(resExpired.valid, false, 'Expired token harus ditolak');

  console.log('Semua assertion lulus verifikasi RFC 9449.');
}

runTests();

Trade-offs dan Pertimbangan Implementasi

  • Beban Komputasi Kriptografi: Verifikasi signature asimetris (ECDSA) pada setiap request membutuhkan siklus CPU lebih tinggi dibandingkan validasi simetris HMAC. Pasang terminasi validasi DPoP sedekat mungkin dengan ingress API gateway.
  • Stateful Replay Tracking: Menjaga keunikan jti membuat backend resource server membutuhkan state tracking. Jika arsitektur backend berbentuk multi-instance horizontal, gunakan distributed cache dengan TTL pendek (misal Redis) agar jti yang sama tidak lolos ketika dikirim ke server berbeda.
  • Response Header Sesuai RFC 9449: Saat request gagal validasi, sertakan header respons: WWW-Authenticate: DPoP error="invalid_dpop_proof" untuk memberi sinyal standar kepada klien bahwa bukti DPoP ditolak.