Anatomi Kerentanan: Custom URI Scheme Interception

Pada aplikasi mobile React Native, alur autentikasi OAuth 2.0 sering kali mengarahkan pengguna ke browser eksternal (atau in-app browser tab) untuk login, lalu mengembalikan authorization_code ke aplikasi via deep link (misalnya myapp://oauth/callback?code=XYZ).

Masalah keamanan kritis muncul ketika aplikasi mengandalkan Custom URI Scheme murni (seperti myapp://). Sistem operasi Android dan iOS tidak menerapkan kepemilikan eksklusif atas skema URI kustom:

  • Android: Beberapa aplikasi di perangkat yang sama dapat mendaftarkan intent-filter dengan skema yang identik di AndroidManifest.xml. Jika terpasang aplikasi berbahaya (malicious app), OS dapat menampilkan dialog disambiguasi kepada pengguna atau bahkan langsung meneruskan intent ke aplikasi berbahaya tersebut.
  • iOS: Jika terdapat dua aplikasi yang mengklaim skema URI kustom yang sama, sistem operasi menentukan aplikasi target secara non-deterministik.

Jika penyerang berhasil mencegat callback tersebut, penyerang memperoleh authorization_code. Tanpa perlindungan kriptografis tambahan, kode tersebut dapat ditukar langsung ke Authorization Server untuk mendapatkan access_token dan refresh_token korban.

Prinsip Pertahanan: Universal Links, PKCE, dan State

Untuk menutup celah ini secara menyeluruh, aplikasi wajib menerapkan tiga lapisan pertahanan:

  1. App Links (Android) & Universal Links (iOS): Menggunakan URL berbasis HTTPS standar yang diverifikasi melalui kepemilikan domain (file assetlinks.json dan apple-app-site-association). OS hanya akan membuka aplikasi resmi yang domainnya terverifikasi secara kriptografis.
  2. PKCE (RFC 7636): Mengharuskan klien membuat secret sementara berkekuatan tinggi (code_verifier) dan mengirimkan versi hash-nya (code_challenge) saat inisiasi login. Authorization Server hanya akan merilis token jika penukar kode dapat membuktikan kepemilikan code_verifier asli. Interseptor yang hanya mencuri authorization_code via deep link tidak dapat melakukan pertukaran token.
  3. Parameter State Kriptografis: Nilai acak dengan entropi tinggi yang mengikat sesi inisiasi login dengan sesi penerimaan callback, memitigasi serangan Login CSRF dan State Injection.
Penyimpanan code_verifier dan state tidak boleh menggunakan AsyncStorage. Data pada AsyncStorage disimpan dalam bentuk plaintext di sandbox file system yang rentan diekstraksi pada perangkat yang di-root atau via backup injection. Gunakan Keychain (iOS) dan Keystore/EncryptedSharedPreferences (Android).

Implementasi PKCE dan Secure Storage di TypeScript

Berikut implementasi utilitas kriptografi PKCE dan abstraksi secure storage menggunakan react-native-keychain. Utilitas ini menghasilkan code_verifier, menghitung SHA-256 code_challenge dalam format Base64URL, serta membuat token state acak.

// authCrypto.ts
import * as Keychain from 'react-native-keychain';

// ponytail: polyfill WebCrypto crypto.getRandomValues jika runtime RN belum mendukung global crypto
function getRandomBytes(byteLength: number): Uint8Array {
  const bytes = new Uint8Array(byteLength);
  if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
    crypto.getRandomValues(bytes);
  } else {
    throw new Error('WebCrypto API tidak tersedia di runtime.');
  }
  return bytes;
}

function base64UrlEncode(arrayBuffer: ArrayBuffer): string {
  const bytes = new Uint8Array(arrayBuffer);
  let binary = '';
  for (let i = 0; i < bytes.byteLength; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  // Konversi binary string ke Base64 standar lalu ganti karakter URL-unsafe
  const base64 = btoa(binary);
  return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

export function generateCodeVerifier(): string {
  const randomBytes = getRandomBytes(32); // 256-bit entropy
  return base64UrlEncode(randomBytes.buffer);
}

export async function generateCodeChallenge(verifier: string): Promise<string> {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(digest);
}

export function generateState(): string {
  const randomBytes = getRandomBytes(16);
  return base64UrlEncode(randomBytes.buffer);
}

const AUTH_STORAGE_SERVICE = 'com.app.auth.pkce';

export async function saveAuthSession(state: string, verifier: string): Promise<void> {
  await Keychain.setGenericPassword(state, verifier, {
    service: AUTH_STORAGE_SERVICE,
    accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
  });
}

export async function retrieveAndClearAuthSession(): Promise<{ state: string; verifier: string } | null> {
  const credentials = await Keychain.getGenericPassword({ service: AUTH_STORAGE_SERVICE });
  if (!credentials) {
    return null;
  }
  // Hapus kredensial segera setelah diambil (one-time secret)
  await Keychain.resetGenericPassword({ service: AUTH_STORAGE_SERVICE });
  return {
    state: credentials.username, // state disimpan pada field username
    verifier: credentials.password, // verifier disimpan pada field password
  };
}

Menangani Callback URL dan Pertukaran Token

Komponen otentikasi harus mengonfigurasi listener untuk event deep link menggunakan modul Linking bawaan React Native. Komponen ini wajib memvalidasi kecocokan parameter state sebelum memicu pemanggilan token endpoint.

// useAuthHandler.ts
import { useEffect } from 'react';
import { Linking } from 'react-native';
import {
  generateCodeVerifier,
  generateCodeChallenge,
  generateState,
  saveAuthSession,
  retrieveAndClearAuthSession,
} from './authCrypto';

const OAUTH_CONFIG = {
  authEndpoint: 'https://auth.example.com/oauth/authorize',
  tokenEndpoint: 'https://auth.example.com/oauth/token',
  clientId: 'mobile-app-client',
  redirectUri: 'https://app.example.com/oauth/callback', // Universal Link / App Link
};

export async function initiateLogin(): Promise<void> {
  const verifier = generateCodeVerifier();
  const challenge = await generateCodeChallenge(verifier);
  const state = generateState();

  await saveAuthSession(state, verifier);

  const authUrl = new URL(OAUTH_CONFIG.authEndpoint);
  authUrl.searchParams.append('response_type', 'code');
  authUrl.searchParams.append('client_id', OAUTH_CONFIG.clientId);
  authUrl.searchParams.append('redirect_uri', OAUTH_CONFIG.redirectUri);
  authUrl.searchParams.append('code_challenge', challenge);
  authUrl.searchParams.append('code_challenge_method', 'S256');
  authUrl.searchParams.append('state', state);

  await Linking.openURL(authUrl.toString());
}

export async function handleRedirectUrl(incomingUrl: string): Promise<{ accessToken: string; idToken?: string }> {
  const url = new URL(incomingUrl);
  const incomingCode = url.searchParams.get('code');
  const incomingState = url.searchParams.get('state');
  const incomingError = url.searchParams.get('error');

  if (incomingError) {
    throw new Error(`OAuth Server Error: ${incomingError}`);
  }

  if (!incomingCode || !incomingState) {
    throw new Error('Parameter code atau state tidak ditemukan pada URL redirect.');
  }

  const session = await retrieveAndClearAuthSession();
  if (!session) {
    throw new Error('Sesi autentikasi lokal tidak ditemukan atau telah kedaluwarsa.');
  }

  // Validasi State Kriptografis
  if (session.state !== incomingState) {
    throw new Error('Potensi serangan CSRF: State yang diterima tidak cocok dengan state lokal.');
  }

  // Tukar Authorization Code + Code Verifier dengan Access Token
  const tokenResponse = await fetch(OAUTH_CONFIG.tokenEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: OAUTH_CONFIG.clientId,
      redirect_uri: OAUTH_CONFIG.redirectUri,
      code: incomingCode,
      code_verifier: session.verifier,
    }).toString(),
  });

  if (!tokenResponse.ok) {
    const errorBody = await tokenResponse.text();
    throw new Error(`Gagal menukar authorization code: ${errorBody}`);
  }

  const tokens = await tokenResponse.json();
  return {
    accessToken: tokens.access_token,
    idToken: tokens.id_token,
  };
}

Pengujian Skenario Serangan (Self-Checking Test)

Berikut unit pengujian berbasis assertion mandiri tanpa test runner eksternal untuk memvalidasi proteksi terhadap skenario state manipulation dan code verifier mismatch.

// authSecurity.test.ts
import { generateCodeVerifier, generateCodeChallenge } from './authCrypto';

async function runSecurityTests() {
  console.log('[TEST] Mulai verifikasi keamanan PKCE & State...');

  // 1. Verifikasi PKCE Challenge S256 RFC 7636
  // Nilai acuan dari RFC 7636 Appendix B
  const testVerifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
  const expectedChallenge = 'E9Melhoa2OwvFrGMTJguCH5rtG6Zv5iN9vURamhkvc8';
  const calculatedChallenge = await generateCodeChallenge(testVerifier);

  console.assert(
    calculatedChallenge === expectedChallenge,
    `PKCE S256 hash mismatch! Got: ${calculatedChallenge}, Expected: ${expectedChallenge}`
  );

  // 2. Skenario: Serangan State Injection (Spoofed State)
  const originalState = 'valid_secret_state_123';
  const interceptedState = 'attacker_injected_state_456';
  
  let csrfBlocked = false;
  try {
    if (originalState !== interceptedState) {
      throw new Error('CSRF_DETECTED');
    }
  } catch (err: any) {
    if (err.message === 'CSRF_DETECTED') {
      csrfBlocked = true;
    }
  }
  console.assert(csrfBlocked, 'Gagal mendeteksi ketidakcocokan parameter state.');

  // 3. Skenario: Penyerang mencegat Authorization Code tanpa memiliki Verifier
  const mockTokenExchange = (receivedVerifier: string, actualVerifier: string) => {
    if (receivedVerifier !== actualVerifier) {
      return { status: 400, error: 'invalid_grant' };
    }
    return { status: 200, access_token: 'valid_token' };
  };

  const legitimateVerifier = generateCodeVerifier();
  const attackerGuessesVerifier = generateCodeVerifier(); // Penyerang mencoba membuat verifier acak

  const attackResult = mockTokenExchange(attackerGuessesVerifier, legitimateVerifier);
  console.assert(
    attackResult.status === 400 && attackResult.error === 'invalid_grant',
    'Authorization server seharusnya menolak token exchange jika verifier tidak valid.'
  );

  console.log('[TEST] Seluruh assertion keamanan lolos.');
}

runSecurityTests();

Checklist Keamanan Tambahan

  • Tolak Fallback ke HTTP: Konfigurasikan Authorization Server untuk hanya mengizinkan redirect URI dengan skema https:// untuk Universal/App Links, bukan skema custom URI.
  • Enforce S256: Nonaktifkan opsi transformasi plain pada code_challenge_method di Authorization Server. Izinkan hanya S256.
  • Satu Kali Pakai (One-Time Use): Pastikan backend memvalidasi bahwa authorization_code langsung hangus setelah sekali percobaan penukaran, terlepas dari apakah penukaran tersebut berhasil atau gagal.
  • Hapus State dan Verifier Segera: Fungsi retrieveAndClearAuthSession() harus selalu mereset Keychain seketika saat data dibaca untuk menghindari replay attack lokal.