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-filterdengan skema yang identik diAndroidManifest.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:
- App Links (Android) & Universal Links (iOS): Menggunakan URL berbasis HTTPS standar yang diverifikasi melalui kepemilikan domain (file
assetlinks.jsondanapple-app-site-association). OS hanya akan membuka aplikasi resmi yang domainnya terverifikasi secara kriptografis. - 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 kepemilikancode_verifierasli. Interseptor yang hanya mencuriauthorization_codevia deep link tidak dapat melakukan pertukaran token. - Parameter State Kriptografis: Nilai acak dengan entropi tinggi yang mengikat sesi inisiasi login dengan sesi penerimaan callback, memitigasi serangan Login CSRF dan State Injection.
Penyimpanancode_verifierdanstatetidak boleh menggunakanAsyncStorage. Data padaAsyncStoragedisimpan 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
plainpadacode_challenge_methoddi Authorization Server. Izinkan hanyaS256. - Satu Kali Pakai (One-Time Use): Pastikan backend memvalidasi bahwa
authorization_codelangsung 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.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!