import { HttpStatus, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as argon2 from 'argon2'; import * as E from 'fp-ts/Either'; import { AUTH_ADMIN_REQUIRED, AUTH_INVALID_LOCAL_CREDENTIALS, AUTH_LOCAL_PROVIDER_DISABLED, AUTH_LOCAL_SETUP_NOT_ALLOWED, AUTH_USERNAME_ALREADY_EXISTS, } from 'src/errors'; import { PrismaService } from 'src/prisma/prisma.service'; import { RESTError } from 'src/types/RESTError'; import { AuthUser } from 'src/types/AuthUser'; import { InfraConfigEnum } from 'src/types/InfraConfig'; import { AuthProvider, authProviderCheck } from './helper'; import { AuthService } from './auth.service'; import { CreateLocalUserDto, LocalSetupAdminDto, LocalSignInDto, } from './dto/local-auth.dto'; type LocalUserWithCredential = Awaited< ReturnType > & { localCredential?: { passwordHash: string; } | null; }; @Injectable() export class LocalAuthService { constructor( private readonly prisma: PrismaService, private readonly authService: AuthService, private readonly configService: ConfigService, ) {} normalizeUsername(username: string) { return username.trim().toLowerCase(); } private invalidCredentials() { return E.left({ message: AUTH_INVALID_LOCAL_CREDENTIALS, statusCode: HttpStatus.UNAUTHORIZED, }); } private async ensureLocalProviderEnabled() { const allowedProviders = this.configService.get( 'INFRA.VITE_ALLOWED_AUTH_PROVIDERS', ); if (!authProviderCheck(AuthProvider.LOCAL, allowedProviders)) { return E.left({ message: AUTH_LOCAL_PROVIDER_DISABLED, statusCode: HttpStatus.NOT_FOUND, }); } return E.right(true); } private async findUserByUsername(username: string) { return this.prisma.user.findFirst({ where: { username: { equals: username, mode: 'insensitive', }, }, include: { localCredential: true, }, }) as Promise; } private async ensureUsernameAvailable(username: string) { const existingUser = await this.prisma.user.findFirst({ where: { username: { equals: username, mode: 'insensitive', }, }, }); if (existingUser) { return E.left({ message: AUTH_USERNAME_ALREADY_EXISTS, statusCode: HttpStatus.CONFLICT, }); } return E.right(true); } private async createLocalUserRecord( username: string, password: string, displayName: string, isAdmin: boolean, ) { const passwordHash = await argon2.hash(password); return this.prisma.user.create({ data: { username, displayName, isAdmin, localCredential: { create: { passwordHash, }, }, providerAccounts: { create: { provider: AuthProvider.LOCAL, providerAccountId: username, }, }, }, }); } async setupFirstAdmin(dto: LocalSetupAdminDto) { const onboardingCompleted = await this.prisma.infraConfig.findUnique({ where: { name: InfraConfigEnum.ONBOARDING_COMPLETED, }, }); if (onboardingCompleted?.value === 'true') { return E.left({ message: AUTH_LOCAL_SETUP_NOT_ALLOWED, statusCode: HttpStatus.FORBIDDEN, }); } const username = this.normalizeUsername(dto.username); const usernameAvailable = await this.ensureUsernameAvailable(username); if (E.isLeft(usernameAvailable)) return usernameAvailable; const user = await this.createLocalUserRecord( username, dto.password, username, true, ); return this.authService.generateAuthTokens(user.uid); } async createLocalUser(dto: CreateLocalUserDto, requester: AuthUser) { if (!requester?.isAdmin) { return E.left({ message: AUTH_ADMIN_REQUIRED, statusCode: HttpStatus.FORBIDDEN, }); } const providerEnabled = await this.ensureLocalProviderEnabled(); if (E.isLeft(providerEnabled)) return providerEnabled; const username = this.normalizeUsername(dto.username); const usernameAvailable = await this.ensureUsernameAvailable(username); if (E.isLeft(usernameAvailable)) return usernameAvailable; const user = await this.createLocalUserRecord( username, dto.password, dto.displayName || username, false, ); return E.right({ uid: user.uid, username: user.username, displayName: user.displayName, email: user.email, photoURL: user.photoURL, isAdmin: user.isAdmin, }); } async signIn(dto: LocalSignInDto) { const providerEnabled = await this.ensureLocalProviderEnabled(); if (E.isLeft(providerEnabled)) return providerEnabled; const username = this.normalizeUsername(dto.username); const user = await this.findUserByUsername(username); if (!user?.localCredential?.passwordHash) { return this.invalidCredentials(); } const passwordMatches = await argon2.verify( user.localCredential.passwordHash, dto.password, ); if (!passwordMatches) { return this.invalidCredentials(); } return this.authService.generateAuthTokens(user.uid); } }