OAuth 2.0 Device Authorization Grant (RFC 8628) dirancang untuk perangkat dengan keterbatasan input teks atau ketiadaan peramban web mandiri, seperti Smart TV, konsol game, CLI terminal, dan perangkat IoT. Alur ini memisahkan kanal otorisasi menjadi dua: perangkat melakukan polling pada endpoint token menggunakan device_code, sementara pengguna menyelesaikan autentikasi di perangkat sekunder (ponsel/laptop) dengan memasukkan user_code.

Pemisahan kanal tersebut membuka celah keamanan spesifik. Format user_code yang sengaja dibuat pendek agar ramah ketik rentan terhadap serangan brute-force, frekuensi polling klien yang tidak terkendali dapat membebani backend (Polling DoS), dan relasi antara sesi perangkat dengan persetujuan pengguna rentan disusupi session fixation. Artikel ini membahas langkah konkret hardening arsitektur RFC 8628 pada backend service.

1. Desain Entropi user_code dan Mitigasi Brute-Force

Dilema utama user_code adalah kompromi antara kemudahan input manusia (usability) dan resistensi terhadap pencarian acak (brute-force). Menggunakan UUID atau string 32 karakter menghancurkan pengalaman pengguna di konsol game atau remote TV, sedangkan string 4 digit numerik dapat ditebak dalam hitungan detik.

Karakter Set Base20 RFC 8628

RFC 8628 merekomendasikan set karakter Base20: BCDFGHJKLMNPQRSTVWXZ. Karakter ini sengaja menghilangkan vokal untuk menghindari pembentukan kata-kata tidak senonoh secara acak, serta membuang karakter ambigu yang rawan salah baca (seperti angka 0 dengan huruf O, atau angka 1 dengan huruf I dan L).

Perhitungan ruang kunci (keyspace) untuk format 8 karakter Base20 (contoh: WDJB-MJHT):

Keyspace = 20^8 = 25.600.000.000 kombinasi (~34,5 bit entropi)

Dengan $2,56 \times 10^{10}$ kemungkinan, penyerang tidak dapat menebak kode secara acak jika backend menerapkan parameter pembatas berikut:

  • TTL Ketat: Batasi masa aktif user_code maksimal 300 hingga 600 detik (5–10 menit).
  • Rate Limiting Verifikasi Web: Terapkan pembatasan percobaan input pada endpoint web otorisasi (maksimal 5 kali gagal per alamat IP atau sesi browser per menit).
  • Burn-on-Failure: Jika satu user_code menerima lebih dari 3 kali percobaan verifikasi yang salah secara berurutan, batalkan authorization request tersebut dari penyimpanan cache secara permanen.

2. Mitigasi Polling DoS dengan Redis dan Status slow_down

Klien perangkat harus melakukan polling ke endpoint /token untuk mengetahui status persetujuan user. RFC 8628 menetapkan parameter interval (satuan detik), yaitu jeda minimum antar permintaan polling yang diizinkan server (misalnya 5 detik).

Masalah muncul ketika ribuan perangkat klien melakukan polling agresif tanpa jeda, baik akibat bug klien, koneksi tidak stabil, maupun serangan terencana. Jika backend langsung mengembalikan respons database, koneksi pool akan habis seketika.

Mekanisme Penalti slow_down

Server wajib mencatat stempel waktu (timestamp) polling terakhir untuk setiap device_code. Ketika klien menembak endpoint sebelum jeda interval terpenuhi, server harus:

  1. Menaikkan nilai interval klien sebagai penalti (misalnya, $interval_{baru} = interval_{lama} + 5$).
  2. Mengembalikan HTTP status 400 Bad Request dengan payload JSON {"error": "slow_down", "interval": interval_baru}.

Implementasi terbaik menggunakan Redis dengan atomic command atau pipeline untuk memverifikasi waktu tanpa menyentuh database relasional.

3. Mengatasi Device Session Fixation & Hijacking

Serangan session fixation pada alur device terjadi ketika penyerang menginisiasi flow, mendapatkan user_code, lalu memancing korban memasukkan kode tersebut (via phishing atau social engineering). Setelah korban login, penyerang yang memegang device_code memperoleh token akses korban.

Langkah Preventif Backend

  • Entropy Asimetris: device_code harus memiliki entropi kriptografi tinggi (minimal 256-bit, contohnya string acak secrets.token_urlsafe(32)), disimpan secara rahasia oleh perangkat klien, dan tidak pernah ditampilkan ke UI.
  • Device Fingerprint Binding: Ikat device_code dengan atribut jaringan atau identitas klien (seperti Client ID dan subnet IP perangkat awal). Tolak polling jika request datang dari Client ID berbeda.
  • Atomic State Invalidation: Begitu token akses diterbitkan melalui endpoint /token, musnahkan device_code dan relasi user_code seketika dalam transaksi atomik untuk mencegah token replay.

4. Implementasi Handler /device/code dan /token

Berikut implementasi backend Python mandiri menggunakan struktur data Redis untuk menangani alur otorisasi, rate limiting polling, penalti slow_down, dan proteksi brute-force.

import time
import secrets
import json

BASE20_CHARSET = "BCDFGHJKLMNPQRSTVWXZ"
DEFAULT_INTERVAL = 5
PENALTY_STEP = 5
CODE_TTL = 300  # 5 menit

def generate_user_code(length=8) -> str:
    raw = "".join(secrets.choice(BASE20_CHARSET) for _ in range(length))
    return f"{raw[:4]}-{raw[4:]}"

class DeviceAuthStore:
    def __init__(self, redis_client):
        self.redis = redis_client

    def create_device_flow(self, client_id: str) -> dict:
        device_code = secrets.token_urlsafe(32)
        user_code = generate_user_code()
        
        session_data = {
            "client_id": client_id,
            "user_code": user_code,
            "status": "authorization_pending",
            "interval": DEFAULT_INTERVAL,
            "last_poll": 0,
            "user_id": None
        }

        pipe = self.redis.pipeline()
        # Key untuk device polling lookup
        pipe.set(f"dev_code:{device_code}", json.dumps(session_data), ex=CODE_TTL)
        # Mapping dua arah untuk verifikasi via user_code di web
        pipe.set(f"usr_code:{user_code}", device_code, ex=CODE_TTL)
        pipe.execute()

        return {
            "device_code": device_code,
            "user_code": user_code,
            "verification_uri": "https://auth.example.com/activate",
            "expires_in": CODE_TTL,
            "interval": DEFAULT_INTERVAL
        }

    def poll_token(self, device_code: str, client_id: str) -> tuple[int, dict]:
        key = f"dev_code:{device_code}"
        raw_session = self.redis.get(key)
        
        if not raw_session:
            return 400, {"error": "expired_token", "error_description": "Kode kedaluwarsa atau tidak valid."}

        session = json.loads(raw_session)
        now = time.time()

        if session["client_id"] != client_id:
            return 401, {"error": "invalid_client"}

        # Validasi jeda polling
        last_poll = session.get("last_poll", 0)
        current_interval = session.get("interval", DEFAULT_INTERVAL)
        elapsed = now - last_poll

        if last_poll > 0 and elapsed < current_interval:
            # Penalti: tambah interval dan balas slow_down
            session["interval"] = current_interval + PENALTY_STEP
            session["last_poll"] = now
            ttl = self.redis.ttl(key)
            self.redis.set(key, json.dumps(session), ex=max(ttl, 1))
            return 400, {
                "error": "slow_down",
                "interval": session["interval"],
                "error_description": f"Polling terlalu cepat. Tingkatkan interval ke {session['interval']}s."
            }

        session["last_poll"] = now

        if session["status"] == "authorization_pending":
            ttl = self.redis.ttl(key)
            self.redis.set(key, json.dumps(session), ex=max(ttl, 1))
            return 400, {
                "error": "authorization_pending",
                "error_description": "Otorisasi belum selesai di browser."
            }

        if session["status"] == "authorized":
            # Burn kode segera untuk mencegah replay
            user_code = session["user_code"]
            self.redis.delete(key, f"usr_code:{user_code}")
            
            return 200, {
                "access_token": secrets.token_hex(32),
                "token_type": "Bearer",
                "expires_in": 3600,
                "user_id": session["user_id"]
            }

        return 400, {"error": "invalid_grant"}

5. Runnable Assert Test: Verifikasi Polling Throttling

Skrip berikut membuktikan kepatuhan terhadap RFC 8628 tanpa ketergantungan library pihak ketiga. Mock Redis internal digunakan untuk memverifikasi transisi state authorization_pending, slow_down akibat polling agresif, serta kegagalan token kedaluwarsa.

class InMemoryRedisMock:
    def __init__(self):
        self.store = {}
        self.ttls = {}

    def pipeline(self):
        return self

    def set(self, key, value, ex=300):
        self.store[key] = value
        self.ttls[key] = ex
        return self

    def get(self, key):
        return self.store.get(key)

    def delete(self, *keys):
        for k in keys:
            self.store.pop(k, None)
            self.ttls.pop(k, None)

    def ttl(self, key):
        return self.ttls.get(key, 0)

    def execute(self):
        return True

def run_tests():
    mock_redis = InMemoryRedisMock()
    service = DeviceAuthStore(mock_redis)
    client_id = "console_app_v1"

    # 1. Inisiasi alur
    auth_data = service.create_device_flow(client_id)
    dev_code = auth_data["device_code"]
    user_code = auth_data["user_code"]
    
    assert len(user_code) == 9, "Format user_code harus XXXX-XXXX (9 karakter)"
    assert user_code[4] == "-", "Format delimiter user_code harus tanda hubung"

    # 2. Polling pertama: ekspektasi authorization_pending
    status, res = service.poll_token(dev_code, client_id)
    assert status == 400
    assert res["error"] == "authorization_pending"

    # 3. Polling langsung tanpa jeda: ekspektasi slow_down dan penalti interval
    status, res = service.poll_token(dev_code, client_id)
    assert status == 400
    assert res["error"] == "slow_down"
    assert res["interval"] == DEFAULT_INTERVAL + PENALTY_STEP, "Interval harus meningkat sebesar penalti"

    # 4. Simulasi persetujuan pengguna di web
    session = json.loads(mock_redis.get(f"dev_code:{dev_code}"))
    session["status"] = "authorized"
    session["user_id"] = "usr_10928"
    session["last_poll"] = 0  # Reset agar lolos jeda polling
    mock_redis.set(f"dev_code:{dev_code}", json.dumps(session), ex=300)

    # 5. Polling setelah disetujui: ekspektasi 200 OK dan pembakaran session
    status, res = service.poll_token(dev_code, client_id)
    assert status == 200
    assert "access_token" in res
    assert res["user_id"] == "usr_10928"

    # 6. Pastikan session telah dibakar (Burn-on-Use)
    assert mock_redis.get(f"dev_code:{dev_code}") is None
    assert mock_redis.get(f"usr_code:{user_code}") is None

    # 7. Polling ulang dengan kode yang sama harus ditolak
    status, res = service.poll_token(dev_code, client_id)
    assert status == 400
    assert res["error"] == "expired_token"

    print("Semua assert lolos: Validasi RFC 8628, slow_down throttling, dan state cleanup terverifikasi.")

if __name__ == "__main__":
    run_tests()

Ringkasan Trade-Off Arsitektur

Catatan Implementasi: Menggunakan Base20 dengan 8 karakter memberikan proteksi brute-force optimal hanya jika dikombinasikan dengan TTL ≤ 10 menit. Jika sistem Anda membutuhkan masa hidup kode lebih lama (> 15 menit), jangan menambah masa aktif pada satu kode; instruksikan klien untuk meminta pasangan device_code/user_code baru ketika sesi lama habis.

Penerapan validasi ketat, pengelolaan status atomik di Redis, dan pengembalian kode error standar RFC 8628 (authorization_pending, slow_down, expired_token) mengeliminasi risiko Denial of Service pada backend sekaligus memitigasi pembajakan sesi otentikasi lintas perangkat.