Menyimpan Access Token dan Refresh Token mentah di sisi browser—baik melalui localStorage, sessionStorage, maupun JavaScript-accessible memory—meninggalkan celah fatal terhadap serangan Cross-Site Scripting (XSS). Sekali script berbahaya berjalan pada context aplikasi frontend, seluruh token OAuth/OIDC dapat diekstraksi dan disalahgunakan langsung dari luar sistem.
Pola Backend-for-Frontend (BFF) menyelesaikan masalah ini dengan bertindak sebagai proxy layer tepercaya antara browser (SPA) dan Core API backend. BFF memindahkan tanggung jawab penanganan token upstream ke server, menerbitkan session identifier acak terenkripsi ke browser melalui HttpOnly secure cookies, serta mengisolasi lifecycle autentikasi sepenuhnya dari jangkauan JavaScript client-side.
Arsitektur Autentikasi dengan BFF Pattern
Dalam topologi ini, browser tidak pernah mengetahui keberadaan OAuth Access Token atau Refresh Token milik backend services. Komunikasi browser ke BFF menggunakan session cookie yang dilindungi atribut ketat, sementara BFF ke API gateway backend menggunakan Bearer <token> standar.
[ SPA Browser ] -- (1) POST /api/auth/login --> [ BFF Server ] -- (2) OIDC Token Exchange --> [ Auth Server / IdP ]
<-- (4) Set-Cookie: __Host-sid <-- [ BFF Server ] <-- (3) Return Access/Refresh Token <--
[ SPA Browser ] -- (5) GET /api/v1/orders + Cookie --> [ BFF Server ]
| (6) Lookup & Refresh Token via Redis
v
[ BFF Server ] -- (7) GET /v1/orders + Bearer Token --> [ Backend Core API ]Komponen Alur Kerja:
- Langkah 1-4 (Login): Kredensial dikirim ke BFF. BFF menukar kredensial ke Identity Provider (IdP), menerima access dan refresh token, menyimpannya di Redis yang diasosiasikan dengan sebuah
Session IDacak kriptografis (misal: UUID v4), lalu mengembalikan session ID ke browser melaluiSet-Cookie. - Langkah 5-7 (API Request): Frontend merequest data ke endpoint proxy BFF. BFF memvalidasi cookie session, mengambil access token valid dari Redis (melakukan refresh token jika expired), lalu menembak Core API backend dengan header
Authorization: Bearer <token>.
Implementasi Session Storage Menggunakan Redis
BFF membutuhkan penyimpanan session state yang cepat dan terisolasi. Redis adalah pilihan standar industri untuk skenario ini karena memiliki native Time-to-Live (TTL) key expiration.
// auth-session.service.ts
import crypto from 'node:crypto';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
interface UserSessionPayload {
userId: string;
accessToken: string;
refreshToken: string;
expiresAt: number; // Unix timestamp dalam ms
}
export class AuthSessionService {
private static PREFIX = 'sess:';
static async createSession(data: UserSessionPayload, ttlSeconds: number): Promise<string> {
const sessionId = crypto.randomBytes(32).toString('hex');
const key = `${this.PREFIX}${sessionId}`;
// Simpan token terenkripsi atau payload mentah jika network Redis privat
await redis.set(key, JSON.stringify(data), 'EX', ttlSeconds);
return sessionId;
}
static async getSession(sessionId: string): Promise<UserSessionPayload | null> {
const key = `${this.PREFIX}${sessionId}`;
const raw = await redis.get(key);
if (!raw) return null;
return JSON.parse(raw) as UserSessionPayload;
}
static async updateTokens(sessionId: string, accessToken: string, refreshToken: string, ttlSeconds: number): Promise<void> {
const key = `${this.PREFIX}${sessionId}`;
const current = await this.getSession(sessionId);
if (!current) throw new Error('Session expired');
current.accessToken = accessToken;
current.refreshToken = refreshToken;
current.expiresAt = Date.now() + (ttlSeconds * 1000);
await redis.set(key, JSON.stringify(current), 'EX', ttlSeconds);
}
static async destroySession(sessionId: string): Promise<void> {
await redis.del(`${this.PREFIX}${sessionId}`);
}
}Konfigurasi Hardened Cookie
Pemberian cookie ke browser wajib mematuhi parameter hardening untuk mencegah serangan CSRF, session hijacking, dan sniffing:
// cookie-config.ts
import { CookieOptions } from 'express';
export const SESSION_COOKIE_NAME = '__Host-session';
export const sessionCookieOptions: CookieOptions = {
httpOnly: true, // Blok akses dari document.cookie / JavaScript
secure: true, // Wajib HTTPS
sameSite: 'lax', // Proteksi CSRF (gunakan 'strict' jika navigasi eksternal tidak butuh auth state)
path: '/', // Wajib '/' jika menggunakan prefix __Host-
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 hari
};Catatan Penting: Prefix
__Host-memaksakan aturan cookie RFC 6265bis: cookie harus dikirim via HTTPS, tidak boleh memiliki atributDomain(hanya berlaku pada origin host persis), dan path wajib berupa/.
Implementasi Reverse Proxy & Token Rotation Middleware
Saat request diteruskan ke backend API, BFF mengecek masa aktif access token. Jika access token sudah kedaluwarsa, BFF menggunakan refresh token untuk mengambil token baru secara transparan tanpa mengganggu request frontend.
// proxy-auth.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { AuthSessionService, SESSION_COOKIE_NAME } from './auth-session.service';
export async function proxyAuthMiddleware(req: Request, res: Response, next: NextFunction) {
const sessionId = req.cookies[SESSION_COOKIE_NAME];
if (!sessionId) {
return res.status(401).json({ error: 'Unauthorized: No active session' });
}
const session = await AuthSessionService.getSession(sessionId);
if (!session) {
return res.status(401).json({ error: 'Unauthorized: Session invalidated' });
}
// Token Rotation logic jika access token mendekati kadaluwarsa (< 60 detik)
const isExpired = Date.now() >= (session.expiresAt - 60000);
if (isExpired) {
try {
const refreshed = await exchangeRefreshToken(session.refreshToken);
await AuthSessionService.updateTokens(
sessionId,
refreshed.accessToken,
refreshed.refreshToken,
refreshed.expiresIn
);
req.headers['authorization'] = `Bearer ${refreshed.accessToken}`;
} catch (err) {
// Refresh token invalid atau reuse detected
await AuthSessionService.destroySession(sessionId);
res.clearCookie(SESSION_COOKIE_NAME);
return res.status(401).json({ error: 'Session expired, re-login required' });
}
} else {
req.headers['authorization'] = `Bearer ${session.accessToken}`;
}
// Inject user info jika backend membutuhkan
req.headers['x-user-id'] = session.userId;
next();
}
async function exchangeRefreshToken(token: string) {
// Panggilan upstream ke Authorization Server (misal Keycloak/Auth0)
// ponytail: implementasi HTTP call upstream diabaikan untuk keringkasan
return {
accessToken: 'new_access_token_jwt',
refreshToken: 'new_refresh_token_jwt',
expiresIn: 3600
};
}Mitigasi Serangan CSRF pada Layer BFF
Dengan mengalihkan session ke cookies, sistem terlindungi dari manipulasi script XSS langsung, tetapi menjadi target potensial untuk Cross-Site Request Forgery (CSRF). Mitigasi wajib diterapkan pada rute mutasi (POST, PUT, DELETE, PATCH):
- Atribut SameSite=Lax: Membatasi cookie dikirim hanya dari same-site requests atau top-level navigation safe method (GET).
- Custom Header Verification: SPA wajib menyertakan header kustom (misal:
X-Requested-With: XMLHttpRequestatauX-Client-Type: Web-BFF) pada setiap request mutasi API. Browser mencegah situs third-party menyuntikkan custom headers tanpa preflight CORS yang valid. - Anti-CSRF Token: Untuk transaksi finansial atau endpoint berisiko tinggi, terapkan sinkronisasi double-submit cookie atau encrypted token pattern.
Checklist Verifikasi Keamanan
Sebelum merilis arsitektur BFF ke production, validasi konfigurasi sistem dengan daftar periksa berikut:
- Cookie Prefix: Cookie session menggunakan prefiks
__Host-(contoh:__Host-session_id). - Flag Proteksi Cookie: Seluruh cookie session memiliki flag
HttpOnly,Secure, danSameSite=LaxatauStrict. - Network Isolation: Node BFF berada di dalam private VPC/network yang sama dengan backend service atau berkomunikasi melalui Mutual TLS (mTLS).
- Zero Client Secrets: Frontend bundle JavaScript sama sekali tidak memuat
client_secretOIDC atau token signing key. - Redis Access Control: Akses Redis dilindungi TLS, password autentikasi kuat, serta restricted network.
- Token Revocation / Logout: Handler logout wajib menghapus record session di Redis sekaligus menginstruksikan upstream Auth Server untuk membatalkan refresh token terkait.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!