import { createHash, randomBytes } from 'node:crypto'; import { Injectable } from '@nestjs/common'; import { AuthTokenPurpose } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; /** * Single-use tokens for e-mail flows (ADR 0007). Only the SHA-256 hash is * stored; consuming marks the row instead of deleting it so replay * attempts remain visible in the data. */ @Injectable() export class AuthTokensService { constructor(private readonly prisma: PrismaService) {} async issue(userId: string, purpose: AuthTokenPurpose, ttlSeconds: number): Promise { const raw = randomBytes(32).toString('base64url'); // Previous unconsumed tokens for the same purpose die with the new // one — only the latest link in the inbox works. await this.prisma.authToken.updateMany({ where: { userId, purpose, consumedAt: null }, data: { consumedAt: new Date() }, }); await this.prisma.authToken.create({ data: { tokenHash: hashToken(raw), userId, purpose, expiresAt: new Date(Date.now() + ttlSeconds * 1000), }, }); return raw; } /** Returns the owning user id, or null for unknown/expired/reused tokens. */ async consume(raw: string, purpose: AuthTokenPurpose): Promise { // Atomic claim: only one request can flip consumedAt from null. const result = await this.prisma.authToken.updateMany({ where: { tokenHash: hashToken(raw), purpose, consumedAt: null, expiresAt: { gt: new Date() }, }, data: { consumedAt: new Date() }, }); if (result.count === 0) return null; const row = await this.prisma.authToken.findUnique({ where: { tokenHash: hashToken(raw) } }); return row?.userId ?? null; } } function hashToken(raw: string): string { return createHash('sha256').update(raw).digest('hex'); }