From 2d7fb285c344eb3dd87c84b7b9385607c1df597d Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 28 Feb 2026 11:59:35 -0600 Subject: [PATCH] feat(api): add break-glass local authentication module Implement LocalAuth module for emergency access without OIDC. Endpoints: POST /api/auth/local/setup (first-time user creation with BREAKGLASS_SETUP_TOKEN), POST /api/auth/local/login (email + password). Both return 404 when ENABLE_LOCAL_AUTH != true. Uses bcrypt (12 rounds) for password hashing and creates BetterAuth-compatible sessions. Co-Authored-By: Claude Opus 4.6 --- apps/api/package.json | 2 + apps/api/src/auth/auth.module.ts | 7 +- .../api/src/auth/local/dto/local-login.dto.ts | 10 + .../api/src/auth/local/dto/local-setup.dto.ts | 20 + .../auth/local/local-auth.controller.spec.ts | 232 +++++++++++ .../src/auth/local/local-auth.controller.ts | 81 ++++ apps/api/src/auth/local/local-auth.guard.ts | 15 + .../src/auth/local/local-auth.service.spec.ts | 389 ++++++++++++++++++ apps/api/src/auth/local/local-auth.service.ts | 230 +++++++++++ pnpm-lock.yaml | 21 +- 10 files changed, 1004 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/auth/local/dto/local-login.dto.ts create mode 100644 apps/api/src/auth/local/dto/local-setup.dto.ts create mode 100644 apps/api/src/auth/local/local-auth.controller.spec.ts create mode 100644 apps/api/src/auth/local/local-auth.controller.ts create mode 100644 apps/api/src/auth/local/local-auth.guard.ts create mode 100644 apps/api/src/auth/local/local-auth.service.spec.ts create mode 100644 apps/api/src/auth/local/local-auth.service.ts diff --git a/apps/api/package.json b/apps/api/package.json index 910f7a3..1e76617 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -52,6 +52,7 @@ "adm-zip": "^0.5.16", "archiver": "^7.0.1", "axios": "^1.13.5", + "bcryptjs": "^3.0.3", "better-auth": "^1.4.17", "bullmq": "^5.67.2", "class-transformer": "^0.5.1", @@ -85,6 +86,7 @@ "@swc/core": "^1.10.18", "@types/adm-zip": "^0.5.7", "@types/archiver": "^7.0.0", + "@types/bcryptjs": "^3.0.0", "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.1", "@types/highlight.js": "^10.1.0", diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 57bd2b7..36275e4 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -3,11 +3,14 @@ import { PrismaModule } from "../prisma/prisma.module"; import { AuthService } from "./auth.service"; import { AuthController } from "./auth.controller"; import { AuthGuard } from "./guards/auth.guard"; +import { LocalAuthController } from "./local/local-auth.controller"; +import { LocalAuthService } from "./local/local-auth.service"; +import { LocalAuthEnabledGuard } from "./local/local-auth.guard"; @Module({ imports: [PrismaModule], - controllers: [AuthController], - providers: [AuthService, AuthGuard], + controllers: [AuthController, LocalAuthController], + providers: [AuthService, AuthGuard, LocalAuthService, LocalAuthEnabledGuard], exports: [AuthService, AuthGuard], }) export class AuthModule {} diff --git a/apps/api/src/auth/local/dto/local-login.dto.ts b/apps/api/src/auth/local/dto/local-login.dto.ts new file mode 100644 index 0000000..51a9671 --- /dev/null +++ b/apps/api/src/auth/local/dto/local-login.dto.ts @@ -0,0 +1,10 @@ +import { IsEmail, IsString, MinLength } from "class-validator"; + +export class LocalLoginDto { + @IsEmail({}, { message: "email must be a valid email address" }) + email!: string; + + @IsString({ message: "password must be a string" }) + @MinLength(1, { message: "password must not be empty" }) + password!: string; +} diff --git a/apps/api/src/auth/local/dto/local-setup.dto.ts b/apps/api/src/auth/local/dto/local-setup.dto.ts new file mode 100644 index 0000000..b0471a5 --- /dev/null +++ b/apps/api/src/auth/local/dto/local-setup.dto.ts @@ -0,0 +1,20 @@ +import { IsEmail, IsString, MinLength, MaxLength } from "class-validator"; + +export class LocalSetupDto { + @IsEmail({}, { message: "email must be a valid email address" }) + email!: string; + + @IsString({ message: "name must be a string" }) + @MinLength(1, { message: "name must not be empty" }) + @MaxLength(255, { message: "name must not exceed 255 characters" }) + name!: string; + + @IsString({ message: "password must be a string" }) + @MinLength(12, { message: "password must be at least 12 characters" }) + @MaxLength(128, { message: "password must not exceed 128 characters" }) + password!: string; + + @IsString({ message: "setupToken must be a string" }) + @MinLength(1, { message: "setupToken must not be empty" }) + setupToken!: string; +} diff --git a/apps/api/src/auth/local/local-auth.controller.spec.ts b/apps/api/src/auth/local/local-auth.controller.spec.ts new file mode 100644 index 0000000..6080047 --- /dev/null +++ b/apps/api/src/auth/local/local-auth.controller.spec.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { + NotFoundException, + ForbiddenException, + UnauthorizedException, + ConflictException, +} from "@nestjs/common"; +import { LocalAuthController } from "./local-auth.controller"; +import { LocalAuthService } from "./local-auth.service"; +import { LocalAuthEnabledGuard } from "./local-auth.guard"; + +describe("LocalAuthController", () => { + let controller: LocalAuthController; + let localAuthService: LocalAuthService; + + const mockLocalAuthService = { + setup: vi.fn(), + login: vi.fn(), + }; + + const mockRequest = { + headers: { "user-agent": "TestAgent/1.0" }, + ip: "127.0.0.1", + socket: { remoteAddress: "127.0.0.1" }, + }; + + const originalEnv = { + ENABLE_LOCAL_AUTH: process.env.ENABLE_LOCAL_AUTH, + }; + + beforeEach(async () => { + process.env.ENABLE_LOCAL_AUTH = "true"; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [LocalAuthController], + providers: [ + { + provide: LocalAuthService, + useValue: mockLocalAuthService, + }, + ], + }) + .overrideGuard(LocalAuthEnabledGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(LocalAuthController); + localAuthService = module.get(LocalAuthService); + + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (originalEnv.ENABLE_LOCAL_AUTH !== undefined) { + process.env.ENABLE_LOCAL_AUTH = originalEnv.ENABLE_LOCAL_AUTH; + } else { + delete process.env.ENABLE_LOCAL_AUTH; + } + }); + + describe("setup", () => { + const setupDto = { + email: "admin@example.com", + name: "Break Glass Admin", + password: "securePassword123!", + setupToken: "valid-token-123", + }; + + const mockSetupResult = { + user: { + id: "user-uuid-123", + email: "admin@example.com", + name: "Break Glass Admin", + isLocalAuth: true, + createdAt: new Date("2026-02-28T00:00:00Z"), + }, + session: { + token: "session-token-abc", + expiresAt: new Date("2026-03-07T00:00:00Z"), + }, + }; + + it("should create a break-glass user and return user data with session", async () => { + mockLocalAuthService.setup.mockResolvedValue(mockSetupResult); + + const result = await controller.setup(setupDto, mockRequest as never); + + expect(result).toEqual({ + user: mockSetupResult.user, + session: mockSetupResult.session, + }); + expect(mockLocalAuthService.setup).toHaveBeenCalledWith( + "admin@example.com", + "Break Glass Admin", + "securePassword123!", + "valid-token-123", + "127.0.0.1", + "TestAgent/1.0" + ); + }); + + it("should extract client IP from x-forwarded-for header", async () => { + mockLocalAuthService.setup.mockResolvedValue(mockSetupResult); + const reqWithProxy = { + ...mockRequest, + headers: { + ...mockRequest.headers, + "x-forwarded-for": "203.0.113.50, 70.41.3.18", + }, + }; + + await controller.setup(setupDto, reqWithProxy as never); + + expect(mockLocalAuthService.setup).toHaveBeenCalledWith( + expect.any(String) as string, + expect.any(String) as string, + expect.any(String) as string, + expect.any(String) as string, + "203.0.113.50", + "TestAgent/1.0" + ); + }); + + it("should propagate ForbiddenException from service", async () => { + mockLocalAuthService.setup.mockRejectedValue(new ForbiddenException("Invalid setup token")); + + await expect(controller.setup(setupDto, mockRequest as never)).rejects.toThrow( + ForbiddenException + ); + }); + + it("should propagate ConflictException from service", async () => { + mockLocalAuthService.setup.mockRejectedValue( + new ConflictException("A user with this email already exists") + ); + + await expect(controller.setup(setupDto, mockRequest as never)).rejects.toThrow( + ConflictException + ); + }); + }); + + describe("login", () => { + const loginDto = { + email: "admin@example.com", + password: "securePassword123!", + }; + + const mockLoginResult = { + user: { + id: "user-uuid-123", + email: "admin@example.com", + name: "Break Glass Admin", + }, + session: { + token: "session-token-abc", + expiresAt: new Date("2026-03-07T00:00:00Z"), + }, + }; + + it("should authenticate and return user data with session", async () => { + mockLocalAuthService.login.mockResolvedValue(mockLoginResult); + + const result = await controller.login(loginDto, mockRequest as never); + + expect(result).toEqual({ + user: mockLoginResult.user, + session: mockLoginResult.session, + }); + expect(mockLocalAuthService.login).toHaveBeenCalledWith( + "admin@example.com", + "securePassword123!", + "127.0.0.1", + "TestAgent/1.0" + ); + }); + + it("should propagate UnauthorizedException from service", async () => { + mockLocalAuthService.login.mockRejectedValue( + new UnauthorizedException("Invalid email or password") + ); + + await expect(controller.login(loginDto, mockRequest as never)).rejects.toThrow( + UnauthorizedException + ); + }); + }); +}); + +describe("LocalAuthEnabledGuard", () => { + let guard: LocalAuthEnabledGuard; + + const originalEnv = process.env.ENABLE_LOCAL_AUTH; + + beforeEach(() => { + guard = new LocalAuthEnabledGuard(); + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.ENABLE_LOCAL_AUTH = originalEnv; + } else { + delete process.env.ENABLE_LOCAL_AUTH; + } + }); + + it("should allow access when ENABLE_LOCAL_AUTH is true", () => { + process.env.ENABLE_LOCAL_AUTH = "true"; + + expect(guard.canActivate()).toBe(true); + }); + + it("should throw NotFoundException when ENABLE_LOCAL_AUTH is not set", () => { + delete process.env.ENABLE_LOCAL_AUTH; + + expect(() => guard.canActivate()).toThrow(NotFoundException); + }); + + it("should throw NotFoundException when ENABLE_LOCAL_AUTH is false", () => { + process.env.ENABLE_LOCAL_AUTH = "false"; + + expect(() => guard.canActivate()).toThrow(NotFoundException); + }); + + it("should throw NotFoundException when ENABLE_LOCAL_AUTH is empty", () => { + process.env.ENABLE_LOCAL_AUTH = ""; + + expect(() => guard.canActivate()).toThrow(NotFoundException); + }); +}); diff --git a/apps/api/src/auth/local/local-auth.controller.ts b/apps/api/src/auth/local/local-auth.controller.ts new file mode 100644 index 0000000..7754ab6 --- /dev/null +++ b/apps/api/src/auth/local/local-auth.controller.ts @@ -0,0 +1,81 @@ +import { + Controller, + Post, + Body, + UseGuards, + Req, + Logger, + HttpCode, + HttpStatus, +} from "@nestjs/common"; +import { Throttle } from "@nestjs/throttler"; +import type { Request as ExpressRequest } from "express"; +import { SkipCsrf } from "../../common/decorators/skip-csrf.decorator"; +import { LocalAuthService } from "./local-auth.service"; +import { LocalAuthEnabledGuard } from "./local-auth.guard"; +import { LocalLoginDto } from "./dto/local-login.dto"; +import { LocalSetupDto } from "./dto/local-setup.dto"; + +@Controller("auth/local") +@UseGuards(LocalAuthEnabledGuard) +export class LocalAuthController { + private readonly logger = new Logger(LocalAuthController.name); + + constructor(private readonly localAuthService: LocalAuthService) {} + + /** + * First-time break-glass user creation. + * Requires BREAKGLASS_SETUP_TOKEN from environment. + */ + @Post("setup") + @SkipCsrf() + @Throttle({ strict: { limit: 5, ttl: 60000 } }) + async setup(@Body() dto: LocalSetupDto, @Req() req: ExpressRequest) { + const ipAddress = this.getClientIp(req); + const userAgent = req.headers["user-agent"]; + + this.logger.log(`Break-glass setup attempt from ${ipAddress}`); + + const result = await this.localAuthService.setup( + dto.email, + dto.name, + dto.password, + dto.setupToken, + ipAddress, + userAgent + ); + + return { + user: result.user, + session: result.session, + }; + } + + /** + * Break-glass login with email + password. + */ + @Post("login") + @SkipCsrf() + @HttpCode(HttpStatus.OK) + @Throttle({ strict: { limit: 10, ttl: 60000 } }) + async login(@Body() dto: LocalLoginDto, @Req() req: ExpressRequest) { + const ipAddress = this.getClientIp(req); + const userAgent = req.headers["user-agent"]; + + const result = await this.localAuthService.login(dto.email, dto.password, ipAddress, userAgent); + + return { + user: result.user, + session: result.session, + }; + } + + private getClientIp(req: ExpressRequest): string { + const forwardedFor = req.headers["x-forwarded-for"]; + if (forwardedFor) { + const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor; + return ips?.split(",")[0]?.trim() ?? "unknown"; + } + return req.ip ?? req.socket.remoteAddress ?? "unknown"; + } +} diff --git a/apps/api/src/auth/local/local-auth.guard.ts b/apps/api/src/auth/local/local-auth.guard.ts new file mode 100644 index 0000000..cbd275e --- /dev/null +++ b/apps/api/src/auth/local/local-auth.guard.ts @@ -0,0 +1,15 @@ +import { Injectable, CanActivate, NotFoundException } from "@nestjs/common"; + +/** + * Guard that checks if local authentication is enabled via ENABLE_LOCAL_AUTH env var. + * Returns 404 when disabled so endpoints are invisible to callers. + */ +@Injectable() +export class LocalAuthEnabledGuard implements CanActivate { + canActivate(): boolean { + if (process.env.ENABLE_LOCAL_AUTH !== "true") { + throw new NotFoundException(); + } + return true; + } +} diff --git a/apps/api/src/auth/local/local-auth.service.spec.ts b/apps/api/src/auth/local/local-auth.service.spec.ts new file mode 100644 index 0000000..0bdb125 --- /dev/null +++ b/apps/api/src/auth/local/local-auth.service.spec.ts @@ -0,0 +1,389 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { + ConflictException, + ForbiddenException, + InternalServerErrorException, + UnauthorizedException, +} from "@nestjs/common"; +import { hash } from "bcryptjs"; +import { LocalAuthService } from "./local-auth.service"; +import { PrismaService } from "../../prisma/prisma.service"; + +describe("LocalAuthService", () => { + let service: LocalAuthService; + + const mockTxSession = { + create: vi.fn(), + }; + + const mockTxWorkspace = { + findFirst: vi.fn(), + create: vi.fn(), + }; + + const mockTxWorkspaceMember = { + create: vi.fn(), + }; + + const mockTxUser = { + create: vi.fn(), + findUnique: vi.fn(), + }; + + const mockTx = { + user: mockTxUser, + workspace: mockTxWorkspace, + workspaceMember: mockTxWorkspaceMember, + session: mockTxSession, + }; + + const mockPrismaService = { + user: { + findUnique: vi.fn(), + }, + session: { + create: vi.fn(), + }, + $transaction: vi + .fn() + .mockImplementation((fn: (tx: typeof mockTx) => Promise) => fn(mockTx)), + }; + + const originalEnv = { + BREAKGLASS_SETUP_TOKEN: process.env.BREAKGLASS_SETUP_TOKEN, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LocalAuthService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(LocalAuthService); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (originalEnv.BREAKGLASS_SETUP_TOKEN !== undefined) { + process.env.BREAKGLASS_SETUP_TOKEN = originalEnv.BREAKGLASS_SETUP_TOKEN; + } else { + delete process.env.BREAKGLASS_SETUP_TOKEN; + } + }); + + describe("setup", () => { + const validSetupArgs = { + email: "admin@example.com", + name: "Break Glass Admin", + password: "securePassword123!", + setupToken: "valid-token-123", + }; + + const mockCreatedUser = { + id: "user-uuid-123", + email: "admin@example.com", + name: "Break Glass Admin", + isLocalAuth: true, + createdAt: new Date("2026-02-28T00:00:00Z"), + }; + + const mockWorkspace = { + id: "workspace-uuid-123", + }; + + beforeEach(() => { + process.env.BREAKGLASS_SETUP_TOKEN = "valid-token-123"; + mockPrismaService.user.findUnique.mockResolvedValue(null); + mockTxUser.create.mockResolvedValue(mockCreatedUser); + mockTxWorkspace.findFirst.mockResolvedValue(mockWorkspace); + mockTxWorkspaceMember.create.mockResolvedValue({}); + mockTxSession.create.mockResolvedValue({}); + }); + + it("should create a local auth user with hashed password", async () => { + const result = await service.setup( + validSetupArgs.email, + validSetupArgs.name, + validSetupArgs.password, + validSetupArgs.setupToken + ); + + expect(result.user).toEqual(mockCreatedUser); + expect(result.session.token).toBeDefined(); + expect(result.session.token.length).toBeGreaterThan(0); + expect(result.session.expiresAt).toBeInstanceOf(Date); + expect(result.session.expiresAt.getTime()).toBeGreaterThan(Date.now()); + + expect(mockTxUser.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + email: "admin@example.com", + name: "Break Glass Admin", + isLocalAuth: true, + emailVerified: true, + passwordHash: expect.any(String) as string, + }), + select: { + id: true, + email: true, + name: true, + isLocalAuth: true, + createdAt: true, + }, + }); + }); + + it("should assign OWNER role on default workspace", async () => { + await service.setup( + validSetupArgs.email, + validSetupArgs.name, + validSetupArgs.password, + validSetupArgs.setupToken + ); + + expect(mockTxWorkspaceMember.create).toHaveBeenCalledWith({ + data: { + workspaceId: "workspace-uuid-123", + userId: "user-uuid-123", + role: "OWNER", + }, + }); + }); + + it("should create a new workspace if none exists", async () => { + mockTxWorkspace.findFirst.mockResolvedValue(null); + mockTxWorkspace.create.mockResolvedValue({ id: "new-workspace-uuid" }); + + await service.setup( + validSetupArgs.email, + validSetupArgs.name, + validSetupArgs.password, + validSetupArgs.setupToken + ); + + expect(mockTxWorkspace.create).toHaveBeenCalledWith({ + data: { + name: "Default Workspace", + ownerId: "user-uuid-123", + settings: {}, + }, + select: { id: true }, + }); + expect(mockTxWorkspaceMember.create).toHaveBeenCalledWith({ + data: { + workspaceId: "new-workspace-uuid", + userId: "user-uuid-123", + role: "OWNER", + }, + }); + }); + + it("should create a BetterAuth-compatible session", async () => { + await service.setup( + validSetupArgs.email, + validSetupArgs.name, + validSetupArgs.password, + validSetupArgs.setupToken, + "192.168.1.1", + "TestAgent/1.0" + ); + + expect(mockTxSession.create).toHaveBeenCalledWith({ + data: { + userId: "user-uuid-123", + token: expect.any(String) as string, + expiresAt: expect.any(Date) as Date, + ipAddress: "192.168.1.1", + userAgent: "TestAgent/1.0", + }, + }); + }); + + it("should reject when BREAKGLASS_SETUP_TOKEN is not set", async () => { + delete process.env.BREAKGLASS_SETUP_TOKEN; + + await expect( + service.setup( + validSetupArgs.email, + validSetupArgs.name, + validSetupArgs.password, + validSetupArgs.setupToken + ) + ).rejects.toThrow(ForbiddenException); + }); + + it("should reject when BREAKGLASS_SETUP_TOKEN is empty", async () => { + process.env.BREAKGLASS_SETUP_TOKEN = ""; + + await expect( + service.setup( + validSetupArgs.email, + validSetupArgs.name, + validSetupArgs.password, + validSetupArgs.setupToken + ) + ).rejects.toThrow(ForbiddenException); + }); + + it("should reject when setup token does not match", async () => { + await expect( + service.setup( + validSetupArgs.email, + validSetupArgs.name, + validSetupArgs.password, + "wrong-token" + ) + ).rejects.toThrow(ForbiddenException); + }); + + it("should reject when email already exists", async () => { + mockPrismaService.user.findUnique.mockResolvedValue({ + id: "existing-user", + email: "admin@example.com", + }); + + await expect( + service.setup( + validSetupArgs.email, + validSetupArgs.name, + validSetupArgs.password, + validSetupArgs.setupToken + ) + ).rejects.toThrow(ConflictException); + }); + + it("should return session token and expiry", async () => { + const result = await service.setup( + validSetupArgs.email, + validSetupArgs.name, + validSetupArgs.password, + validSetupArgs.setupToken + ); + + expect(typeof result.session.token).toBe("string"); + expect(result.session.token.length).toBe(64); // 32 bytes hex + expect(result.session.expiresAt).toBeInstanceOf(Date); + }); + }); + + describe("login", () => { + const validPasswordHash = "$2a$12$LJ3m4ys3Lz/YgP7xYz5k5uU6b5F6X1234567890abcdefghijkl"; + + beforeEach(async () => { + // Create a real bcrypt hash for testing + const realHash = await hash("securePassword123!", 4); // Low rounds for test speed + mockPrismaService.user.findUnique.mockResolvedValue({ + id: "user-uuid-123", + email: "admin@example.com", + name: "Break Glass Admin", + isLocalAuth: true, + passwordHash: realHash, + deactivatedAt: null, + }); + mockPrismaService.session.create.mockResolvedValue({}); + }); + + it("should authenticate a valid local auth user", async () => { + const result = await service.login("admin@example.com", "securePassword123!"); + + expect(result.user).toEqual({ + id: "user-uuid-123", + email: "admin@example.com", + name: "Break Glass Admin", + }); + expect(result.session.token).toBeDefined(); + expect(result.session.expiresAt).toBeInstanceOf(Date); + }); + + it("should create a session with ip and user agent", async () => { + await service.login("admin@example.com", "securePassword123!", "10.0.0.1", "Mozilla/5.0"); + + expect(mockPrismaService.session.create).toHaveBeenCalledWith({ + data: { + userId: "user-uuid-123", + token: expect.any(String) as string, + expiresAt: expect.any(Date) as Date, + ipAddress: "10.0.0.1", + userAgent: "Mozilla/5.0", + }, + }); + }); + + it("should reject when user does not exist", async () => { + mockPrismaService.user.findUnique.mockResolvedValue(null); + + await expect(service.login("nonexistent@example.com", "password123456")).rejects.toThrow( + UnauthorizedException + ); + }); + + it("should reject when user is not a local auth user", async () => { + mockPrismaService.user.findUnique.mockResolvedValue({ + id: "user-uuid-123", + email: "admin@example.com", + name: "OIDC User", + isLocalAuth: false, + passwordHash: null, + deactivatedAt: null, + }); + + await expect(service.login("admin@example.com", "password123456")).rejects.toThrow( + UnauthorizedException + ); + }); + + it("should reject when user is deactivated", async () => { + const realHash = await hash("securePassword123!", 4); + mockPrismaService.user.findUnique.mockResolvedValue({ + id: "user-uuid-123", + email: "admin@example.com", + name: "Deactivated User", + isLocalAuth: true, + passwordHash: realHash, + deactivatedAt: new Date("2026-01-01"), + }); + + await expect(service.login("admin@example.com", "securePassword123!")).rejects.toThrow( + new UnauthorizedException("Account has been deactivated") + ); + }); + + it("should reject when password is incorrect", async () => { + await expect(service.login("admin@example.com", "wrongPassword123!")).rejects.toThrow( + UnauthorizedException + ); + }); + + it("should throw InternalServerError when local auth user has no password hash", async () => { + mockPrismaService.user.findUnique.mockResolvedValue({ + id: "user-uuid-123", + email: "admin@example.com", + name: "Broken User", + isLocalAuth: true, + passwordHash: null, + deactivatedAt: null, + }); + + await expect(service.login("admin@example.com", "securePassword123!")).rejects.toThrow( + InternalServerErrorException + ); + }); + + it("should not reveal whether email exists in error messages", async () => { + mockPrismaService.user.findUnique.mockResolvedValue(null); + + try { + await service.login("nonexistent@example.com", "password123456"); + } catch (error) { + expect(error).toBeInstanceOf(UnauthorizedException); + expect((error as UnauthorizedException).message).toBe("Invalid email or password"); + } + }); + }); +}); diff --git a/apps/api/src/auth/local/local-auth.service.ts b/apps/api/src/auth/local/local-auth.service.ts new file mode 100644 index 0000000..6271bae --- /dev/null +++ b/apps/api/src/auth/local/local-auth.service.ts @@ -0,0 +1,230 @@ +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 }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf55d88..fe14a5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,6 +149,9 @@ importers: axios: specifier: ^1.13.5 version: 1.13.5 + bcryptjs: + specifier: ^3.0.3 + version: 3.0.3 better-auth: specifier: ^1.4.17 version: 1.4.17(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -243,6 +246,9 @@ importers: '@types/archiver': specifier: ^7.0.0 version: 7.0.0 + '@types/bcryptjs': + specifier: ^3.0.0 + version: 3.0.0 '@types/cookie-parser': specifier: ^1.4.10 version: 1.4.10(@types/express@5.0.6) @@ -1597,7 +1603,6 @@ packages: '@mosaicstack/telemetry-client@0.1.1': resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz} - engines: {node: '>=18'} '@mrleebo/prisma-ast@0.13.1': resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==} @@ -3053,6 +3058,10 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/bcryptjs@3.0.0': + resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} + deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -3789,6 +3798,10 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} + hasBin: true + better-auth@1.4.17: resolution: {integrity: sha512-VmHGQyKsEahkEs37qguROKg/6ypYpNF13D7v/lkbO7w7Aivz0Bv2h+VyUkH4NzrGY0QBKXi1577mGhDCVwp0ew==} peerDependencies: @@ -10354,6 +10367,10 @@ snapshots: dependencies: '@babel/types': 7.28.6 + '@types/bcryptjs@3.0.0': + dependencies: + bcryptjs: 3.0.3 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -11274,6 +11291,8 @@ snapshots: dependencies: tweetnacl: 0.14.5 + bcryptjs@3.0.3: {} + better-auth@1.4.17(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0)