/** * The one place a login becomes a session: JWT, cookie, response body, * audit line and the user_login internal event. Every login method ends here, so * they all behave the same way. */ import { isExternalAccount } from "./external-account.js"; import type { Request, Response } from "express"; import { AuthManager } from "../utils/auth-manager.js"; import { authLogger } from "../utils/logger.js"; import { loginRateLimiter } from "../utils/login-rate-limiter.js"; import { logAudit, getRequestMeta } from "../utils/audit-logger.js"; import { parseUserAgent } from "../utils/user-agent-parser.js"; import { emitInternalEvent } from "../hosts/internal-events.js"; import { createCurrentSettingsRepository, createCurrentUserAuthRepository, } from "../database/repositories/factory.js"; import type { UserRecord } from "../database/repositories/user-repository.js"; import type { ExternalSessionRef } from "./types.js"; const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; export function isNativeAppRequest(req: Request): boolean { return ( (req.get("User-Agent") || "").startsWith("Termix-Mobile/") || req.get("X-Electron-App") === "true" ); } /** Cookie lifetime: 30 days when remembered, else session_timeout_hours. */ async function sessionCookieMaxAge(rememberMe: boolean): Promise { if (rememberMe) return THIRTY_DAYS_MS; const value = await createCurrentSettingsRepository().get( "session_timeout_hours", ); const hours = value ? parseInt(value, 10) || 24 : 24; return hours * 60 * 60 * 1000; } export async function syncSharedCredentialsForUserRoles( userId: string, operation: string, ): Promise { try { const { SharedHostSecretsManager } = await import("../utils/shared-host-secrets-manager.js"); await SharedHostSecretsManager.getInstance().snapshotForUserRoles(userId); const { SharedCredentialSecretsManager } = await import("../utils/shared-credential-secrets-manager.js"); await SharedCredentialSecretsManager.getInstance().snapshotForUserRoles( userId, ); } catch (error) { authLogger.warn("Failed to sync role shared host secrets", { operation, userId, error, }); } } export interface IssueSessionOptions { methodId: string; rememberMe: boolean; /** Username the rate limiter counted attempts under, to clear them. */ rateLimitUsername?: string; externalSession?: ExternalSessionRef | null; /** SSO logins keep desktop and mobile apps signed in for 30 days. */ longLivedForApps?: boolean; /** Login policy cap on the session length. */ maxAgeSeconds?: number | null; /** See SECOND_FACTOR_SETUP. */ restriction?: string | null; idleTimeoutSeconds?: number | null; } export interface IssuedSession { token: string; maxAge: number; body: Record; } /** * Mints the JWT and writes the audit line. The caller decides how the token * reaches the client: a JSON body with a cookie, or a redirect. */ export async function issueSession( req: Request, user: UserRecord, options: IssueSessionOptions, ): Promise { const authManager = AuthManager.getInstance(); const deviceInfo = parseUserAgent(req); const token = await authManager.generateJWTToken(user.id, { rememberMe: options.rememberMe, deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, externalSession: options.externalSession ?? null, maxAgeSeconds: options.maxAgeSeconds ?? null, restriction: options.restriction ?? null, idleTimeoutSeconds: options.idleTimeoutSeconds ?? null, }); if (options.rateLimitUsername) { const clientIp = req.ip || req.socket?.remoteAddress || "unknown"; loginRateLimiter.resetAttempts(clientIp, options.rateLimitUsername); } const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId: user.id, username: user.username, action: "login", resourceType: "session", details: JSON.stringify({ method: options.methodId }), ipAddress, userAgent, success: true, }); emitInternalEvent("user_login", user.id, undefined, { username: user.username, ipAddress, }); authLogger.success("User login successful", { operation: "user_login_complete", userId: user.id, username: user.username, method: options.methodId, }); let maxAge = options.longLivedForApps && (deviceInfo.type === "desktop" || deviceInfo.type === "mobile") ? THIRTY_DAYS_MS : await sessionCookieMaxAge(options.rememberMe); if (options.maxAgeSeconds) { maxAge = Math.min(maxAge, options.maxAgeSeconds * 1000); } return { token, maxAge, body: { success: true, is_admin: !!user.isAdmin, username: user.username, userId: user.id, is_external: isExternalAccount(user), // Any second factor; the name is what 2.8 clients read. totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor( user.id, ), ...(options.restriction ? { restriction: options.restriction } : {}), ...(isNativeAppRequest(req) ? { token } : {}), }, }; } /** Sets the cookie and sends the body. */ export function sendSession( req: Request, res: Response, session: IssuedSession, ): Response { const authManager = AuthManager.getInstance(); return res .cookie( "jwt", session.token, authManager.getSecureCookieOptions(req, session.maxAge), ) .json(session.body); }