Cross-App Authentication System
The MyStoryFlow ecosystem uses a cross-app authentication system that allows users to move between different applications (web app, admin app, tools app) with authentication context. The implementation differs across apps based on their specific needs.
🏗️ Architecture Overview
Core Components
- AuthProvider Component (
@mystoryflow/auth) - Shared authentication context for React applications - SessionBridge Class (
@mystoryflow/auth) - Available for cross-domain session transfer (currently not exported from package) - Supabase SSR - Server-side session management with cookies
- Middleware - Route protection and session validation
Current Implementation Status
Note: While the SessionBridge class exists in the codebase (packages/auth/src/session-bridge.ts), it is not currently exported from the @mystoryflow/auth package and is not actively used in production apps. The primary authentication mechanism uses Supabase SSR with cookies.
Session Storage Strategy
The system currently uses these mechanisms:
- HTTP-only Cookies - Primary storage via Supabase SSR (server-side)
- Supabase Auth Client - Client-side session management
- Browser Storage - localStorage/sessionStorage for client state (not for cross-domain transfer)
- Session Validation - Middleware checks for protected routes
🚀 Implementation Guide
1. Package Setup
The auth package is available as a workspace package:
// package.json
{
"dependencies": {
"@mystoryflow/auth": "workspace:*",
"@supabase/ssr": "^0.6.1"
}
}Current exports from @mystoryflow/auth:
AuthProvider/useAuth- Client-side authentication contextProtectedRoute- Route protection componentAuthButton- Pre-built auth UI componentcreateClient/createClientFromEnv- Supabase client utilities- Role-based routing utilities
Not exported (but exists in codebase):
SessionBridgeclass and related functions
2. Setting Up AuthProvider
Web App Example (apps/web-app/app/components/ClientAuthProvider.tsx):
'use client'
import { AuthProvider } from '@mystoryflow/auth'
import { ReactNode } from 'react'
export function ClientAuthProvider({ children }: { children: ReactNode }) {
return (
<AuthProvider
supabaseUrl={process.env.NEXT_PUBLIC_SUPABASE_URL!}
supabaseAnonKey={process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!}
>
{children}
</AuthProvider>
)
}Admin App Example (apps/admin-app/src/app/providers.tsx):
'use client'
import { AuthProvider } from '@mystoryflow/auth'
export function Providers({ children }: { children: React.ReactNode }) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
return <div>Configuration Error: Missing environment variables</div>
}
return (
<AuthProvider
supabaseUrl={supabaseUrl}
supabaseAnonKey={supabaseAnonKey}
>
{children}
</AuthProvider>
)
}3. Using Authentication in Components
import { useAuth } from '@mystoryflow/auth'
export function UserProfile() {
const { user, profile, loading, signOut } = useAuth()
if (loading) return <div>Loading...</div>
if (!user) return <div>Please sign in</div>
return (
<div>
<h1>Welcome, {profile?.full_name || user.email}</h1>
<p>Role: {profile?.role}</p>
<button onClick={signOut}>Sign Out</button>
</div>
)
}Available Auth Context Values:
interface AuthContextType {
user: User | null
profile: Profile | null
loading: boolean
error: string | null
setupCompleted: boolean
firstCampaignCreated: boolean
incompleteCampaign: IncompleteCampaign | null
checkSetupStatus: () => Promise<SetupStatus>
completeSetup: () => Promise<void>
signIn: (email: string, password: string) => Promise<void>
signUp: (email: string, password: string, metadata: Record<string, unknown>) => Promise<void>
signOut: () => Promise<void>
signInWithGoogle: () => Promise<void>
signInWithApple: () => Promise<void>
signInWithMagicLink: (email: string) => Promise<void>
sendPasswordReset: (email: string) => Promise<void>
updateProfile: (updates: Partial<Profile>) => Promise<void>
}4. Server-Side Middleware (Web App)
Middleware Configuration (apps/web-app/middleware.ts):
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/middleware'
export async function middleware(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}Session Update Function (apps/web-app/lib/supabase/middleware.ts):
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let response = NextResponse.next({
request: { headers: request.headers }
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return request.cookies.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
request.cookies.set({ name, value, ...options })
response.cookies.set({ name, value, ...options })
},
remove(name: string, options: CookieOptions) {
request.cookies.set({ name, value: '', ...options })
response.cookies.set({ name, value: '', ...options })
},
},
}
)
// Always update session for SSR
await supabase.auth.getUser()
// Route protection logic
const pathname = request.nextUrl.pathname
const isDashboard = pathname.startsWith('/dashboard')
if (isDashboard) {
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
// Redirect to login
return NextResponse.redirect(new URL('/login', request.url))
}
// Check setup completion
const { data: profile } = await supabase
.from('profiles')
.select('setup_completed')
.eq('id', user.id)
.single()
if (!profile?.setup_completed) {
return NextResponse.redirect(new URL('/campaigns/create?step=1', request.url))
}
}
return response
}5. Cross-App Navigation (Current Implementation)
Simple Link-Based Navigation (apps/web-app/app/components/AdminPortalLink.tsx):
'use client'
import { useEffect, useState } from 'react'
import { createClientFromEnv } from '@mystoryflow/auth'
import { Shield, ExternalLink } from 'lucide-react'
export default function AdminPortalLink() {
const [isAdmin, setIsAdmin] = useState(false)
const supabase = createClientFromEnv()
useEffect(() => {
async function checkAdminStatus() {
const { data: { user } } = await supabase.auth.getUser()
if (user) {
const { data: profile } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single()
const adminRoles = ['admin', 'moderator', 'super_admin', 'customer_support']
setIsAdmin(adminRoles.includes(profile?.role))
}
}
checkAdminStatus()
}, [supabase])
if (!isAdmin) return null
const handleAdminPortalClick = () => {
const adminUrl = process.env.NEXT_PUBLIC_ADMIN_APP_URL || 'http://localhost:3003'
// Simple navigation - session handled by cookies
window.open(`${adminUrl}/dashboard`, '_blank')
}
return (
<button onClick={handleAdminPortalClick} className="...">
<Shield className="h-4 w-4" />
<span>Admin Portal</span>
<ExternalLink className="h-3 w-3" />
</button>
)
}Current Behavior:
- Sessions are managed via HTTP-only cookies set by Supabase SSR
- When navigating between apps on the same domain, cookies are automatically shared
- For different domains, users need to authenticate separately in each app
- The
SessionBridgeclass exists but is not currently used for cross-domain transfer
🔄 How Authentication Works Across Apps
Current Authentication Flow
- User logs in to any app (web, admin, or tools)
- Supabase sets HTTP-only cookies containing session tokens
- Session is validated on both client and server side
- AuthProvider syncs state across the app
- Middleware protects routes by checking session validity
- Profile data is fetched from the
profilestable - User can navigate within the same app seamlessly
Same-Domain Session Sharing
When apps are deployed on the same domain (e.g., mystoryflow.com):
- Cookies are automatically shared across subdomains
- Navigation between
app.mystoryflow.comandadmin.mystoryflow.compreserves sessions - No additional code needed for session transfer
Cross-Domain Scenarios (Development)
During local development, apps run on different ports (localhost:3000, localhost:3003):
- Each app maintains separate sessions via Supabase auth
- Users may need to authenticate separately in each app
- The
SessionBridgeclass was built to handle this but is not currently integrated
Tools App Session Management
The tools app uses a different approach:
// tools-app/src/lib/session.ts
export class SessionManager {
static async initializeSession(userId?: string): Promise<string> {
const sessionId = this.getSessionId()
// Create session record in tools_sessions table
await supabaseTools.from('tools_sessions').insert({
session_id: sessionId,
user_id: userId || null,
ip_address: await this.getClientIP(),
user_agent: navigator.userAgent,
})
return sessionId
}
}Tools App Specifics:
- Maintains its own
tools_sessionstable for analytics - Supports both authenticated and anonymous usage
- Tracks session activity and usage patterns
🔧 Configuration
Environment Variables
Each app requires these environment variables:
# Supabase Configuration (Required)
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
# App URLs for cross-navigation (Optional)
NEXT_PUBLIC_WEB_APP_URL=http://localhost:3000
NEXT_PUBLIC_ADMIN_APP_URL=http://localhost:3003
NEXT_PUBLIC_TOOLS_APP_URL=http://localhost:3001TypeScript Types
From @mystoryflow/auth package:
// User type (from Supabase)
export type User = SupabaseUser
// Profile structure
export type Profile = {
id: string
full_name?: string
phone?: string
avatar_url?: string
role?: string
setup_completed?: boolean
first_campaign_created?: boolean
[key: string]: unknown
}
// Setup status
export interface SetupStatus {
setupCompleted: boolean
firstCampaignCreated: boolean
incompleteCampaign?: IncompleteCampaign | null
}
export interface IncompleteCampaign {
id: string
currentStep: string
progress: Record<string, unknown> | null
}SessionBridge types (exists but not exported):
// packages/auth/src/session-bridge.ts
export interface SessionData {
access_token: string
refresh_token: string
expires_at: number
user: any
}🔍 Debugging
Console Logs
The AuthProvider provides auth state change logs:
🔄 Auth state changed: SIGNED_IN
Auth error: [error details]
Error fetching user and profile: [error details]
Auth initialization timed out after 5 secondsTools App Session Logs:
✅ Valid session already exists
🔍 No valid session found, checking stored session...
📦 Found stored session, restoring...
❌ No valid session foundCommon Issues
Authentication Not Working
Check Supabase Configuration:
// Verify environment variables are set
console.log('Supabase URL:', process.env.NEXT_PUBLIC_SUPABASE_URL)
console.log('Has Anon Key:', !!process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)Check User Session:
const { user, loading, error } = useAuth()
if (loading) {
console.log('Auth loading...')
return <Loading />
}
if (error) {
console.error('Auth error:', error)
return <div>Error: {error}</div>
}
if (!user) {
console.log('No user found')
return <LoginForm />
}
console.log('User authenticated:', user.id)
return <AuthenticatedApp />Middleware Redirect Loops
// Check middleware logic for infinite loops
// Ensure protected routes don't redirect to themselves
if (isDashboard && !user) {
// Make sure /login is not a protected route
return NextResponse.redirect(new URL('/login', request.url))
}Profile Not Loading
const { user, profile, loading } = useAuth()
useEffect(() => {
if (!loading && user && !profile) {
console.error('Profile not found for user:', user.id)
// Profile may not exist in database
}
}, [user, profile, loading])🔒 Security Considerations
Session Data Protection
The current implementation uses secure practices:
- HTTP-only Cookies - Session tokens stored in HTTP-only cookies (not accessible to JavaScript)
- Secure Flag - Cookies use the Secure flag in production (HTTPS only)
- SameSite Protection - Cookies configured with SameSite attribute to prevent CSRF
- Token Expiration - Sessions automatically expire and refresh via Supabase
- Server-Side Validation - Middleware validates sessions on every request
Best Practices
Production Configuration:
// Supabase automatically handles secure cookie storage in production
// No manual cookie configuration needed
// For custom storage (if needed):
const supabase = createBrowserClient(url, key, {
auth: {
storage: {
// Custom storage implementation
getItem: (key) => localStorage.getItem(key),
setItem: (key, value) => localStorage.setItem(key, value),
removeItem: (key) => localStorage.removeItem(key),
},
// Supabase SSR handles secure cookies automatically
detectSessionInUrl: true,
flowType: 'pkce' // Use PKCE flow for better security
},
})Middleware Security:
// Always validate user on protected routes
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.redirect(new URL('/login', request.url))
}
// Verify user roles for admin routes
const { data: profile } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single()
if (!['admin', 'super_admin'].includes(profile?.role)) {
return NextResponse.redirect(new URL('/unauthorized', request.url))
}📱 Multi-App Support
Current Applications
Production Apps (using @mystoryflow/auth):
- ✅ Web App (localhost:3000) - Main user interface with full AuthProvider integration
- ✅ Admin App (localhost:3003) - Administrative dashboard with role-based access
- ✅ Tools App (localhost:3001) - Utility tools with custom session tracking
- 🔄 Marketing Site (localhost:3002) - Public marketing (minimal auth needed)
- 🔄 Docs App (localhost:3004) - Documentation (public access)
App-Specific Implementations
Web App:
- Full AuthProvider with profile management
- Setup completion tracking
- Campaign progress tracking
- Middleware route protection
Admin App:
- Standard AuthProvider setup
- Role-based access control
- Admin-specific features
Tools App:
- Simple auth with
useAuth - Custom
SessionManagerfor analytics - Supports anonymous and authenticated users
- Tracks tool usage per session
Adding New Apps
- Install the auth package:
npm install @mystoryflow/auth --workspace=apps/your-app- Add AuthProvider to app layout:
// apps/your-app/app/layout.tsx
import { AuthProvider } from '@mystoryflow/auth'
export default function RootLayout({ children }) {
return (
<html>
<body>
<AuthProvider
supabaseUrl={process.env.NEXT_PUBLIC_SUPABASE_URL!}
supabaseAnonKey={process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!}
>
{children}
</AuthProvider>
</body>
</html>
)
}- Configure environment variables:
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key- Add middleware (if needed):
// apps/your-app/middleware.ts
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/middleware'
export async function middleware(request: NextRequest) {
return await updateSession(request)
}🚀 Testing
Manual Testing
1. Start multiple apps:
# Terminal 1 - Web App
cd apps/web-app && npm run dev
# Terminal 2 - Admin App
cd apps/admin-app && npm run dev
# Terminal 3 - Tools App
cd apps/tools-app && npm run dev2. Test authentication flow:
- Login to web app at localhost:3000
- Verify user profile loads correctly
- Check browser cookies (should see Supabase auth cookies)
- Try accessing protected routes (e.g., /dashboard)
3. Test cross-app navigation:
- From web app, click Admin Portal link (if you have admin role)
- Opens admin app in new tab
- Note: In local development, you’ll need to login again in admin app
- In production (same domain), session will be shared via cookies
4. Test role-based access:
- Login as regular user
- Admin Portal link should not appear
- Login as admin user
- Admin Portal link should be visible
Automated Testing
Auth Context Tests (apps/web-app/__tests__/auth/auth-context.test.tsx):
import { renderHook, waitFor } from '@testing-library/react'
import { AuthProvider, useAuth } from '@mystoryflow/auth'
describe('AuthProvider', () => {
it('should provide auth context', async () => {
const wrapper = ({ children }) => (
<AuthProvider
supabaseUrl={process.env.NEXT_PUBLIC_SUPABASE_URL!}
supabaseAnonKey={process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!}
>
{children}
</AuthProvider>
)
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
expect(result.current.user).toBeDefined()
})
})Session Management Tests (apps/web-app/tests/e2e/critical/session-management.spec.ts):
import { test, expect } from '@playwright/test'
test.describe('Session Management', () => {
test('should maintain session across page reloads', async ({ page }) => {
await page.goto('/login')
// Login flow...
await page.waitForURL('/dashboard')
// Reload page
await page.reload()
// Should still be authenticated
await expect(page).toHaveURL('/dashboard')
})
})📈 Performance
Current Optimizations
AuthProvider:
- Debounced Updates - Auth state changes debounced to prevent excessive re-renders
- Cached Profile Data - Profile data cached to reduce database queries
- Loading Timeout - 5-second failsafe prevents infinite loading states
- Lazy Initialization - Auth context only initializes client-side
Middleware:
- Efficient Cookie Handling - Server-side session validation via cookies
- Selective Database Queries - Only queries database for protected routes
- Route-Specific Logic - Conditional checks based on pathname
Tools App:
- Session Caching - localStorage-based session ID caching
- Periodic Updates - Session activity updated every 30 seconds
- Minimal Database Calls - Only creates session record on initialization
Performance Metrics
- Auth initialization: ~100-300ms
- Profile fetch: ~50-150ms
- Middleware validation: ~10-50ms
- Session persistence: Automatic via cookies
🔮 Future Enhancements
Potential Improvements
- Export SessionBridge - Make
SessionBridgeclass available from package for cross-domain scenarios - URL-Based Session Transfer - Implement the existing
SessionBridgefor development environments - Multi-Tenant Support - Organization-based session isolation
- Session Analytics Dashboard - Track authentication metrics across apps
- Offline Support - Handle auth state when network is unavailable
SessionBridge Integration (Future)
The SessionBridge class exists and could be enabled:
// packages/auth/src/index.ts (add these exports)
export { SessionBridge, createSessionBridge, useSessionBridge } from './session-bridge'
// Then use in apps:
import { createSessionBridge } from '@mystoryflow/auth'
const sessionBridge = createSessionBridge(supabaseUrl, anonKey)
await sessionBridge.initializeSession()
sessionBridge.setupSessionSync()
// Cross-domain navigation:
const url = sessionBridge.createCrossDomainUrl(targetUrl, session)
window.open(url, '_blank')📞 Support
Troubleshooting Checklist
Authentication Issues:
- Check browser console for auth errors
- Verify environment variables are set (
NEXT_PUBLIC_SUPABASE_URL,NEXT_PUBLIC_SUPABASE_ANON_KEY) - Ensure Supabase project is active and accessible
- Check browser cookies for Supabase session tokens
- Test with a fresh browser session (clear cookies)
Cross-App Navigation:
- Verify apps are running on correct ports
- Check that user has appropriate role for target app
- In development, expect to re-authenticate in each app
- In production, ensure apps are on same domain for cookie sharing
Profile Loading:
- Verify user exists in
profilestable - Check database RLS policies allow profile access
- Ensure profile fetch query matches schema
- Check for any database connection issues
Middleware Redirects:
- Check middleware matcher patterns
- Verify redirect logic doesn’t create loops
- Ensure login/signup routes are not protected
- Test with different user roles
Support Resources
- Supabase Logs: Check auth logs in Supabase dashboard
- Browser DevTools: Network tab for failed requests
- Server Logs: Check Next.js server logs for errors
- Database: Verify user and profile data in Supabase tables