// src/services/auth.ts // Copyright (C) 2026 DTP Technologies, LLC // All Rights Reserved import jwt from "jsonwebtoken"; import { DtpService } from "../lib/service.js"; import env from "../config/env.js"; import UserService from "./user.js"; import SessionService from "./session.js"; import { IUser } from "../models/user.js"; import { ISession } from "../models/session.js"; export interface JwtPayload { userId: string; username: string; sessionId: string; } export interface AuthTokens { accessToken: string; expiresIn: string; } export interface AuthResult { success: boolean; user: IUser; session: ISession; tokens: AuthTokens; } export class AuthService extends DtpService { get name(): string { return "AuthService"; } get slug(): string { return "auth"; } private jwtSecret: string; private jwtExpiresIn: number = 24 * 60 * 60; // 24 hours in seconds private jwtRefreshThreshold: number = 20; // Refresh when < 20 hours remaining constructor() { super(); const secret = env.user.jwtSecret; if (!secret) { throw new Error( "JWT secret not configured. Set environmentSalt or JWT_SECRET in config.", ); } this.jwtSecret = secret; } async start(): Promise { this.log.info("service started", { jwtExpiresIn: this.jwtExpiresIn, refreshThreshold: this.jwtRefreshThreshold, }); } async stop(): Promise { this.log.info("service stopped"); } /** * Authenticate user with username and password */ async authenticate( username: string, password: string, ipAddress?: string, userAgent?: string, ): Promise { this.log.info("authenticate() called", { username, ipAddress, userAgent }); // Validate input if (!username || !password) { this.log.error("authenticate() failed - missing credentials", { hasUsername: !!username, hasPassword: !!password, }); throw new Error("Username and password are required"); } this.log.debug("authenticate() - looking up user", { username }); // Find user with credentials const user = await UserService.getUserByUsernameWithCredentials(username); if (!user) { this.log.error("authenticate() failed - user not found", { username }); throw new Error("Invalid username or password"); } this.log.debug("authenticate() - user found, verifying password", { userId: user._id, username: user.username, }); // Verify password const isValid = await UserService.verifyPassword(user, password); if (!isValid) { this.log.error("authenticate() failed - invalid password", { userId: user._id, username: user.username, }); throw new Error("Invalid username or password"); } this.log.debug("authenticate() - password valid, checking ban status", { userId: user._id, username: user.username, }); // Check if user is banned if (UserService.isUserBanned(user)) { this.log.error("authenticate() failed - user is banned", { userId: user._id, username: user.username, }); throw new Error("Account has been suspended"); } this.log.debug( "authenticate() - user not banned, revoking existing sessions", { userId: user._id, username: user.username, }, ); // Revoke any existing sessions (single session policy) await SessionService.revokeAllForUser(user._id.toString()); this.log.debug("authenticate() - existing sessions revoked", { userId: user._id, username: user.username, }); // Create new session this.log.debug("authenticate() - creating new session", { userId: user._id, username: user.username, ipAddress, userAgent, }); const session = await SessionService.create( user._id.toString(), ipAddress, userAgent, ); this.log.debug("authenticate() - session created", { sessionId: session._id, userId: user._id, username: user.username, }); // Generate JWT token this.log.debug("authenticate() - generating JWT token", { sessionId: session._id, userId: user._id, username: user.username, }); const tokens = this.generateJwtToken(user, session); this.log.debug("authenticate() - JWT token generated", { sessionId: session._id, userId: user._id, username: user.username, expiresIn: tokens.expiresIn, accessTokenLength: tokens.accessToken.length, }); this.log.info("User authenticated successfully", { userId: user._id, username: user.username, sessionId: session._id, tokenExpiresIn: tokens.expiresIn, }); return { success: true, user, session, tokens, }; } /** * Generate JWT token for user */ generateJwtToken(user: IUser, session: ISession): AuthTokens { this.log.debug("generateJwtToken() - creating JWT", { userId: user._id, username: user.username, sessionId: session._id, expiresIn: this.jwtExpiresIn, }); const payload: JwtPayload = { userId: user._id.toString(), username: user.username, sessionId: session._id.toString(), }; const accessToken = jwt.sign(payload, this.jwtSecret, { expiresIn: this.jwtExpiresIn, }); this.log.debug("generateJwtToken() - JWT created", { sessionId: session._id, tokenLength: accessToken.length, first20Chars: accessToken.substring(0, 20) + "...", }); return { accessToken, expiresIn: `${this.jwtExpiresIn}s`, }; } /** * Verify JWT token and return payload */ verifyToken(token: string): JwtPayload | null { this.log.debug("verifyToken() - verifying token", { tokenLength: token.length, first20Chars: token.substring(0, 20) + "...", }); try { const decoded = jwt.verify(token, this.jwtSecret) as JwtPayload; this.log.debug("verifyToken() - token verified successfully", { userId: decoded.userId, username: decoded.username, sessionId: decoded.sessionId, }); return decoded; } catch (error) { const err = error as Error; this.log.error("verifyToken() - token verification failed", { errorName: err.name, errorMessage: err.message, tokenLength: token.length, }); return null; } } /** * Check if token needs refresh (returns true if should refresh) */ shouldRefreshToken(token: string): boolean { this.log.debug("shouldRefreshToken() - checking token", { tokenLength: token.length, refreshThreshold: this.jwtRefreshThreshold, }); try { const decoded = jwt.decode(token) as jwt.JwtPayload & JwtPayload; if (!decoded || !decoded.exp) { this.log.debug("shouldRefreshToken() - no exp claim, not refreshing"); return false; } const now = Math.floor(Date.now() / 1000); const remainingSeconds = decoded.exp - now; const remainingHours = remainingSeconds / 3600; this.log.debug("shouldRefreshToken() - token age analysis", { exp: decoded.exp, now, remainingSeconds, remainingHours, shouldRefresh: remainingHours < this.jwtRefreshThreshold, }); return remainingHours < this.jwtRefreshThreshold; } catch (error) { const err = error as Error; this.log.error("shouldRefreshToken() - decode failed", { errorMessage: err.message, }); return false; } } /** * Refresh JWT token */ async refreshToken(currentToken: string): Promise { this.log.info("refreshToken() - called", { tokenLength: currentToken.length, first20Chars: currentToken.substring(0, 20) + "...", }); const payload = this.verifyToken(currentToken); if (!payload) { this.log.error("refreshToken() - verifyToken failed, returning null"); return null; } this.log.debug("refreshToken() - token verified, checking session", { sessionId: payload.sessionId, userId: payload.userId, }); // Validate session still exists const session = await SessionService.findByToken(payload.sessionId); if (!session) { this.log.warn("refreshToken() - session not found", { sessionId: payload.sessionId, userId: payload.userId, }); return null; } this.log.debug("refreshToken() - session found, fetching user", { sessionId: session._id, userId: payload.userId, }); // Get user const user = await UserService.getUserByIdWithCredentials( new (await import("mongoose")).Types.ObjectId(payload.userId), ); if (!user) { this.log.error("refreshToken() - user not found", { userId: payload.userId, sessionId: payload.sessionId, }); return null; } this.log.debug("refreshToken() - user found, extending session", { userId: user._id, sessionId: session._id, }); // Extend session await SessionService.extend(session); this.log.debug("refreshToken() - session extended", { sessionId: session._id, userId: user._id, }); // Generate new token this.log.info("refreshToken() - generating new token", { sessionId: session._id, userId: user._id, }); const tokens = this.generateJwtToken(user, session); this.log.info("refreshToken() - token refreshed successfully", { sessionId: session._id, userId: user._id, expiresIn: tokens.expiresIn, }); return tokens; } /** * Logout user (revoke session) */ async logout(token: string): Promise { this.log.info("logout() - called", { tokenLength: token.length, first20Chars: token.substring(0, 20) + "...", }); const payload = this.verifyToken(token); if (!payload) { this.log.warn("logout() - invalid token, returning false"); return false; } this.log.debug("logout() - token valid, revoking session", { sessionId: payload.sessionId, userId: payload.userId, }); const revoked = await SessionService.revoke(payload.sessionId); if (revoked) { this.log.info("logout() - session revoked successfully", { sessionId: payload.sessionId, userId: payload.userId, }); } else { this.log.warn("logout() - session revoke failed", { sessionId: payload.sessionId, userId: payload.userId, }); } return revoked; } /** * Get cookie options for JWT */ getCookieOptions() { const cookieDomain = env.web.cookieDomain; this.log.debug("getCookieOptions() - returning cookie config", { httpOnly: true, secure: true, sameSite: "lax", maxAge: 24 * 60 * 60 * 1000, domain: cookieDomain || "(none - default to request host)", }); return { httpOnly: true, secure: true, // Always secure - both prod and dev use HTTPS sameSite: "lax" as const, // Allow cross-site for dev proxy maxAge: 24 * 60 * 60 * 1000, // 24 hours ...(cookieDomain ? { domain: cookieDomain } : {}), // Only set domain if configured }; } /** * Generate Set-Cookie header to clear auth_token cookie * This clears ALL duplicate cookies by setting an expired date */ getClearCookieHeader(): string { const options = this.getCookieOptions(); const expires = "Thu, 01 Jan 1970 00:00:00 GMT"; this.log.debug("getClearCookieHeader() - generating clear cookie header", { domain: options.domain, expires, }); const parts = ["auth_token=", "Expires=" + expires, "Max-Age=0", "Path=/"]; if (options.domain) { parts.push("Domain=" + options.domain); } if (options.httpOnly) { parts.push("HttpOnly"); } if (options.secure) { parts.push("Secure"); } if (options.sameSite) { parts.push("SameSite=" + options.sameSite); } const header = parts.join("; "); this.log.debug("getClearCookieHeader() - generated header", { headerLength: header.length, header, }); return header; } } export default new AuthService();