@donotdev/auth
v0.0.4
Published
Everything you'd need to manage authentication and authorization
Maintainers
Readme
DoNotDev Auth
Complete authentication solution with property-based API for optimal performance.
Quick Start
import { useAuth } from '@donotdev/auth';
function LoginForm() {
// State (reactive) - re-renders when values change
const loading = useAuth('loading');
const error = useAuth('error');
// Method (stable) - never re-renders
const signIn = useAuth('signInWithEmail');
const handleSubmit = async (email: string, password: string) => {
try {
await signIn(email, password);
} catch (err) {
// Error already in store and shown via toast
}
};
if (loading) return <Spinner />;
if (error) return <Alert>{error.message}</Alert>;
return <form onSubmit={handleSubmit}>...</form>;
}The Golden Rule
// Want state that re-renders? Use useAuth('propertyName')
const user = useAuth('user');
// Want a method that never re-renders? Use useAuth('methodName')
const signIn = useAuth('signInWithEmail');TypeScript autocomplete shows you everything available.
Available Properties
State (Reactive - Re-renders on Change)
user- Current authenticated userloading- Loading stateerror- Error stateuserProfile- User profile (role, displayName)userSubscription- Subscription (tier, features)emailVerification- Email verification statusinitialized- Whether auth system is ready
Methods (Stable - Never Re-renders)
Authentication:
signInWithEmail(email, password)- Sign increateUserWithEmail(email, password)- Create accountsignInWithPartner(partnerId, method?)- OAuth sign insignOut()- Sign outsendPasswordResetEmail(email)- Reset passwordsendEmailVerification()- Verify email
Security (Firebase-verified):
hasRole(role)- Check role (async, verified)hasTier(tier)- Check tier (async, verified)hasFeature(feature)- Check feature (async, verified)
Computed (Reactive - Re-renders When Dependencies Change)
isAuthenticated- Whether user is signed inuserRole- User's role (cached)userTier- User's tier (cached)
Common Patterns
Protected Route
function ProfilePage() {
const user = useAuth('user');
if (!user) return <Navigate to="/login" />;
return <div>Welcome, {user.email}!</div>;
}Role-Based Access
function AdminPanel() {
const userRole = useAuth('userRole'); // Cached
const hasRole = useAuth('hasRole'); // Verified
// For display
if (userRole !== 'admin') {
return <AccessDenied />;
}
// For security operations
const handleDelete = async () => {
const isAdmin = await hasRole('admin');
if (!isAdmin) throw new Error('Unauthorized');
// ... proceed
};
return <AdminDashboard />;
}Avoiding Infinite Loops
// ✅ Good - prevents loops
function Component() {
const user = useAuth('user');
const signIn = useAuth('signInWithEmail');
const [attempted, setAttempted] = useState(false);
useEffect(() => {
if (!user && !attempted) {
setAttempted(true); // Guard
signIn('[email protected]', 'password');
}
}, [user, attempted, signIn]);
}Performance Tips
- Only subscribe to what you need - Each
useAuth('property')creates a separate subscription - Methods are free - Methods never cause re-renders
- Computed values are optimized - Only re-render when dependencies change
Error Handling
All async methods throw DoNotDevError with user-friendly messages:
const signIn = useAuth('signInWithEmail');
try {
await signIn(email, password);
} catch (error) {
// Error is DoNotDevError
// Also automatically set in error state
console.error(error.message);
}Architecture
4-Layer Architecture
useAuth (Entry Point - React binding)
↓
FirebaseAuth (Complete orchestrator: Firebase + errors + store + partner state)
↓
SDK (Firebase wrapper)
↓
Firebase Auth SDKPhilosophy: Simple, honest, and complete. Each layer has one clear responsibility.
What Production Apps Actually Do
Firebase's Automatic Persistence (Google's Apps)
Firebase SDK handles everything automatically:
- Stores auth state in IndexedDB (primary)
- Falls back to localStorage if IndexedDB unavailable
- Auto-refreshes tokens in background
- Restores user on page reload
// YOU DO NOTHING - Firebase handles it
firebase.auth().onAuthStateChanged((user) => {
if (user) {
// User restored from IndexedDB automatically
// Tokens refreshed automatically
}
});Google's own apps (Gmail, Drive, YouTube) do this:
- Firebase SDK persists user in IndexedDB ✅
- sessionStorage for OAuth state/PKCE verifiers ✅
- React state/Zustand for UI only ✅
Auth0
Storage Strategy:
- Auth tokens: IndexedDB via Auth0 SDK
- OAuth state: sessionStorage (PKCE verifiers)
- UI state: Zustand/Redux (in-memory only)
// Auth0 SDK handles persistence
const { user, isLoading } = useAuth0();
// sessionStorage for OAuth flow
sessionStorage.setItem('auth0.state', state);Supabase
Storage Strategy:
- Auth tokens: localStorage via Supabase SDK
- OAuth state: sessionStorage (PKCE verifiers)
- UI state: Zustand/Redux (in-memory only)
// Supabase SDK handles persistence
const {
data: { user },
} = await supabase.auth.getUser();
// sessionStorage for OAuth PKCE
sessionStorage.setItem('supabase.auth.pkce_verifier', verifier);Clerk
Storage Strategy:
- Session tokens: IndexedDB via Clerk SDK
- OAuth state: sessionStorage
- UI state: React state/Zustand (in-memory only)
// Clerk SDK handles persistence
const { user, isLoaded } = useUser();
// sessionStorage for redirect context
sessionStorage.setItem('clerk_redirect', returnUrl);NextAuth.js
Storage Strategy:
- Session: HTTP-only cookies (server-side)
- CSRF token: localStorage
- OAuth state: sessionStorage
- UI state: Zustand/Redux (in-memory only)
// NextAuth handles persistence via cookies
const { data: session } = useSession();
// sessionStorage for OAuth state
sessionStorage.setItem('nextauth.state', state);Common Pattern Across All Production Apps
Every major auth provider uses the same 3-tier approach:
SDK Persistence (Automatic)
- User auth state
- Tokens
- IndexedDB or localStorage
- YOU DO NOTHING
sessionStorage (Manual)
- OAuth redirect state
- Account linking context
- PKCE verifiers
- Return URLs
State Management (Manual)
- Loading states
- Error messages
- UI-specific state
- In-memory only (Zustand/Redux/React)
Why Zustand Without Persist?
- Firebase already persists user (IndexedDB)
- Duplicating storage = conflicts + bugs
- Zustand persist = localStorage (same as Firebase fallback)
- UI state doesn't need persistence (transient)
Why Not Zustand for OAuth Redirects?
- Zustand without persist = in-memory only
- Page reload destroys all Zustand state
- sessionStorage survives page reloads
- This is why every auth provider uses sessionStorage
Layer Responsibilities
Layer 1: SDK (@donotdev/firebase/sdk.ts)
Singleton wrapper around Firebase Auth SDK with TypeScript types.
- Pure Firebase method wrappers
- Initialized by FirebaseAuth
- No business logic
const sdk = getFirebaseSDK();
await sdk.initialize();
const user = await sdk.signInWithEmailAndPassword(email, password);Layer 2: FirebaseAuth (FirebaseAuth.ts)
Singleton complete business logic orchestrator.
- All Firebase operations (via SDK)
- Smart recovery (network retry, rate limiting, account linking)
- Error wrapping with
handleError() - Store updates via domain methods
- Partner state management
- Security checks (roles, tiers, features)
- Toast notifications
const auth = getFirebaseAuth();
auth.setStore(authStore);
await auth.initialize();
// Smart recovery + error handling + store updates all handled internally
const result = await auth.signInWithEmail(email, password);Smart Recovery:
- User not found → Auto-signup
- Network issues → Retry with backoff
- Rate limiting → Exponential backoff
- Account exists → Intelligent linking
- User cancelled → Return null
Layer 3: authStore (AuthStore.ts)
Zustand store (in-memory only, no persist).
- Pure state storage - no business logic
- Domain methods for updates
- No persistence - Firebase handles user persistence automatically
- UI state only (loading, errors, partner states)
// Domain methods pattern
setAuthenticated: (user, subscription, profile) => set({...}),
setUnauthenticated: () => set({...}),
setPartnerState: (partnerId, state, error?) => set({...}),
clearAllPartnerStates: () => set({...}),
setAuthLoading: (loading) => set({...}),
setAuthError: (error) => set({...}),
clearAuthError: () => set({...}),
setEmailVerificationStatus: (status, error?) => set({...}),Layer 4: useAuth (useAuth.ts)
React hook - single entry point.
- Lazy loads FirebaseAuth + authStore
- Connects FirebaseAuth to store
- Returns store state + FirebaseAuth methods
const { user, loading, signInWithEmail, hasRole } = useAuth();
// user, loading = from store (fast, sync)
// signInWithEmail = calls FirebaseAuth (async)
// hasRole = security check (async, Firebase-verified)Initialization Chain
// 1. Component calls useAuth()
useAuth()
// 2. Lazy loads and connects (first call only)
→ import FirebaseAuth
→ import authStore
→ firebaseAuth.setStore(store)
→ store.setAuthService(firebaseAuth)
// 3. FirebaseAuth initializes
→ firebaseAuth.initialize()
→ SDK.initialize()
→ Firebase SDK readyError Handling Pattern
Single Point of Wrapping: Errors are wrapped once in SmartRecovery, then propagated up.
// FirebaseSmartRecovery.ts - wraps errors
try {
return await retryWithBackoff(operation, deps);
} catch (retryError) {
throw handleError(retryError, {
userMessage: 'Network connection failed. Please check your internet.',
showNotification: true,
});
}
// FirebaseAuth.ts - updates partner state and re-throws
try {
return await handleSmartRecovery(error, operation, context, deps);
} catch (wrappedError) {
// Error already wrapped by SmartRecovery
this.storeState.setPartnerState('password', PARTNER_STATE.ERROR);
throw wrappedError; // No double wrapping
}
// Component - catches wrapped error
try {
await signInWithEmail(email, password);
} catch (error) {
// DoNotDevError with user message already shown via toast
console.error(error);
}Key Points:
handleSmartRecoverywraps all unrecoverable errors before throwingFirebaseAuthfocuses on orchestration (partner state management)- No double wrapping -
handleError()called once - User-friendly messages mapped from Firebase error codes
Account Linking (Firebase 12.3)
FirebaseAuth's internal concern - other layers unaware.
Linking Scenarios
All handled automatically by FirebaseAuth + SmartRecovery:
- OAuth → Password: Shows password form, links after sign-in
- OAuth → EmailLink: Shows email link form, links after sign-in
- OAuth → OAuth: Triggers existing provider, Firebase auto-links
- Password → OAuth: Triggers OAuth flow, links after sign-in
- EmailLink → OAuth: Triggers OAuth flow, links after sign-in
Storage Pattern
SessionStorage = Only for Cross-Page-Load Data
Used only when:
- OAuth redirect (user leaves app, comes back)
- Account linking context (survives page reload)
// FirebaseAuth only
if (typeof window !== 'undefined') {
sessionStorage.setItem('accountLinkingInfo', JSON.stringify(info));
}Zustand Persistence + Firebase = Handle Everything Else
Domain Methods Pattern
Services never call setState - only domain methods:
// ❌ WRONG
this.store.setState({ user, authenticated: true });
// ✅ CORRECT
this.store.getState().setAuthenticated(user, subscription, profile);
this.store.getState().clearAllPartnerStates();
this.store.getState().setPartnerState(partnerId, PARTNER_STATE.LOADING);Constants Over Magic Strings
// authStore.ts
export const PARTNER_STATE = {
IDLE: 'idle',
LOADING: 'loading',
ERROR: 'error',
AUTHENTICATED: 'authenticated',
} as const;
export type PartnerState = (typeof PARTNER_STATE)[keyof typeof PARTNER_STATE];
// Usage
setPartnerState(partnerId, PARTNER_STATE.LOADING);Partner State Management
Tracked per partner for granular UI feedback:
// FirebaseAuth manages partner state
this.storeState.setPartnerState('google', PARTNER_STATE.LOADING);
try {
const result = await this.sdk.signInWithPopup(provider);
// Success - cleared by onAuthStateChanged
} catch (error) {
this.storeState.setPartnerState('google', PARTNER_STATE.ERROR);
throw error;
}
// Component uses partner state
const googleState = getPartnerState('google');
if (googleState === PARTNER_STATE.LOADING) {
return <Spinner />;
}Partner State Flow:
- Start:
LOADING - Success: cleared by
onAuthStateChanged→clearAllPartnerStates() - User cancelled:
IDLE - Error:
ERROR
Security vs Display Data
Display Data (Sync - from Store):
const { user, userProfile, userSubscription, loading } = useAuth();
// Fast, cached, for UI rendering onlySecurity Operations (Async - from FirebaseAuth → Firebase):
const { hasRole, hasTier, hasFeature } = useAuth();
const isAdmin = await hasRole('admin'); // Real-time Firebase verificationRule: Display = store. Security = FirebaseAuth.
Security Methods:
- Force refresh Firebase ID token
- Get latest custom claims from server
- Update store with verified data
- Return boolean result
Type Safety with AUTH_PARTNERS Schema
Email/password stays in AUTH_PARTNERS for user configuration simplicity:
export const AUTH_PARTNERS = {
google: { id: 'google', firebaseProviderId: 'google.com', ... },
github: { id: 'github', firebaseProviderId: 'github.com', ... },
password: { id: 'password', name: 'Email & Password', enabled: true, ... },
emailLink: { id: 'emailLink', name: 'Email Link', enabled: true, ... },
} as const;
export type AuthPartnerId = keyof typeof AUTH_PARTNERS;Special handling in code - type-safe, no as any:
// Components check for email authentication methods
if (partnerId === 'password') {
return <EmailPasswordForm />;
}
if (partnerId === 'emailLink') {
return <EmailLinkForm />;
}
// OAuth partners
const config = AUTH_PARTNERS[partnerId]; // Type-safe
const provider = this.sdk.createOAuthProvider(config.firebaseProviderId);Logging Pattern
// @donotdev/utils/logger.ts
export const logger = {
debug: (...args: any[]) => import.meta.env.DEV && console.log(...args),
info: console.log,
warn: console.warn,
error: console.error,
};
// Usage
logger.debug('[FirebaseAuth] Processing redirect'); // DEV only
logger.error('[FirebaseAuth] Sign in failed', error);File Naming Convention
FirebaseAuth.ts // Singleton classes = PascalCase
AuthStore.ts // Zustand store = PascalCase
useAuth.ts // React hooks = camelCaseSmart Recovery Strategies
Network Issues (Linear Backoff)
Attempt 1: 1s delay
Attempt 2: 2s delay
Attempt 3: 3s delayRate Limiting (Exponential Backoff)
Attempt 1: 2s delay
Attempt 2: 4s delay
Attempt 3: 8s delayInternal Errors (Simple Retry)
Attempt 1: 2s delay
Attempt 2: 2s delayAccount Linking
Delegates to FirebaseAccountLinking.ts with proper context.
User Cancellation
Returns null (not an error).
Toast Notifications
Two sources:
FirebaseAuth - Operation success
toast('success', 'Signed in successfully!');handleError - Operation failure
throw handleError(error, { userMessage: 'Sign in failed', showNotification: true, });
Usage Examples
Basic Authentication
const { user, loading, signInWithEmail } = useAuth();
if (loading) return <Spinner />;
if (user) {
return <div>Welcome {user.email}</div>;
}
const handleSignIn = async () => {
try {
await signInWithEmail(email, password);
// Success toast shown automatically
} catch (error) {
// Error toast shown automatically
console.error(error);
}
};OAuth with Partner State
const { signInWithPartner, getPartnerState } = useAuth();
const googleState = getPartnerState('google');
const handleGoogleSignIn = async () => {
try {
await signInWithPartner('google', 'popup');
} catch (error) {
console.error(error);
}
};
return (
<button onClick={handleGoogleSignIn} disabled={googleState === PARTNER_STATE.LOADING}>
{googleState === PARTNER_STATE.LOADING ? 'Signing in...' : 'Sign in with Google'}
</button>
);Security Checks
const { hasRole, hasTier } = useAuth();
// Check role (Firebase-verified)
const isAdmin = await hasRole('admin');
if (isAdmin) {
// Show admin panel
}
// Check subscription tier (Firebase-verified)
const isPro = await hasTier('pro');
if (isPro) {
// Show premium features
}Display vs Security
const { user, userSubscription, hasTier } = useAuth();
// Display (sync, fast, cached)
const tierDisplay = userSubscription?.tier || 'free';
// Security check (async, Firebase-verified, authoritative)
const canAccessFeature = async () => {
return await hasTier('pro');
};Key Architectural Decisions
Why Drop AuthService?
- Not truly provider-agnostic - FirebaseAuth is already Firebase-specific
- Thin wrapper - Just called FirebaseAuth and wrapped errors
- Artificial separation - Added complexity without value
- Honest architecture - Building a Firebase solution, not a generic one
Why FirebaseAuth Knows About Store?
- Complete orchestrator - Handles Firebase ops + store updates together
- Clean separation - React (useAuth) ≠ Business Logic (FirebaseAuth) ≠ SDK
- Cohesive responsibility - Firebase state changes → store updates (same concern)
Why Single Error Wrapping Point?
- DRY -
handleError()called once in SmartRecovery - Consistency - All errors have same format (DoNotDevError)
- Better messages - Firebase errors mapped to user-friendly text
- Simpler debugging - One place to look for error handling
Migration from 5-Layer Architecture
If migrating from the old 5-layer architecture (with AuthService):
- Delete
AuthService.ts - Update
FirebaseAuth.tsto include:setStore()method- Store updates via domain methods
- Partner state management
- Security methods (hasRole, hasTier, hasFeature)
- Update
FirebaseSmartRecovery.tsto wrap errors withhandleError() - Update
useAuth.tsto call FirebaseAuth directly (skip AuthService)
Critical Rules
- Never manually persist user data - Firebase handles this automatically in IndexedDB
- Never use localStorage/sessionStorage for user auth - Firebase uses IndexedDB for this
- Use sessionStorage ONLY for cross-page-load UI state (OAuth redirects, account linking)
- Zustand = in-memory UI state only (no persist middleware)
- Never call setState directly - only use domain methods
- Never double-wrap errors -
handleError()called once in SmartRecovery - Always use AUTH_PARTNERS schema - no hardcoded provider strings
- Display from store, security from Firebase - never mix concerns
- Partner state per provider - granular UI feedback
- Smart recovery shows toasts - user feedback during recovery
- Clear partner states on success -
onAuthStateChangedhandles cleanup
File Structure
packages/features/auth/src/
├── FirebaseAuth.ts # Complete orchestrator
├── FirebaseSmartRecovery.ts # Error recovery + wrapping
├── FirebaseAccountLinking.ts # Account linking logic
├── AuthStore.ts # Zustand store
├── useAuth.ts # React hook entry point
├── constants.ts # PARTNER_STATE, etc.
└── types/ # TypeScript typesSummary
4-Layer Architecture = Simple, Honest, Complete
- useAuth: React binding (lazy load, connect, expose)
- FirebaseAuth: Complete orchestrator (ops + errors + store + state)
- SDK: Firebase wrapper (pure Firebase methods)
- Firebase SDK: The actual Firebase library
Each layer has one clear responsibility. No artificial separation. No double error handling. No complexity for hypothetical futures.
Storage Strategy = Three-Tier System
- Firebase SDK (Automatic): User auth in IndexedDB - you do nothing
- sessionStorage (Manual): OAuth redirects and account linking - survives page reload
- Zustand (No Persist): UI state only - in-memory, doesn't need persistence
This is the architecture that works. This is what every production auth provider does.
