/** * @factory/auth/tokens — JWT (HS256) access tokens + refresh-token rotation. * * Implemented with node:crypto only (no jsonwebtoken dep): the wire format is * standard compact JWT (base64url header.payload.signature, HMAC-SHA256), so * any consumer (NestJS guard, realtime WS handshake) can verify it. * * Refresh tokens are opaque random strings; only their SHA-256 hash is stored * (prisma RefreshToken.tokenHash), and rotation tracks a FAMILY so a reused * (already-rotated) token revokes the whole family — the classic refresh-token * theft defense (ROADMAP Phase 1 / docs/security). */ import { createHmac, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { ApiError } from "@factory/shared/errors"; import { TOKEN_LIFETIMES } from "@factory/shared/constants"; // --- time helpers (parse "15m" / "7d" / "30d" / raw ms) --------------------- /** Parse a human lifetime ("15m", "7d", "30d", "3600s") into milliseconds. */ export function parseLifetime(ttl: string): number { const m = /^(\d+)\s*(ms|s|m|h|d)$/.exec(ttl.trim()); if (!m) throw new ApiError("VALIDATION_ERROR", { message: `invalid ttl: ${ttl}` }); const n = Number(m[1]); const unit = m[2]!; const factor = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000; return n * factor; } const b64url = (buf: Buffer | string): string => Buffer.from(buf).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); const b64urlJson = (obj: unknown): string => b64url(JSON.stringify(obj)); // --- HS256 JWT -------------------------------------------------------------- export interface AccessTokenPayload { /** user id */ sub: string; workspaceId?: string; roles?: string[]; jti?: string; } export interface DecodedAccessToken extends AccessTokenPayload { iat: number; exp: number; jti: string; } const JWT_SECRET_ENV = "JWT_SECRET"; export function signAccessToken( payload: AccessTokenPayload, secret: string, ttl: string = TOKEN_LIFETIMES.ACCESS_TOKEN ): string { if (!secret) { throw new ApiError("INTERNAL", { message: `${JWT_SECRET_ENV} is not configured` }); } const now = Math.floor(Date.now() / 1000); const ttlMs = parseLifetime(ttl); const jti = payload.jti ?? randomUUID().replace(/-/g, ""); const body: Record = { sub: payload.sub, iat: now, exp: now + Math.floor(ttlMs / 1000), jti }; if (payload.workspaceId !== undefined) body.workspaceId = payload.workspaceId; if (payload.roles !== undefined) body.roles = payload.roles; const header = b64urlJson({ alg: "HS256", typ: "JWT" }); const claims = b64urlJson(body); const sig = createHmac("sha256", secret).update(`${header}.${claims}`).digest("base64") .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); return `${header}.${claims}.${sig}`; } export function verifyAccessToken(token: string, secret: string, nowMs: number = Date.now()): DecodedAccessToken { if (!secret) throw new ApiError("INTERNAL", { message: `${JWT_SECRET_ENV} is not configured` }); const parts = token.split("."); if (parts.length !== 3) throw unauthorized("malformed token"); const [headerB64, claimsB64, sigB64] = parts as [string, string, string]; let header: { alg?: string }; try { header = JSON.parse(Buffer.from(headerB64.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")); } catch { throw unauthorized("malformed token header"); } if (header.alg !== "HS256") { // "none" and any non-HS256 alg are rejected outright (SECURITY: no alg confusion). throw unauthorized("unsupported algorithm"); } const expected = createHmac("sha256", secret).update(`${headerB64}.${claimsB64}`).digest(); let actual: Buffer; try { actual = Buffer.from(sigB64.replace(/-/g, "+").replace(/_/g, "/"), "base64"); } catch { throw unauthorized("malformed signature"); } if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) { throw unauthorized("invalid signature"); } let claims: DecodedAccessToken; try { claims = JSON.parse(Buffer.from(claimsB64.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")); } catch { throw unauthorized("malformed claims"); } if (typeof claims.sub !== "string" || typeof claims.exp !== "number") { throw unauthorized("malformed claims"); } const nowSec = Math.floor(nowMs / 1000); if (claims.exp <= nowSec) throw unauthorized("token expired"); if (typeof claims.iat === "number" && claims.iat > nowSec + 30) { throw unauthorized("token not yet valid"); } return claims; } const unauthorized = (message: string) => new ApiError("UNAUTHORIZED", { message, details: { reason: message } }); export const generateJti = (): string => randomUUID().replace(/-/g, ""); /** Opaque refresh token: 64 bytes of entropy, hex. */ export const generateRefreshToken = (): string => randomBytes(64).toString("hex"); /** SHA-256 hex — the only form of a refresh token that may be stored. */ export const hashToken = (token: string): string => createHash("sha256").update(token).digest("hex"); // --- refresh rotation with family reuse detection ---------------------------- export interface RefreshTokenRecord { tokenHash: string; familyId: string; userId: string; jti: string; /** Set when this token was rotated into a newer one (reuse detection). */ replacedBy?: string; /** Set when the whole family was revoked. */ revoked: boolean; createdAt: number; expiresAt: number; } /** * Storage adapter. The API implements this over prisma RefreshToken; * everything here stays pure so it is fully unit-testable. */ export interface RefreshTokenStore { getByHash(hash: string): Promise; getByFamily(familyId: string): Promise; put(record: RefreshTokenRecord): Promise; revokeFamily(familyId: string): Promise; } export interface RefreshTokenIssueOptions { familyId?: string; userId: string; ttlMs?: number; nowMs?: number; } export interface RefreshTokenIssued { /** Opaque token — hand to the client; never store it. */ token: string; familyId: string; expiresAt: number; } export function issueRefreshToken( store: RefreshTokenStore, opts: RefreshTokenIssueOptions ): Promise { return (async () => { const now = opts.nowMs ?? Date.now(); const ttl = opts.ttlMs ?? TOKEN_LIFETIMES.REFRESH_TOKEN_MS; const token = generateRefreshToken(); const record: RefreshTokenRecord = { tokenHash: hashToken(token), familyId: opts.familyId ?? generateJti(), userId: opts.userId, jti: generateJti(), revoked: false, createdAt: now, expiresAt: now + ttl }; await store.put(record); return { token, familyId: record.familyId, expiresAt: record.expiresAt }; })(); } /** * Rotate a presented refresh token: mark the old row `replacedBy` and issue a * new token in the same family. If the presented token is ALREADY marked * replacedBy (it was rotated out, then presented again → theft signal), revoke * the entire family and throw UNAUTHORIZED. Expired tokens simply throw. */ export async function rotateRefreshToken( store: RefreshTokenStore, presentedToken: string, opts: { ttlMs?: number; nowMs?: number } = {} ): Promise { const now = opts.nowMs ?? Date.now(); const ttl = opts.ttlMs ?? TOKEN_LIFETIMES.REFRESH_TOKEN_MS; const record = await store.getByHash(hashToken(presentedToken)); if (!record) throw unauthorized("unknown refresh token"); // Reuse: the token was already revoked, or already rotated out (replacedBy // set). Both mean the presented token is stale → the whole family is // compromised. Revoke the family and fail closed. if (record.revoked || record.replacedBy) { await store.revokeFamily(record.familyId); throw unauthorized("refresh token reuse detected"); } if (record.expiresAt <= now) throw unauthorized("refresh token expired"); const next = generateRefreshToken(); const nextRecord: RefreshTokenRecord = { tokenHash: hashToken(next), familyId: record.familyId, userId: record.userId, jti: generateJti(), revoked: false, createdAt: now, expiresAt: now + ttl }; // Mark the old row as replaced (reuse-detection marker) and store the new row. await store.put({ ...record, replacedBy: nextRecord.tokenHash }); await store.put(nextRecord); return { token: next, familyId: record.familyId, expiresAt: nextRecord.expiresAt }; }