@dudousxd/nestjs-telescope
Version:
Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.
90 lines • 3.57 kB
JavaScript
// packages/core/src/auth/session-cookie.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
/** Clock-skew grace applied to `exp` so a marginally-late clock doesn't bounce a valid cookie. */
const EXP_GRACE_MS = 30_000;
function base64urlEncode(value) {
return Buffer.from(value, 'utf8').toString('base64url');
}
function base64urlDecode(value) {
return Buffer.from(value, 'base64url').toString('utf8');
}
function hmac(secret, payload) {
return createHmac('sha256', secret).update(payload).digest('base64url');
}
/**
* Sign a session into the cookie value `base64url(payload).base64url(hmac)`.
* Stateless HMAC-SHA256, no store. `node:crypto` only — no JWT dependency.
*/
export function signSessionCookie(user, options) {
const issuedAt = options.now ?? Date.now();
const session = {
sub: user.id,
...(user.name !== undefined ? { name: user.name } : {}),
roles: user.roles ?? [],
iat: issuedAt,
exp: issuedAt + options.ttlMs,
};
const encodedPayload = base64urlEncode(JSON.stringify(session));
const signature = hmac(options.secret, encodedPayload);
return `${encodedPayload}.${signature}`;
}
/** Type guard for the decoded payload shape — defends against tampered/legacy payloads. */
function isSessionPayload(value) {
if (typeof value !== 'object' || value === null)
return false;
const record = { ...value };
if (typeof record.sub !== 'string')
return false;
if (record.name !== undefined && typeof record.name !== 'string')
return false;
if (!Array.isArray(record.roles))
return false;
if (!record.roles.every((role) => typeof role === 'string'))
return false;
if (typeof record.iat !== 'number' || !Number.isFinite(record.iat))
return false;
if (typeof record.exp !== 'number' || !Number.isFinite(record.exp))
return false;
return true;
}
/**
* Verify a cookie value and return the session, or `null` for anything that's
* tampered, malformed, or expired (past `exp` + a 30s grace). Constant-time
* signature comparison. NEVER throws — any parse failure yields `null`.
*/
export function verifySessionCookie(value, options) {
try {
const dot = value.indexOf('.');
if (dot <= 0 || dot === value.length - 1)
return null;
const encodedPayload = value.slice(0, dot);
const providedSignature = value.slice(dot + 1);
const expectedSignature = hmac(options.secret, encodedPayload);
const provided = Buffer.from(providedSignature, 'base64url');
const expected = Buffer.from(expectedSignature, 'base64url');
// timingSafeEqual throws on a length mismatch — guard it so a wrong-length
// (tampered) signature returns null instead of throwing.
if (provided.length !== expected.length)
return null;
if (!timingSafeEqual(provided, expected))
return null;
const decoded = JSON.parse(base64urlDecode(encodedPayload));
if (!isSessionPayload(decoded))
return null;
const now = options.now ?? Date.now();
if (now > decoded.exp + EXP_GRACE_MS)
return null;
const session = {
sub: decoded.sub,
...(decoded.name !== undefined ? { name: decoded.name } : {}),
roles: decoded.roles,
iat: decoded.iat,
exp: decoded.exp,
};
return session;
}
catch {
return null;
}
}
//# sourceMappingURL=session-cookie.js.map