Usuário pediu explicitamente pelo menos 8h. O TTL curto original era deliberado (ADR-0003, reduzir janela de token vazado), mas o frontend nunca implementou refresh silencioso — na prática o usuário era derrubado no meio do trabalho sem nenhuma renovação automática. 8h é um paliativo real, documentado como tal num adendo na própria ADR-0003 (com a mitigação que já existe — revogação de sessão continua granular — e o que resolveria isso de verdade sem abrir mão de segurança: refresh silencioso no frontend). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
97 lines
4.0 KiB
TypeScript
97 lines
4.0 KiB
TypeScript
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<MfaChallengePayload, "purpose">): { 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<MfaChallengePayload>;
|
|
if (payload.purpose !== MFA_CHALLENGE_PURPOSE || !payload.sub || !payload.app) {
|
|
throw new Error("Token de desafio MFA inválido");
|
|
}
|
|
return payload as MfaChallengePayload;
|
|
}
|
|
}
|