import { initializeApp, getApps } from 'firebase-admin/app'; import { getAuth } from 'firebase-admin/auth'; import { User } from './types.js'; import { randomUUID } from 'node:crypto'; /** * Logger interface for FirebaseAuthTool */ export interface AuthLogger { info: (message: string, meta?: Record) => void; warn: (message: string, meta?: Record) => void; } // Default console logger const consoleLogger: AuthLogger = { info: (message: string, meta?: Record) => meta ? console.info(message, meta) : console.info(message), warn: (message: string, meta?: Record) => meta ? console.warn(message, meta) : console.warn(message) }; let authLogger: AuthLogger = consoleLogger; export const setAuthLogger = (logger: AuthLogger): void => { authLogger = logger; }; export class FirebaseAuthTool { private auth: ReturnType; private adminEmails: Set; private adminDomains: Set; private adminUids: Set; // FIX: Add UID-based admin fallback private readonly allowTestTokens: boolean; private readonly testTokenMap: Record; constructor() { // Initialize Firebase Admin if not already initialized if (getApps().length === 0) { // Priority order for Firebase Auth project ID (AI Universe only): // 1. AI_UNIVERSE_FIREBASE_PROJECT_ID (required) // 2. Hardcoded fallback (ai-universe-b3551) for non-prod only const projectId = process.env.AI_UNIVERSE_FIREBASE_PROJECT_ID || 'ai-universe-b3551'; authLogger.info('Initializing Firebase Auth', { projectId, source: process.env.AI_UNIVERSE_FIREBASE_PROJECT_ID ? 'AI_UNIVERSE_FIREBASE_PROJECT_ID' : 'hardcoded-fallback' }); const app = initializeApp({ projectId, }); this.auth = getAuth(app); } else { this.auth = getAuth(); } // SECURITY FIX: Load admin emails from environment variables (support legacy + new names) const adminEmailsEnv = process.env.ADMIN_EMAILS ?? process.env.FIREBASE_ADMIN_EMAILS ?? ''; this.adminEmails = new Set( adminEmailsEnv .split(',') .map(email => email.trim().toLowerCase()) .filter(Boolean) ); this.adminDomains = new Set( (process.env.FIREBASE_ADMIN_DOMAINS ?? '') .split(',') .map(domain => domain.trim().toLowerCase()) .filter(Boolean) ); // FIX: Add UID-based admin fallback for cases where Firebase token lacks email claim // This resolves the bug where admin users with missing email in token get treated as anonymous // NOTE: Email-based checks remain primary and the UID path only runs when email is missing this.adminUids = new Set( (process.env.FIREBASE_ADMIN_UIDS ?? '') .split(',') .map(uid => uid.trim()) .filter(Boolean) ); authLogger.info('Firebase Auth tool initialized'); const allowEnvFlag = process.env.ALLOW_TEST_FIREBASE_TOKENS === 'true'; const nodeEnv = process.env.NODE_ENV?.toLowerCase(); const isCiSimulation = process.env.CI_SIMULATION === 'true'; const isTestEnv = nodeEnv === 'test' || isCiSimulation; const isProdEnv = nodeEnv === 'production' || nodeEnv === 'prod'; if (allowEnvFlag && isProdEnv) { authLogger.warn('ALLOW_TEST_FIREBASE_TOKENS ignored in production environment'); } this.allowTestTokens = isTestEnv || (allowEnvFlag && !isProdEnv); this.testTokenMap = this.allowTestTokens ? this.loadTestTokenMap() : {}; } /** * Verify Firebase ID token and return user info */ async verifyIdToken(idToken: string): Promise { const mockUser = this.resolveTestToken(idToken); if (mockUser) { authLogger.info('Using mock Firebase token for testing', { uid: mockUser.id }); return mockUser; } try { const decodedToken = await this.auth.verifyIdToken(idToken); const user: User = { id: decodedToken.uid, uid: decodedToken.uid, email: decodedToken.email || '', name: decodedToken.name || decodedToken.email || 'Anonymous', picture: decodedToken.picture, isAuthenticated: true }; authLogger.info('User authenticated via Firebase', { uid: user.id, // email: user.email, -- REMOVED for PII compliance isAdmin: this.isAdmin(user) }); return user; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Token verification failed'; authLogger.warn('Firebase token verification failed', { error: errorMessage }); throw new Error(`Invalid Firebase token: ${errorMessage}`); } } /** * Create an anonymous user for unauthenticated requests * * @param clientUserId - Optional client-provided user ID for session continuity * If provided, enables anonymous users to continue conversations * across requests by maintaining the same user ID * * SECURITY: Client-provided IDs are namespaced with 'anon-' prefix to prevent * collision with authenticated user IDs * * SESSION CONTINUITY: When clientUserId is provided, the same ID is returned * across multiple requests, enabling conversation threading * and proper rate limiting * * ISOLATION: When no clientUserId is provided, generates unique random ID * to prevent data leakage between different anonymous users */ createAnonymousUser(clientUserId?: string): User { let anonymousId: string; if (clientUserId) { // Client-provided ID for session continuity // Namespace to prevent collision with authenticated user IDs anonymousId = `anon-${clientUserId}`; } else { // Generate unique anonymous ID using crypto-safe random UUID const randomId = randomUUID(); anonymousId = `anonymous-${randomId}`; } return { id: anonymousId, uid: anonymousId, email: '', name: 'Anonymous', isAuthenticated: false }; } /** * Check if user is an admin (for rate limiting) * * FIX: Added UID-based fallback to handle cases where Firebase token * does not contain email claim, preventing admin users from being * incorrectly treated as anonymous (which caused 5/hour rate limit). */ isAdmin(user: User): boolean { if (!user.isAuthenticated) return false; // Check explicit admin emails (primary method) if (user.email && this.adminEmails.has(user.email.toLowerCase())) { return true; } // Check admin domains if (user.email) { const emailDomain = user.email.split('@')[1]?.toLowerCase(); if (emailDomain && this.adminDomains.has(emailDomain)) { return true; } } // FIX: Fallback to UID check when email is missing or empty // This handles the case where Firebase token lacks email claim if ((!user.email || user.email.trim() === '') && user.uid && this.adminUids.has(user.uid)) { const maskedUid = user.uid.length > 8 ? `${user.uid.slice(0, 8)}...` : user.uid; authLogger.info('Admin recognized via UID fallback (email missing in token)', { uid: maskedUid }); return true; } return false; } /** * Generate authentication instructions for frontend */ getAuthInstructions(): string { return JSON.stringify({ type: 'firebase', description: 'Firebase Auth with Google SSO Integration', instructions: 'Firebase Auth with Google SSO Integration', setup: { firebase: [ '1. Go to https://console.firebase.google.com', '2. Create a new project or use existing one', '3. Enable Authentication > Sign-in methods > Google', '4. Get your Firebase config (Project Settings > General > Web apps)', '5. Add authorized domains for your app' ], frontend: [ 'npm install firebase', 'Initialize Firebase with your config', 'Use signInWithPopup(auth, googleProvider)', 'Get ID token with user.getIdToken()', 'Send token in Authorization header: Bearer ' ], backend: [ 'Server verifies Firebase ID tokens automatically', 'No API keys needed in .env for token verification', 'Admin users configured in code for MVP' ] }, usage: { authentication: 'Authorization: Bearer ', adminInfo: { adminEmailCount: this.adminEmails.size, adminDomainCount: this.adminDomains.size }, rateLimits: { anonymous: '5 requests/hour', authenticated: '50 requests/hour', admin: '1000 requests/hour' } }, exampleFlow: [ '1. User clicks "Sign in with Google" on frontend', '2. Firebase handles Google OAuth popup', '3. Frontend gets Firebase ID token', '4. Frontend sends requests with Authorization: Bearer ', '5. Backend verifies token and extracts user info', '6. Backend applies appropriate rate limits based on user' ] }, null, 2); } /** * Extract user from request headers * * @param headers - Request headers (may include Authorization, X-Client-User-Id) * @param clientUserId - Optional client-provided user ID for anonymous session continuity * * For anonymous users (no auth token), checks for: * 1. clientUserId parameter (from caller) * 2. X-Client-User-Id header (from HTTP request) * * This enables anonymous users to maintain the same identity across requests * for conversation continuity and rate limiting. */ async getUserFromRequest(headers: any, clientUserId?: string): Promise { const authHeader = headers.authorization || headers.Authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { // For anonymous users, check for client-provided user ID const effectiveClientUserId = clientUserId || headers['x-client-user-id'] || headers['X-Client-User-Id']; return this.createAnonymousUser(effectiveClientUserId); } const idToken = authHeader.substring(7); // Remove 'Bearer ' prefix try { return await this.verifyIdToken(idToken); } catch (error) { authLogger.warn('Failed to authenticate user from request', { error: error instanceof Error ? error.message : 'Unknown error' }); // On auth failure, also check for client-provided user ID const effectiveClientUserId = clientUserId || headers['x-client-user-id'] || headers['X-Client-User-Id']; return this.createAnonymousUser(effectiveClientUserId); } } /** * Health check for Firebase Auth */ async healthCheck(): Promise<{ status: string; details?: any }> { try { // Test that Firebase Admin is properly initialized // We can't really test without making a real call, so just check initialization const app = this.auth.app; return { status: 'healthy', details: { firebaseInitialized: !!app, adminEmails: this.adminEmails.size, adminDomains: this.adminDomains.size } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; return { status: 'unhealthy', details: { error: errorMessage } }; } } private loadTestTokenMap(): Record { const raw = process.env.FIREBASE_TEST_TOKEN_MAP; if (!raw) { return {}; } try { const parsed = JSON.parse(raw) as Record; return parsed && typeof parsed === 'object' ? parsed : {}; } catch (error) { authLogger.warn('Failed to parse FIREBASE_TEST_TOKEN_MAP - ignoring', { error: error instanceof Error ? error.message : String(error) }); return {}; } } private resolveTestToken(idToken: string): User | null { if (!this.allowTestTokens) { return null; } const mapped = this.testTokenMap[idToken]; const record = mapped ?? (idToken.startsWith('mock-token-') ? { uid: idToken.replace('mock-token-', ''), email: `${idToken.replace('mock-token-', '')}@test.invalid`, name: idToken.replace('mock-token-', '') } : null); if (!record) { return null; } const uid = record.uid; const email = record.email !== undefined ? record.email : `${uid}@test.invalid`; const name = record.name ?? uid; return { id: uid, uid, email, name, isAuthenticated: true }; } }