import { randomBytes, createHash } from "node:crypto"; import { Inject, Injectable } from "@nestjs/common"; import jwt from "jsonwebtoken"; import type { EdenEnv } from "@eden/config"; import { EDEN_ENV } from "../config/config.constants.js"; export interface AccessTokenPayload { sub: string; // user id app: "core" | "reseller" | "subscriber"; } /** * 8h — revisado a partir dos 15min originais da ADR-0003 (ver o adendo no * próprio documento): o frontend não implementa refresh silencioso ainda * (`AuthContext.tsx`, `session.ts` — "No refresh-token rotation in this * leva"), então um access token de 15min derrubava o usuário no meio do * trabalho. 8h cobre um expediente inteiro sem exigir login de novo; * revogação continua granular via a tabela `sessions` (logout/"encerrar * todas as sessões" não dependem da expiração do JWT). */ const ACCESS_TOKEN_TTL_SECONDS = 8 * 60 * 60; const REFRESH_TOKEN_TTL_DAYS = 30; const MFA_CHALLENGE_TTL_SECONDS = 2 * 60; const MFA_CHALLENGE_PURPOSE = "mfa_challenge" as const; export interface MfaChallengePayload { sub: string; app: "core" | "reseller" | "subscriber"; purpose: typeof MFA_CHALLENGE_PURPOSE; } @Injectable() export class TokenService { private readonly secret: string; constructor(@Inject(EDEN_ENV) env: EdenEnv) { this.secret = env.JWT_SIGNING_SECRET; } signAccessToken(payload: AccessTokenPayload): { token: string; expiresIn: number } { const token = jwt.sign(payload, this.secret, { expiresIn: ACCESS_TOKEN_TTL_SECONDS }); return { token, expiresIn: ACCESS_TOKEN_TTL_SECONDS }; } verifyAccessToken(token: string): AccessTokenPayload { const payload = jwt.verify(token, this.secret) as AccessTokenPayload & { purpose?: unknown }; // Both token kinds are signed with the same secret, so signature // verification alone doesn't distinguish them — a real access token // must never carry a `purpose` claim (only single-purpose tokens like // the MFA challenge do). Without this check, a password-verified MFA // challenge token would be indistinguishable from a full access token // and grant complete API access without ever checking the second // factor — this was caught live (a real bug, not hypothetical). if (payload.purpose !== undefined) { throw new Error("Token não é um access token válido"); } return payload; } /** Opaque, high-entropy refresh token — never a JWT (ADR-0003). Returns the * plaintext (shown once to the client) and its SHA-256 hash (the only * thing persisted). */ generateRefreshToken(): { plaintext: string; hash: string; expiresAt: Date } { const plaintext = randomBytes(32).toString("hex"); // 256 bits const hash = this.hashRefreshToken(plaintext); const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000); return { plaintext, hash, expiresAt }; } hashRefreshToken(plaintext: string): string { return createHash("sha256").update(plaintext).digest("hex"); } /** * Short-lived, single-purpose token issued after password verification * when the account has MFA enabled — proves "password already checked" * without granting API access. The `purpose` claim is what keeps this * out of JwtAuthGuard (see verifyAccessToken's explicit rejection of any * `purpose` claim) — without it this token would be a fully valid * access token, defeating MFA entirely. */ signMfaChallenge(payload: Omit): { token: string; expiresIn: number } { const token = jwt.sign({ ...payload, purpose: MFA_CHALLENGE_PURPOSE }, this.secret, { expiresIn: MFA_CHALLENGE_TTL_SECONDS, }); return { token, expiresIn: MFA_CHALLENGE_TTL_SECONDS }; } verifyMfaChallenge(token: string): MfaChallengePayload { const payload = jwt.verify(token, this.secret) as Partial; if (payload.purpose !== MFA_CHALLENGE_PURPOSE || !payload.sub || !payload.app) { throw new Error("Token de desafio MFA inválido"); } return payload as MfaChallengePayload; } }