@tzam-org/sessions
v0.2.1
Published
Sessões agnósticas: token opaco (só o hash é guardado), expiração total e por inatividade, revogação; consentimento de cookies assinado (subcaminho /consent). Persistência por interface implementada em cada app.
Readme
@tzam-org/sessions
Sessões com token opaco: o navegador guarda o token, o banco guarda só o hash. Expiração total,
expiração por inatividade e revogação. No subcaminho /consent, o consentimento de cookies
(categorias, cookie assinado, versão do aviso) — veja abaixo.
Instalação
pnpm add @tzam-org/sessionsSem dependências de runtime (usa node:crypto).
Garantias
- Só o hash é guardado. O token tem 32 bytes aleatórios (base64url); o store recebe o
SHA-256 dele (
tokenHash). Um vazamento do banco não entrega sessões vivas. - Duas expirações:
ttlMs(absoluta, a partir do início) eidleMs(sem uso).validatedevolvenullpara sessão desconhecida, revogada, expirada ou ociosa, e para token fora do formato (que nem chega ao store). - Revogação vale na próxima requisição.
revokeOwnsó revoga se a sessão é do usuário informado. ended(token)responde se a sessão foi revogada (logout em outro lugar), sem contar como atividade.
Uso
O app implementa SessionStore sobre o próprio banco. Exemplo em memória:
import { createSessionManager, type SessionRecord, type SessionStore } from '@tzam-org/sessions';
function memoryStore(): SessionStore {
const rows: SessionRecord[] = [];
return {
async create(data) {
const row: SessionRecord = { ...data, id: String(rows.length + 1), revokedAt: null };
rows.push(row);
return row;
},
findByTokenHash: async (tokenHash) => rows.find((r) => r.tokenHash === tokenHash) ?? null,
async touch(id, at) {
const row = rows.find((r) => r.id === id);
if (row) row.lastSeenAt = at;
},
async revoke(id, at) {
const row = rows.find((r) => r.id === id);
if (row) row.revokedAt = at;
},
async revokeAllForUser(userId, at) {
for (const r of rows) if (r.userId === userId && !r.revokedAt) r.revokedAt = at;
},
listActiveForUser: async (userId, now) => rows.filter((r) => r.userId === userId && !r.revokedAt && r.expiresAt > now),
};
}
const sessions = createSessionManager({
store: memoryStore(),
ttlMs: 8 * 60 * 60 * 1000, // 8 h no máximo
idleMs: 30 * 60 * 1000, // 30 min sem uso
});
// Login: grave `token` num cookie HttpOnly; o banco fica só com o hash.
const { token, session } = await sessions.start('u1', { userAgent: 'Mozilla/5.0', ip: '203.0.113.7', authMethod: 'password' });
// A cada requisição: a sessão viva, ou null.
const current = await sessions.validate(token);
await sessions.revokeOwn('u1', session.id); // tela "minhas sessões"
await sessions.revokeAll('u1'); // sair de todos os lugaresSair com SSO (apps clientes de um provedor OIDC)
Este pacote cuida da sessão local. Quando o app entra por um provedor OIDC (o Tzam), o
"Sair" encerra também a sessão do provedor — servidor a servidor, sem levar o navegador lá — e
a pessoa volta ao login do próprio app. A dinâmica e os helpers (appLogout, endSession,
loginEntry), com o diagrama de sequência, estão no README do
@tzam-org/oidc. Daqui
o app só passa o revoke da sessão local como endLocal, e guarda junto da sessão o sid do
id_token (é ele que nomeia a sessão do provedor no logout e no back-channel).
API
| Nome | Tipo | O que faz |
|---|---|---|
| createSessionManager({ store, ttlMs, idleMs, clock? }) | função | Cria o gerenciador: start, validate, ended, revoke, revokeOwn, revokeAll, listActive. |
| SessionManager | tipo | Retorno de createSessionManager. |
| SessionManagerOptions | interface | Opções de createSessionManager. |
| SessionStore | interface | O que o app implementa: create, findByTokenHash, touch, revoke, revokeAllForUser, listActiveForUser. |
| SessionRecord | interface | id, userId, tokenHash, createdAt, lastSeenAt, expiresAt, revokedAt, userAgent?, ip?, authMethod?. |
| hashToken(token) | função | SHA-256 hex do token (o mesmo usado para guardar). |
Erros
createSessionManager lança Error('ttlMs and idleMs must be positive') quando um dos dois não é
positivo. As outras funções não lançam por token inválido: validate devolve null e ended,
false. Erros do store são repassados.
Consentimento de cookies (@tzam-org/sessions/consent)
Subcaminho sem framework e sem texto de app. O app declara os próprios cookies por categoria e uma versão do aviso; o módulo assina e lê a escolha, e diz o que pode ser gravado.
Garantias
essentialé sempre aceita e não pode ser desligada (LGPD: o estritamente necessário só é informado).buildConsentereadConsentsempre a incluem;allows(…, 'essential')é sempretrue.- Opcional só depois do aceite, e sob a versão atual do aviso: consentimento dado a outra
versão não libera nada opcional e
needsPromptvolta a pedir. - Cookie assinado (
v1.<payload>.<hmac>, HMAC-SHA256 com chave de 32+ bytes do app). A assinatura cobre o nome do cookie, então um valor assinado para outro cookie não serve. Cookie forjado, adulterado, cortado, vencido (maxAgeDays, padrão 365) ou datado no futuro →null(pede de novo).readConsentnunca lança por valor hostil. - O próprio cookie de consentimento é essencial e tem que estar listado nessa categoria:
defineConsentPolicyrecusa a política sem ele, cookie repetido entre categorias ou categoria repetida. - Cookie não declarado nunca é permitido:
canSetCookie(nome)é falso para o que não está na política, o que obriga o app a listar tudo o que grava. - Prova sem IP.
ConsentLogrecebeid(o mesmo que vai dentro do cookie assinado), data, versão, categorias e osubde quem está logado (ounull). IP não é guardado, nem em hash: não acrescenta à prova e seria dado pessoal guardado sem outra finalidade (LGPD art. 6º, III).
Uso
import { acceptedFrom, buildConsent, consentGate, defineConsentPolicy, deriveConsentKey, readConsent, recordConsent, type ConsentLog } from '@tzam-org/sessions/consent';
const policy = defineConsentPolicy({
version: '2026-10-04',
cookieName: 'app_consent',
categories: [
{
id: 'essential',
cookies: [
{ name: 'app_session', purpose: 'Mantém você conectado.', duration: '8 horas' },
{ name: 'app_consent', purpose: 'Guarda esta escolha.', duration: '12 meses' },
],
},
{ id: 'preferences', cookies: [{ name: 'app_theme', purpose: 'Tema escolhido.', duration: '1 ano' }] },
],
});
// Chave do app: derivada de um segredo de 32+ caracteres; o salt nomeia o app.
const key = deriveConsentKey('um-segredo-do-app-com-32-caracteres-ou-mais', 'app-exemplo');
// A cada requisição: o que a pessoa escolheu (ou null) e o que pode ser gravado.
const gate = consentGate(readConsent('valor-do-cookie', { policy, key }), policy);
if (gate.canSetCookie('app_theme')) {
// grava o cookie de tema
}
// No POST do formulário: escolha → cookie assinado + registro.
const accepted = acceptedFrom(policy, { choice: 'custom', picked: ['preferences'] });
if (accepted) {
const { consent, cookie } = buildConsent({ policy, key, accepted });
const log: ConsentLog = { record: async () => {} }; // o app grava no próprio banco
await recordConsent(log, consent, { sub: 'id-da-pessoa' });
// response.cookies.set(cookie)
}API
| Nome | Tipo | O que faz |
|---|---|---|
| defineConsentPolicy({ version, cookieName, categories, maxAgeDays? }) | função | Valida e devolve a ConsentPolicy (com optional). Lança ConsentPolicyError. |
| deriveConsentKey(secret, salt) | função | HKDF-SHA256 (info cookie-consent:v1), 32 bytes. Segredo com 32+ caracteres. |
| buildConsent({ policy, key, accepted, now?, id? }) | função | { consent, cookie }: cookie HttpOnly, Secure, SameSite=Lax, path=/, maxAge da política. |
| readConsent(value, { policy, key, now? }) | função | Consent ou null (ausente, forjado, adulterado, vencido). |
| needsPrompt(consent, version) | função | Sem consentimento ou de outra versão → true. |
| allows(consent, policy, category) / canSetCookie(consent, policy, name) | função | O que pode ser usado/gravado. |
| consentGate(consent, policy) | função | { consent, needsPrompt, allows(category), canSetCookie(name) }. |
| acceptedFrom(policy, { choice, picked }) | função | all / essential / custom → categorias opcionais aceitas; outra escolha → null. |
| recordConsent(log, consent, { sub? }) | função | Grava ConsentRecord pelo ConsentLog do app; erro do log é repassado. |
| ESSENTIAL | constante | 'essential'. |
| ConsentPolicy, ConsentPolicyInput, ConsentCategory, CookieInfo, Consent, ConsentCookie, ConsentRecord, ConsentLog, ConsentGate | tipos | |
Erros: defineConsentPolicy lança ConsentPolicyError (versão, nome do cookie, categoria ou
maxAgeDays fora do formato; essential ausente; cookie de consentimento fora de essential;
repetições). deriveConsentKey, buildConsent e readConsent lançam Error para segredo ou
chave curtos (erro de configuração, nunca de entrada do navegador).
Faz parte dos módulos @tzam-org/*
(github.com/Tzam-St/packages-modules): sem
framework e sem texto de produto.
