import { Injectable, Logger, ForbiddenException, UnauthorizedException, ConflictException, InternalServerErrorException, } from "@nestjs/common"; import { WorkspaceMemberRole } from "@prisma/client"; import { hash, compare } from "bcryptjs"; import { randomBytes, timingSafeEqual } from "crypto"; import { PrismaService } from "../../prisma/prisma.service"; const BCRYPT_ROUNDS = 12; /** Session expiry: 7 days (matches BetterAuth config in auth.config.ts) */ const SESSION_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; interface SetupResult { user: { id: string; email: string; name: string; isLocalAuth: boolean; createdAt: Date; }; session: { token: string; expiresAt: Date; }; } interface LoginResult { user: { id: string; email: string; name: string; }; session: { token: string; expiresAt: Date; }; } @Injectable() export class LocalAuthService { private readonly logger = new Logger(LocalAuthService.name); constructor(private readonly prisma: PrismaService) {} /** * First-time break-glass user creation. * Validates the setup token, creates a local auth user with bcrypt-hashed password, * and assigns OWNER role on the default workspace. */ async setup( email: string, name: string, password: string, setupToken: string, ipAddress?: string, userAgent?: string ): Promise { this.validateSetupToken(setupToken); const existing = await this.prisma.user.findUnique({ where: { email } }); if (existing) { throw new ConflictException("A user with this email already exists"); } const passwordHash = await hash(password, BCRYPT_ROUNDS); const result = await this.prisma.$transaction(async (tx) => { const user = await tx.user.create({ data: { email, name, isLocalAuth: true, passwordHash, emailVerified: true, }, select: { id: true, email: true, name: true, isLocalAuth: true, createdAt: true, }, }); // Find or create a default workspace and assign OWNER role await this.assignDefaultWorkspace(tx, user.id); // Create a BetterAuth-compatible session const session = await this.createSession(tx, user.id, ipAddress, userAgent); return { user, session }; }); this.logger.log(`Break-glass user created: ${email}`); return result; } /** * Break-glass login: verify email + password against bcrypt hash. * Only works for users with isLocalAuth=true. */ async login( email: string, password: string, ipAddress?: string, userAgent?: string ): Promise { const user = await this.prisma.user.findUnique({ where: { email }, select: { id: true, email: true, name: true, isLocalAuth: true, passwordHash: true, deactivatedAt: true, }, }); if (!user?.isLocalAuth) { throw new UnauthorizedException("Invalid email or password"); } if (user.deactivatedAt) { throw new UnauthorizedException("Account has been deactivated"); } if (!user.passwordHash) { this.logger.error(`Local auth user ${email} has no password hash`); throw new InternalServerErrorException("Account configuration error"); } const passwordValid = await compare(password, user.passwordHash); if (!passwordValid) { throw new UnauthorizedException("Invalid email or password"); } const session = await this.createSession(this.prisma, user.id, ipAddress, userAgent); this.logger.log(`Break-glass login: ${email}`); return { user: { id: user.id, email: user.email, name: user.name }, session, }; } /** * Validate the setup token against the environment variable. */ private validateSetupToken(token: string): void { const expectedToken = process.env.BREAKGLASS_SETUP_TOKEN; if (!expectedToken || expectedToken.trim() === "") { throw new ForbiddenException( "Break-glass setup is not configured. Set BREAKGLASS_SETUP_TOKEN environment variable." ); } const tokenBuffer = Buffer.from(token); const expectedBuffer = Buffer.from(expectedToken); if ( tokenBuffer.length !== expectedBuffer.length || !timingSafeEqual(tokenBuffer, expectedBuffer) ) { this.logger.warn("Invalid break-glass setup token attempt"); throw new ForbiddenException("Invalid setup token"); } } /** * Find the first workspace or create a default one, then assign OWNER role. */ private async assignDefaultWorkspace( tx: Parameters[0]>[0], userId: string ): Promise { let workspace = await tx.workspace.findFirst({ orderBy: { createdAt: "asc" }, select: { id: true }, }); workspace ??= await tx.workspace.create({ data: { name: "Default Workspace", ownerId: userId, settings: {}, }, select: { id: true }, }); await tx.workspaceMember.create({ data: { workspaceId: workspace.id, userId, role: WorkspaceMemberRole.OWNER, }, }); } /** * Create a BetterAuth-compatible session record. */ private async createSession( tx: { session: { create: typeof PrismaService.prototype.session.create } }, userId: string, ipAddress?: string, userAgent?: string ): Promise<{ token: string; expiresAt: Date }> { const token = randomBytes(32).toString("hex"); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); await tx.session.create({ data: { userId, token, expiresAt, ipAddress: ipAddress ?? null, userAgent: userAgent ?? null, }, }); return { token, expiresAt }; } }