import { createHash, randomBytes, timingSafeEqual } from 'crypto' import { getDatabase } from './db' import { hashPassword, verifyPassword } from './password' import { logSecurityEvent } from './security-events' import { parseMcSessionCookieHeader } from './session-cookie' // Plugin hook: extensions can register a custom API key resolver without modifying this file. type AuthResolverHook = (apiKey: string, agentName: string | null) => User | null let _authResolverHook: AuthResolverHook | null = null export function registerAuthResolver(hook: AuthResolverHook): void { _authResolverHook = hook } /** * Constant-time string comparison to prevent timing attacks. */ export function safeCompare(a: string, b: string): boolean { if (typeof a !== 'string' || typeof b !== 'string') return false const bufA = Buffer.from(a) const bufB = Buffer.from(b) if (bufA.length !== bufB.length) { // Compare against dummy buffer to avoid timing leak on length mismatch const dummy = Buffer.alloc(bufA.length) timingSafeEqual(bufA, dummy) return false } return timingSafeEqual(bufA, bufB) } export interface User { id: number username: string display_name: string role: 'admin' | 'operator' | 'viewer' workspace_id: number tenant_id: number provider?: 'local' | 'google' | 'proxy' email?: string | null avatar_url?: string | null is_approved?: number created_at: number updated_at: number last_login_at: number | null /** Agent name when request is made on behalf of a specific agent (via X-Agent-Name header) */ agent_name?: string | null } export interface UserSession { id: number token: string user_id: number workspace_id: number tenant_id: number expires_at: number created_at: number ip_address: string | null user_agent: string | null } interface SessionQueryRow { id: number username: string display_name: string role: 'admin' | 'operator' | 'viewer' provider: 'local' | 'google' | null email: string | null avatar_url: string | null is_approved: number workspace_id: number tenant_id: number created_at: number updated_at: number last_login_at: number | null session_id: number } interface UserQueryRow { id: number username: string display_name: string role: 'admin' | 'operator' | 'viewer' provider: 'local' | 'google' | null email: string | null avatar_url: string | null is_approved: number workspace_id: number tenant_id?: number created_at: number updated_at: number last_login_at: number | null password_hash: string } // Session management const SESSION_DURATION = 7 * 24 * 60 * 60 // 7 days in seconds function getDefaultWorkspaceContext(): { workspaceId: number; tenantId: number } { try { const db = getDatabase() const row = db.prepare(` SELECT id, tenant_id FROM workspaces ORDER BY CASE WHEN slug = 'default' THEN 0 ELSE 1 END, id ASC LIMIT 1 `).get() as { id?: number; tenant_id?: number } | undefined return { workspaceId: row?.id || 1, tenantId: row?.tenant_id || 1, } } catch { return { workspaceId: 1, tenantId: 1 } } } export function getWorkspaceIdFromRequest(request: Request): number { const user = getUserFromRequest(request) return user?.workspace_id || getDefaultWorkspaceContext().workspaceId } export function getTenantIdFromRequest(request: Request): number { const user = getUserFromRequest(request) return user?.tenant_id || getDefaultWorkspaceContext().tenantId } function resolveTenantForWorkspace(workspaceId: number): number { const db = getDatabase() const row = db.prepare(`SELECT tenant_id FROM workspaces WHERE id = ? LIMIT 1`).get(workspaceId) as { tenant_id?: number } | undefined return row?.tenant_id || getDefaultWorkspaceContext().tenantId } export function createSession( userId: number, ipAddress?: string, userAgent?: string, workspaceId?: number ): { token: string; expiresAt: number } { const db = getDatabase() const token = randomBytes(32).toString('hex') const now = Math.floor(Date.now() / 1000) const expiresAt = now + SESSION_DURATION const resolvedWorkspaceId = workspaceId ?? ((db.prepare('SELECT workspace_id FROM users WHERE id = ?').get(userId) as { workspace_id?: number } | undefined)?.workspace_id || getDefaultWorkspaceContext().workspaceId) const resolvedTenantId = resolveTenantForWorkspace(resolvedWorkspaceId) db.prepare(` INSERT INTO user_sessions (token, user_id, expires_at, ip_address, user_agent, workspace_id, tenant_id) VALUES (?, ?, ?, ?, ?, ?, ?) `).run(token, userId, expiresAt, ipAddress || null, userAgent || null, resolvedWorkspaceId, resolvedTenantId) // Update user's last login db.prepare('UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?').run(now, now, userId) // Clean up expired sessions db.prepare('DELETE FROM user_sessions WHERE expires_at < ?').run(now) return { token, expiresAt } } export function validateSession(token: string): (User & { sessionId: number }) | null { if (!token) return null const db = getDatabase() const now = Math.floor(Date.now() / 1000) const row = db.prepare(` SELECT u.id, u.username, u.display_name, u.role, u.provider, u.email, u.avatar_url, u.is_approved, COALESCE(s.workspace_id, u.workspace_id, 1) as workspace_id, COALESCE(s.tenant_id, w.tenant_id, 1) as tenant_id, u.created_at, u.updated_at, u.last_login_at, s.id as session_id FROM user_sessions s JOIN users u ON u.id = s.user_id LEFT JOIN workspaces w ON w.id = COALESCE(s.workspace_id, u.workspace_id, 1) WHERE s.token = ? AND s.expires_at > ? `).get(token, now) as SessionQueryRow | undefined if (!row) return null return { id: row.id, username: row.username, display_name: row.display_name, role: row.role, workspace_id: row.workspace_id || getDefaultWorkspaceContext().workspaceId, tenant_id: row.tenant_id || getDefaultWorkspaceContext().tenantId, provider: row.provider || 'local', email: row.email ?? null, avatar_url: row.avatar_url ?? null, is_approved: typeof row.is_approved === 'number' ? row.is_approved : 1, created_at: row.created_at, updated_at: row.updated_at, last_login_at: row.last_login_at, sessionId: row.session_id, } } export function destroySession(token: string): void { const db = getDatabase() db.prepare('DELETE FROM user_sessions WHERE token = ?').run(token) } export function destroyAllUserSessions(userId: number): void { const db = getDatabase() db.prepare('DELETE FROM user_sessions WHERE user_id = ?').run(userId) } // Dummy hash used for constant-time rejection when user doesn't exist. // This ensures authenticateUser takes the same time whether or not the username is valid, // preventing timing-based username enumeration. const DUMMY_HASH = '0000000000000000000000000000000000000000000000000000000000000000:0000000000000000000000000000000000000000000000000000000000000000' // User management export function authenticateUser(username: string, password: string): User | null { const db = getDatabase() const row = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as UserQueryRow | undefined if (!row) { // Always run verifyPassword to prevent timing-based username enumeration verifyPassword(password, DUMMY_HASH) try { logSecurityEvent({ event_type: 'auth_failure', severity: 'warning', source: 'auth', detail: JSON.stringify({ username, reason: 'user_not_found' }), workspace_id: 1, tenant_id: 1 }) } catch {} return null } if ((row.provider || 'local') !== 'local') { verifyPassword(password, DUMMY_HASH) try { logSecurityEvent({ event_type: 'auth_failure', severity: 'warning', source: 'auth', detail: JSON.stringify({ username, reason: 'wrong_provider' }), workspace_id: 1, tenant_id: 1 }) } catch {} return null } if ((row.is_approved ?? 1) !== 1) { verifyPassword(password, DUMMY_HASH) try { logSecurityEvent({ event_type: 'auth_failure', severity: 'warning', source: 'auth', detail: JSON.stringify({ username, reason: 'not_approved' }), workspace_id: 1, tenant_id: 1 }) } catch {} return null } if (!verifyPassword(password, row.password_hash)) { try { logSecurityEvent({ event_type: 'auth_failure', severity: 'warning', source: 'auth', detail: JSON.stringify({ username, reason: 'invalid_password' }), workspace_id: 1, tenant_id: 1 }) } catch {} return null } return { id: row.id, username: row.username, display_name: row.display_name, role: row.role, workspace_id: row.workspace_id || getDefaultWorkspaceContext().workspaceId, tenant_id: resolveTenantForWorkspace(row.workspace_id || getDefaultWorkspaceContext().workspaceId), provider: row.provider || 'local', email: row.email ?? null, avatar_url: row.avatar_url ?? null, is_approved: row.is_approved ?? 1, created_at: row.created_at, updated_at: row.updated_at, last_login_at: row.last_login_at, } } export function getUserById(id: number): User | null { const db = getDatabase() const row = db.prepare(` SELECT u.id, u.username, u.display_name, u.role, u.workspace_id, COALESCE(w.tenant_id, 1) as tenant_id, u.provider, u.email, u.avatar_url, u.is_approved, u.created_at, u.updated_at, u.last_login_at FROM users u LEFT JOIN workspaces w ON w.id = u.workspace_id WHERE u.id = ? `).get(id) as User | undefined return row ? { ...row, tenant_id: row.tenant_id || getDefaultWorkspaceContext().tenantId } : null } export function getAllUsers(): User[] { const db = getDatabase() return db.prepare(` SELECT u.id, u.username, u.display_name, u.role, u.workspace_id, COALESCE(w.tenant_id, 1) as tenant_id, u.provider, u.email, u.avatar_url, u.is_approved, u.created_at, u.updated_at, u.last_login_at FROM users u LEFT JOIN workspaces w ON w.id = u.workspace_id ORDER BY u.created_at `).all() as User[] } export function createUser( username: string, password: string, displayName: string, role: User['role'] = 'operator', options?: { provider?: 'local' | 'google'; provider_user_id?: string | null; email?: string | null; avatar_url?: string | null; is_approved?: 0 | 1; approved_by?: string | null; approved_at?: number | null; workspace_id?: number } ): User { const db = getDatabase() if (password.length < 12) throw new Error('Password must be at least 12 characters') const passwordHash = hashPassword(password) const provider = options?.provider || 'local' const workspaceId = options?.workspace_id || getDefaultWorkspaceContext().workspaceId const result = db.prepare(` INSERT INTO users (username, display_name, password_hash, role, provider, provider_user_id, email, avatar_url, is_approved, approved_by, approved_at, workspace_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( username, displayName, passwordHash, role, provider, options?.provider_user_id || null, options?.email || null, options?.avatar_url || null, typeof options?.is_approved === 'number' ? options.is_approved : 1, options?.approved_by || null, options?.approved_at || null, workspaceId, ) return getUserById(Number(result.lastInsertRowid))! } export function updateUser(id: number, updates: { display_name?: string; role?: User['role']; password?: string; email?: string | null; avatar_url?: string | null; is_approved?: 0 | 1 }): User | null { const db = getDatabase() const fields: string[] = [] const params: any[] = [] if (updates.display_name !== undefined) { fields.push('display_name = ?'); params.push(updates.display_name) } if (updates.role !== undefined) { fields.push('role = ?'); params.push(updates.role) } if (updates.password !== undefined) { fields.push('password_hash = ?'); params.push(hashPassword(updates.password)) } if (updates.email !== undefined) { fields.push('email = ?'); params.push(updates.email) } if (updates.avatar_url !== undefined) { fields.push('avatar_url = ?'); params.push(updates.avatar_url) } if (updates.is_approved !== undefined) { fields.push('is_approved = ?'); params.push(updates.is_approved) } if (fields.length === 0) return getUserById(id) fields.push('updated_at = ?') params.push(Math.floor(Date.now() / 1000)) params.push(id) db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...params) return getUserById(id) } export function deleteUser(id: number): boolean { const db = getDatabase() destroyAllUserSessions(id) const result = db.prepare('DELETE FROM users WHERE id = ?').run(id) return result.changes > 0 } /** * Seed admin user from environment variables on first run. * If no users exist, creates an admin from AUTH_USER/AUTH_PASS env vars. */ /** * Get user from request - checks session cookie or API key. * For API key auth, returns a synthetic "api" user. */ /** * Resolve a user by username for proxy auth. * If the user does not exist and MC_PROXY_AUTH_DEFAULT_ROLE is set, auto-provisions them. * Auto-provisioned users receive a random unusable password — they cannot log in locally. */ function resolveOrProvisionProxyUser(username: string): User | null { try { const db = getDatabase() const { workspaceId } = getDefaultWorkspaceContext() const row = db.prepare(` SELECT u.id, u.username, u.display_name, u.role, u.workspace_id, COALESCE(w.tenant_id, 1) as tenant_id, u.provider, u.email, u.avatar_url, u.is_approved, u.created_at, u.updated_at, u.last_login_at FROM users u LEFT JOIN workspaces w ON w.id = u.workspace_id WHERE u.username = ? `).get(username) as UserQueryRow | undefined if (row) { if ((row.is_approved ?? 1) !== 1) return null return { id: row.id, username: row.username, display_name: row.display_name, role: row.role, workspace_id: row.workspace_id || workspaceId, tenant_id: resolveTenantForWorkspace(row.workspace_id || workspaceId), provider: row.provider || 'local', email: row.email ?? null, avatar_url: row.avatar_url ?? null, is_approved: row.is_approved ?? 1, created_at: row.created_at, updated_at: row.updated_at, last_login_at: row.last_login_at, } } // Auto-provision if MC_PROXY_AUTH_DEFAULT_ROLE is configured const defaultRole = (process.env.MC_PROXY_AUTH_DEFAULT_ROLE || '').trim() if (!defaultRole || !(['viewer', 'operator', 'admin'] as const).includes(defaultRole as User['role'])) { return null } // Random password — proxy users cannot log in via the local login form return createUser(username, randomBytes(32).toString('hex'), username, defaultRole as User['role']) } catch { return null } } export function getUserFromRequest(request: Request): User | null { // Extract agent identity header (optional, for attribution) const agentName = (request.headers.get('x-agent-name') || '').trim() || null // Proxy / trusted-header auth (MC_PROXY_AUTH_HEADER) // When the gateway has already authenticated the user and injects their username // as a trusted header (e.g. X-Auth-Username from Envoy OIDC claimToHeaders), // skip the local login form entirely. const proxyAuthHeader = (process.env.MC_PROXY_AUTH_HEADER || '').trim() if (proxyAuthHeader) { const proxyUsername = (request.headers.get(proxyAuthHeader) || '').trim() if (proxyUsername) { const user = resolveOrProvisionProxyUser(proxyUsername) if (user) return { ...user, agent_name: agentName } } } // Check session cookie const cookieHeader = request.headers.get('cookie') || '' const sessionToken = parseMcSessionCookieHeader(cookieHeader) if (sessionToken) { const user = validateSession(sessionToken) if (user) return { ...user, agent_name: agentName } } // Check API key - DB override first, then env var const apiKey = extractApiKeyFromHeaders(request.headers) const configuredApiKey = resolveActiveApiKey() if (configuredApiKey && apiKey && safeCompare(apiKey, configuredApiKey)) { return { id: 0, username: 'api', display_name: 'API Access', role: 'admin', workspace_id: getDefaultWorkspaceContext().workspaceId, tenant_id: getDefaultWorkspaceContext().tenantId, created_at: 0, updated_at: 0, last_login_at: null, agent_name: agentName, } } // Agent-scoped API keys if (apiKey) { try { const db = getDatabase() const keyHash = hashApiKey(apiKey) const now = Math.floor(Date.now() / 1000) const row = db.prepare(` SELECT id, agent_id, workspace_id, scopes, expires_at, revoked_at FROM agent_api_keys WHERE key_hash = ? LIMIT 1 `).get(keyHash) as { id: number agent_id: number workspace_id: number scopes: string expires_at: number | null revoked_at: number | null } | undefined if (row && !row.revoked_at && (!row.expires_at || row.expires_at > now)) { const scopes = parseAgentScopes(row.scopes) const agent = db .prepare('SELECT id, name FROM agents WHERE id = ? AND workspace_id = ?') .get(row.agent_id, row.workspace_id) as { id: number; name: string } | undefined if (agent) { if (agentName && agentName !== agent.name && !scopes.has('admin')) { return null } db.prepare('UPDATE agent_api_keys SET last_used_at = ?, updated_at = ? WHERE id = ?').run(now, now, row.id) return { id: -row.id, username: `agent:${agent.name}`, display_name: agent.name, role: deriveRoleFromScopes(scopes), workspace_id: row.workspace_id, tenant_id: getDefaultWorkspaceContext().tenantId, created_at: 0, updated_at: now, last_login_at: now, agent_name: agent.name, } } } } catch { // ignore missing table / startup race } } // Plugin hook: allow Pro (or other extensions) to resolve custom API keys if (apiKey && _authResolverHook) { const resolved = _authResolverHook(apiKey, agentName) if (resolved) return resolved } return null } /** * Resolve the active API key: check DB settings override first, then env var. */ function resolveActiveApiKey(): string { try { const db = getDatabase() const row = db.prepare( "SELECT value FROM settings WHERE key = 'security.api_key'" ).get() as { value: string } | undefined if (row?.value) return row.value } catch { // DB not ready yet — fall back to env } return (process.env.API_KEY || '').trim() } function extractApiKeyFromHeaders(headers: Headers): string | null { const direct = (headers.get('x-api-key') || '').trim() if (direct) return direct const authorization = (headers.get('authorization') || '').trim() if (!authorization) return null const [scheme, ...rest] = authorization.split(/\s+/) if (!scheme || rest.length === 0) return null const normalized = scheme.toLowerCase() if (normalized === 'bearer' || normalized === 'apikey' || normalized === 'token') { return rest.join(' ').trim() || null } return null } function hashApiKey(rawKey: string): string { return createHash('sha256').update(rawKey).digest('hex') } function parseAgentScopes(raw: string): Set { try { const parsed = JSON.parse(raw) if (Array.isArray(parsed)) return new Set(parsed.map((scope) => String(scope))) } catch { // ignore parse errors } return new Set() } function deriveRoleFromScopes(scopes: Set): User['role'] { if (scopes.has('admin')) return 'admin' if (scopes.has('operator')) return 'operator' return 'viewer' } /** * Role hierarchy levels for access control. * viewer < operator < admin */ const ROLE_LEVELS: Record = { viewer: 0, operator: 1, admin: 2 } /** * Check if a user meets the minimum role requirement. * Returns { user } on success, or { error, status } on failure (401 or 403). */ export function requireRole( request: Request, minRole: User['role'] ): { user: User; error?: never; status?: never } | { user?: never; error: string; status: 401 | 403 } { const user = getUserFromRequest(request) if (!user) { return { error: 'Authentication required', status: 401 } } if ((ROLE_LEVELS[user.role] ?? -1) < ROLE_LEVELS[minRole]) { return { error: `Requires ${minRole} role or higher`, status: 403 } } return { user } }