import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcryptjs'; import * as crypto from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; import { UsersService } from '../users/users.service'; import { AuditLogService } from '../audit/audit-log.service'; import { PermissionResolverService } from './permissions/permission-resolver.service'; import { AuthenticatedUser } from './types/authenticated-user'; import { parseDurationToMs } from './util/duration'; interface AuthTokens { accessToken: string; refreshToken: string; } export interface CurrentUserProfile { id: string; email: string; name: string; role: string; roleKey: string; roleName: string; permissions: string[]; } const REFRESH_TOKEN_BYTES = 40; @Injectable() export class AuthService { constructor( private readonly usersService: UsersService, private readonly prisma: PrismaService, private readonly jwtService: JwtService, private readonly auditLog: AuditLogService, private readonly permissionResolver: PermissionResolverService, ) {} async getCurrentUserProfile(userId: string): Promise { const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, include: { role: { select: { key: true, name: true } } }, }); const permissions = await this.permissionResolver.getPermissions(userId); return { id: user.id, email: user.email, name: user.name, role: user.legacyRole, roleKey: user.role.key, roleName: user.role.name, permissions: Array.from(permissions), }; } private hashRefreshToken(token: string): string { return crypto.createHash('sha256').update(token).digest('hex'); } private signAccessToken(user: AuthenticatedUser): string { const expiresInSeconds = Math.floor( parseDurationToMs(process.env.JWT_ACCESS_EXPIRES_IN ?? '15m') / 1000, ); return this.jwtService.sign( { sub: user.id, email: user.email, role: user.role }, { secret: process.env.JWT_ACCESS_SECRET, expiresIn: expiresInSeconds, }, ); } private async issueRefreshToken(userId: string): Promise { const token = crypto.randomBytes(REFRESH_TOKEN_BYTES).toString('hex'); const expiresAt = new Date( Date.now() + parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'), ); await this.prisma.refreshToken.create({ data: { userId, tokenHash: this.hashRefreshToken(token), expiresAt }, }); return token; } async login(email: string, password: string): Promise { const user = await this.usersService.findByEmail(email); if (!user || !user.isActive) { await this.auditLog.record({ entityType: 'AuthSession', entityId: user?.id ?? email, action: 'LOGIN_FAILED', userId: user?.id, }); throw new UnauthorizedException('Invalid credentials'); } const passwordMatches = await bcrypt.compare(password, user.passwordHash); if (!passwordMatches) { await this.auditLog.record({ entityType: 'AuthSession', entityId: user.id, action: 'LOGIN_FAILED', userId: user.id, }); throw new UnauthorizedException('Invalid credentials'); } const authenticatedUser: AuthenticatedUser = { id: user.id, email: user.email, role: user.legacyRole, }; const accessToken = this.signAccessToken(authenticatedUser); const refreshToken = await this.issueRefreshToken(user.id); await this.auditLog.record({ entityType: 'AuthSession', entityId: user.id, action: 'LOGIN', userId: user.id, }); return { accessToken, refreshToken }; } async refresh(refreshToken: string): Promise { const tokenHash = this.hashRefreshToken(refreshToken); const stored = await this.prisma.refreshToken.findUnique({ where: { tokenHash }, }); if (!stored || stored.revokedAt || stored.expiresAt < new Date()) { throw new UnauthorizedException('Invalid refresh token'); } const user = await this.usersService.findById(stored.userId); if (!user.isActive) { throw new UnauthorizedException('Invalid refresh token'); } await this.prisma.refreshToken.update({ where: { id: stored.id }, data: { revokedAt: new Date() }, }); const authenticatedUser: AuthenticatedUser = { id: user.id, email: user.email, role: user.legacyRole, }; const accessToken = this.signAccessToken(authenticatedUser); const newRefreshToken = await this.issueRefreshToken(user.id); await this.auditLog.record({ entityType: 'AuthSession', entityId: user.id, action: 'TOKEN_REFRESHED', userId: user.id, }); return { accessToken, refreshToken: newRefreshToken }; } async logout(refreshToken: string): Promise { const tokenHash = this.hashRefreshToken(refreshToken); const stored = await this.prisma.refreshToken.findUnique({ where: { tokenHash }, }); if (!stored || stored.revokedAt) { return; } await this.prisma.refreshToken.update({ where: { id: stored.id }, data: { revokedAt: new Date() }, }); await this.auditLog.record({ entityType: 'AuthSession', entityId: stored.userId, action: 'LOGOUT', userId: stored.userId, }); } }