import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import * as crypto from 'crypto'; import { AdminOperationTokenEntity, AdminOperationKind } from '../../../database/entities/admin-operation-token.entity'; import { ApiError } from '../../../common/errors/api-error'; import { ERROR_CODES } from '../../../common/errors/error-codes'; export interface IssueTokenInput { userId: string; operation: AdminOperationKind; } export interface IssueTokenResult { token: string; expiresAt: string; id: string; } export interface ConsumeTokenInput { userId: string; operation: AdminOperationKind; token: string; stagingId?: string; } @Injectable() export class ConfirmationTokenService { private readonly logger = new Logger(ConfirmationTokenService.name); private readonly ttlMs: number; constructor( config: ConfigService, @InjectRepository(AdminOperationTokenEntity) private readonly repo: Repository, private readonly dataSource: DataSource, ) { this.ttlMs = config.get('SYSTEM_OP_CONFIRM_TOKEN_TTL_MS', 5 * 60_000); } /** * Issue a cryptographically random single-use token for the supplied * administrator and operation. Only the SHA-256 hash is persisted; raw * tokens are returned exactly once and never logged. */ async issue(input: IssueTokenInput): Promise { if (!input.userId) throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_INVALID, 'Missing user', 400); if ((input.operation as string) === undefined) { throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_INVALID, 'Missing operation', 400); } const raw = crypto.randomBytes(32).toString('base64url'); const hash = ConfirmationTokenService.hash(raw); const now = new Date(); const expiresAt = new Date(now.getTime() + this.ttlMs); const id = crypto.randomUUID(); await this.repo.insert({ id, userId: input.userId, operation: input.operation, tokenHash: hash, issuedAt: now.toISOString(), expiresAt: expiresAt.toISOString(), consumedAt: null, }); return { token: raw, expiresAt: expiresAt.toISOString(), id }; } /** * Atomically consume a token: only one caller may succeed. Tokens that * don't match (unknown, wrong user, wrong operation, wrong stage, * expired, or previously consumed) produce distinct stable codes. */ async consume(input: ConsumeTokenInput): Promise<{ id: string }> { if (!input || !input.token || !input.userId) { throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_INVALID, 'Confirmation token required', 400); } const hash = ConfirmationTokenService.hash(input.token); return this.dataSource.transaction(async (manager) => { const repo = manager.getRepository(AdminOperationTokenEntity); const row = await repo.findOne({ where: { tokenHash: hash } }); if (!row) { throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_INVALID, 'Unknown confirmation token', 400); } if (row.userId !== input.userId) { throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_MISMATCH, 'Token was not issued to this administrator', 400); } if (row.operation !== input.operation) { throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_MISMATCH, 'Token was not issued for this operation', 400); } if (row.consumedAt) { throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_REUSED, 'Confirmation token already used', 400); } const expiryMs = new Date(row.expiresAt).getTime(); if (!Number.isFinite(expiryMs) || expiryMs < Date.now()) { throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_EXPIRED, 'Confirmation token expired', 400); } const consumedAt = new Date().toISOString(); const result = await repo .createQueryBuilder() .update(AdminOperationTokenEntity) .set({ consumedAt }) .where('id = :id', { id: row.id }) .andWhere('consumedAt IS NULL') .execute(); if (!result.affected) { throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_REUSED, 'Confirmation token already used', 400); } return { id: row.id }; }); } /** * Best-effort expiry sweep. Safe to call frequently. */ async purgeExpired(): Promise { const result = await this.repo .createQueryBuilder() .delete() .from(AdminOperationTokenEntity) .where('expiresAt < :now', { now: new Date().toISOString() }) .orWhere('consumedAt IS NOT NULL AND issued_at < :old', { old: new Date(Date.now() - 24 * 3600_000).toISOString() }) .execute() .catch(() => ({ affected: 0 } as any)); return result.affected ?? 0; } /** * Constant-time SHA-256 hashing of a raw token string. Tokens are stored * only as their hash so a leaked database row alone cannot be used as a * bearer credential. */ static hash(raw: string): string { return crypto.createHash('sha256').update(raw).digest('hex'); } }