import { ConflictException, Injectable, UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { createHash, randomUUID } from 'crypto'; import type { Session, User } from '#generated/prisma/client'; import { HttpErrorCode } from '#src/common/error-code'; import { AuthRepository } from '../infrastructure/auth.repository'; import { AuthErrorCode } from './auth-error-code'; import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types'; const PASSWORD_HASH_ROUNDS = 12; export 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 repository: AuthRepository, private readonly jwt: JwtService, ) {} async register( email: string, password: string, ): Promise { const existing = await this.repository.findUserByEmail(email); if (existing) { throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); } const user = await this.repository.createUser({ id: randomUUID(), email, passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS), }); if (!user) { throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); } return this.createSession(user); } async login( email: string, password: string, ): Promise { const user = await this.repository.findUserByEmail(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.repository.findSessionWithUser(payload.sid); if ( !session || session.userId !== payload.sub || session.revokedAt || session.expiresAt <= new Date() ) { if (session && !session.revokedAt) { await this.repository.deleteSession(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.repository.findSession(payload.sid); if ( session && !session.revokedAt && session.refreshTokenHash === this.hash(token) ) { await this.repository.revokeSession(session.id); } } toUser(user: User): AuthUser { return { id: user.id, email: user.email }; } async userById(id: string): Promise { const user = await this.repository.findUserById(id); if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); return user; } private async createSession( user: User, ): Promise { const session = await this.repository.createSession(user.id, randomUUID()); 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 (!(await this.repository.rotateSession(session.id, data, expectedRefreshTokenHash))) { throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } 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'); } }