MyCuppa

@mycuppa/auth

The @mycuppa/auth package provides a complete authentication system with token management, automatic token refresh, role-based access control (RBAC), and session persistence.

Installation

npm install @mycuppa/auth react react-dom
# or
pnpm add @mycuppa/auth react react-dom

Quick Start

import { createAuthManager, useAuth } from '@mycuppa/auth'
import { useState } from 'react'

// Create auth manager once at app initialization
const authManager = createAuthManager({
  storageType: 'localStorage',
  autoRefresh: true,
  refreshThreshold: 5 * 60 * 1000, // 5 minutes
})

function LoginForm() {
  const { login, isLoading, isAuthenticated } = useAuth(authManager)
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  const handleLogin = async (e) => {
    e.preventDefault()

    // Call your API
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    })

    const { user, tokens } = await response.json()

    // Set user and tokens in auth manager
    await login(user, tokens)
  }

  if (isAuthenticated) {
    return <div>Welcome back!</div>
  }

  return (
    <form onSubmit={handleLogin}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        required
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
        required
      />
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Logging in...' : 'Login'}
      </button>
    </form>
  )
}

Core Concepts

AuthManager

The AuthManager is the core of the authentication system. It manages user state, tokens, and authentication lifecycle.

import { createAuthManager } from '@mycuppa/auth'

const authManager = createAuthManager({
  // Storage key for tokens (default: 'cuppa_auth_tokens')
  storageKey: 'my_app_tokens',

  // Storage type (default: 'localStorage')
  storageType: 'localStorage', // or 'sessionStorage'

  // Enable automatic token refresh (default: false)
  autoRefresh: true,

  // Time before token expiry to trigger refresh (default: 5 minutes)
  refreshThreshold: 5 * 60 * 1000,

  // Token refresh handler
  onTokenRefresh: async (tokens) => {
    const response = await fetch('/api/refresh', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken: tokens.refreshToken }),
    })
    return response.json() // Should return new { accessToken, refreshToken }
  },

  // Logout handler
  onLogout: async () => {
    await fetch('/api/logout', { method: 'POST' })
  },

  // Error handler
  onError: (error) => {
    console.error('Auth error:', error)
  },
})

User Object

The user object represents the authenticated user:

interface User {
  id: string
  email: string
  name?: string
  roles?: string[] // For RBAC
  permissions?: string[] // For RBAC
  metadata?: Record<string, unknown> // Custom fields
}

Token Pair

Authentication tokens consist of an access token and refresh token:

interface TokenPair {
  accessToken: string // Short-lived token for API requests
  refreshToken: string // Long-lived token for getting new access tokens
}

useAuth Hook

The useAuth hook provides access to authentication state and methods in your React components.

Basic Usage

import { useAuth } from '@mycuppa/auth'

function UserProfile() {
  const { user, isAuthenticated, logout } = useAuth(authManager)

  if (!isAuthenticated) {
    return <div>Please log in</div>
  }

  return (
    <div>
      <h1>Welcome, {user.name}!</h1>
      <p>Email: {user.email}</p>
      <button onClick={logout}>Logout</button>
    </div>
  )
}

Authentication State

function AppHeader() {
  const {
    user,
    isAuthenticated,
    isLoading,
    error,
  } = useAuth(authManager)

  return (
    <header>
      {isLoading && <span>Loading...</span>}
      {error && <span>Error: {error.message}</span>}
      {isAuthenticated ? (
        <span>Hello, {user.name}</span>
      ) : (
        <a href="/login">Login</a>
      )}
    </header>
  )
}

Login & Logout

Login

function LoginPage() {
  const { login, isLoading, error } = useAuth(authManager)

  const handleLogin = async (credentials) => {
    try {
      // Call your authentication API
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credentials),
      })

      if (!response.ok) {
        throw new Error('Login failed')
      }

      const data = await response.json()

      // Login with user data and tokens
      await login(
        {
          id: data.user.id,
          email: data.user.email,
          name: data.user.name,
          roles: data.user.roles,
        },
        {
          accessToken: data.accessToken,
          refreshToken: data.refreshToken,
        }
      )

      // Navigate to dashboard or home
    } catch (error) {
      console.error('Login error:', error)
    }
  }

  return (
    <form onSubmit={(e) => {
      e.preventDefault()
      const formData = new FormData(e.target)
      handleLogin({
        email: formData.get('email'),
        password: formData.get('password'),
      })
    }}>
      <input name="email" type="email" placeholder="Email" required />
      <input name="password" type="password" placeholder="Password" required />
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Logging in...' : 'Login'}
      </button>
      {error && <div className="error">{error.message}</div>}
    </form>
  )
}

Logout

function LogoutButton() {
  const { logout, isLoading } = useAuth(authManager)

  const handleLogout = async () => {
    try {
      await logout()
      // Navigate to login page
    } catch (error) {
      console.error('Logout error:', error)
    }
  }

  return (
    <button onClick={handleLogout} disabled={isLoading}>
      {isLoading ? 'Logging out...' : 'Logout'}
    </button>
  )
}

Token Management

Access Tokens

Use access tokens for authenticated API requests:

function Dashboard() {
  const { getAccessToken } = useAuth(authManager)

  const fetchData = async () => {
    const token = getAccessToken()

    const response = await fetch('/api/dashboard', {
      headers: {
        'Authorization': `Bearer ${token}`,
      },
    })

    return response.json()
  }

  // Use with @mycuppa/data
  const { data } = useQuery({
    queryKey: 'dashboard',
    queryFn: fetchData,
  })

  return <div>{/* Render dashboard */}</div>
}

Automatic Token Refresh

When autoRefresh is enabled, tokens are automatically refreshed before expiration:

const authManager = createAuthManager({
  autoRefresh: true,
  refreshThreshold: 5 * 60 * 1000, // 5 minutes before expiry

  onTokenRefresh: async (currentTokens) => {
    // Call your token refresh endpoint
    const response = await fetch('/api/refresh', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${currentTokens.refreshToken}`,
      },
    })

    if (!response.ok) {
      throw new Error('Token refresh failed')
    }

    const { accessToken, refreshToken } = await response.json()
    return { accessToken, refreshToken }
  },
})

Manual Token Refresh

function RefreshButton() {
  const { refreshTokens, isLoading } = useAuth(authManager)

  const handleRefresh = async () => {
    try {
      const newTokens = await refreshTokens()
      console.log('Tokens refreshed:', newTokens)
    } catch (error) {
      console.error('Refresh failed:', error)
    }
  }

  return (
    <button onClick={handleRefresh} disabled={isLoading}>
      {isLoading ? 'Refreshing...' : 'Refresh Tokens'}
    </button>
  )
}

Role-Based Access Control (RBAC)

Check Roles

function AdminPanel() {
  const { hasRole, hasAnyRole, hasAllRoles } = useAuth(authManager)

  // Check single role
  if (!hasRole('admin')) {
    return <div>Access denied</div>
  }

  // Check if user has any of the roles
  const canView = hasAnyRole(['admin', 'moderator'])

  // Check if user has all roles
  const isSuperAdmin = hasAllRoles(['admin', 'super'])

  return (
    <div>
      <h1>Admin Panel</h1>
      {canView && <section>Moderation Tools</section>}
      {isSuperAdmin && <section>Super Admin Tools</section>}
    </div>
  )
}

Check Permissions

function DocumentEditor() {
  const { hasPermission, hasAnyPermission, hasAllPermissions } = useAuth(authManager)

  // Check single permission
  const canEdit = hasPermission('documents:edit')

  // Check if user has any permission
  const canView = hasAnyPermission(['documents:view', 'documents:edit'])

  // Check if user has all permissions
  const canPublish = hasAllPermissions(['documents:edit', 'documents:publish'])

  return (
    <div>
      {canView && <DocumentViewer />}
      {canEdit && <DocumentEditor />}
      {canPublish && <button>Publish</button>}
    </div>
  )
}

Conditional Rendering

function FeatureList() {
  const { user, hasRole } = useAuth(authManager)

  return (
    <div>
      <h2>Features</h2>
      <ul>
        <li>Dashboard (All users)</li>
        {hasRole('admin') && <li>Admin Panel</li>}
        {hasRole('moderator') && <li>Content Moderation</li>}
        {user?.roles?.includes('super') && <li>System Settings</li>}
      </ul>
    </div>
  )
}

Protected Routes

Route Protection with useAuth

import { useAuth } from '@mycuppa/auth'
import { useRouter } from '@mycuppa/router'

function ProtectedRoute({ children, requiredRoles, requiredPermissions }) {
  const { isAuthenticated, canAccessRoute } = useAuth(authManager)
  const { navigate } = useRouter()

  const canAccess = canAccessRoute({
    requireAuth: true,
    requiredRoles,
    requiredPermissions,
  })

  if (!isAuthenticated) {
    navigate('/login')
    return null
  }

  if (!canAccess) {
    return <div>Access denied. Insufficient permissions.</div>
  }

  return <>{children}</>
}

// Usage
function App() {
  return (
    <div>
      {/* Public route */}
      <Route path="/" component={Home} />

      {/* Protected route - requires authentication */}
      <Route
        path="/dashboard"
        component={() => (
          <ProtectedRoute>
            <Dashboard />
          </ProtectedRoute>
        )}
      />

      {/* Protected route - requires admin role */}
      <Route
        path="/admin"
        component={() => (
          <ProtectedRoute requiredRoles={['admin']}>
            <AdminPanel />
          </ProtectedRoute>
        )}
      />

      {/* Protected route - requires specific permissions */}
      <Route
        path="/editor"
        component={() => (
          <ProtectedRoute requiredPermissions={['documents:edit']}>
            <Editor />
          </ProtectedRoute>
        )}
      />
    </div>
  )
}

Route Guards

function RouteGuard({ config, children }) {
  const { isAuthenticated, canAccessRoute } = useAuth(authManager)
  const { navigate } = useRouter()

  useEffect(() => {
    if (!isAuthenticated && config.requireAuth) {
      navigate(config.redirectTo || '/login')
    }
  }, [isAuthenticated])

  const canAccess = canAccessRoute(config)

  if (!canAccess) {
    return <div>Access denied</div>
  }

  return <>{children}</>
}

// Usage
<RouteGuard
  config={{
    requireAuth: true,
    requiredRoles: ['admin'],
    redirectTo: '/login',
  }}
>
  <AdminPanel />
</RouteGuard>

Session Persistence

Sessions are automatically persisted to storage (localStorage or sessionStorage):

// Configure storage
const authManager = createAuthManager({
  storageKey: 'my_app_auth',

  // localStorage - persists across browser sessions
  storageType: 'localStorage',

  // sessionStorage - cleared when tab is closed
  // storageType: 'sessionStorage',
})

// Session is automatically restored on page reload
// If tokens exist, user is marked as authenticated

Session Restoration

function App() {
  const { isAuthenticated, user } = useAuth(authManager)
  const [isCheckingSession, setIsCheckingSession] = useState(true)

  useEffect(() => {
    const restoreSession = async () => {
      if (isAuthenticated && !user) {
        // Tokens exist but no user data
        // Fetch user data from server
        try {
          const token = authManager.getAccessToken()
          const response = await fetch('/api/me', {
            headers: { 'Authorization': `Bearer ${token}` },
          })
          const userData = await response.json()

          // Update user in auth manager
          authManager.updateUser(userData)
        } catch (error) {
          // Token is invalid, logout
          await authManager.logout()
        }
      }
      setIsCheckingSession(false)
    }

    restoreSession()
  }, [isAuthenticated, user])

  if (isCheckingSession) {
    return <div>Loading...</div>
  }

  return <div>{/* Your app */}</div>
}

Update User Data

function ProfileEditor() {
  const { user, updateUser } = useAuth(authManager)
  const [name, setName] = useState(user?.name || '')

  const handleSave = async () => {
    // Update on server
    await fetch('/api/profile', {
      method: 'PATCH',
      body: JSON.stringify({ name }),
    })

    // Update in auth manager
    updateUser({ name })
  }

  return (
    <div>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Name"
      />
      <button onClick={handleSave}>Save</button>
    </div>
  )
}

Integration with API Requests

Create an HTTP Client

import { createAuthManager } from '@mycuppa/auth'

class ApiClient {
  constructor(authManager) {
    this.authManager = authManager
  }

  async request(url, options = {}) {
    const token = this.authManager.getAccessToken()

    const response = await fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': token ? `Bearer ${token}` : '',
        'Content-Type': 'application/json',
      },
    })

    // Handle 401 Unauthorized
    if (response.status === 401) {
      // Try to refresh token
      const newTokens = await this.authManager.refreshTokens()

      if (newTokens) {
        // Retry request with new token
        return this.request(url, options)
      }

      // Refresh failed, logout
      await this.authManager.logout()
      throw new Error('Authentication failed')
    }

    return response
  }

  get(url) {
    return this.request(url, { method: 'GET' })
  }

  post(url, data) {
    return this.request(url, {
      method: 'POST',
      body: JSON.stringify(data),
    })
  }

  put(url, data) {
    return this.request(url, {
      method: 'PUT',
      body: JSON.stringify(data),
    })
  }

  delete(url) {
    return this.request(url, { method: 'DELETE' })
  }
}

// Create API client
const api = new ApiClient(authManager)

// Use it
await api.get('/api/users')
await api.post('/api/users', { name: 'John' })

Integration with @mycuppa/data

import { useQuery } from '@mycuppa/data'
import { useAuth } from '@mycuppa/auth'

function UserList() {
  const { getAccessToken, isAuthenticated } = useAuth(authManager)

  const { data, isLoading, error } = useQuery({
    queryKey: 'users',
    queryFn: async () => {
      const token = getAccessToken()
      const response = await fetch('/api/users', {
        headers: {
          'Authorization': `Bearer ${token}`,
        },
      })
      return response.json()
    },
    enabled: isAuthenticated, // Only fetch when authenticated
  })

  if (!isAuthenticated) return <div>Please log in</div>
  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>

  return (
    <ul>
      {data.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

Best Practices

  1. Create AuthManager once - Initialize at app root, share across components

    // In your app.tsx or index.tsx
    const authManager = createAuthManager({ /* config */ })
    
  2. Enable auto-refresh - Keep users logged in seamlessly

    autoRefresh: true
    
  3. Use RBAC for authorization - Check roles and permissions, not user properties

    // Good
    if (hasRole('admin')) { /* ... */ }
    
    // Avoid
    if (user.email === 'admin@example.com') { /* ... */ }
    
  4. Handle token expiration - Implement proper error handling

    onError: (error) => {
      if (error.message.includes('token')) {
        navigate('/login')
      }
    }
    
  5. Protect sensitive routes - Use route guards for admin pages

    <ProtectedRoute requiredRoles={['admin']}>
      <AdminPanel />
    </ProtectedRoute>
    
  6. Store minimal data in tokens - Keep tokens small and fast

    // Good: ID and basic info
    { id: '123', email: 'user@example.com', roles: ['user'] }
    
    // Avoid: Large objects
    { id: '123', settings: { /* huge object */ }, history: [...] }
    
  7. Use TypeScript - Get full type safety

    interface MyUser extends User {
      customField: string
    }
    
    const { user } = useAuth<MyUser>(authManager)
    

API Reference

createAuthManager(config?)

Creates a new AuthManager instance.

Parameters:

  • storageKey?: string - Storage key (default: 'cuppa_auth_tokens')
  • storageType?: 'localStorage' | 'sessionStorage' - Storage type (default: 'localStorage')
  • autoRefresh?: boolean - Enable auto-refresh (default: false)
  • refreshThreshold?: number - Refresh threshold in ms (default: 300000)
  • onTokenRefresh?: (tokens) => Promise<TokenPair> - Refresh handler
  • onLogout?: () => void | Promise<void> - Logout handler
  • onError?: (error) => void - Error handler

Returns: AuthManager

useAuth(authManager)

Hook for accessing auth state and methods.

Parameters:

  • authManager: AuthManager - The auth manager instance

Returns:

  • user: User | null - Current user
  • isAuthenticated: boolean - Authentication status
  • isLoading: boolean - Loading state
  • error: Error | null - Error state
  • login: (user, tokens) => Promise<void> - Login function
  • logout: () => Promise<void> - Logout function
  • refreshTokens: () => Promise<TokenPair | null> - Refresh function
  • updateUser: (updates) => void - Update user function
  • getAccessToken: () => string | null - Get access token
  • hasRole: (role) => boolean - Check role
  • hasAnyRole: (roles) => boolean - Check any role
  • hasAllRoles: (roles) => boolean - Check all roles
  • hasPermission: (permission) => boolean - Check permission
  • hasAnyPermission: (permissions) => boolean - Check any permission
  • hasAllPermissions: (permissions) => boolean - Check all permissions
  • canAccessRoute: (config) => boolean - Check route access

AuthManager Methods

  • login(user, tokens) - Set user and tokens
  • logout() - Clear user and tokens
  • refreshTokens() - Refresh access token
  • getTokens() - Get current tokens
  • getAccessToken() - Get access token
  • updateUser(updates) - Update user data
  • hasRole(role) - Check single role
  • hasAnyRole(roles) - Check any role
  • hasAllRoles(roles) - Check all roles
  • hasPermission(permission) - Check single permission
  • hasAnyPermission(permissions) - Check any permission
  • hasAllPermissions(permissions) - Check all permissions
  • canAccessRoute(config) - Check route access
  • destroy() - Clean up manager

Comparison to Traditional Approach

Traditional approach (PHP sessions):

// Server-side session management
session_start();
$_SESSION['user_id'] = $user->id;

// Check authentication
if (!isset($_SESSION['user_id'])) {
  header('Location: /login');
  exit;
}

// Check roles
if (!in_array('admin', $_SESSION['roles'])) {
  die('Access denied');
}

With @mycuppa/auth:

// Client-side token management, automatic persistence
const { isAuthenticated, hasRole } = useAuth(authManager)

// Declarative authentication check
if (!isAuthenticated) return <Navigate to="/login" />

// Declarative authorization check
if (!hasRole('admin')) return <AccessDenied />

The Cuppa approach provides a modern, client-side authentication system with automatic token management, seamless refresh, and declarative access control that integrates perfectly with React.