import { ConflictException, Injectable, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { createHash, randomBytes, randomUUID } from 'node:crypto'; import type { JwtAccessPayload } from '../../common/auth/auth-user'; import { DateTime } from '../../common/value-objects/date-time/date-time'; import type { User } from '../users/user'; import { UsersService } from '../users/users.service'; import { RefreshTokensRepository } from './refresh-tokens.repository'; import { RevokedAccessTokensRepository } from './revoked-access-tokens.repository'; export type TokenPair = { readonly accessToken: string; readonly refreshToken: string; }; /** Precomputed bcrypt hash used only to equalize login timing on unknown users. */ const DUMMY_PASSWORD_HASH = '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'; @Injectable() export class AuthService { constructor( private readonly usersService: UsersService, private readonly jwtService: JwtService, private readonly config: ConfigService, private readonly refreshTokensRepository: RefreshTokensRepository, private readonly revokedAccessTokensRepository: RevokedAccessTokensRepository, ) {} async register( username: string, password: string, ): Promise<{ id: string; username: string; status: string }> { const existing = await this.usersService.findByUsername(username); if (existing) { throw new ConflictException('Username already registered'); } const saltRounds = this.config.getOrThrow('BCRYPT_SALT_ROUNDS'); const passwordHash = await bcrypt.hash(password, saltRounds); const user = await this.usersService.create(username, passwordHash); return { id: user.id, username: user.username, status: user.status.value, }; } async login(username: string, password: string): Promise { const user = await this.usersService.findByUsername(username); const passwordHash = user?.passwordHash ?? DUMMY_PASSWORD_HASH; const match = await bcrypt.compare(password, passwordHash); if (!user || !match) { throw new UnauthorizedException('Invalid credentials'); } this.usersService.assertCanAuthenticate(user); const { tokens } = await this.issueTokenPair(user); return tokens; } async refresh(refreshToken: string): Promise { const tokenHash = this.hashRefreshToken(refreshToken); const claimed = await this.refreshTokensRepository.claimForRotation(tokenHash); if (!claimed) { const existing = await this.refreshTokensRepository.findByTokenHash(tokenHash); if (existing) { await this.revokeAllSessionsForUser(existing.userId); } throw new UnauthorizedException('Invalid refresh token'); } if (claimed.expiresAt.value <= Date.now()) { throw new UnauthorizedException('Invalid refresh token'); } const user = await this.usersService.findById(claimed.userId); if (!user) { throw new UnauthorizedException('Invalid refresh token'); } this.usersService.assertCanAuthenticate(user); await this.denylistAccessJti(claimed.accessJti); const issued = await this.issueTokenPair(user); await this.refreshTokensRepository.setReplacedBy( claimed.id, issued.sessionId, ); return issued.tokens; } async revoke(refreshToken: string): Promise { const tokenHash = this.hashRefreshToken(refreshToken); const session = await this.refreshTokensRepository.findByTokenHash(tokenHash); if (!session || session.revokedAt !== null) { return; } const claimed = await this.refreshTokensRepository.markRevoked(session.id); if (claimed) { await this.denylistAccessJti(session.accessJti); } } async isAccessJtiRevoked(jti: string): Promise { return this.revokedAccessTokensRepository.exists(jti); } private async revokeAllSessionsForUser(userId: string): Promise { const revoked = await this.refreshTokensRepository.revokeAllForUser(userId); await Promise.all( revoked.map((session) => this.denylistAccessJti(session.accessJti)), ); } private async issueTokenPair( user: User, ): Promise<{ tokens: TokenPair; sessionId: string }> { const jti = randomUUID(); const payload: JwtAccessPayload = { sub: user.id, username: user.username, jti, typ: 'access', }; const expiresIn = this.config.getOrThrow('JWT_ACCESS_EXPIRES_IN'); const accessToken = await this.jwtService.signAsync( { ...payload }, { expiresIn: expiresIn as `${number}${'s' | 'm' | 'h' | 'd'}` }, ); const refreshToken = randomBytes(32).toString('hex'); const refreshMs = this.config.getOrThrow( 'REFRESH_TOKEN_EXPIRES_IN_MS', ); const expiresAt = DateTime.fromUnixMs(Date.now() + refreshMs); const session = await this.refreshTokensRepository.create({ userId: user.id, tokenHash: this.hashRefreshToken(refreshToken), accessJti: jti, expiresAt, }); return { tokens: { accessToken, refreshToken }, sessionId: session.id, }; } private hashRefreshToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } private async denylistAccessJti(jti: string): Promise { const ttlMs = this.config.getOrThrow('JWT_ACCESS_EXPIRES_IN_MS'); const expiresAt = DateTime.fromUnixMs(Date.now() + ttlMs); await this.revokedAccessTokensRepository.add(jti, expiresAt); } }