Pada integrasi OAuth di SvelteKit, masalah yang sering luput bukan login gagal, melainkan callback yang berhasil diproses lebih dari sekali. Penyebabnya bisa sederhana: pengguna menekan refresh, browser membuka tab ganda, reverse proxy melakukan retry, jaringan tidak stabil, atau provider mengirim ulang request yang sama. Jika endpoint callback tidak didesain idempotent, satu authorization code dapat memicu double exchange, pembuatan session ganda, atau state aplikasi yang tidak konsisten.

Solusinya bukan sekadar memeriksa state. Endpoint callback perlu memiliki kontrak yang jelas: state hanya boleh dipakai sekali, authorization code diperlakukan sebagai single-use, exchange ke provider tidak boleh dieksekusi dua kali untuk code yang sama, dan hasil callback duplikat harus aman. Di SvelteKit, implementasi ini biasanya ditempatkan di src/routes/auth/callback/+server.ts dengan penyimpanan state/nonce, guard di database atau Redis, serta strategi respons yang stabil saat request duplikat datang.

Masalah Nyata: Retry OAuth Callback dan Double Exchange

Secara teori, authorization code pada OAuth memang dirancang satu kali pakai. Namun dalam praktik, bug tetap muncul karena aplikasi Anda bisa mencoba melakukan exchange dua kali sebelum provider menolak request kedua. Bahkan bila provider menolak exchange kedua, efek samping lokal bisa tetap terlanjur terjadi: record session sudah dua kali dibuat, log audit berantakan, atau user diarahkan ke state yang salah.

Skenario umum:

  • Pengguna login, lalu menekan refresh saat berada di URL callback.
  • Browser atau aplikasi membuka callback di dua tab hampir bersamaan.
  • Load balancer atau proxy me-retry request GET ke callback.
  • Provider mengirim ulang callback karena timeout atau integrasi upstream bermasalah.
  • Frontend melakukan navigasi ulang ke endpoint callback karena race pada client-side routing.

Jika endpoint callback Anda hanya berisi alur linear “validasi state → exchange code → buat session → redirect”, maka request duplikat dapat menabrak bagian-bagian yang seharusnya sekali jalan.

Kontrak Endpoint Callback yang Aman di SvelteKit

Untuk menutup celah ini, anggap endpoint callback sebagai operasi yang memiliki kontrak idempoten. Bukan berarti request yang sama akan selalu menghasilkan respons identik byte-per-byte, tetapi efek sampingnya harus aman bila callback datang lebih dari sekali.

Prinsip kontraknya

  1. State/nonce wajib tervalidasi dan single-use. Setelah callback pertama yang valid diterima, state harus ditandai sudah dipakai.
  2. Authorization code harus diguard sebelum dan sesudah exchange. Ini mencegah dua proses paralel melakukan exchange pada code yang sama.
  3. Pembuatan session harus deduplikasi. Jika callback duplikat datang setelah session dibuat, sistem sebaiknya menemukan hasil yang sama alih-alih membuat session baru.
  4. Respons callback duplikat harus aman. Idealnya redirect ke tujuan final yang sama, atau tampilkan respons yang tidak membocorkan detail sensitif.
  5. Logging harus membedakan callback pertama, callback duplikat, dan callback mencurigakan.

Di SvelteKit, endpoint semacam ini lazim ditulis sebagai handler GET di +server.ts:

// src/routes/auth/callback/+server.ts
import { redirect, type RequestHandler } from '@sveltejs/kit';

export const GET: RequestHandler = async (event) => {
  const url = event.url;
  const code = url.searchParams.get('code');
  const state = url.searchParams.get('state');
  const error = url.searchParams.get('error');

  if (error) {
    // Tangani error dari provider tanpa membuat side effect tambahan
    throw redirect(303, '/login?oauth_error=1');
  }

  if (!code || !state) {
    throw redirect(303, '/login?oauth_invalid_callback=1');
  }

  // Lanjutkan ke validasi state, guard code, exchange token, dan issue session
  // Implementasi detail dibahas di bagian berikutnya.
  throw redirect(303, '/app');
};

Yang penting bukan bentuk file-nya, melainkan bahwa seluruh operasi sensitif dijalankan di server dan tidak mengandalkan state dari browser semata.

Penyimpanan State/Nonce: Jangan Hanya Valid, Tapi Juga Single-Use

Banyak implementasi berhenti di “apakah state cocok?”. Itu belum cukup. Untuk kasus retry OAuth callback, Anda perlu memodelkan state sebagai token sekali pakai yang punya masa hidup pendek.

Data minimal yang perlu disimpan

Saat memulai login, simpan record state yang berisi:

  • state acak berentropi tinggi
  • nonce bila alur Anda memerlukannya
  • provider
  • redirect_to atau tujuan akhir setelah login
  • created_at dan expires_at
  • consumed_at untuk menandai state sudah dipakai
  • request_id atau correlation id untuk logging

Contoh desain tabel:

create table oauth_login_state (
  state text primary key,
  provider text not null,
  nonce text,
  redirect_to text,
  request_id text,
  created_at timestamptz not null,
  expires_at timestamptz not null,
  consumed_at timestamptz,
  consumed_by_code_hash text,
  user_agent text,
  ip_address text
);

create index oauth_login_state_expires_idx
  on oauth_login_state (expires_at);

Cara validasi yang benar

Validasi state idealnya dilakukan secara atomik: ambil record state yang belum kedaluwarsa dan belum pernah dipakai, lalu tandai sebagai consumed dalam transaksi yang sama. Dengan begitu, callback kedua yang datang dengan state yang sama tidak akan lolos sebagai request baru.

type StateRecord = {
  state: string;
  provider: string;
  redirect_to: string | null;
  expires_at: Date;
  consumed_at: Date | null;
};

async function consumeStateOnce(state: string, codeHash: string): Promise<StateRecord | null> {
  // Pseudo-code; implementasi aktual tergantung database/ORM
  // Tujuan: hanya satu request yang bisa mengubah consumed_at dari null menjadi now()
  return db.transaction(async (tx) => {
    const row = await tx.queryOne<StateRecord>(`
      select * from oauth_login_state
      where state = $1
        and expires_at > now()
        and consumed_at is null
      for update
    `, [state]);

    if (!row) return null;

    await tx.execute(`
      update oauth_login_state
      set consumed_at = now(), consumed_by_code_hash = $2
      where state = $1
    `, [state, codeHash]);

    return row;
  });
}

Catatan: Menyimpan hash dari code lebih aman dibanding menyimpan nilai mentahnya di database atau log. Tujuannya untuk korelasi dan debugging, bukan untuk dipakai ulang.

Trade-off penting di sini: bila state langsung ditandai consumed sebelum exchange ke provider selesai, lalu proses mati di tengah jalan, callback retry berikutnya bisa tertolak padahal login belum selesai. Karena itu, validasi state saja tidak cukup; Anda juga perlu status proses callback yang bisa dipulihkan atau dikenali sebagai duplikat aman.

Guard Single-Use Code dan Idempotent Exchange

Masalah inti double exchange ada pada balapan dua request yang membawa code sama. Mengandalkan provider untuk menolak request kedua belum cukup, karena dua proses lokal bisa berjalan hampir bersamaan sebelum salah satunya menerima respons gagal.

Pola record callback/exchange

Buat satu record terpisah yang merepresentasikan pemrosesan callback berdasarkan identity yang stabil. Biasanya yang paling praktis adalah hash dari kombinasi provider + code.

create table oauth_callback_exchange (
  provider text not null,
  code_hash text not null,
  state text not null,
  status text not null,
  session_id text,
  provider_subject text,
  error_code text,
  created_at timestamptz not null,
  updated_at timestamptz not null,
  primary key (provider, code_hash)
);

Status yang berguna misalnya:

  • processing: callback pertama sedang mengerjakan exchange
  • succeeded: token exchange sukses dan session sudah dibuat
  • failed: proses gagal dan tidak boleh dianggap berhasil

Alur idempoten yang disarankan

  1. Hitung code_hash dari authorization code.
  2. Coba buat record oauth_callback_exchange dengan status processing.
  3. Jika insert sukses, request ini menjadi owner yang berhak melakukan exchange ke provider.
  4. Jika insert gagal karena konflik unik, berarti ada request lain yang lebih dulu memproses code itu. Ambil record yang sudah ada.
  5. Jika status record yang ada succeeded, jangan buat session baru; cukup gunakan hasil yang sudah ada.
  6. Jika status processing, responskan secara aman: bisa redirect ke halaman “sedang menyelesaikan login” atau cek ulang singkat sebelum memutuskan.
  7. Jika status failed, perlakukan sebagai gagal dan jangan coba exchange ulang secara membabi buta, kecuali Anda memang punya strategi retry internal yang terkendali.

Pseudo-code TypeScript:

import { createHash } from 'node:crypto';

function sha256(input: string): string {
  return createHash('sha256').update(input).digest('hex');
}

type ExchangeRow = {
  provider: string;
  code_hash: string;
  status: 'processing' | 'succeeded' | 'failed';
  session_id: string | null;
  provider_subject: string | null;
};

async function beginExchange(provider: string, code: string, state: string): Promise<{ owner: boolean; row: ExchangeRow }> {
  const codeHash = sha256(`${provider}:${code}`);

  const inserted = await db.tryInsert('oauth_callback_exchange', {
    provider,
    code_hash: codeHash,
    state,
    status: 'processing',
    created_at: new Date(),
    updated_at: new Date()
  });

  if (inserted) {
    return {
      owner: true,
      row: {
        provider,
        code_hash: codeHash,
        status: 'processing',
        session_id: null,
        provider_subject: null
      }
    };
  }

  const row = await db.getOne<ExchangeRow>('oauth_callback_exchange', {
    provider,
    code_hash: codeHash
  });

  return { owner: false, row };
}

Lalu di handler callback:

export const GET: RequestHandler = async (event) => {
  const code = event.url.searchParams.get('code');
  const state = event.url.searchParams.get('state');
  const provider = 'example-oauth';

  if (!code || !state) {
    throw redirect(303, '/login?oauth_invalid_callback=1');
  }

  const codeHash = sha256(`${provider}:${code}`);
  const stateRow = await consumeStateOnce(state, codeHash);

  if (!stateRow) {
    // Bisa state invalid, expired, atau callback duplikat.
    // Jangan langsung anggap aman; cek apakah code ini sudah pernah berhasil diproses.
    const existing = await db.getOne<ExchangeRow>('oauth_callback_exchange', {
      provider,
      code_hash: codeHash
    });

    if (existing?.status === 'succeeded' && existing.session_id) {
      await attachExistingSession(event, existing.session_id);
      throw redirect(303, stateRow?.redirect_to || '/app');
    }

    throw redirect(303, '/login?oauth_state_invalid=1');
  }

  const started = await beginExchange(provider, code, state);

  if (!started.owner) {
    if (started.row.status === 'succeeded' && started.row.session_id) {
      await attachExistingSession(event, started.row.session_id);
      throw redirect(303, stateRow.redirect_to || '/app');
    }

    if (started.row.status === 'processing') {
      throw redirect(303, '/auth/finishing');
    }

    throw redirect(303, '/login?oauth_exchange_failed=1');
  }

  try {
    const tokenSet = await exchangeCodeWithProvider(code);
    const profile = await fetchUserProfile(tokenSet);

    const session = await issueOrReuseSession({
      provider,
      providerSubject: profile.id,
      userEmail: profile.email
    });

    await db.update('oauth_callback_exchange', {
      provider,
      code_hash: codeHash
    }, {
      status: 'succeeded',
      session_id: session.id,
      provider_subject: profile.id,
      updated_at: new Date()
    });

    await attachExistingSession(event, session.id);
    throw redirect(303, stateRow.redirect_to || '/app');
  } catch (err) {
    await db.update('oauth_callback_exchange', {
      provider,
      code_hash: codeHash
    }, {
      status: 'failed',
      error_code: 'exchange_or_session_failed',
      updated_at: new Date()
    });

    throw redirect(303, '/login?oauth_exchange_failed=1');
  }
};

Pola ini bekerja karena hanya satu request yang dapat menjadi pemilik pemrosesan untuk satu code. Request duplikat lain tidak akan mengeksekusi exchange kedua kali, melainkan membaca status hasil yang sudah ada.

Mencegah Pembuatan Session Ganda

Walaupun guard pada code sudah ada, pembuatan session tetap perlu aman. Penyebabnya:

  • Callback pertama bisa sukses membuat session lalu gagal menyimpan status succeeded.
  • Callback duplikat bisa datang saat status masih processing tetapi session sebenarnya sudah tercipta.
  • Race condition bisa muncul di lapisan session atau user provisioning.

Pisahkan identitas login dari session

Gunakan kunci deduplikasi yang stabil untuk hasil autentikasi, misalnya kombinasi provider + provider_subject. Session bisa tetap banyak seiring login berbeda, tetapi untuk satu callback tertentu Anda sebaiknya dapat menemukan session yang sudah diterbitkan.

Contoh tabel tambahan:

create table oauth_identity (
  provider text not null,
  provider_subject text not null,
  user_id uuid not null,
  created_at timestamptz not null,
  primary key (provider, provider_subject)
);

create table app_session (
  id text primary key,
  user_id uuid not null,
  created_at timestamptz not null,
  revoked_at timestamptz
);

Strategi issueOrReuseSession

Untuk callback yang sama, Anda bisa memilih salah satu dari dua pendekatan:

  1. Reuse session hasil callback pertama, jika session-id sudah tersimpan di record exchange.
  2. Buat session baru hanya saat benar-benar belum ada hasil callback tersimpan.

Pendekatan pertama lebih sederhana untuk mencegah duplikasi. Artinya record oauth_callback_exchange menjadi sumber kebenaran hasil callback tertentu.

Praktik yang sering salah: membuat session lebih dulu, lalu baru mencoba mencatat bahwa callback sudah selesai. Urutannya sebaiknya dibangun agar hasil session bisa ditautkan ke record exchange dan ditemukan kembali oleh request duplikat.

Race Condition yang Perlu Diantisipasi

1. Dua callback masuk hampir bersamaan

Ini kasus paling umum. Jika hanya memeriksa “apakah state ada?” tanpa lock atau update atomik, kedua request bisa sama-sama lolos. Solusi: konsumsi state secara atomik dan gunakan unique key pada provider + code_hash.

2. State sudah consumed, tapi exchange pertama belum selesai

Callback kedua datang setelah state ditandai terpakai namun sebelum exchange pertama selesai. Jika aplikasi langsung mengembalikan error “state invalid”, user bisa melihat kegagalan padahal login sebenarnya sedang berlangsung.

Solusi praktis:

  • Setelah state gagal dikonsumsi, cek apakah ada record exchange untuk code_hash yang statusnya processing atau succeeded.
  • Jika processing, arahkan ke halaman interim seperti /auth/finishing yang melakukan polling ringan atau memberi tahu user untuk menunggu.
  • Jika succeeded, pasang session yang sama dan redirect ke tujuan akhir.

3. Exchange ke provider sukses, tapi proses lokal timeout

Misalnya provider sudah mengembalikan token, tetapi aplikasi gagal menulis session karena database timeout. Dalam kasus ini, status callback bisa tertinggal di processing atau berubah ke failed.

Yang perlu dijaga:

  • Update status exchange harus eksplisit di semua jalur error.
  • Record processing yang terlalu lama perlu dianggap stale dan ditangani oleh job pembersih atau kebijakan timeout internal.
  • Jangan otomatis exchange ulang code yang sama hanya karena menemukan status stale; provider bisa sudah menganggap code terpakai.

4. Session terset di respons pertama, lalu callback kedua datang tanpa cookie baru

Browser bisa saja belum menyimpan cookie dari respons pertama ketika request kedua sudah berjalan. Karena itu, callback kedua tidak boleh mengandalkan keberadaan cookie saat ini untuk menentukan apakah login sudah selesai. Gunakan record server-side seperti oauth_callback_exchange.session_id sebagai acuan.

Redis vs Database untuk Guard Callback

Baik Redis maupun database relasional bisa dipakai untuk guard single-use code dan state consumption. Pilih berdasarkan kebutuhan konsistensi dan arsitektur Anda.

Kapan database lebih cocok

  • Anda ingin sumber kebenaran permanen untuk audit dan debugging.
  • Anda butuh transaksi bersama data user/session.
  • Volume callback tidak terlalu tinggi dan latensi database masih wajar.

Kapan Redis cocok

  • Anda butuh lock atau deduplikasi cepat dengan TTL.
  • State/nonce hanya perlu hidup singkat.
  • Aplikasi Anda sudah sangat bergantung pada Redis sebagai koordinasi lintas instance.

Pola umum di Redis adalah SET key value NX EX ttl untuk claim pertama kali. Namun Redis murni lebih cocok sebagai guard sementara, bukan satu-satunya jejak hasil callback, karena Anda tetap memerlukan record hasil yang bisa diaudit. Dalam banyak sistem, kombinasi yang sehat adalah:

  • Redis untuk claim cepat atau lock singkat
  • Database untuk status callback final, relasi identitas user, dan session

Jika ingin sederhana, database saja sudah cukup asalkan operasi atomiknya benar.

Respons Aman untuk Callback Duplikat

Tujuannya bukan memaksa semua callback duplikat gagal, melainkan membuat hasilnya aman dan tidak membingungkan user.

Respons yang disarankan

  • Jika callback sudah succeeded: pasang session yang sama bila memungkinkan, lalu redirect ke halaman akhir yang sama.
  • Jika callback masih processing: redirect ke halaman finishing, bukan menampilkan stack trace atau pesan generik yang menyesatkan.
  • Jika state invalid dan code tidak dikenal: anggap sebagai callback tidak sah dan arahkan kembali ke login.
  • Jika status failed: tampilkan kegagalan yang aman tanpa mengungkap detail token exchange.

Hindari mengembalikan isi error provider mentah ke browser. Simpan detail itu di log internal, bukan di query string atau halaman publik.

Logging dan Observability

Bug retry OAuth callback sulit dilacak tanpa log yang tepat. Anda perlu bisa menjawab: callback ini yang pertama, duplikat, atau serangan?

Field log yang berguna

  • request_id
  • provider
  • state_hash dan code_hash
  • exchange_status
  • state_consumed true/false
  • session_id bila sudah ada
  • redirect_to
  • remote_ip dan user_agent secukupnya
  • duration_ms

Contoh event log yang layak dibedakan:

  • oauth.callback.received
  • oauth.state.consumed
  • oauth.exchange.claimed
  • oauth.exchange.duplicate
  • oauth.exchange.succeeded
  • oauth.exchange.failed
  • oauth.callback.replayed

Tip debugging: log nilai hash, bukan code atau token mentah. Anda tetap bisa menghubungkan event antar-sistem tanpa membocorkan kredensial.

Checklist Pengujian Lokal dengan Provider Simulasi

Jangan menunggu bug ini muncul di produksi. Anda bisa menguji kontrak callback secara lokal dengan provider simulasi sederhana atau stub endpoint token/profile.

Skenario uji yang wajib

  1. Callback normal satu kali
    Pastikan state dikonsumsi, exchange sukses, session dibuat, dan redirect final benar.
  2. Refresh pada URL callback
    Pastikan request kedua tidak membuat exchange baru dan tidak membuat session kedua.
  3. Dua request paralel dengan code/state sama
    Kirim dua request hampir bersamaan ke /auth/callback. Hasil yang diharapkan: satu request jadi owner, satu lagi membaca status existing.
  4. State sama, code sama, provider token endpoint lambat
    Tambahkan delay di provider simulasi. Ini memaksa cabang processing dan membantu menguji halaman finishing.
  5. State expired
    Pastikan callback ditolak aman dan tidak memulai exchange.
  6. State sudah consumed, lalu callback diulang
    Jika exchange sudah sukses, callback ulang harus tetap aman dan tidak memicu side effect baru.
  7. Exchange sukses, write session gagal
    Simulasikan kegagalan database setelah token diterima. Pastikan status exchange dan error handling konsisten.
  8. Provider mengirim ulang callback dengan parameter identik
    Verifikasi bahwa sistem memperlakukan ini sebagai duplikat, bukan login baru.

Cara sederhana mensimulasikan provider

Anda tidak harus membangun provider OAuth penuh. Untuk pengujian lokal, cukup buat dua endpoint stub:

  • /fake-provider/token yang menerima code dan mengembalikan token palsu
  • /fake-provider/userinfo yang mengembalikan subject/id pengguna tetap

Lalu arahkan fungsi exchangeCodeWithProvider ke stub lokal. Dengan ini Anda bisa mengontrol delay, error, dan perilaku replay dengan mudah.

let tokenCallCount = 0;

export async function exchangeCodeWithProvider(code: string) {
  tokenCallCount += 1;

  // Simulasi provider lambat
  await new Promise((r) => setTimeout(r, 800));

  return {
    access_token: `fake-token-for-${code}`,
    token_type: 'Bearer'
  };
}

export async function fetchUserProfile() {
  return {
    id: 'provider-user-123',
    email: '[email protected]'
  };
}

Untuk menguji race condition, jalankan dua request paralel dari script atau test runner integrasi dan pastikan tokenCallCount hanya bernilai 1 untuk code yang sama.

Kesalahan Implementasi yang Sering Terjadi

  • Hanya menyimpan state di cookie browser tanpa jejak server-side. Ini menyulitkan single-use enforcement dan audit.
  • Menganggap GET callback aman dari retry. Dalam kenyataan, GET sangat mudah di-refresh atau diulang.
  • Menganggap validasi state sudah cukup tanpa guard pada code.
  • Membuat session sebelum memastikan idempotensi callback.
  • Tidak menyimpan status processing/succeeded/failed, sehingga request duplikat tidak punya sumber kebenaran.
  • Melog token atau code mentah ke log aplikasi.
  • Langsung menghapus semua jejak state terlalu cepat, padahal masih dibutuhkan untuk diagnosis callback duplikat.

Penutup

Untuk menutup celah retry OAuth callback dan double exchange di SvelteKit, fokusnya bukan pada teori OAuth umum, melainkan pada desain endpoint callback yang tahan terhadap request berulang. Intinya adalah state single-use, guard unik untuk authorization code, exchange yang idempoten, dan session yang tidak dibuat dua kali.

Di SvelteKit, pendekatan paling praktis adalah memusatkan semua logika di +server.ts, menyimpan state/nonce di server, memakai unique guard di database atau Redis untuk code yang sudah dipakai, dan mengembalikan respons aman saat callback duplikat datang. Dengan kontrak ini, refresh, retry jaringan, tab ganda, atau replay dari provider tidak lagi mengubah satu login menjadi dua efek samping yang berbeda.