From 0ec0ae442a1de8ee3abe4286cdc66217e2ba5793 Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Wed, 6 May 2026 08:27:26 +0200 Subject: [PATCH] feat: add local auth service --- .../src/auth/auth.module.ts | 3 +- .../src/auth/dto/local-auth.dto.ts | 29 ++ .../src/auth/local-auth.service.spec.ts | 284 ++++++++++++++++++ .../src/auth/local-auth.service.ts | 210 +++++++++++++ packages/hoppscotch-backend/src/errors.ts | 34 +++ 5 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 packages/hoppscotch-backend/src/auth/dto/local-auth.dto.ts create mode 100644 packages/hoppscotch-backend/src/auth/local-auth.service.spec.ts create mode 100644 packages/hoppscotch-backend/src/auth/local-auth.service.ts diff --git a/packages/hoppscotch-backend/src/auth/auth.module.ts b/packages/hoppscotch-backend/src/auth/auth.module.ts index 0e6c48ec..eead47e4 100644 --- a/packages/hoppscotch-backend/src/auth/auth.module.ts +++ b/packages/hoppscotch-backend/src/auth/auth.module.ts @@ -16,6 +16,7 @@ import { isInfraConfigTablePopulated, } from 'src/infra-config/helper'; import { InfraConfigModule } from 'src/infra-config/infra-config.module'; +import { LocalAuthService } from './local-auth.service'; @Module({ imports: [ @@ -29,7 +30,7 @@ import { InfraConfigModule } from 'src/infra-config/infra-config.module'; }), InfraConfigModule, ], - providers: [AuthService], + providers: [AuthService, LocalAuthService], controllers: [AuthController], }) export class AuthModule { diff --git a/packages/hoppscotch-backend/src/auth/dto/local-auth.dto.ts b/packages/hoppscotch-backend/src/auth/dto/local-auth.dto.ts new file mode 100644 index 00000000..5b6bd609 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/dto/local-auth.dto.ts @@ -0,0 +1,29 @@ +import { + IsOptional, + IsString, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; + +export class LocalSignInDto { + @IsString() + @MinLength(3) + @MaxLength(40) + @Matches(/^[a-zA-Z0-9_.-]+$/) + username: string; + + @IsString() + @MinLength(12) + @MaxLength(256) + password: string; +} + +export class LocalSetupAdminDto extends LocalSignInDto {} + +export class CreateLocalUserDto extends LocalSignInDto { + @IsOptional() + @IsString() + @MaxLength(80) + displayName?: string; +} diff --git a/packages/hoppscotch-backend/src/auth/local-auth.service.spec.ts b/packages/hoppscotch-backend/src/auth/local-auth.service.spec.ts new file mode 100644 index 00000000..bba047d3 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/local-auth.service.spec.ts @@ -0,0 +1,284 @@ +import { HttpStatus } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import * as argon2 from 'argon2'; +import * as E from 'fp-ts/Either'; +import { + AUTH_ADMIN_REQUIRED, + AUTH_INVALID_LOCAL_CREDENTIALS, + AUTH_LOCAL_SETUP_NOT_ALLOWED, + AUTH_USERNAME_ALREADY_EXISTS, +} from 'src/errors'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { InfraConfigEnum } from 'src/types/InfraConfig'; +import { AuthUser } from 'src/types/AuthUser'; +import { AuthTokens } from 'src/types/AuthTokens'; +import { AuthService } from './auth.service'; +import { LocalAuthService } from './local-auth.service'; + +jest.mock('argon2', () => ({ + hash: jest.fn(), + verify: jest.fn(), +})); + +const mockPrisma = mockDeep(); +const mockAuthService = mockDeep(); +const mockConfigService = mockDeep(); + +const localAuthService = new LocalAuthService( + mockPrisma, + mockAuthService, + mockConfigService, +); + +const currentTime = new Date(); + +const user: AuthUser = { + uid: 'user-1', + username: 'dwight', + email: null, + displayName: 'Dwight Schrute', + photoURL: null, + isAdmin: false, + refreshToken: null, + lastLoggedOn: currentTime, + lastActiveOn: currentTime, + createdOn: currentTime, + currentGQLSession: null, + currentRESTSession: null, +}; + +const adminUser: AuthUser = { + ...user, + uid: 'admin-1', + username: 'admin', + isAdmin: true, +}; + +const tokens: AuthTokens = { + access_token: 'access-token', + refresh_token: 'refresh-token', +}; + +describe('LocalAuthService', () => { + beforeEach(() => { + mockReset(mockPrisma); + mockReset(mockAuthService); + mockReset(mockConfigService); + jest.mocked(argon2.hash).mockReset(); + jest.mocked(argon2.verify).mockReset(); + + mockConfigService.get.mockReturnValue('LOCAL'); + mockAuthService.generateAuthTokens.mockResolvedValue(E.right(tokens)); + jest.mocked(argon2.hash).mockResolvedValue('hashed-password'); + }); + + it('creates the first local admin before onboarding is completed', async () => { + mockPrisma.infraConfig.findUnique.mockResolvedValue({ + id: 'onboarding', + name: InfraConfigEnum.ONBOARDING_COMPLETED, + value: 'false', + lastSyncedEnvFileValue: null, + isEncrypted: false, + createdOn: currentTime, + updatedOn: currentTime, + }); + mockPrisma.user.findFirst.mockResolvedValue(null); + mockPrisma.user.create.mockResolvedValue(adminUser); + + const result = await localAuthService.setupFirstAdmin({ + username: ' Dwight ', + password: 'strong-password', + }); + + expect(mockPrisma.user.create).toHaveBeenCalledWith({ + data: { + username: 'dwight', + displayName: 'dwight', + isAdmin: true, + localCredential: { + create: { + passwordHash: 'hashed-password', + }, + }, + providerAccounts: { + create: { + provider: 'LOCAL', + providerAccountId: 'dwight', + }, + }, + }, + }); + expect(result).toEqualRight(tokens); + }); + + it('refuses setup admin after onboarding is completed', async () => { + mockPrisma.infraConfig.findUnique.mockResolvedValue({ + id: 'onboarding', + name: InfraConfigEnum.ONBOARDING_COMPLETED, + value: 'true', + lastSyncedEnvFileValue: null, + isEncrypted: false, + createdOn: currentTime, + updatedOn: currentTime, + }); + + const result = await localAuthService.setupFirstAdmin({ + username: 'dwight', + password: 'strong-password', + }); + + expect(result).toEqualLeft({ + message: AUTH_LOCAL_SETUP_NOT_ALLOWED, + statusCode: HttpStatus.FORBIDDEN, + }); + expect(mockPrisma.user.create).not.toHaveBeenCalled(); + }); + + it('signs in a local user with valid credentials', async () => { + mockPrisma.user.findFirst.mockResolvedValue({ + ...user, + localCredential: { + id: 'credential-1', + userUid: user.uid, + passwordHash: 'hashed-password', + createdOn: currentTime, + updatedOn: currentTime, + }, + } as any); + jest.mocked(argon2.verify).mockResolvedValue(true); + + const result = await localAuthService.signIn({ + username: 'Dwight', + password: 'strong-password', + }); + + expect(mockPrisma.user.findFirst).toHaveBeenCalledWith({ + where: { + username: { + equals: 'dwight', + mode: 'insensitive', + }, + }, + include: { + localCredential: true, + }, + }); + expect(result).toEqualRight(tokens); + }); + + it('returns generic invalid credentials for an unknown username', async () => { + mockPrisma.user.findFirst.mockResolvedValue(null); + + const result = await localAuthService.signIn({ + username: 'unknown', + password: 'strong-password', + }); + + expect(result).toEqualLeft({ + message: AUTH_INVALID_LOCAL_CREDENTIALS, + statusCode: HttpStatus.UNAUTHORIZED, + }); + }); + + it('returns generic invalid credentials for a bad password', async () => { + mockPrisma.user.findFirst.mockResolvedValue({ + ...user, + localCredential: { + id: 'credential-1', + userUid: user.uid, + passwordHash: 'hashed-password', + createdOn: currentTime, + updatedOn: currentTime, + }, + } as any); + jest.mocked(argon2.verify).mockResolvedValue(false); + + const result = await localAuthService.signIn({ + username: 'dwight', + password: 'wrong-password', + }); + + expect(result).toEqualLeft({ + message: AUTH_INVALID_LOCAL_CREDENTIALS, + statusCode: HttpStatus.UNAUTHORIZED, + }); + }); + + it('refuses duplicate username case-insensitively', async () => { + mockPrisma.user.findFirst.mockResolvedValue(user); + + const result = await localAuthService.createLocalUser( + { + username: 'Dwight', + password: 'strong-password', + displayName: 'Dwight Schrute', + }, + adminUser, + ); + + expect(result).toEqualLeft({ + message: AUTH_USERNAME_ALREADY_EXISTS, + statusCode: HttpStatus.CONFLICT, + }); + expect(mockPrisma.user.create).not.toHaveBeenCalled(); + }); + + it('creates a non-admin local user when requested by an admin', async () => { + mockPrisma.user.findFirst.mockResolvedValue(null); + mockPrisma.user.create.mockResolvedValue(user); + + const result = await localAuthService.createLocalUser( + { + username: 'Dwight', + password: 'strong-password', + displayName: 'Dwight Schrute', + }, + adminUser, + ); + + expect(mockPrisma.user.create).toHaveBeenCalledWith({ + data: { + username: 'dwight', + displayName: 'Dwight Schrute', + isAdmin: false, + localCredential: { + create: { + passwordHash: 'hashed-password', + }, + }, + providerAccounts: { + create: { + provider: 'LOCAL', + providerAccountId: 'dwight', + }, + }, + }, + }); + expect(result).toEqualRight({ + uid: user.uid, + username: user.username, + displayName: user.displayName, + email: user.email, + photoURL: user.photoURL, + isAdmin: user.isAdmin, + }); + }); + + it('refuses local user creation when requester is not admin', async () => { + const result = await localAuthService.createLocalUser( + { + username: 'jim', + password: 'strong-password', + displayName: 'Jim Halpert', + }, + user, + ); + + expect(result).toEqualLeft({ + message: AUTH_ADMIN_REQUIRED, + statusCode: HttpStatus.FORBIDDEN, + }); + expect(mockPrisma.user.create).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/hoppscotch-backend/src/auth/local-auth.service.ts b/packages/hoppscotch-backend/src/auth/local-auth.service.ts new file mode 100644 index 00000000..4e639606 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/local-auth.service.ts @@ -0,0 +1,210 @@ +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); + } +} diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index 56bcbfbe..43fefb4b 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -612,6 +612,40 @@ export const INVALID_ACCESS_TOKEN = 'auth/invalid_access_token' as const; */ export const INVALID_REFRESH_TOKEN = 'auth/invalid_refresh_token' as const; +/** + * Local username/password authentication failed + * (LocalAuthService) + */ +export const AUTH_INVALID_LOCAL_CREDENTIALS = + 'auth/invalid_local_credentials' as const; + +/** + * Local auth provider is not enabled + * (LocalAuthService) + */ +export const AUTH_LOCAL_PROVIDER_DISABLED = + 'auth/local_provider_disabled' as const; + +/** + * Initial local admin setup is no longer allowed + * (LocalAuthService) + */ +export const AUTH_LOCAL_SETUP_NOT_ALLOWED = + 'auth/local_setup_not_allowed' as const; + +/** + * Username is already used by another account + * (LocalAuthService) + */ +export const AUTH_USERNAME_ALREADY_EXISTS = + 'auth/username_already_exists' as const; + +/** + * Admin privileges are required for this auth action + * (LocalAuthService) + */ +export const AUTH_ADMIN_REQUIRED = 'auth/admin_required' as const; + /** * The provided title for the user collection is short (less than 3 characters) * (UserCollectionService)