feat: add local auth service

This commit is contained in:
thibaud-leclere 2026-05-06 08:27:26 +02:00
parent c8b7a172a4
commit 0ec0ae442a
5 changed files with 559 additions and 1 deletions

View file

@ -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 {

View file

@ -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;
}

View file

@ -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<PrismaService>();
const mockAuthService = mockDeep<AuthService>();
const mockConfigService = mockDeep<ConfigService>();
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();
});
});

View file

@ -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<PrismaService['user']['findFirst']>
> & {
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(<RESTError>{
message: AUTH_INVALID_LOCAL_CREDENTIALS,
statusCode: HttpStatus.UNAUTHORIZED,
});
}
private async ensureLocalProviderEnabled() {
const allowedProviders = this.configService.get<string>(
'INFRA.VITE_ALLOWED_AUTH_PROVIDERS',
);
if (!authProviderCheck(AuthProvider.LOCAL, allowedProviders)) {
return E.left(<RESTError>{
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<LocalUserWithCredential | null>;
}
private async ensureUsernameAvailable(username: string) {
const existingUser = await this.prisma.user.findFirst({
where: {
username: {
equals: username,
mode: 'insensitive',
},
},
});
if (existingUser) {
return E.left(<RESTError>{
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(<RESTError>{
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(<RESTError>{
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);
}
}

View file

@ -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)