React SPA + backend proxy
Gunakan sso-frappe/browser di aplikasi React dan sso-frappe/server di backend (Express, NestJS, Hono, Fastify, ...).
React tidak memanggil token endpoint Frappe secara langsung. Backend menyimpan
clientSecretdi server-side.
Arsitektur
Tombol/halaman React
│ startLogin()
▼
Halaman authorize Frappe
│ redirect balik dengan code + state
▼
Halaman callback React
│ POST { code, codeVerifier }
▼
Backend kamu
│ exchangeCode() + getUserProfile()
▼
Buat session/cookie/JWT lokalEnv frontend
VITE_FRAPPE_SSO_BASE_URL=https://erp.example.com
VITE_FRAPPE_SSO_CLIENT_ID=<oauth-client-id>
VITE_FRAPPE_SSO_REDIRECT_URI=http://localhost:5173/auth/callback
clientSecretsengaja tidak ada di sini.
1. Tombol login
// src/components/LoginWithFrappeButton.tsx
import { createFrappeSSOBrowser } from 'sso-frappe/browser';
const sso = createFrappeSSOBrowser({
baseUrl: import.meta.env.VITE_FRAPPE_SSO_BASE_URL,
clientId: import.meta.env.VITE_FRAPPE_SSO_CLIENT_ID,
redirectUri: import.meta.env.VITE_FRAPPE_SSO_REDIRECT_URI,
allowInsecureHttp: true, // hanya dev lokal
});
export function LoginWithFrappeButton() {
return (
<button onClick={() => sso.startLogin()} className="...">
Login with Frappe
</button>
);
}2. Halaman callback
// src/pages/FrappeCallbackPage.tsx
import { useEffect, useState } from 'react';
import { createFrappeSSOBrowser } from 'sso-frappe/browser';
import { useNavigate } from 'react-router-dom';
const sso = createFrappeSSOBrowser({ /* sama seperti di atas */ });
export default function FrappeCallbackPage() {
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
const { code, codeVerifier } = await sso.handleCallback();
const response = await fetch('/api/auth/frappe/callback', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code, codeVerifier }),
});
if (!response.ok) throw new Error('SSO callback failed');
sso.clear();
navigate('/dashboard');
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown SSO error');
}
})();
}, [navigate]);
if (error) return <p>Login gagal: {error}</p>;
return <p>Menyelesaikan login...</p>;
}3. Backend Express
import express from 'express';
import session from 'express-session';
import { createFrappeSSO } from 'sso-frappe/server';
const app = express();
app.use(express.json());
app.use(session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, sameSite: 'lax', secure: false }, // dev lokal
}));
const sso = createFrappeSSO({
baseUrl: process.env.FRAPPE_SSO_BASE_URL!,
clientId: process.env.FRAPPE_SSO_CLIENT_ID!,
clientSecret: process.env.FRAPPE_SSO_CLIENT_SECRET!,
redirectUri: process.env.FRAPPE_SSO_REDIRECT_URI!,
allowInsecureHttp: true,
});
app.post('/api/auth/frappe/callback', async (req, res) => {
try {
const { code, codeVerifier } = req.body as {
code?: string;
codeVerifier?: string;
};
if (!code || !codeVerifier) {
return res.status(400).json({ message: 'Missing code or codeVerifier' });
}
const token = await sso.exchangeCode({ code, codeVerifier });
const profile = await sso.getUserProfile(token);
// Match/create user lokal (lihat /docs/id/database-schema)
const user = await upsertUserFromFrappe(profile);
req.session.userId = user.id;
return res.status(200).json({ ok: true });
} catch (err) {
console.error(err);
return res.status(400).json({ message: 'SSO callback failed' });
}
});Pencocokan user yang direkomendasikan
- Cari
user_identities(provider='frappe', provider_subject=profile.subject) - Jika tidak ada, fallback ke
users.email = profile.email - Jika email ada, insert baris identity
frappedan link-kan - Jika tetap tidak ada, buat baris
users+user_identities - Buat session aplikasi setelah user lokal selesai
NestJS / Fastify / Hono / Koa
Alurnya sama. Hanya sintaks route yang berubah. Kode client sso-frappe/server identik.
Kesalahan umum
| Kesalahan | Kenapa gagal |
|---|---|
Menaruh FRAPPE_SSO_CLIENT_SECRET di VITE_* atau NEXT_PUBLIC_* |
Secret terekspos di bundle browser |
Menjalankan exchangeCode() di React |
Butuh secret — harus di backend |
| Match user hanya via email | Email bisa berubah di Frappe; selalu utamakan provider_subject |
| Membuat password yang diharapkan dipakai login | User SSO sebaiknya punya password = NULL |
Opsional: route /start terpusat
Jika ingin backend yang memiliki state + redirect (alih-alih SPA memanggil startLogin()), expose /api/auth/frappe/start dan tombol cukup window.location.href = '/api/auth/frappe/start'. Route backend itu membuat { url, state, codeVerifier }, menyimpannya di session server, lalu redirect ke Frappe.