Package Exports
- @edcalderon/auth
- @edcalderon/auth/firebase-native
- @edcalderon/auth/firebase-web
- @edcalderon/auth/hybrid-native
- @edcalderon/auth/hybrid-web
- @edcalderon/auth/supabase
Readme
@edcalderon/auth
A universal, provider-agnostic authentication orchestration package for React applications. Swap between Supabase, Firebase, Directus, Google OAuth, or any custom provider without changing a single line of component code.
๐ Latest Changes (v1.1.2)
Changed
- ๐งช Test release to verify readme-maintainer guard via pre-push hook
For full version history, see CHANGELOG.md and GitHub releases
๐๏ธ Architecture
The package follows a Single Source of Truth model with a Federated OAuth Strategy:
- Principal Database (Source of Truth): Supabase anchors user identities, metadata, roles, and RLS policies in PostgreSQL (
auth.users,auth.identities). - OAuth / Identity Providers: External services (Firebase, Directus, native Google OAuth, Auth0, etc.) handle frontend login bridges or federated SSO flows.
- The Orchestrator (
@edcalderon/auth): A thin bridge layer that exposes generic interfaces (User,AuthClient). Applications consume a unified context without coupling to any specific vendor.
Architecture Flow:
- Frontend Applications
=>consume@edcalderon/authviauseAuth() @edcalderon/authorchestrates the adapters:=>Supabase Adapter (Direct Session)=>Hybrid Bridge (Firebase OAuth + Supabase Session)=>Custom Adapters (e.g. Directus SSO, Auth0)
- Identity Providers (Firebase/Directus)
=>Sync Session to Supabase - Supabase
=>Manages Roles & Scopes in the PostgreSQL Database
Features
- ๐ฏ Provider-Agnostic โ one interface, any backend
- โ๏ธ React-First โ
AuthProvider+useAuthhook with full TypeScript types - ๐ Extensible Adapter System โ implement
AuthClientto add any provider - ๐ Hybrid Flows โ Firebase popup โ Supabase session bridging out of the box
- ๐ก๏ธ Unified User Model โ
Usertype normalizes identities across providers - ๐ Session Token Access โ
getSessionToken()for API calls regardless of provider - ๐ฆ Tree-Shakeable โ import only the adapters you need
- ๐๏ธ Zero Lock-In โ swap providers by changing one line of dependency injection
Installation
From NPM (public)
npm install @edcalderon/auth
# or
pnpm add @edcalderon/auth
# or
yarn add @edcalderon/authFrom Monorepo (internal workspace)
pnpm --filter <your-app> add @edcalderon/auth@workspace:*Peer Dependencies
Install the peer dependencies for your chosen provider(s):
# For Supabase
pnpm add @supabase/supabase-js
# For Firebase
pnpm add firebase
# For Hybrid (Firebase + Supabase)
pnpm add @supabase/supabase-js firebaseNote:
reactandreact-dom(v18+ or v19+) are required peer dependencies.
Quick Start
1. Choose Your Provider
| Provider | Class | Peer Dependency | Use Case |
|---|---|---|---|
| Supabase | SupabaseClient |
@supabase/supabase-js |
Direct Supabase Auth |
| Firebase | FirebaseClient |
firebase |
Firebase-only applications |
| Hybrid | HybridClient |
Both | Firebase popup โ Supabase session |
| Custom | Implement AuthClient |
Your choice | Directus, Auth0, Keycloak, etc. |
2. Create the Provider Wrapper
Supabase (Direct)
// components/auth/AuthProvider.tsx
"use client";
import { AuthProvider as UniversalAuthProvider, SupabaseClient, useAuth as useUniversalAuth } from "@edcalderon/auth";
import { supabase } from "@/lib/supabase";
import { useMemo, type ReactNode } from "react";
export function AuthProvider({ children }: { children: ReactNode }) {
const client = useMemo(() => new SupabaseClient(supabase), []);
return <UniversalAuthProvider client={client}>{children}</UniversalAuthProvider>;
}
export const useAuth = useUniversalAuth;Hybrid (Firebase โ Supabase)
"use client";
import { AuthProvider as UniversalAuthProvider, HybridClient, useAuth as useUniversalAuth } from "@edcalderon/auth";
import { supabase } from "@/lib/supabase";
import { auth, googleProvider, signInWithPopup, signOut, GoogleAuthProvider } from "@/lib/firebase";
import { useMemo, type ReactNode } from "react";
export function AuthProvider({ children }: { children: ReactNode }) {
const client = useMemo(() => new HybridClient({
supabase,
firebaseAuth: auth,
firebaseMethods: {
signInWithPopup,
signOut,
credentialFromResult: GoogleAuthProvider.credentialFromResult,
},
googleProvider,
}), []);
return <UniversalAuthProvider client={client}>{children}</UniversalAuthProvider>;
}
export const useAuth = useUniversalAuth;3. Use in Components
Every component consumes identical signatures regardless of which provider is active:
import { useAuth } from "@/components/auth/AuthProvider";
export default function Dashboard() {
const { user, loading, error, signInWithGoogle, signOutUser } = useAuth();
if (loading) return <Spinner />;
if (error) return <p>Error: {error}</p>;
if (!user) return <button onClick={() => signInWithGoogle()}>Sign In with Google</button>;
return (
<div>
<p>Welcome, {user.email} (via {user.provider})</p>
<button onClick={signOutUser}>Sign Out</button>
</div>
);
}๐ Extensibility โ Custom Adapters
The core strength of @edcalderon/auth is that any authentication provider can be integrated by implementing the AuthClient interface. No changes to your React components are required.
The AuthClient Interface
export interface AuthClient {
getUser(): Promise<User | null>;
signInWithEmail(email: string, password: string): Promise<User>;
signInWithGoogle(redirectTo?: string): Promise<void>;
signOut(): Promise<void>;
onAuthStateChange(callback: (user: User | null) => void): () => void;
getSessionToken(): Promise<string | null>;
}The User Type
export interface User {
id: string;
email?: string;
avatarUrl?: string;
provider?: string;
metadata?: Record<string, any>;
}Example: Directus Adapter
A custom Directus adapter that uses Directus SSO (e.g., Google OAuth through Directus) and optionally syncs sessions back to Supabase:
import type { AuthClient, User } from "@edcalderon/auth";
interface DirectusClientOptions {
directusUrl: string;
supabase?: any; // Optional: sync to Supabase as source of truth
}
export class DirectusClient implements AuthClient {
private directusUrl: string;
private supabase: any;
private currentUser: User | null = null;
private listeners: Set<(user: User | null) => void> = new Set();
constructor(options: DirectusClientOptions) {
this.directusUrl = options.directusUrl;
this.supabase = options.supabase;
}
private mapUser(directusUser: any): User | null {
if (!directusUser) return null;
return {
id: directusUser.id,
email: directusUser.email,
avatarUrl: directusUser.avatar
? `${this.directusUrl}/assets/${directusUser.avatar}`
: undefined,
provider: "directus",
metadata: {
firstName: directusUser.first_name,
lastName: directusUser.last_name,
role: directusUser.role,
},
};
}
async getUser(): Promise<User | null> {
try {
const res = await fetch(`${this.directusUrl}/users/me`, {
credentials: "include",
});
if (!res.ok) return null;
const { data } = await res.json();
this.currentUser = this.mapUser(data);
return this.currentUser;
} catch {
return null;
}
}
async signInWithEmail(email: string, password: string): Promise<User> {
const res = await fetch(`${this.directusUrl}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error("Directus login failed");
const user = await this.getUser();
if (!user) throw new Error("No user after login");
this.notifyListeners(user);
// Optional: sync to Supabase
if (this.supabase) {
await this.syncToSupabase(user);
}
return user;
}
async signInWithGoogle(redirectTo?: string): Promise<void> {
// Directus SSO โ redirect to Directus Google OAuth endpoint
const callback = redirectTo || window.location.origin + "/auth/callback";
window.location.href =
`${this.directusUrl}/auth/login/google?redirect=${encodeURIComponent(callback)}`;
}
async signOut(): Promise<void> {
await fetch(`${this.directusUrl}/auth/logout`, {
method: "POST",
credentials: "include",
});
this.currentUser = null;
this.notifyListeners(null);
}
onAuthStateChange(callback: (user: User | null) => void): () => void {
this.listeners.add(callback);
return () => { this.listeners.delete(callback); };
}
async getSessionToken(): Promise<string | null> {
try {
const res = await fetch(`${this.directusUrl}/auth/refresh`, {
method: "POST",
credentials: "include",
});
if (!res.ok) return null;
const { data } = await res.json();
return data?.access_token ?? null;
} catch {
return null;
}
}
private notifyListeners(user: User | null) {
this.listeners.forEach((cb) => cb(user));
}
private async syncToSupabase(user: User) {
// Sync user identity to Supabase as source of truth
// Implementation depends on your Supabase setup
}
}Usage:
import { AuthProvider as UniversalAuthProvider } from "@edcalderon/auth";
import { DirectusClient } from "./adapters/DirectusClient";
const client = new DirectusClient({
directusUrl: "https://directus.example.com",
supabase: supabaseInstance, // optional sync
});
<UniversalAuthProvider client={client}>
<App />
</UniversalAuthProvider>Example: Auth0 Adapter (Skeleton)
import type { AuthClient, User } from "@edcalderon/auth";
export class Auth0Client implements AuthClient {
constructor(private auth0: any) {}
async getUser(): Promise<User | null> { /* ... */ }
async signInWithEmail(email: string, password: string): Promise<User> { /* ... */ }
async signInWithGoogle(redirectTo?: string): Promise<void> { /* ... */ }
async signOut(): Promise<void> { /* ... */ }
onAuthStateChange(callback: (user: User | null) => void): () => void { /* ... */ }
async getSessionToken(): Promise<string | null> { /* ... */ }
}By implementing the AuthClient interface, any provider fits into the same <AuthProvider> and useAuth() workflow โ zero changes to your component tree.
Built-in Adapters
SupabaseClient
Direct Supabase Auth adapter. Uses @supabase/supabase-js for session management, OAuth, and email/password.
import { SupabaseClient } from "@edcalderon/auth";
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
const client = new SupabaseClient(supabase);Features:
- Email/password sign-in (
signInWithPassword) - Google OAuth (
signInWithOAuth) - Session token via
getSession().access_token - Real-time auth state changes via
onAuthStateChange
FirebaseClient
Firebase-only adapter. Uses Firebase Auth methods via dependency injection (tree-shaking friendly).
import { FirebaseClient } from "@edcalderon/auth";
import { getAuth, GoogleAuthProvider, signInWithEmailAndPassword, signInWithPopup, signOut, onAuthStateChanged } from "firebase/auth";
const auth = getAuth(app);
const client = new FirebaseClient(auth, {
signInWithEmailAndPassword,
signInWithPopup,
signOut,
onAuthStateChanged,
}, new GoogleAuthProvider());Features:
- Email/password sign-in
- Google popup sign-in
- Firebase ID token via
getIdToken() - Real-time auth state changes
HybridClient
Bridges Firebase Google popup โ Supabase signInWithIdToken. Perfect for apps that need Firebase's popup UX but Supabase as the data backend.
import { HybridClient } from "@edcalderon/auth";
const client = new HybridClient({
supabase,
firebaseAuth: auth,
firebaseMethods: { signInWithPopup, signOut, credentialFromResult: GoogleAuthProvider.credentialFromResult },
googleProvider: new GoogleAuthProvider(),
});Features:
- Firebase popup โ extracts Google OIDC ID token โ passes to Supabase
signInWithIdToken - Graceful fallback to Supabase native OAuth when Firebase is not configured
- Dual sign-out (Firebase + Supabase)
- Auth state tracked via Supabase session
API Reference
<AuthProvider>
React context provider that wraps your app with authentication state.
<AuthProvider client={authClient}>
{children}
</AuthProvider>| Prop | Type | Description |
|---|---|---|
client |
AuthClient |
The authentication adapter instance |
children |
ReactNode |
Child components |
useAuth()
React hook that returns the current authentication state and actions.
const {
user, // User | null
loading, // boolean
error, // string | null
client, // AuthClient (direct access)
signInWithEmail, // (email: string, password: string) => Promise<User>
signInWithGoogle, // (redirectTo?: string) => Promise<void>
signOutUser, // () => Promise<void>
} = useAuth();Note:
useAuth()must be called within an<AuthProvider>. It will throw if used outside the provider tree.
Publishing & Releases
Automated NPM Publishing
This package uses GitHub Actions for automated publishing to NPM when version tags are created.
Release Process
Update Version: Bump the version in
package.jsoncd packages/auth npm version patch # or minor, major
Create Git Tag: Create and push an
auth-v*taggit add packages/auth/package.json git commit -m "chore(auth): bump version to X.Y.Z" git tag auth-vX.Y.Z git push && git push --tags
Automated Publishing: GitHub Actions will automatically build and publish to NPM
NPM Token Setup
To enable automated publishing:
- Go to NPM โ Access Tokens โ Generate New Token
- Create a token with Automation scope
- Add to GitHub repository secrets as
NPM_TOKEN
Documentation
- CHANGELOG โ Version history and changes
- GitHub Releases โ Tagged releases
License
MIT ยฉ Edward