feat: implement authentication and RBAC
- packages/auth: Argon2id password hashing, JWT access tokens (jose), opaque refresh tokens with rotation, generic error messages (no user-enumeration via timing or message differences) - roles/permissions/role_permissions/user_roles/sessions/audit_logs schema (agente.md secoes 142-150); RBAC scope PLATFORM vs TENANT - withUserContext(): narrow RLS exception so a user can discover their own tenant_memberships before a tenant is chosen (login flow) - userHasPermission()/isPlatformUser(): explicit service-layer RBAC checks (roles/permissions tables are not RLS-protected — documented why in docs/AUTHENTICATION.md) - seed: permission catalog, 4 system roles, initial Platform Super Admin (password written once to FIRST_LOGIN.txt, 600, outside Git) - automated end-to-end test: login, RBAC check, refresh rotation, logout
This commit is contained in:
21
packages/auth/package.json
Normal file
21
packages/auth/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@b2bcall/auth",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"seed": "tsx src/seed.ts",
|
||||
"test:auth": "tsx src/__tests__/auth.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@b2bcall/database": "workspace:*",
|
||||
"@node-rs/argon2": "^2.1.0",
|
||||
"jose": "^6.2.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
110
packages/auth/src/__tests__/auth.test.ts
Normal file
110
packages/auth/src/__tests__/auth.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Verificação end-to-end do fluxo de autenticação (agente.md secao 148):
|
||||
* login, refresh rotation, logout/revogação, e checagem de RBAC.
|
||||
*
|
||||
* Roda com: pnpm --filter @b2bcall/auth run test:auth
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getPrismaClient } from "@b2bcall/database";
|
||||
import { hashPassword } from "../password";
|
||||
import {
|
||||
InvalidCredentialsError,
|
||||
InvalidRefreshTokenError,
|
||||
login,
|
||||
logout,
|
||||
refreshSession,
|
||||
} from "../session";
|
||||
import { userHasPermission } from "../permissions";
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (!condition) {
|
||||
throw new Error(`FALHOU: ${message}`);
|
||||
}
|
||||
console.log(`OK: ${message}`);
|
||||
}
|
||||
|
||||
async function assertThrows(fn: () => Promise<unknown>, ErrorClass: new (...a: any[]) => Error, message: string) {
|
||||
try {
|
||||
await fn();
|
||||
throw new Error(`FALHOU: ${message} (nao lancou erro)`);
|
||||
} catch (err) {
|
||||
assert(err instanceof ErrorClass, message);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const prisma = getPrismaClient();
|
||||
const suffix = randomUUID().slice(0, 8);
|
||||
const email = `auth-test-${suffix}@test.local`;
|
||||
const password = "S3nhaForteDeTeste!123";
|
||||
|
||||
const tenant = await prisma.tenant.create({
|
||||
data: { code: `auth-test-${suffix}`, slug: `auth-test-${suffix}`, legalName: "Auth Test LTDA" },
|
||||
});
|
||||
const user = await prisma.user.create({
|
||||
data: { email, passwordHash: await hashPassword(password), name: "Auth Test User" },
|
||||
});
|
||||
const agentRole = await prisma.role.findUniqueOrThrow({ where: { key: "agent" } });
|
||||
await prisma.userRole.create({
|
||||
data: { userId: user.id, roleId: agentRole.id, tenantId: tenant.id },
|
||||
});
|
||||
|
||||
// Login com senha errada / e-mail inexistente: mesmo erro genérico.
|
||||
await assertThrows(
|
||||
() => login({ email, password: "senha-errada" }),
|
||||
InvalidCredentialsError,
|
||||
"Senha errada rejeitada com erro genérico",
|
||||
);
|
||||
await assertThrows(
|
||||
() => login({ email: `nao-existe-${suffix}@test.local`, password }),
|
||||
InvalidCredentialsError,
|
||||
"E-mail inexistente rejeitado com o MESMO erro genérico (sem user-enumeration)",
|
||||
);
|
||||
|
||||
// Login correto.
|
||||
const result = await login({ email, password, ipAddress: "127.0.0.1", userAgent: "test-agent" });
|
||||
assert(!result.mustChangePassword, "Usuário de teste não precisa trocar senha");
|
||||
assert(Boolean(result.accessToken) && Boolean(result.refreshToken), "Login retorna access e refresh token");
|
||||
|
||||
// RBAC: agent tem dashboard.view, não tem tenants.manage.
|
||||
assert(
|
||||
await userHasPermission(user.id, "dashboard.view", tenant.id),
|
||||
"Agent tem permissão dashboard.view no próprio tenant",
|
||||
);
|
||||
assert(
|
||||
!(await userHasPermission(user.id, "tenants.manage", tenant.id)),
|
||||
"Agent NÃO tem permissão tenants.manage",
|
||||
);
|
||||
|
||||
// Refresh rotation: token antigo morre assim que um novo é emitido.
|
||||
const refreshed = await refreshSession(result.refreshToken);
|
||||
assert(Boolean(refreshed.accessToken), "Refresh emite novo access token");
|
||||
assert(refreshed.refreshToken !== result.refreshToken, "Refresh token rotacionado é diferente do anterior");
|
||||
await assertThrows(
|
||||
() => refreshSession(result.refreshToken),
|
||||
InvalidRefreshTokenError,
|
||||
"Refresh token antigo (já rotacionado) não funciona mais",
|
||||
);
|
||||
|
||||
// Logout revoga a sessão — refresh subsequente falha.
|
||||
await logout(result.sessionId);
|
||||
await assertThrows(
|
||||
() => refreshSession(refreshed.refreshToken),
|
||||
InvalidRefreshTokenError,
|
||||
"Refresh token de sessão revogada (logout) não funciona",
|
||||
);
|
||||
|
||||
// Limpeza.
|
||||
await prisma.userRole.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.session.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
await prisma.tenant.delete({ where: { id: tenant.id } });
|
||||
|
||||
console.log("\nFluxo de autenticação OK: login, RBAC, refresh rotation e logout funcionam.");
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
29
packages/auth/src/audit.ts
Normal file
29
packages/auth/src/audit.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Prisma, PrismaClient } from "@b2bcall/database";
|
||||
|
||||
export interface AuditEvent {
|
||||
action: string;
|
||||
tenantId?: string | null;
|
||||
userId?: string | null;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
before?: Prisma.InputJsonValue;
|
||||
after?: Prisma.InputJsonValue;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export async function recordAuditEvent(prisma: PrismaClient, event: AuditEvent): Promise<void> {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
action: event.action,
|
||||
tenantId: event.tenantId ?? null,
|
||||
userId: event.userId ?? null,
|
||||
entityType: event.entityType,
|
||||
entityId: event.entityId,
|
||||
before: event.before,
|
||||
after: event.after,
|
||||
ipAddress: event.ipAddress,
|
||||
userAgent: event.userAgent,
|
||||
},
|
||||
});
|
||||
}
|
||||
5
packages/auth/src/index.ts
Normal file
5
packages/auth/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./password";
|
||||
export * from "./tokens";
|
||||
export * from "./audit";
|
||||
export * from "./session";
|
||||
export * from "./permissions";
|
||||
17
packages/auth/src/password.ts
Normal file
17
packages/auth/src/password.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Algorithm, hash, verify } from "@node-rs/argon2";
|
||||
|
||||
// OWASP-recommended minimums for Argon2id (agente.md secao 148: Argon2id).
|
||||
const ARGON2_OPTIONS = {
|
||||
algorithm: Algorithm.Argon2id,
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
parallelism: 1,
|
||||
};
|
||||
|
||||
export function hashPassword(plainPassword: string): Promise<string> {
|
||||
return hash(plainPassword, ARGON2_OPTIONS);
|
||||
}
|
||||
|
||||
export function verifyPassword(passwordHash: string, plainPassword: string): Promise<boolean> {
|
||||
return verify(passwordHash, plainPassword);
|
||||
}
|
||||
40
packages/auth/src/permissions.ts
Normal file
40
packages/auth/src/permissions.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { getPrismaClient } from "@b2bcall/database";
|
||||
|
||||
/**
|
||||
* Verifica se `userId` tem a permissão `permissionKey`, considerando roles
|
||||
* PLATFORM (tenantId nulo, valem em qualquer contexto) e roles TENANT
|
||||
* atribuídas especificamente em `tenantId` (agente.md secao 144: role scope
|
||||
* PLATFORM ou TENANT — nunca escopo global acidental).
|
||||
*
|
||||
* Não usa RLS (ver comentário na migration auth_and_rbac): a checagem é
|
||||
* feita explicitamente aqui, na camada de serviço confiável.
|
||||
*/
|
||||
export async function userHasPermission(
|
||||
userId: string,
|
||||
permissionKey: string,
|
||||
tenantId?: string,
|
||||
): Promise<boolean> {
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
const where = tenantId
|
||||
? { userId, OR: [{ tenantId }, { tenantId: null }] }
|
||||
: { userId, tenantId: null };
|
||||
|
||||
const userRoles = await prisma.userRole.findMany({
|
||||
where,
|
||||
include: { role: { include: { rolePermissions: { include: { permission: true } } } } },
|
||||
});
|
||||
|
||||
return userRoles.some((userRole) =>
|
||||
userRole.role.rolePermissions.some((rp) => rp.permission.key === permissionKey),
|
||||
);
|
||||
}
|
||||
|
||||
/** Atalho: usuário tem QUALQUER role com scope PLATFORM (ex.: platform_super_admin). */
|
||||
export async function isPlatformUser(userId: string): Promise<boolean> {
|
||||
const prisma = getPrismaClient();
|
||||
const count = await prisma.userRole.count({
|
||||
where: { userId, tenantId: null, role: { scope: "PLATFORM" } },
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
172
packages/auth/src/seed.ts
Normal file
172
packages/auth/src/seed.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Seed de RBAC (agente.md secoes 142, 145, 199, 200): catálogo de
|
||||
* permissions, roles de sistema, e o Platform Super Admin inicial.
|
||||
*
|
||||
* Idempotente — seguro rodar de novo (upsert por chave única). Roda com:
|
||||
* pnpm --filter @b2bcall/auth run seed
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { writeFileSync, chmodSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { getPrismaClient } from "@b2bcall/database";
|
||||
import { hashPassword } from "./password";
|
||||
|
||||
// Catálogo completo de permissions (agente.md secao 145).
|
||||
const PERMISSIONS: Array<{ key: string; description: string }> = [
|
||||
{ key: "tenants.view", description: "Ver tenants (plataforma)" },
|
||||
{ key: "tenants.manage", description: "Criar/editar/suspender tenants" },
|
||||
{ key: "billing.view", description: "Ver consumo e faturas" },
|
||||
{ key: "billing.manage", description: "Gerenciar fechamentos de billing" },
|
||||
{ key: "pricing.manage", description: "Gerenciar planos e price books" },
|
||||
{ key: "dashboard.view", description: "Ver dashboards" },
|
||||
{ key: "extensions.view", description: "Ver ramais" },
|
||||
{ key: "extensions.manage", description: "Criar/editar ramais" },
|
||||
{ key: "trunks.view", description: "Ver troncos" },
|
||||
{ key: "trunks.manage", description: "Criar/editar troncos" },
|
||||
{ key: "agents.view", description: "Ver agentes" },
|
||||
{ key: "agents.manage", description: "Criar/editar agentes" },
|
||||
{ key: "queues.view", description: "Ver filas" },
|
||||
{ key: "queues.manage", description: "Criar/editar filas" },
|
||||
{ key: "campaigns.view", description: "Ver campanhas" },
|
||||
{ key: "campaigns.create", description: "Criar campanhas" },
|
||||
{ key: "campaigns.update", description: "Editar campanhas" },
|
||||
{ key: "campaigns.start", description: "Iniciar campanhas" },
|
||||
{ key: "campaigns.pause", description: "Pausar campanhas" },
|
||||
{ key: "campaigns.stop", description: "Parar campanhas" },
|
||||
{ key: "monitoring.view", description: "Ver monitoramento em tempo real" },
|
||||
{ key: "reports.view", description: "Ver relatórios" },
|
||||
{ key: "reports.export", description: "Exportar relatórios" },
|
||||
{ key: "recordings.view", description: "Ver gravações" },
|
||||
{ key: "recordings.download", description: "Baixar gravações" },
|
||||
{ key: "ai.view", description: "Ver análises de IA" },
|
||||
{ key: "ai.manage", description: "Configurar providers/prompts de IA" },
|
||||
{ key: "ai.analyze", description: "Disparar análise de IA manualmente" },
|
||||
{ key: "freeswitch.view", description: "Ver estado do FreeSWITCH" },
|
||||
{ key: "freeswitch.configure", description: "Configurar FreeSWITCH" },
|
||||
{ key: "users.manage", description: "Gerenciar usuários" },
|
||||
{ key: "roles.manage", description: "Gerenciar roles/permissões" },
|
||||
{ key: "audit.view", description: "Ver audit log" },
|
||||
];
|
||||
|
||||
// Mapeamento inicial role -> permissions. Ponto de partida razoável;
|
||||
// revisar quando existir uma UI de administração de RBAC.
|
||||
const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
platform_super_admin: PERMISSIONS.map((p) => p.key), // tudo
|
||||
tenant_admin: PERMISSIONS.map((p) => p.key).filter(
|
||||
(key) => !["tenants.view", "tenants.manage", "pricing.manage"].includes(key),
|
||||
),
|
||||
supervisor: [
|
||||
"dashboard.view",
|
||||
"extensions.view",
|
||||
"trunks.view",
|
||||
"agents.view",
|
||||
"agents.manage",
|
||||
"queues.view",
|
||||
"campaigns.view",
|
||||
"campaigns.update",
|
||||
"campaigns.start",
|
||||
"campaigns.pause",
|
||||
"campaigns.stop",
|
||||
"monitoring.view",
|
||||
"reports.view",
|
||||
"reports.export",
|
||||
"recordings.view",
|
||||
"recordings.download",
|
||||
"ai.view",
|
||||
],
|
||||
agent: ["dashboard.view", "campaigns.view"],
|
||||
};
|
||||
|
||||
const SYSTEM_ROLES: Array<{ key: string; name: string; scope: "PLATFORM" | "TENANT" }> = [
|
||||
{ key: "platform_super_admin", name: "Platform Super Admin", scope: "PLATFORM" },
|
||||
{ key: "tenant_admin", name: "Tenant Admin", scope: "TENANT" },
|
||||
{ key: "supervisor", name: "Supervisor", scope: "TENANT" },
|
||||
{ key: "agent", name: "Agente", scope: "TENANT" },
|
||||
];
|
||||
|
||||
const PLATFORM_ADMIN_EMAIL = "admin@b2bcall.local";
|
||||
|
||||
async function main() {
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
for (const permission of PERMISSIONS) {
|
||||
await prisma.permission.upsert({
|
||||
where: { key: permission.key },
|
||||
update: { description: permission.description },
|
||||
create: permission,
|
||||
});
|
||||
}
|
||||
console.log(`Permissions: ${PERMISSIONS.length} sincronizadas.`);
|
||||
|
||||
for (const role of SYSTEM_ROLES) {
|
||||
const created = await prisma.role.upsert({
|
||||
where: { key: role.key },
|
||||
update: { name: role.name, scope: role.scope, isSystem: true },
|
||||
create: { ...role, isSystem: true },
|
||||
});
|
||||
|
||||
const permissionKeys = ROLE_PERMISSIONS[role.key] ?? [];
|
||||
const permissions = await prisma.permission.findMany({
|
||||
where: { key: { in: permissionKeys } },
|
||||
});
|
||||
|
||||
await prisma.rolePermission.deleteMany({ where: { roleId: created.id } });
|
||||
if (permissions.length > 0) {
|
||||
await prisma.rolePermission.createMany({
|
||||
data: permissions.map((p) => ({ roleId: created.id, permissionId: p.id })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
console.log(`Role '${role.key}': ${permissions.length} permissions.`);
|
||||
}
|
||||
|
||||
const existingPlatformAdmin = await prisma.userRole.findFirst({
|
||||
where: { tenantId: null, role: { key: "platform_super_admin" } },
|
||||
});
|
||||
|
||||
if (existingPlatformAdmin) {
|
||||
console.log("Platform Super Admin já existe, pulando criação.");
|
||||
} else {
|
||||
const platformSuperAdminRole = await prisma.role.findUniqueOrThrow({
|
||||
where: { key: "platform_super_admin" },
|
||||
});
|
||||
|
||||
const initialPassword = randomBytes(18).toString("base64url");
|
||||
const passwordHash = await hashPassword(initialPassword);
|
||||
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: PLATFORM_ADMIN_EMAIL },
|
||||
update: {},
|
||||
create: {
|
||||
email: PLATFORM_ADMIN_EMAIL,
|
||||
passwordHash,
|
||||
name: "Platform Super Admin",
|
||||
mustChangePassword: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.userRole.create({
|
||||
data: { userId: admin.id, roleId: platformSuperAdminRole.id, tenantId: null },
|
||||
});
|
||||
|
||||
const firstLoginPath = resolve(__dirname, "../../../FIRST_LOGIN.txt");
|
||||
writeFileSync(
|
||||
firstLoginPath,
|
||||
`B2BCall — Platform Super Admin (gerado em ${new Date().toISOString()})\n` +
|
||||
`Email: ${PLATFORM_ADMIN_EMAIL}\n` +
|
||||
`Senha: ${initialPassword}\n\n` +
|
||||
`Troca de senha OBRIGATÓRIA no primeiro login. Apague este arquivo depois de guardar a senha em um local seguro.\n`,
|
||||
);
|
||||
chmodSync(firstLoginPath, 0o600);
|
||||
|
||||
console.log(`Platform Super Admin criado: ${PLATFORM_ADMIN_EMAIL}`);
|
||||
console.log(`Senha salva em ${firstLoginPath} (permissao 600) — nao sera exibida no terminal.`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
187
packages/auth/src/session.ts
Normal file
187
packages/auth/src/session.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { getPrismaClient, withUserContext } from "@b2bcall/database";
|
||||
import { recordAuditEvent } from "./audit";
|
||||
import { verifyPassword } from "./password";
|
||||
import {
|
||||
REFRESH_TOKEN_TTL_MS,
|
||||
generateRefreshToken,
|
||||
hashRefreshToken,
|
||||
signAccessToken,
|
||||
} from "./tokens";
|
||||
|
||||
// Hash Argon2id de uma senha aleatória fixa, usado só para manter o tempo de
|
||||
// resposta do login constante quando o e-mail não existe — evita
|
||||
// user-enumeration via timing attack. Nunca corresponde a uma senha real.
|
||||
const DUMMY_PASSWORD_HASH =
|
||||
"$argon2id$v=19$m=19456,t=2,p=1$ue+qGHKnsPX6ducjF4h61Q$wRqupd7dHkad21UhvTxLuv8bDqNr63dZmuydRkjohr0";
|
||||
|
||||
export class InvalidCredentialsError extends Error {
|
||||
constructor() {
|
||||
super("E-mail ou senha invalidos");
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidRefreshTokenError extends Error {
|
||||
constructor() {
|
||||
super("Refresh token invalido, expirado ou revogado");
|
||||
}
|
||||
}
|
||||
|
||||
export class NotATenantMemberError extends Error {
|
||||
constructor() {
|
||||
super("Usuario nao pertence a este tenant");
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoginParams {
|
||||
email: string;
|
||||
password: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
sessionId: string;
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login por e-mail/senha (agente.md secao 148). Sempre roda verify() de
|
||||
* Argon2id mesmo quando o e-mail não existe (contra timing attack), e nunca
|
||||
* revela via mensagem se foi o e-mail ou a senha que estava errada.
|
||||
*/
|
||||
export async function login(params: LoginParams): Promise<AuthResult> {
|
||||
const prisma = getPrismaClient();
|
||||
const email = params.email.trim().toLowerCase();
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
|
||||
const passwordOk = await verifyPassword(user?.passwordHash ?? DUMMY_PASSWORD_HASH, params.password);
|
||||
const isUsable = Boolean(user) && !user!.deletedAt && user!.status === "ACTIVE";
|
||||
|
||||
if (!user || !passwordOk || !isUsable) {
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "LOGIN_FAILED",
|
||||
userId: user?.id,
|
||||
ipAddress: params.ipAddress,
|
||||
userAgent: params.userAgent,
|
||||
after: { email },
|
||||
});
|
||||
throw new InvalidCredentialsError();
|
||||
}
|
||||
|
||||
const refreshToken = generateRefreshToken();
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
refreshTokenHash: hashRefreshToken(refreshToken),
|
||||
userAgent: params.userAgent,
|
||||
ipAddress: params.ipAddress,
|
||||
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = await signAccessToken({ sub: user.id, sessionId: session.id });
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "LOGIN",
|
||||
userId: user.id,
|
||||
ipAddress: params.ipAddress,
|
||||
userAgent: params.userAgent,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
sessionId: session.id,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotaciona o refresh token (agente.md secao 148: "refresh rotation"). O
|
||||
* token antigo para de funcionar assim que um novo é emitido — a linha de
|
||||
* `sessions` é a mesma, só o hash e a expiração mudam.
|
||||
*/
|
||||
export async function refreshSession(
|
||||
refreshToken: string,
|
||||
): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
const prisma = getPrismaClient();
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { refreshTokenHash: hashRefreshToken(refreshToken) },
|
||||
});
|
||||
|
||||
if (!session || session.revokedAt || session.expiresAt.getTime() < Date.now()) {
|
||||
throw new InvalidRefreshTokenError();
|
||||
}
|
||||
|
||||
const newRefreshToken = generateRefreshToken();
|
||||
await prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data: {
|
||||
refreshTokenHash: hashRefreshToken(newRefreshToken),
|
||||
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = await signAccessToken({
|
||||
sub: session.userId,
|
||||
sessionId: session.id,
|
||||
tenantId: session.activeTenantId ?? undefined,
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken: newRefreshToken };
|
||||
}
|
||||
|
||||
/** Revoga a sessão (logout / revogação administrativa). Idempotente. */
|
||||
export async function logout(sessionId: string): Promise<void> {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.session.updateMany({
|
||||
where: { id: sessionId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista os tenants aos quais o usuário pertence — precisa do contexto
|
||||
* especial de RLS "olhar as próprias memberships" (withUserContext), já que
|
||||
* ainda não existe um tenant escolhido nesse ponto do fluxo (ver
|
||||
* docs/AUTHENTICATION.md e docs/TENANT_ISOLATION.md).
|
||||
*/
|
||||
export async function listUserTenants(userId: string) {
|
||||
const prisma = getPrismaClient();
|
||||
return withUserContext(prisma, userId, (tx) =>
|
||||
tx.tenantMembership.findMany({
|
||||
where: { userId },
|
||||
include: { tenant: true },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Troca o tenant ativo de uma sessão já autenticada, validando a membership
|
||||
* antes de emitir um novo access token com `tenantId` nas claims.
|
||||
*/
|
||||
export async function setActiveTenant(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
): Promise<string> {
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
const membership = await withUserContext(prisma, userId, (tx) =>
|
||||
tx.tenantMembership.findUnique({
|
||||
where: { tenantId_userId: { tenantId, userId } },
|
||||
}),
|
||||
);
|
||||
if (!membership) {
|
||||
throw new NotATenantMemberError();
|
||||
}
|
||||
|
||||
const session = await prisma.session.update({
|
||||
where: { id: sessionId },
|
||||
data: { activeTenantId: tenantId },
|
||||
});
|
||||
|
||||
return signAccessToken({ sub: userId, sessionId: session.id, tenantId: session.activeTenantId! });
|
||||
}
|
||||
48
packages/auth/src/tokens.ts
Normal file
48
packages/auth/src/tokens.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { SignJWT, jwtVerify, type JWTPayload } from "jose";
|
||||
|
||||
const ACCESS_TOKEN_TTL = "15m";
|
||||
export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 dias
|
||||
|
||||
export interface AccessTokenClaims extends JWTPayload {
|
||||
sub: string; // userId
|
||||
sessionId: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
function getAccessTokenSecret(): Uint8Array {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("JWT_SECRET nao configurado");
|
||||
}
|
||||
return new TextEncoder().encode(secret);
|
||||
}
|
||||
|
||||
export async function signAccessToken(
|
||||
claims: Omit<AccessTokenClaims, "iat" | "exp">,
|
||||
): Promise<string> {
|
||||
return new SignJWT(claims)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(ACCESS_TOKEN_TTL)
|
||||
.sign(getAccessTokenSecret());
|
||||
}
|
||||
|
||||
export async function verifyAccessToken(token: string): Promise<AccessTokenClaims> {
|
||||
const { payload } = await jwtVerify(token, getAccessTokenSecret());
|
||||
return payload as AccessTokenClaims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh tokens são strings opacas de alta entropia — nunca JWT, nunca
|
||||
* guardadas em texto puro. O que fica em `sessions.refresh_token_hash` é o
|
||||
* SHA-256 do token; posse do token original é a prova de identidade, então
|
||||
* hash reverso não compromete a sessão.
|
||||
*/
|
||||
export function generateRefreshToken(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
export function hashRefreshToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
8
packages/auth/tsconfig.json
Normal file
8
packages/auth/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user