import { env } from '$env/dynamic/private'; import argon2 from 'argon2'; import { getLocalCredentialByIdentifier, hashLocalPassword } from './local-credentials.js'; export interface LocalAuthUser { id: string; name: string; email?: string | null; } export type LocalAuthResult = | { status: 'success'; user: LocalAuthUser } | { status: 'invalid' | 'locked' | 'disabled' }; interface AttemptState { count: number; firstAttemptAt: number; lockedUntil?: number; } const attemptsByIdentifier = new Map(); const attemptsByIp = new Map(); let dummyHashPromise: Promise | null = null; const getEnvValue = (key: keyof typeof env): string | undefined => { return process.env[key] ?? env[key]; }; const getNumberEnv = (value: string | undefined, fallback: number): number => { const parsed = Number.parseInt(value ?? '', 10); return Number.isFinite(parsed) ? parsed : fallback; }; const getLocalAuthConfig = () => { return { enabled: getEnvValue('LOCAL_AUTH_ENABLED') === 'true', maxAttempts: getNumberEnv(getEnvValue('LOCAL_AUTH_MAX_ATTEMPTS'), 5), windowMs: getNumberEnv(getEnvValue('LOCAL_AUTH_WINDOW_SECONDS'), 900) * 1000, lockoutMs: getNumberEnv(getEnvValue('LOCAL_AUTH_LOCKOUT_SECONDS'), 900) * 1000 }; }; const normalizeIdentifier = (value: string): string => value.trim().toLowerCase(); const normalizePassword = (value: string): string => value.trim(); const normalizeIp = (value?: string | null): string => { if (!value) return 'unknown'; const trimmed = value.trim(); if (!trimmed) return 'unknown'; return trimmed.split(',')[0]?.trim() || 'unknown'; }; const getDummyHash = async (): Promise => { if (!dummyHashPromise) { dummyHashPromise = hashLocalPassword('invalid-password-placeholder'); } return dummyHashPromise; }; const getEffectiveState = (state: AttemptState | undefined, now: number, windowMs: number) => { if (!state) return { count: 0, firstAttemptAt: now }; if (state.lockedUntil && state.lockedUntil <= now) { return { count: 0, firstAttemptAt: now }; } if (now - state.firstAttemptAt > windowMs) { return { count: 0, firstAttemptAt: now }; } return { ...state }; }; const isLocked = (state: AttemptState, now: number): boolean => { return Boolean(state.lockedUntil && state.lockedUntil > now); }; const recordFailure = ( map: Map, key: string, now: number, windowMs: number, lockoutMs: number, maxAttempts: number ): void => { const state = getEffectiveState(map.get(key), now, windowMs); const nextCount = state.count + 1; const nextState: AttemptState = { count: nextCount, firstAttemptAt: state.firstAttemptAt }; if (nextCount >= maxAttempts) { nextState.lockedUntil = now + lockoutMs; } map.set(key, nextState); }; const clearAttemptState = (map: Map, key: string): void => { map.delete(key); }; export function resetLocalAuthRateLimits(): void { attemptsByIdentifier.clear(); attemptsByIp.clear(); } export async function verifyLocalCredentials( identifierInput: string, passwordInput: string, ipAddress?: string | null ): Promise { const config = getLocalAuthConfig(); if (!config.enabled) return { status: 'disabled' }; const identifier = normalizeIdentifier(identifierInput ?? ''); const password = normalizePassword(passwordInput ?? ''); const identifierKey = identifier || 'unknown'; const ipKey = normalizeIp(ipAddress); const now = Date.now(); const identifierState = getEffectiveState( attemptsByIdentifier.get(identifierKey), now, config.windowMs ); const ipState = getEffectiveState(attemptsByIp.get(ipKey), now, config.windowMs); const locked = isLocked(identifierState, now) || isLocked(ipState, now); const record = identifier ? getLocalCredentialByIdentifier(identifier) : null; const passwordHash = record?.passwordHash ?? (await getDummyHash()); const passwordMatches = await argon2.verify(passwordHash, password); if (!locked && record && passwordMatches) { clearAttemptState(attemptsByIdentifier, identifierKey); clearAttemptState(attemptsByIp, ipKey); return { status: 'success', user: { id: record.userId, name: record.fullName || record.username, email: record.email ?? undefined } }; } recordFailure( attemptsByIdentifier, identifierKey, now, config.windowMs, config.lockoutMs, config.maxAttempts ); recordFailure(attemptsByIp, ipKey, now, config.windowMs, config.lockoutMs, config.maxAttempts); return { status: locked ? 'locked' : 'invalid' }; }