import { ConflictException, Injectable, UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { createHash, randomUUID } from 'crypto'; import { Session, User } from '../../../../generated/prisma/client'; import { HttpErrorCode } from '../../../common/error-code'; import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code'; import { PrismaService } from '../../../prisma/prisma.service'; import { AuthErrorCode } from './auth-error-code'; import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types'; const EXPECTED_ROTATED_SESSIONS = 1; const PASSWORD_HASH_ROUNDS = 12; const REFRESH_TTL_MS = 604_800_000; const ACCESS_TTL = '15m'; const REFRESH_TTL = '7d'; const DUMMY_PASSWORD_HASH = // eslint-disable-next-line sonarjs/no-hardcoded-passwords -- Public dummy bcrypt hash preserves login timing for nonexistent users; it is not a credential. '$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6'; @Injectable() export class AuthService { constructor( private readonly prisma: PrismaService, private readonly jwt: JwtService, ) {} async register( email: string, password: string, ): Promise { const existing = await this.prisma.user.findUnique({ where: { email }, }); if (existing) { throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); } let user: User; try { user = await this.prisma.user.create({ data: { id: randomUUID(), email, passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS), }, }); } catch (error) { if (isUniqueConstraintError(error)) { throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); } throw error; } return this.createSession(user); } async login( email: string, password: string, ): Promise { const user = await this.prisma.user.findUnique({ where: { email }, }); const valid = await bcrypt.compare( password, user?.passwordHash ?? DUMMY_PASSWORD_HASH, ); if (!user || !valid) { throw new UnauthorizedException({ code: AuthErrorCode.InvalidCredentials }); } return this.createSession(user); } async refresh( token: string, ): Promise { let payload: RefreshPayload; try { payload = await this.jwt.verifyAsync(token, { algorithms: ['HS256'], }); } catch { throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } if (payload.type !== 'refresh') { throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } const session = await this.prisma.session.findUnique({ where: { id: payload.sid }, include: { user: true }, }); if ( !session || session.userId !== payload.sub || session.revokedAt || session.expiresAt <= new Date() ) { if (session && !session.revokedAt) { await this.prisma.session.delete({ where: { id: session.id } }); } throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } if (session.refreshTokenHash !== this.hash(token)) { throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } return this.rotateSession(session, session.refreshTokenHash); } async logout(token?: string): Promise { if (!token) return; let payload: RefreshPayload; try { payload = await this.jwt.verifyAsync(token, { algorithms: ['HS256'], }); } catch { return; } if (payload.type !== 'refresh') return; const session = await this.prisma.session.findUnique({ where: { id: payload.sid }, }); if ( session && !session.revokedAt && session.refreshTokenHash === this.hash(token) ) { await this.prisma.session.update({ where: { id: session.id }, data: { revokedAt: new Date() }, }); } } toUser(user: User): AuthUser { return { id: user.id, email: user.email }; } async userById(id: string): Promise { const user = await this.prisma.user.findUnique({ where: { id } }); if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); return user; } private async createSession( user: User, ): Promise { const session = await this.prisma.session.create({ data: { id: randomUUID(), userId: user.id, refreshTokenHash: '', expiresAt: new Date(), }, }); return this.rotateSession({ ...session, user }); } private async rotateSession( session: Session & { user: User }, expectedRefreshTokenHash?: string, ): Promise { const refreshToken = await this.jwt.signAsync( { sub: session.userId, sid: session.id, type: 'refresh', jti: randomUUID(), }, { expiresIn: REFRESH_TTL, algorithm: 'HS256' }, ); const data = { refreshTokenHash: this.hash(refreshToken), expiresAt: new Date(Date.now() + REFRESH_TTL_MS), }; if (expectedRefreshTokenHash) { const updated = await this.prisma.session.updateMany({ where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash }, data, }); if (updated.count !== EXPECTED_ROTATED_SESSIONS) { throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } } else { await this.prisma.session.update({ where: { id: session.id }, data }); } return { user: this.toUser(session.user), accessToken: await this.jwt.signAsync( { sub: session.userId, sid: session.id, type: 'access' }, { expiresIn: ACCESS_TTL, algorithm: 'HS256' }, ), refreshToken, }; } private hash(token: string): string { return createHash('sha256').update(token).digest('hex'); } } function isUniqueConstraintError(error: unknown): boolean { return ( typeof error === 'object' && error !== null && 'code' in error && isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint) ); }