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 { PrismaService } from '../../../prisma/prisma.service'; import { AuthUser, RefreshPayload } from './auth.types'; const PASSWORD_HASH_ROUNDS = 12; const REFRESH_TTL_MS = 604_800_000; const ACCESS_TTL = '15m'; const REFRESH_TTL = '7d'; const DUMMY_PASSWORD_HASH = '$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6'; @Injectable() export class AuthService { constructor( private readonly prisma: PrismaService, private readonly jwt: JwtService, ) {} async register( email: string, password: string, ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { const normalizedEmail = this.normalizeEmail(email); const existing = await this.prisma.user.findUnique({ where: { email: normalizedEmail }, }); if (existing) throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); let user: User; try { user = await this.prisma.user.create({ data: { id: randomUUID(), email: normalizedEmail, passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS), }, }); } catch (error) { if (isUniqueConstraintError(error)) throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); throw error; } return this.createSession(user); } async login( email: string, password: string, ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { const user = await this.prisma.user.findUnique({ where: { email: this.normalizeEmail(email) }, }); const valid = await bcrypt.compare( password, user?.passwordHash ?? DUMMY_PASSWORD_HASH, ); if (!user || !valid) throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' }); return this.createSession(user); } async refresh( token: string, ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { let payload: RefreshPayload; try { payload = await this.jwt.verifyAsync(token, { algorithms: ['HS256'], }); } catch { throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); } if (payload.type !== 'refresh') throw new UnauthorizedException({ code: '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: 'UNAUTHORIZED' }); } if (session.refreshTokenHash !== this.hash(token)) throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); return this.rotateSession(session, session.refreshTokenHash); } async logout(token?: string): Promise { if (!token) return; try { const payload = await this.jwt.verifyAsync(token, { algorithms: ['HS256'], }); 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() }, }); } } catch { // Logout is intentionally idempotent. } } 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: 'UNAUTHORIZED' }); return user; } private async createSession( user: User, ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { 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<{ user: AuthUser; accessToken: string; refreshToken: string }> { 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 !== 1) throw new UnauthorizedException({ code: '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 normalizeEmail(email: string): string { return email; } 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 && (error as { code?: unknown }).code === 'P2002' ); }