@kandiforge/kandi-login
v0.1.1
Published
Universal OAuth login component for React — supports Tauri, Electron, and Web with MUI, Tailwind, or headless rendering
Readme
kandi-login
Multi-platform OAuth authentication framework. 5 client SDKs connect to 1 Node.js server SDK.
Supports Web (React), Electron, Tauri, iOS (Swift), and Android (Kotlin/Compose). Works with MUI, Tailwind, or headless rendering. Supports Apple, Google, Facebook, and Hello.coop OAuth providers.
npm install kandi-loginArchitecture
┌─────────────────────────────────────┐
│ CLIENT SDKs │
│ │
│ Web (React) → Next.js, Vite │
│ Electron → Desktop │
│ Tauri → Desktop (Rust) │
│ iOS (Swift) → iPhone, iPad │
│ Android → Kotlin/Compose │
└──────────────┬──────────────────────┘
│ HTTPS
┌──────────────▼──────────────────────┐
│ NODE.JS SERVER SDK │
│ kandi-login/server │
│ createAuthServer() │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ YOUR DATABASE │
└─────────────────────────────────────┘The server SDK is framework-agnostic — it works with Next.js API routes, Express, Fastify, or any Node.js HTTP framework. The client SDK auto-detects the platform (Tauri, Electron, or Web) and uses the appropriate OAuth flow and token storage for each.
Integration Guide
This guide walks through a complete integration into a monorepo project with a Next.js frontend and API server. Adapt the framework-specific parts for Express, Fastify, Vite, etc.
Step 1: Install
npm install kandi-loginPeer dependencies (install only what you use):
# React (required for all client components)
npm install react react-dom
# MUI components (optional)
npm install @mui/material @emotion/react @emotion/styled
# Tauri (optional)
npm install @tauri-apps/apiStep 2: Server — Implement the UserAdapter
The server SDK needs 5 database functions. This is the only code you write on the server side — everything else is handled by createAuthServer.
// lib/user-adapter.ts
import type { UserAdapter, OAuthProfile } from 'kandi-login/server';
// Replace with your actual database client (Prisma, Drizzle, Supabase, etc.)
import { db } from './db';
export const userAdapter: UserAdapter = {
// Called first: check if this provider+ID combo already exists
async findByProviderId(provider, providerUserId) {
return await db.user.findFirst({
where: { [`${provider}_sub`]: providerUserId }
});
},
// Called second: cross-provider account linking by email
async findByEmail(email) {
return await db.user.findFirst({ where: { email } });
},
// Called third: create new user if neither lookup found a match
async createUser(profile: OAuthProfile) {
return await db.user.create({
data: {
email: profile.email,
name: profile.name,
avatar_url: profile.avatarUrl,
[`${profile.provider}_sub`]: profile.providerUserId,
}
});
},
// Called when an existing user (found by email) logs in with a new provider
async linkProvider(userId, provider, providerUserId) {
await db.user.update({
where: { id: userId },
data: { [`${provider}_sub`]: providerUserId }
});
},
// Called during token refresh and validation to get fresh user data
async getUserById(id) {
return await db.user.findUnique({ where: { id } });
},
};The user object you return must have at least id and email. Additional fields like name, display_name, avatar_url, and role are included in the JWT access token if present.
Step 3: Server — Create the Auth Server
// lib/auth-server.ts
import { createAuthServer } from 'kandi-login/server';
import { userAdapter } from './user-adapter';
export const auth = createAuthServer({
// JWT configuration (required)
jwt: {
secret: process.env.JWT_SECRET!, // min 32 chars, used for HS256
issuer: 'auth.yourapp.com', // iss claim
accessTokenTtl: '1h', // default: 1h
refreshTokenTtl: '30d', // default: 30d
},
// OAuth providers — configure only the ones you use
providers: {
// Hello.coop acts as an OIDC gateway to Google, Apple, etc.
// One client ID covers all upstream providers via provider_hint
hellocoop: {
clientId: process.env.HELLO_CLIENT_ID!,
},
// Direct Google integration (for native mobile ID token flow)
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
},
// Apple (native ID token flow only, no server redirect)
apple: {
clientId: process.env.APPLE_CLIENT_ID!, // iOS Bundle ID or Services ID
additionalClientIds: ['com.yourapp.web'], // optional: web Services ID
},
// Facebook requires a server redirect flow with app secret
facebook: {
appId: process.env.FACEBOOK_APP_ID!,
appSecret: process.env.FACEBOOK_APP_SECRET!,
},
},
// Your database adapter (required)
userAdapter,
// Your server's public URL (required for redirect flows)
baseUrl: process.env.BASE_URL!,
// Where to redirect after successful web login
successRedirectUrl: process.env.SUCCESS_REDIRECT_URL!,
// Lifecycle hooks (optional)
onLogin: async (user, provider) => {
// Called before token issuance. Throw to reject login.
console.log(`${user.email} logged in via ${provider}`);
},
onUserCreated: async (user, provider) => {
// Called after a new user is created
console.log(`New user: ${user.email}`);
},
// Test personas (development only — see Testing section)
enableTestPersonas: process.env.NODE_ENV !== 'production',
});Step 4: Server — Mount the Route Handler
Next.js App Router:
// app/api/auth/[...path]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth-server';
import type { AuthRequest, AuthResponse } from 'kandi-login/server';
const routes: Record<string, { handler: typeof auth.handleLogin; methods: string[] }> = {
login: { handler: auth.handleLogin, methods: ['GET'] },
callback: { handler: auth.handleCallback, methods: ['GET'] },
native: { handler: auth.handleNativeLogin, methods: ['POST'] },
refresh: { handler: auth.handleRefresh, methods: ['POST'] },
validate: { handler: auth.handleValidate, methods: ['GET'] },
logout: { handler: auth.handleLogout, methods: ['POST'] },
// Test endpoints (only available when enableTestPersonas is true)
...(auth.handleSeedPersonas ? { 'test/seed': { handler: auth.handleSeedPersonas, methods: ['POST'] } } : {}),
...(auth.handleListPersonas ? { 'test/personas': { handler: auth.handleListPersonas, methods: ['GET'] } } : {}),
...(auth.handleLoginAs ? { 'test/login-as': { handler: auth.handleLoginAs, methods: ['POST'] } } : {}),
};
async function handle(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
const route = routes[path.join('/')];
if (!route) return NextResponse.json({ error: 'Not found' }, { status: 404 });
if (!route.methods.includes(request.method)) return NextResponse.json({ error: 'Method not allowed' }, { status: 405 });
const url = new URL(request.url);
const query: Record<string, string> = {};
url.searchParams.forEach((v, k) => { query[k] = v; });
const headers: Record<string, string> = {};
request.headers.forEach((v, k) => { headers[k] = v; });
let body: unknown;
if (request.method === 'POST') {
try { body = await request.json(); } catch { body = {}; }
}
const authReq: AuthRequest = { method: request.method, url: request.url, query, headers, body };
let statusCode = 200;
let responseBody: unknown = null;
let redirectUrl: string | null = null;
const resHeaders: Record<string, string> = {};
const authRes: AuthResponse = {
status(code) { statusCode = code; return authRes; },
json(data) { responseBody = data; },
redirect(url) { redirectUrl = url; statusCode = 302; },
send(data) { responseBody = data; },
setHeader(name, value) { resHeaders[name] = value; return authRes; },
};
await route.handler(authReq, authRes);
let response: NextResponse;
if (redirectUrl) response = NextResponse.redirect(redirectUrl, statusCode as 301 | 302 | 303 | 307 | 308);
else if (typeof responseBody === 'string') response = new NextResponse(responseBody, { status: statusCode });
else response = NextResponse.json(responseBody ?? {}, { status: statusCode });
for (const [k, v] of Object.entries(resHeaders)) response.headers.set(k, v);
return response;
}
export const GET = handle;
export const POST = handle;
export async function OPTIONS() { return new NextResponse(null, { status: 204 }); }Express:
// routes/auth.ts
import { Router } from 'express';
import { auth } from '../lib/auth-server';
import type { AuthRequest, AuthResponse } from 'kandi-login/server';
const router = Router();
function adapt(handler: typeof auth.handleLogin) {
return async (req: any, res: any) => {
const authReq: AuthRequest = {
method: req.method,
url: `${req.protocol}://${req.get('host')}${req.originalUrl}`,
query: req.query,
headers: req.headers,
body: req.body,
};
const authRes: AuthResponse = {
status(code) { res.status(code); return authRes; },
json(data) { res.json(data); },
redirect(url) { res.redirect(url); },
send(data) { res.send(data); },
setHeader(name, value) { res.setHeader(name, value); return authRes; },
};
await handler(authReq, authRes);
};
}
router.get('/login', adapt(auth.handleLogin));
router.get('/callback', adapt(auth.handleCallback));
router.post('/native', adapt(auth.handleNativeLogin));
router.post('/refresh', adapt(auth.handleRefresh));
router.get('/validate', adapt(auth.handleValidate));
router.post('/logout', adapt(auth.handleLogout));
export default router;
// Mount: app.use('/api/auth', authRouter);Step 5: Server — Protect Your API Routes
Use auth.verifyToken() in middleware to protect routes:
// middleware/requireAuth.ts
import { auth } from '../lib/auth-server';
export async function requireAuth(req: any, res: any, next: any) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'No token' });
try {
const payload = await auth.verifyToken(token);
req.user = payload; // { sub, email, role, display_name, avatar_url }
next();
} catch {
return res.status(401).json({ error: 'Invalid token' });
}
}Step 6: Client — Configure and Wrap Your App
Define the config outside your component (or use useMemo) so the reference is stable:
// lib/auth-config.ts
import type { KandiLoginConfig } from 'kandi-login';
export const authConfig: KandiLoginConfig = {
// Required: your auth server URL
authServerUrl: process.env.NEXT_PUBLIC_AUTH_URL ?? 'http://localhost:3000',
// Optional: separate API server for user profile endpoint
// Defaults to authServerUrl if not set
apiServerUrl: process.env.NEXT_PUBLIC_API_URL,
// OAuth providers to show in the UI
providers: [
{ id: 'google', name: 'Google' },
{ id: 'apple', name: 'Apple' },
{ id: 'facebook', name: 'Facebook' },
],
// Desktop apps only: custom URL scheme for deep link callbacks
// deepLinkScheme: 'myapp',
// All paths are configurable (these are the defaults):
// loginPath: '/api/auth/login',
// refreshPath: '/api/auth/refresh',
// profilePath: '/api/v1/users/me',
// logoutPath: '/api/auth/logout',
// webCallbackPath: '/auth/callback',
};Wrap your app with AuthProvider:
// app/providers.tsx (or app/layout.tsx)
'use client';
import { AuthProvider } from 'kandi-login/react';
import { authConfig } from '@/lib/auth-config';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<AuthProvider
config={authConfig}
onLogin={(user) => console.log('Logged in:', user.email)}
onLogout={() => console.log('Logged out')}
onError={(err) => console.error('Auth error:', err)}
>
{children}
</AuthProvider>
);
}Step 7: Client — Add Login UI
Choose one of three rendering options:
Option A: MUI Components
import { MuiLoginChip } from 'kandi-login/react/mui';
import { authConfig } from '@/lib/auth-config';
function Header() {
return (
<nav>
<MuiLoginChip
variant="glass" // 'glass' | 'flat'
providers={authConfig.providers}
showOverlayWhenUnauthenticated={true} // auto-show login overlay
appName="My App"
menuItems={[
{ label: 'Profile', onClick: () => router.push('/profile') },
{ label: 'Settings', onClick: () => router.push('/settings'), position: 'bottom' },
]}
/>
</nav>
);
}Option B: Tailwind Components
import { TailwindLoginChip } from 'kandi-login/tailwind';
import 'kandi-login/tailwind/kandi-login.css'; // CSS custom properties for theming
import { authConfig } from '@/lib/auth-config';
function Header() {
return (
<nav>
<TailwindLoginChip
variant="glass"
providers={authConfig.providers}
showOverlayWhenUnauthenticated={true}
appName="My App"
/>
</nav>
);
}Override the theme via CSS custom properties:
:root {
--kl-primary: #667eea;
--kl-text: #1a1a2e;
--kl-bg: #ffffff;
--kl-border: rgba(0,0,0,0.1);
--kl-error: #ef4444;
--kl-radius: 6px;
}Option C: Headless (Bring Your Own UI)
import { HeadlessLoginChip } from 'kandi-login/react/headless';
function Header() {
return (
<HeadlessLoginChip>
{({ user, isAuthenticated, login, logout, isLoading, menuOpen, openMenu, closeMenu }) => (
isAuthenticated ? (
<div>
<button onClick={openMenu}>{user?.name}</button>
{menuOpen && (
<div>
<button onClick={logout}>Logout</button>
</div>
)}
</div>
) : (
<button onClick={() => login('google')} disabled={isLoading}>
Sign In
</button>
)
)}
</HeadlessLoginChip>
);
}Option D: Direct Hook (Full Control)
import { useAuth } from 'kandi-login/react';
function MyComponent() {
const { user, isAuthenticated, isLoading, login, logout, getToken } = useAuth();
if (isLoading) return <div>Loading...</div>;
if (!isAuthenticated) {
return <button onClick={() => login('google')}>Sign In with Google</button>;
}
return (
<div>
<p>Welcome, {user?.name}</p>
<button onClick={logout}>Sign Out</button>
</div>
);
}Step 8: Client — Make Authenticated API Calls
The AuthService (accessible via context) provides authenticatedFetch which automatically attaches the Bearer token and retries once on 401 after refreshing:
import { useAuth } from 'kandi-login/react';
function Dashboard() {
const { getToken } = useAuth();
async function fetchData() {
const token = await getToken();
const res = await fetch('/api/data', {
headers: { Authorization: `Bearer ${token}` },
});
return res.json();
}
// ...
}Server API Reference
Route Handlers
createAuthServer() returns an object with these handlers. Mount them at your preferred paths.
| Handler | Method | Path (suggested) | Description |
|---|---|---|---|
| handleLogin | GET | /api/auth/login | Initiates OAuth flow. Query: provider, return_url, client_type |
| handleCallback | GET | /api/auth/callback | Receives OAuth redirect. Exchanges code, upserts user, issues tokens |
| handleNativeLogin | POST | /api/auth/native | Native mobile ID token flow. Body: { provider, id_token } |
| handleRefresh | POST | /api/auth/refresh | Rolling token refresh. Body: { refresh_token } |
| handleValidate | GET | /api/auth/validate | Validates access token. Header: Authorization: Bearer <token> |
| handleLogout | POST | /api/auth/logout | Acknowledges logout (stateless — tokens are not blacklisted) |
Utility Methods
| Method | Signature | Description |
|---|---|---|
| verifyToken | (token: string) => Promise<AccessTokenPayload> | Verify any access token for middleware use |
| signTokens | (user: KandiLoginUser) => Promise<{ access_token, refresh_token }> | Sign tokens for custom flows |
UserAdapter Interface
You implement all 5 methods. The server SDK calls them in this order during login:
1. findByProviderId(provider, providerUserId) → user or null
2. findByEmail(email) → user or null (triggers linkProvider if found)
3. createUser(profile) → new user| Method | Called When |
|---|---|
| findByProviderId(provider, id) | Every login — primary lookup |
| findByEmail(email) | When findByProviderId returns null — cross-provider linking |
| createUser(profile) | When both lookups return null — new user |
| linkProvider(userId, provider, id) | When findByEmail matched — links new provider to existing user |
| getUserById(id) | Token refresh and validation — fetches fresh user data |
JWT Access Token Payload
{
sub: string; // user ID
email: string;
role: string; // from your user record
display_name: string;
avatar_url: string;
aud: string; // default: 'authenticated'
iss: string; // your configured issuer
iat: number;
exp: number;
}Client API Reference
useAuth() Hook
const {
user, // KandiLoginUser | null
isAuthenticated, // boolean
isLoading, // true during initial session restore
error, // string | null
login, // (provider?: string) => Promise<void>
logout, // () => Promise<void>
refreshUser, // () => Promise<void> — force-refresh from API
getToken, // () => Promise<string | null>
} = useAuth();useLoginOverlay() Hook
For building custom overlay UIs:
const {
showOverlay, // true when not authenticated
isLoggingIn, // true during active login attempt
error, // string | null
handleLogin, // (provider?: string) => Promise<void>
clearError, // () => void
} = useLoginOverlay();KandiLoginConfig
| Field | Type | Required | Default |
|---|---|---|---|
| authServerUrl | string | Yes | — |
| apiServerUrl | string | No | authServerUrl |
| providers | OAuthProviderConfig[] | Yes | — |
| deepLinkScheme | string | No | — |
| keychainService | string | No | 'kandi-login' |
| tokenStorage | TokenStorageAdapter | No | auto-detected |
| platformAdapter | PlatformAdapter | No | auto-detected |
| loginPath | string | No | '/api/mobile/login' |
| refreshPath | string | No | '/api/mobile/refresh' |
| profilePath | string | No | '/api/v1/users/me' |
| webCallbackPath | string | No | '/auth/callback' |
| logoutPath | string | No | '/api/mobile/logout' |
Platform Behavior
| Platform | Detection | OAuth Flow | Token Storage |
|---|---|---|---|
| Web | Default | Popup window (fallback: redirect) | localStorage |
| Tauri | window.__TAURI__ | In-app WebviewWindow + deep link | OS Keychain (via Rust) |
| Electron | userAgent check | System browser + deep link | safeStorage (encrypted) |
| iOS | Native SDK | ASWebAuthenticationSession | iOS Keychain |
| Android | Native SDK | Chrome Custom Tabs + intent filter | EncryptedSharedPreferences |
Electron Setup
// main.ts
import { app, BrowserWindow, shell, ipcMain, safeStorage } from 'electron';
const PROTOCOL = 'myapp';
app.setAsDefaultProtocolClient(PROTOCOL);
// Single instance lock (required for deep links on Windows/Linux)
if (!app.requestSingleInstanceLock()) { app.quit(); }
// Handle deep link callback
function handleDeepLink(url: string) {
const parsed = new URL(url);
const accessToken = parsed.searchParams.get('access_token');
const refreshToken = parsed.searchParams.get('refresh_token');
const error = parsed.searchParams.get('error');
if (error) mainWindow?.webContents.send('oauth-error', { error });
else mainWindow?.webContents.send('oauth-callback', { access_token: accessToken, refresh_token: refreshToken });
}
app.on('open-url', (_, url) => handleDeepLink(url)); // macOS
app.on('second-instance', (_, argv) => { // Windows/Linux
const url = argv.find(arg => arg.startsWith(`${PROTOCOL}://`));
if (url) handleDeepLink(url);
});
// Expose APIs to renderer via preload
ipcMain.handle('open-external', (_, url) => shell.openExternal(url));// preload.ts
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
onOAuthCallback: (cb: (data: any) => void) => {
ipcRenderer.on('oauth-callback', (_, data) => cb(data));
},
onOAuthError: (cb: (data: any) => void) => {
ipcRenderer.on('oauth-error', (_, data) => cb(data));
},
secureStorage: {
get: (key: string) => ipcRenderer.invoke('secure-get', key),
set: (key: string, value: string) => ipcRenderer.invoke('secure-set', key, value),
delete: (key: string) => ipcRenderer.invoke('secure-delete', key),
},
});Tauri Setup
- Add the deep link scheme to
tauri.conf.json:
{
"app": {
"security": {
"dangerousRemoteDomainIpcAccess": [
{ "domain": "wallet.hello.coop", "enableTauriAPI": false, "windows": ["login"] }
]
}
}
}- Implement the Rust commands (see
tauri-plugin/commands.rs.templatefor a starter):
// src-tauri/src/commands/auth.rs
#[tauri::command]
pub async fn start_oauth(app: tauri::AppHandle, provider: String) -> Result<(), String> {
// Create WebviewWindow, intercept deep link navigation
}
#[tauri::command]
pub async fn get_token(service: String, key: String) -> Result<Option<String>, String> {
// Read from OS keychain (use keyring crate)
}
#[tauri::command]
pub async fn store_token(service: String, key: String, value: String) -> Result<(), String> {
// Write to OS keychain
}
#[tauri::command]
pub async fn clear_tokens(service: String) -> Result<(), String> {
// Delete from OS keychain
}Testing
Test Personas
Enable test personas for development and integration testing:
const auth = createAuthServer({
// ... your config
enableTestPersonas: true, // NEVER enable in production
});This exposes three additional endpoints:
| Endpoint | Method | Description |
|---|---|---|
| /test/seed | POST | Creates test users in your real database |
| /test/personas | GET | Lists available test personas |
| /test/login-as | POST | Returns real JWT tokens for a persona |
# Seed test users
curl -X POST http://localhost:3000/api/auth/test/seed
# List personas
curl http://localhost:3000/api/auth/test/personas
# Get tokens for a test user
curl -X POST http://localhost:3000/api/auth/test/login-as \
-H 'Content-Type: application/json' \
-d '{"personaId": "alice"}'Warning: Test endpoints issue real JWT tokens with no authentication. They must be protected by network-layer controls or disabled entirely in production.
Built-in Personas
| ID | Name | Email | Role |
|---|---|---|---|
| alice | Alice Tester | [email protected] | admin |
| bob | Bob Developer | [email protected] | user |
| charlie | Charlie Viewer | [email protected] | viewer |
| diana | Diana Manager | [email protected] | manager |
Environment Variables
# Required
JWT_SECRET= # min 32 chars, HS256 signing key
BASE_URL=https://yourapp.com # your server's public URL
# OAuth Providers (configure the ones you use)
HELLO_CLIENT_ID= # Hello.coop client ID
GOOGLE_CLIENT_ID= # Google OAuth client ID
APPLE_CLIENT_ID= # Apple Services ID or Bundle ID
FACEBOOK_APP_ID= # Facebook App ID
FACEBOOK_APP_SECRET= # Facebook App Secret
# Optional
JWT_ISSUER=auth.yourapp.com # JWT issuer claim
SUCCESS_REDIRECT_URL= # where to redirect after web loginCLI
npx kandi-login init # Interactive setup wizard
npx kandi-login # Development diagnostic toolThe init wizard generates:
.env.kandi-loginwith all required environment variable stubssrc/auth-config.tswith a complete client configuration
The dev tool provides:
- Auth server connection testing
- OAuth flow testing
- Environment validation
- Tauri Rust command generation
Entry Points
| Import | Contents |
|---|---|
| kandi-login | Core + React (convenience re-export) |
| kandi-login/core | Platform detection, token storage, AuthService, platform adapters |
| kandi-login/react | AuthProvider, useAuth, useLoginOverlay |
| kandi-login/react/mui | MuiLoginChip, MuiLoginOverlay, MuiUserAvatar, icons |
| kandi-login/react/headless | HeadlessLoginChip (render props) |
| kandi-login/tailwind | TailwindLoginChip, TailwindLoginOverlay |
| kandi-login/server | createAuthServer, JWT utils, security utils, test personas |
| kandi-login/tailwind/kandi-login.css | CSS custom properties for Tailwind theming |
Known Limitations (v0.1.0)
- Stateless logout: Tokens are HS256 with no server-side blacklist.
handleLogoutacknowledges the request but tokens remain valid until expiration. Clients must discard stored tokens. - Token delivery: Web redirect and desktop deep link flows deliver tokens via URL query parameters. Strip tokens from browser history with
window.history.replaceStateafter receipt. - React peer dependency: The package lists
reactandreact-domas peer dependencies even for server-only usage (kandi-login/server). You can safely ignore the peer dependency warnings.
License
MIT
