sfsso-frappe

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 clientSecret di 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 lokal

Env 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

clientSecret sengaja 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

  1. Cari user_identities(provider='frappe', provider_subject=profile.subject)
  2. Jika tidak ada, fallback ke users.email = profile.email
  3. Jika email ada, insert baris identity frappe dan link-kan
  4. Jika tetap tidak ada, buat baris users + user_identities
  5. 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.