import crypto from 'node:crypto';
import { randomUUID } from 'node:crypto';
import { promisify } from 'node:util';
import { InMemoryRateLimit } from './rateLimit.js';
import { normalizeIp, ipMatchesAny, isValidIpRule, isWithinSessionNetwork } from './ipMatch.js';
const scrypt = promisify(crypto.scrypt);
const deviceTtlMs = 30 * 24 * 60 * 60 * 1000;
export class AuthError extends Error {
    code;
    statusCode;
    constructor(code, message, statusCode = 400) {
        super(message);
        this.code = code;
        this.statusCode = statusCode;
    }
}
export class AuthService {
    config;
    store;
    now;
    passwordRateLimit;
    sessions = new Map();
    totpService;
    constructor(config, store, options = {}) {
        this.config = config;
        this.store = store;
        this.now = options.now ?? Date.now;
        this.passwordRateLimit =
            options.passwordRateLimit ??
                new InMemoryRateLimit({
                    limit: 5,
                    windowMs: 60_000,
                    now: this.now
                });
        this.totpService = options.totpService ?? null;
    }
    async setupPassword(password, ctx = {}) {
        this.validatePassword(password);
        const state = await this.store.read();
        if (state.passwordCredential) {
            throw new AuthError('PASSWORD_ALREADY_SET', 'Password is already set', 409);
        }
        const timestamp = new Date(this.now()).toISOString();
        const salt = crypto.randomBytes(16).toString('base64url');
        const credential = {
            algorithm: 'scrypt',
            hash: await this.hashPassword(password, salt),
            salt,
            createdAt: timestamp,
            updatedAt: timestamp
        };
        const { token: deviceToken, record: device } = this.buildTrustedDevice(ctx);
        const ip = normalizeIp(ctx.ip);
        await this.store.update((current) => {
            if (current.passwordCredential) {
                throw new AuthError('PASSWORD_ALREADY_SET', 'Password is already set', 409);
            }
            return {
                ...current,
                passwordCredential: credential,
                trustedDevices: [device],
                ipWhitelist: this.maybeAddIp(current.ipWhitelist, ip, ctx.rememberNetwork)
            };
        }, { flush: 'immediate' });
        const session = this.createSession(ip, device.id);
        return { session, deviceToken };
    }
    async loginWithPassword(password, otp, ctx = {}) {
        const state = await this.store.read();
        const credential = state.passwordCredential;
        if (!credential) {
            throw new AuthError('PASSWORD_NOT_SET', 'Password is not set', 409);
        }
        const rateLimitKey = `password:${ctx.clientKey ?? 'default'}`;
        const rate = this.passwordRateLimit.consume(rateLimitKey);
        if (!rate.allowed) {
            throw new AuthError('RATE_LIMITED', 'Too many password attempts', 429);
        }
        if (!(await this.verifyPassword(password, credential))) {
            throw new AuthError('INVALID_PASSWORD', 'Invalid password', 401);
        }
        const totpCredential = state.totpCredential;
        if (totpCredential) {
            if (!otp) {
                throw new AuthError('OTP_REQUIRED', 'OTP code is required', 401);
            }
            if (!this.totpService?.verify(totpCredential.secret, otp)) {
                throw new AuthError('INVALID_OTP', 'Invalid OTP code', 401);
            }
        }
        this.passwordRateLimit.reset(rateLimitKey);
        const { token: deviceToken, record: device } = this.buildTrustedDevice(ctx);
        const ip = normalizeIp(ctx.ip);
        await this.store.update((current) => {
            const devices = this.pruneDevices(current.trustedDevices).filter((d) => !this.deviceMatchesToken(ctx.deviceToken, d));
            return {
                ...current,
                trustedDevices: [...devices, device],
                ipWhitelist: this.maybeAddIp(current.ipWhitelist, ip, ctx.rememberNetwork)
            };
        }, { flush: 'immediate' });
        const session = this.createSession(ip, device.id);
        return { session, deviceToken };
    }
    async reauthWithOtp(otp, ctx = {}) {
        const state = await this.store.read();
        const device = this.findValidDevice(ctx.deviceToken, state.trustedDevices);
        if (!device) {
            throw new AuthError('DEVICE_NOT_TRUSTED', 'This device is not trusted', 401);
        }
        const ip = normalizeIp(ctx.ip);
        const totpCredential = state.totpCredential;
        const ipTrusted = ipMatchesAny(ip, state.ipWhitelist);
        // OTP-only reauth is only meaningful once TOTP is enabled. Without it, a leftover device
        // cookie (e.g. between password setup and TOTP activation, or after disabling 2FA) could mint
        // a session with no second factor — force a full password login instead.
        if (!totpCredential) {
            throw new AuthError('PASSWORD_REQUIRED', 'Full login required', 401);
        }
        if (!ipTrusted) {
            const rateLimitKey = `otp:${ctx.clientKey ?? 'default'}`;
            const rate = this.passwordRateLimit.consume(rateLimitKey);
            if (!rate.allowed) {
                throw new AuthError('RATE_LIMITED', 'Too many attempts', 429);
            }
            if (!otp) {
                throw new AuthError('OTP_REQUIRED', 'OTP code is required', 401);
            }
            if (!this.totpService?.verify(totpCredential.secret, otp)) {
                throw new AuthError('INVALID_OTP', 'Invalid OTP code', 401);
            }
            this.passwordRateLimit.reset(rateLimitKey);
        }
        const seenAt = new Date(this.now()).toISOString();
        await this.store.update((current) => ({
            ...current,
            trustedDevices: current.trustedDevices.map((d) => (d.id === device.id ? { ...d, lastSeenAt: seenAt } : d)),
            ipWhitelist: this.maybeAddIp(current.ipWhitelist, ip, ctx.rememberNetwork)
        }));
        return this.createSession(ip, device.id);
    }
    async setupTotp(sessionId, ip) {
        if (!(await this.validateSession(sessionId, ip))) {
            throw new AuthError('UNAUTHORIZED', 'Not authenticated', 401);
        }
        if (!this.totpService) {
            throw new AuthError('TOTP_NOT_AVAILABLE', 'TOTP service is not configured', 500);
        }
        const state = await this.store.read();
        if (state.totpCredential) {
            throw new AuthError('TOTP_ALREADY_SET', 'TOTP is already enabled', 409);
        }
        return this.totpService.generateSetup('nterminal');
    }
    async activateTotp(sessionId, secret, otp, ip) {
        if (!(await this.validateSession(sessionId, ip))) {
            throw new AuthError('UNAUTHORIZED', 'Not authenticated', 401);
        }
        if (!this.totpService) {
            throw new AuthError('TOTP_NOT_AVAILABLE', 'TOTP service is not configured', 500);
        }
        if (!this.totpService.verify(secret, otp)) {
            throw new AuthError('INVALID_OTP', 'Invalid OTP code', 401);
        }
        const credential = {
            secret,
            algorithm: 'SHA1',
            digits: 6,
            period: 30,
            createdAt: new Date(this.now()).toISOString()
        };
        await this.store.update((current) => {
            if (current.totpCredential) {
                throw new AuthError('TOTP_ALREADY_SET', 'TOTP is already enabled', 409);
            }
            return { ...current, totpCredential: credential };
        }, { flush: 'immediate' });
    }
    async disableTotp(sessionId, ip) {
        if (!(await this.validateSession(sessionId, ip))) {
            throw new AuthError('UNAUTHORIZED', 'Not authenticated', 401);
        }
        await this.store.update((current) => ({ ...current, totpCredential: null }), { flush: 'immediate' });
    }
    async isTotpEnabled() {
        return Boolean((await this.store.read()).totpCredential);
    }
    async validateSession(sessionId, ip, options = {}) {
        const destroy = options.destroy ?? true;
        if (!sessionId) {
            return false;
        }
        const session = this.sessions.get(sessionId);
        if (!session) {
            return false;
        }
        if (session.expiresAtMs <= this.now()) {
            this.sessions.delete(sessionId);
            return false;
        }
        // IP binding: enforced when an IP is supplied for an IP-bound session.
        if (ip !== undefined && session.ip && ip) {
            const normalized = normalizeIp(ip);
            // Accept the originally bound /24 (IPv4) or /48 (IPv6) — strict /32
            // binding broke residential/CGNAT users whose ISP rotates inside the
            // same /24 every few minutes (each rotation destroyed the session,
            // forcing a reauth dance on every WebSocket reconnect or split). The
            // /24 still excludes unrelated networks, and ANY whitelisted CIDR
            // (broader roaming) keeps working through ipMatchesAny below. An
            // unparseable IP fails closed.
            if (normalized && isWithinSessionNetwork(normalized, session.ip)) {
                return true;
            }
            const { ipWhitelist } = await this.store.read();
            if (normalized && ipMatchesAny(normalized, ipWhitelist)) {
                return true;
            }
            if (destroy) {
                this.sessions.delete(sessionId);
            }
            return false;
        }
        return true;
    }
    async diagnoseSession(sessionId, ip) {
        const base = () => ({
            valid: false,
            reason: 'missing_session_cookie',
            sessionCookiePresent: Boolean(sessionId),
            sessionKnown: false,
            sessionIpBound: false,
            ipProvided: ip !== undefined && ip !== null && ip !== '',
            ipParsed: false,
            ipMatchedSessionNetwork: false,
            ipWhitelisted: false,
            expiresAt: null
        });
        if (!sessionId) {
            return base();
        }
        const session = this.sessions.get(sessionId);
        if (!session) {
            return {
                ...base(),
                reason: 'unknown_session',
                sessionKnown: false
            };
        }
        const expiresAt = new Date(session.expiresAtMs).toISOString();
        if (session.expiresAtMs <= this.now()) {
            return {
                ...base(),
                reason: 'expired',
                sessionKnown: true,
                sessionIpBound: Boolean(session.ip),
                expiresAt
            };
        }
        if (ip !== undefined && session.ip && ip) {
            const normalized = normalizeIp(ip);
            const ipMatchedSessionNetwork = Boolean(normalized && isWithinSessionNetwork(normalized, session.ip));
            const { ipWhitelist } = await this.store.read();
            const ipWhitelisted = Boolean(normalized && ipMatchesAny(normalized, ipWhitelist));
            return {
                valid: ipMatchedSessionNetwork || ipWhitelisted,
                reason: ipMatchedSessionNetwork || ipWhitelisted ? 'valid' : 'ip_mismatch',
                sessionCookiePresent: true,
                sessionKnown: true,
                sessionIpBound: true,
                ipProvided: true,
                ipParsed: Boolean(normalized),
                ipMatchedSessionNetwork,
                ipWhitelisted,
                expiresAt
            };
        }
        return {
            valid: true,
            reason: 'valid',
            sessionCookiePresent: true,
            sessionKnown: true,
            sessionIpBound: Boolean(session.ip),
            ipProvided: ip !== undefined && ip !== null && ip !== '',
            ipParsed: Boolean(normalizeIp(ip)),
            ipMatchedSessionNetwork: false,
            ipWhitelisted: false,
            expiresAt
        };
    }
    async getSessionStatus(sessionId, ctx = {}) {
        const state = await this.store.read();
        // Status reads must not revoke: an IP-mismatched poll reports unauthenticated but leaves the
        // session for the protected routes (requireSession) to revoke on an actual mutating request.
        const authenticated = state.passwordCredential ? await this.validateSession(sessionId, ctx.ip, { destroy: false }) : false;
        const session = sessionId ? this.sessions.get(sessionId) : undefined;
        const ip = normalizeIp(ctx.ip);
        return {
            passwordSet: Boolean(state.passwordCredential),
            authenticated,
            totpEnabled: Boolean(state.totpCredential),
            deviceTrusted: this.findValidDevice(ctx.deviceToken, state.trustedDevices) !== null,
            currentIpWhitelisted: ipMatchesAny(ip, state.ipWhitelist),
            expiresAt: authenticated && session ? new Date(session.expiresAtMs).toISOString() : null
        };
    }
    async requireSession(request) {
        const state = await this.store.read();
        if (!state.passwordCredential) {
            const cookies = request.cookies ?? this.parseCookieHeader(request.headers?.cookie);
            const sessionId = cookies.nterminal_session;
            if (sessionId) {
                this.sessions.delete(sessionId);
            }
            return null;
        }
        const cookies = request.cookies ?? this.parseCookieHeader(request.headers?.cookie);
        const sessionId = cookies.nterminal_session;
        const ok = await this.validateSession(sessionId, request.ip ?? null);
        return ok && sessionId ? { sessionId } : null;
    }
    async logout(sessionId, deviceToken) {
        if (sessionId) {
            this.sessions.delete(sessionId);
        }
        if (deviceToken) {
            await this.store.update((current) => ({
                ...current,
                trustedDevices: current.trustedDevices.filter((d) => !this.deviceMatchesToken(deviceToken, d))
            }), { flush: 'immediate' });
        }
    }
    async listTrustedDevices(currentDeviceToken) {
        const { trustedDevices } = await this.store.read();
        const currentHash = currentDeviceToken ? this.hashDeviceToken(currentDeviceToken) : null;
        return this.pruneDevices(trustedDevices).map((d) => ({
            id: d.id,
            label: d.label,
            createdAt: d.createdAt,
            lastSeenAt: d.lastSeenAt,
            current: currentHash !== null && this.safeEqual(currentHash, d.tokenHash)
        }));
    }
    async revokeTrustedDevice(id) {
        await this.store.update((current) => ({
            ...current,
            trustedDevices: current.trustedDevices.filter((d) => d.id !== id)
        }), { flush: 'immediate' });
    }
    async listIpRules() {
        return (await this.store.read()).ipWhitelist.map((r) => ({ id: r.id, value: r.value, label: r.label, createdAt: r.createdAt }));
    }
    async addIpRule(value, label) {
        const trimmed = value.trim();
        if (!isValidIpRule(trimmed)) {
            throw new AuthError('INVALID_IP_RULE', 'Invalid IP or CIDR', 400);
        }
        const rule = {
            id: randomUUID(),
            value: trimmed,
            label: label.trim() || trimmed,
            createdAt: new Date(this.now()).toISOString()
        };
        await this.store.update((current) => ({ ...current, ipWhitelist: [...current.ipWhitelist, rule] }), { flush: 'immediate' });
        return { id: rule.id, value: rule.value, label: rule.label, createdAt: rule.createdAt };
    }
    async removeIpRule(id) {
        await this.store.update((current) => ({ ...current, ipWhitelist: current.ipWhitelist.filter((r) => r.id !== id) }), { flush: 'immediate' });
    }
    createSession(ip = null, deviceId = null) {
        // Single active session: any new session supersedes existing ones.
        this.sessions.clear();
        const sessionId = `sess_${crypto.randomBytes(32).toString('base64url')}`;
        const expiresAtMs = this.now() + this.config.sessionTtlMs;
        this.sessions.set(sessionId, { expiresAtMs, ip, deviceId });
        return { sessionId, expiresAt: new Date(expiresAtMs).toISOString() };
    }
    buildTrustedDevice(ctx) {
        const token = `dev_${crypto.randomBytes(32).toString('base64url')}`;
        const timestamp = new Date(this.now()).toISOString();
        const record = {
            id: randomUUID(),
            tokenHash: this.hashDeviceToken(token),
            label: deviceLabel(ctx.userAgent),
            createdAt: timestamp,
            lastSeenAt: timestamp,
            expiresAt: new Date(this.now() + deviceTtlMs).toISOString()
        };
        return { token, record };
    }
    pruneDevices(devices) {
        const nowMs = this.now();
        return devices.filter((d) => Date.parse(d.expiresAt) > nowMs);
    }
    findValidDevice(token, devices) {
        if (!token) {
            return null;
        }
        const hash = this.hashDeviceToken(token);
        const nowMs = this.now();
        for (const device of devices) {
            if (Date.parse(device.expiresAt) > nowMs && this.safeEqual(hash, device.tokenHash)) {
                return device;
            }
        }
        return null;
    }
    deviceMatchesToken(token, device) {
        if (!token) {
            return false;
        }
        return this.safeEqual(this.hashDeviceToken(token), device.tokenHash);
    }
    hashDeviceToken(token) {
        return crypto.createHmac('sha256', this.config.sessionSecret).update(token).digest('base64url');
    }
    maybeAddIp(rules, ip, remember) {
        if (!remember || !ip || ipMatchesAny(ip, rules)) {
            return rules;
        }
        return [
            ...rules,
            { id: randomUUID(), value: ip, label: ip, createdAt: new Date(this.now()).toISOString() }
        ];
    }
    validatePassword(password) {
        if (password.length < 8) {
            throw new AuthError('PASSWORD_TOO_SHORT', 'Password must be at least 8 characters', 400);
        }
    }
    async hashPassword(password, salt) {
        const hash = (await scrypt(`${password}:${this.config.sessionSecret}`, salt, 64));
        return hash.toString('base64url');
    }
    async verifyPassword(password, credential) {
        const candidate = await this.hashPassword(password, credential.salt);
        return this.safeEqual(candidate, credential.hash);
    }
    safeEqual(left, right) {
        const leftBuffer = Buffer.from(left);
        const rightBuffer = Buffer.from(right);
        return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
    }
    parseCookieHeader(header) {
        const raw = Array.isArray(header) ? header.join('; ') : header;
        if (!raw) {
            return {};
        }
        return Object.fromEntries(raw
            .split(';')
            .map((part) => part.trim())
            .filter(Boolean)
            .map((part) => {
            const separator = part.indexOf('=');
            if (separator === -1) {
                return [part, ''];
            }
            return [part.slice(0, separator), decodeURIComponent(part.slice(separator + 1))];
        }));
    }
}
function deviceLabel(userAgent) {
    if (!userAgent) {
        return 'Unknown device';
    }
    const os = /Windows/i.test(userAgent)
        ? 'Windows'
        : /Mac OS X|Macintosh/i.test(userAgent)
            ? 'macOS'
            : /iPhone|iPad|iOS/i.test(userAgent)
                ? 'iOS'
                : /Android/i.test(userAgent)
                    ? 'Android'
                    : /Linux/i.test(userAgent)
                        ? 'Linux'
                        : 'Unknown OS';
    const browser = /Edg/i.test(userAgent)
        ? 'Edge'
        : /OPR|Opera/i.test(userAgent)
            ? 'Opera'
            : /Chrome/i.test(userAgent)
                ? 'Chrome'
                : /Firefox/i.test(userAgent)
                    ? 'Firefox'
                    : /Safari/i.test(userAgent)
                        ? 'Safari'
                        : 'Browser';
    return `${browser} / ${os}`;
}
//# sourceMappingURL=authService.js.map