gadget/docs/archive/services/auth.ts

458 lines
12 KiB
TypeScript

// 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<void> {
this.log.info("service started", {
jwtExpiresIn: this.jwtExpiresIn,
refreshThreshold: this.jwtRefreshThreshold,
});
}
async stop(): Promise<void> {
this.log.info("service stopped");
}
/**
* Authenticate user with username and password
*/
async authenticate(
username: string,
password: string,
ipAddress?: string,
userAgent?: string,
): Promise<AuthResult> {
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<AuthTokens | null> {
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<boolean> {
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();