Lewati ke konten utama

Integrasi React SPA

Recipe ini menunjukkan cara browser-only React single-page application memakai Authorization Code Flow with PKCE dan memanggil external API Muhajir Studio langsung dari browser.

Model Integrasi Browser

React SPAs adalah Public PKCE clients. Aplikasi tidak memakai client_secret, dan semua token/API browser calls harus berasal dari registered web origin.

ServiceURL ProduksiDigunakan untuk
APIhttps://api.muhajirstudio.com/Authorization, token exchange, public metadata, dan external APIs.
Main webhttps://login.muhajirstudio.com/Hosted user login, signup, email verification, dan consent.
Admin webhttps://admin.muhajirstudio.com/Application registration.

Setup Admin

Daftarkan SPA dengan:

FieldContohCatatan
Allowed web originhttps://spa.example.comHarus sama persis dengan browser Origin.
Redirect path/auth/callbackRoute yang ditangani SPA setelah login.
Scopesopenid profile emailScope yang dipilih admin mengontrol effective grants.

Dynamic third-party CORS diaktifkan untuk token endpoint dan /api/external/*. CORS ini hanya mengizinkan registered origins, memakai credentials: false, dan mengizinkan header Authorization serta Content-Type.

1. Generate PKCE di Browser

Generate verifier, challenge, state, dan nonce untuk setiap login attempt. Simpan nilai short-lived di sessionStorage agar callback dapat memvalidasinya.

function randomBase64Url(bytes = 32) {
const values = crypto.getRandomValues(new Uint8Array(bytes));
return btoa(String.fromCharCode(...values))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}

async function sha256Base64Url(value: string) {
const data = new TextEncoder().encode(value);
const hash = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}

2. Redirect ke Authorization

Buat authorization URL memakai production API base URL.

Pada contoh di bawah, apiBaseUrl adalah production API URL dan clientId adalah public client ID dari web-admin.

const verifier = randomBase64Url();
const challenge = await sha256Base64Url(verifier);
const state = randomBase64Url();
const nonce = randomBase64Url();

sessionStorage.setItem('oidc.pkce.verifier', verifier);
sessionStorage.setItem('oidc.oauth.state', state);
sessionStorage.setItem('oidc.oauth.nonce', nonce);

const params = new URLSearchParams({
client_id: clientId,
redirect_uri: `${window.location.origin}/auth/callback`,
response_type: 'code',
scope: 'openid profile email',
state,
nonce,
code_challenge: challenge,
code_challenge_method: 'S256',
});

window.location.assign(`${apiBaseUrl}/api/auth/oauth2/authorize?${params}`);

redirect_uri harus sama persis dengan derived redirect URI yang terdaftar di web-admin.

3. Tangani Callback

Pada /auth/callback, validasi state, lalu tukar authorization code.

const search = new URLSearchParams(window.location.search);

if (search.get('error')) {
throw new Error(search.get('error_description') ?? search.get('error')!);
}

if (search.get('state') !== sessionStorage.getItem('oidc.oauth.state')) {
throw new Error('Invalid OAuth state');
}

const tokenRes = await fetch(`${apiBaseUrl}/api/auth/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: search.get('code')!,
code_verifier: sessionStorage.getItem('oidc.pkce.verifier')!,
client_id: clientId,
redirect_uri: `${window.location.origin}/auth/callback`,
}),
});

const tokenBody = await tokenRes.json();
if (!tokenRes.ok) {
throw new Error(tokenBody.error_description ?? tokenBody.error);
}

Jangan set credentials: 'include' untuk third-party token atau external API requests. Third-party CORS bersifat non-credentialed.

4. Simpan dan Pakai Tokens dengan Hati-hati

Utamakan in-memory token storage untuk SPAs. Jika Anda menyimpan tokens di browser storage, perlakukan itu sebagai security trade-off eksplisit karena JavaScript-accessible storage terekspos ke XSS.

Panggil external API dengan bearer access token:

const meRes = await fetch(`${apiBaseUrl}/api/external/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});

Tangani response umum:

StatusArtiTindakan client
200Token diterima.Render scope-filtered profile yang dikembalikan.
401Token hilang, expired, atau invalid.Hapus local auth state dan mulai login lagi.
403Token valid tetapi missing required scope.Minta admin memperbarui scopes atau minta login baru setelah perubahan scope.

Checklist Produksi

  • Daftarkan production origin dan callback path yang tepat.
  • Gunakan hanya code_challenge_method=S256.
  • Validasi state sebelum token exchange.
  • Jangan memakai client_secret.
  • Jangan bergantung pada cookies untuk /api/external/*; kirim Authorization: Bearer ACCESS_TOKEN.
  • Minimalkan token storage dan perhitungkan risiko XSS.
  • Jalankan login ulang ketika scopes diubah oleh admin.