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.
| Service | URL Produksi | Digunakan untuk |
|---|---|---|
| API | https://api.muhajirstudio.com/ | Authorization, token exchange, public metadata, dan external APIs. |
| Main web | https://login.muhajirstudio.com/ | Hosted user login, signup, email verification, dan consent. |
| Admin web | https://admin.muhajirstudio.com/ | Application registration. |
Setup Admin
Daftarkan SPA dengan:
| Field | Contoh | Catatan |
|---|---|---|
| Allowed web origin | https://spa.example.com | Harus sama persis dengan browser Origin. |
| Redirect path | /auth/callback | Route yang ditangani SPA setelah login. |
| Scopes | openid profile email | Scope 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:
| Status | Arti | Tindakan client |
|---|---|---|
200 | Token diterima. | Render scope-filtered profile yang dikembalikan. |
401 | Token hilang, expired, atau invalid. | Hapus local auth state dan mulai login lagi. |
403 | Token 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
statesebelum token exchange. - Jangan memakai
client_secret. - Jangan bergantung pada cookies untuk
/api/external/*; kirimAuthorization: Bearer ACCESS_TOKEN. - Minimalkan token storage dan perhitungkan risiko XSS.
- Jalankan login ulang ketika scopes diubah oleh admin.