Implementasi Refresh Token Rotation (RTR) sering menimbulkan bug fatal pada aplikasi mobile: pengguna tiba-tiba terlempar ke halaman login (forced logout). Masalah ini terjadi saat beberapa HTTP request paralel menerima status 401 Unauthorized secara bersamaan ketika access token kedaluwarsa. Tanpa mekanisme sinkronisasi, setiap request akan memicu pemanggilan refresh token terpisah menggunakan token yang sama.

Sebagian besar backend dengan standar keamanan ketat menganggap pemanggilan refresh ganda pada satu token yang sama sebagai replay attack. Akibatnya, seluruh token family dicabut dan sesi pengguna dimatikan seketika. Solusi teknis untuk masalah ini adalah menerapkan locking mechanism menggunakan antrean mutex (Mutual Exclusion) pada interceptor HTTP client.

Akar Masalah: Race Condition pada Refresh Token Rotation

Pada arsitektur mobile, sebuah layar umumnya memuat beberapa komponen yang mengeksekusi fetch data secara bersamaan via Promise.all atau trigger lifecycle paralel. Skenario race condition berjalan seperti berikut:

  1. Access token kedaluwarsa di client.
  2. Aplikasi mengirimkan 3 request bersamaan: Request A, Request B, dan Request C.
  3. Ketiga request mengembalikan HTTP 401 dari backend.
  4. Handler 401 pada client secara naif mengeksekusi endpoint /auth/refresh sebanyak 3 kali dengan Refresh Token 1 (RT1).
  5. Request refresh pertama berhasil; backend merotasi RT1 menjadi RT2.
  6. Request refresh kedua dan ketiga tiba di backend masih membawa RT1 yang sudah dinyatakan hangus.
  7. Backend mendeteksi penggunaan ulang token (token reuse detection), mencurigai terjadinya pembobolan sesi, lalu menghapus seluruh session token di database.

Arsitektur Mutex Interceptor Axios

Untuk menghentikan loop kegagalan ini, aplikasi memerlukan single in-flight refresh promise. Request pertama yang menemui status 401 bertindak sebagai inisiator rotasi token (mengunci status refresh). Request 401 berikutnya yang datang saat proses refresh masih berlangsung tidak boleh menembak backend lagi, melainkan harus ditangguhkan ke dalam antrean (queue).

Setelah request rotasi pertama selesai dan menghasilkan pasangan token baru, seluruh request yang berada di dalam antrean dieksekusi ulang (replay) menggunakan access token yang baru. Jika rotasi gagal (misalnya refresh token sudah expired permanen), seluruh antrean ditolak (reject) dan credential dibersihkan.

Implementasi Interceptor dan react-native-keychain

Penyimpanan token pada React Native harus menggunakan secure enclave hardware, bukan AsyncStorage. Library react-native-keychain menyediakan antarmuka aman ke Keychain (iOS) dan KeyStore (Android).

Berikut adalah implementasi minimalis, fungsional, dan thread-safe untuk Axios interceptor:

import axios from 'axios';
import * as Keychain from 'react-native-keychain';

const KEYCHAIN_SERVICE = 'com.myapp.auth';

export const apiClient = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 10000,
});

// State locking dan antrean request
let isRefreshing = false;
let failedQueue = [];

const processQueue = (error, token = null) => {
  failedQueue.forEach((promise) => {
    if (error) {
      promise.reject(error);
    } else {
      promise.resolve(token);
    }
  });
  failedQueue = [];
};

apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    // Lewatkan jika bukan error 401 atau request sudah pernah di-retry
    if (error.response?.status !== 401 || originalRequest._retry) {
      return Promise.reject(error);
    }

    // Tangani kondisi saat rotasi token sedang berjalan
    if (isRefreshing) {
      return new Promise((resolve, reject) => {
        failedQueue.push({ resolve, reject });
      })
        .then((token) => {
          originalRequest.headers.Authorization = `Bearer ${token}`;
          return apiClient(originalRequest);
        })
        .catch((err) => Promise.reject(err));
    }

    originalRequest._retry = true;
    isRefreshing = true;

    try {
      const credentials = await Keychain.getGenericPassword({ service: KEYCHAIN_SERVICE });
      if (!credentials) {
        throw new Error('Sesi autentikasi tidak ditemukan');
      }

      const { refreshToken } = JSON.parse(credentials.password);

      // Gunakan instance axios standar untuk menghindari loop interceptor
      const refreshResponse = await axios.post(
        'https://api.example.com/auth/refresh',
        { refreshToken },
        { timeout: 10000 }
      );

      const { accessToken: newAccessToken, refreshToken: newRefreshToken } = refreshResponse.data;

      // Simpan atomic ke Keychain
      await Keychain.setGenericPassword(
        'session',
        JSON.stringify({ accessToken: newAccessToken, refreshToken: newRefreshToken }),
        { service: KEYCHAIN_SERVICE }
      );

      apiClient.defaults.headers.common.Authorization = `Bearer ${newAccessToken}`;
      originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;

      processQueue(null, newAccessToken);
      return apiClient(originalRequest);
    } catch (refreshError) {
      processQueue(refreshError, null);
      await Keychain.resetGenericPassword({ service: KEYCHAIN_SERVICE });
      
      // Emit global event logout jika diperlukan
      return Promise.reject(refreshError);
    } finally {
      isRefreshing = false;
    }
  }
);

Penanganan Edge Cases

1. Refresh Token Expired (HTTP 401/403 pada Endpoint Refresh)

Ketika refresh token itu sendiri kedaluwarsa atau dicabut, endpoint /auth/refresh akan mengembalikan status 401 atau 403. Blok catch menangani skenario ini dengan memanggil processQueue(refreshError, null). Seluruh promise dalam failedQueue akan otomatis di-reject dengan error yang sama. Hapus data Keychain seketika dan arahkan user ke root autentikasi/layar login via auth-state controller aplikasi.

2. Network Timeout dan Connectivity Drop

Jika endpoint refresh mengalami timeout (misalnya koneksi seluler berpindah ke area blank spot), flag isRefreshing harus dipastikan kembali bernilai false di blok finally. Menentukan timeout eksplisit (misal 10 detik) pada call refresh wajib dilakukan agar queue tidak menggantung (stale) tanpa batas waktu dan menghabiskan resource memori JavaScript thread.

Peringatan: Jangan gunakan instance apiClient yang sama untuk memanggil endpoint refresh di dalam interceptor. Gunakan instance axios dasar tanpa konfigurasi interceptor untuk mencegah infinite recursive interceptor loop saat call refresh gagal.

Trade-off dan Alternatif

Pola Queue Mutex di sisi client adalah mitigasi wajib jika backend menerapkan zero-tolerance rotation. Alternatif sisi arsitektur backend adalah menerapkan leeway window (grace period) berdurasi 10–30 detik, di mana refresh token lama tetap dianggap valid untuk request paralel sebelum sepenuhnya dimusnahkan. Namun, dari sudut pandang pertahanan berlapis (defense in depth), proteksi client-side melalui mutex interceptor tetap mutlak diperlukan untuk stabilitas network layer aplikasi React Native.