diff --git a/TODO.md b/TODO.md index b0c006b..ebd6d4d 100644 --- a/TODO.md +++ b/TODO.md @@ -29,8 +29,17 @@ - [x] Teste automatizado de isolamento (`pnpm --filter @b2bcall/database run test:isolation`) ## PHASE 04 — Authentication / RBAC -- [ ] Login (Argon2id), access/refresh tokens -- [ ] roles/permissions/user_roles/role_permissions +- [x] `packages/auth`: hash Argon2id (`@node-rs/argon2`), JWT access token (`jose`), + refresh token opaco com rotation +- [x] Tabelas `roles`, `permissions`, `role_permissions`, `user_roles`, `sessions`, `audit_logs` +- [x] `login()` / `refreshSession()` / `logout()` / `listUserTenants()` / `setActiveTenant()` +- [x] `userHasPermission()` (RBAC com scope PLATFORM/TENANT) +- [x] Seed: catálogo de permissions + roles de sistema + Platform Super Admin inicial + (senha em `FIRST_LOGIN.txt`, fora do Git, `mustChangePassword=true`) +- [x] Teste automatizado (`pnpm --filter @b2bcall/auth run test:auth`) +- [ ] Camada HTTP (endpoints, rate limit por IP, guards) — depende de `apps/api` existir, + ver docs/AUTHENTICATION.md → "O que falta" +- [ ] Password reset por e-mail — depende de SMTP configurado ## PHASE 05+ — ver `agente.md` seções 15 em diante (FreeSWITCH, Telefonia, Call Center, Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests) diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md new file mode 100644 index 0000000..77be696 --- /dev/null +++ b/docs/AUTHENTICATION.md @@ -0,0 +1,88 @@ +# Autenticação e RBAC + +Implementado em `packages/auth`, sobre o schema criado pela migration +`auth_and_rbac` (`packages/database/prisma/schema.prisma`). + +## Login (agente.md secao 148) + +- Senha: Argon2id via `@node-rs/argon2` (binário pré-compilado, sem node-gyp), + parâmetros padrão OWASP (memoryCost 19 MiB, timeCost 2, parallelism 1). +- `login()` sempre roda `verify()` contra um hash Argon2id fixo mesmo quando o + e-mail não existe, e devolve o mesmo erro genérico (`InvalidCredentialsError`) + em qualquer caso de falha — mitiga user-enumeration por diferença de tempo + de resposta ou mensagem. +- Access token: JWT (HS256, `jose`), TTL 15 minutos, claims `{ sub, sessionId, + tenantId? }`. +- Refresh token: string opaca aleatória (32 bytes), **nunca** JWT. Só o + SHA-256 fica salvo em `sessions.refresh_token_hash` — posse do token original + é a prova de identidade. +- Refresh rotation: cada uso de `refreshSession()` troca o hash guardado na + mesma linha de `sessions`; o token anterior para de bater com qualquer hash + no banco imediatamente. +- Logout / revogação: `sessions.revoked_at` — idempotente. +- `mustChangePassword` no `User`: setado `true` no Platform Super Admin + inicial (seed); a aplicação (quando existir a camada HTTP) deve forçar troca + de senha antes de liberar qualquer outra rota quando essa flag estiver ativa. + +## Resolução de tenant (agente.md secao 31) + +Login não recebe nem decide `tenant_id`. O fluxo é: + +1. `login()` autentica só por e-mail/senha — devolve tokens sem tenant. +2. `listUserTenants(userId)` devolve os tenants aos quais o usuário pertence + (via `tenant_memberships`), usando `withUserContext` — a única exceção + documentada de RLS "olhar os próprios dados sem tenant ainda escolhido" + (ver docs/TENANT_ISOLATION.md). +3. `setActiveTenant(sessionId, userId, tenantId)` valida a membership de novo + (nunca confia em `tenantId` vindo do cliente sem checar) e emite um novo + access token já com `tenantId` nas claims. + +## RBAC (agente.md secoes 142-145) + +Tabelas: `roles`, `permissions`, `role_permissions`, `user_roles`. + +- `Role.scope`: `PLATFORM` (vale em qualquer tenant, `UserRole.tenantId = + null`) ou `TENANT` (vale só no tenant especificado em `UserRole.tenantId`). +- `userHasPermission(userId, permissionKey, tenantId?)`: junta roles PLATFORM + do usuário com as roles TENANT do tenant informado, e checa se alguma delas + carrega a permission. Chamado explicitamente pela camada de serviço — **não + depende de RLS** (ver próxima seção). +- Seed (`packages/auth/src/seed.ts`, agente.md secao 200) cria o catálogo de + permissions e 4 roles de sistema: `platform_super_admin` (PLATFORM, todas as + permissions), `tenant_admin`, `supervisor`, `agent` (TENANT, mapeamento + inicial documentado no próprio seed — ajustar quando existir UI de RBAC). + +## Por que roles/permissions/user_roles/sessions/audit_logs NÃO têm RLS + +Diferente das tabelas de negócio tenant-scoped (extensions, agents, campaigns, +calls, ...), essas tabelas são infraestrutura de autenticação/autorização, +tocadas exclusivamente pelo código confiável de `packages/auth`, que já +resolve o filtro de tenant explicitamente em cada query (`userHasPermission`, +`setActiveTenant`, etc.). Isso é a camada "RBAC + object authorization" da +defesa em profundidade da seção 32 do `agente.md` — complementar à RLS, não +substituída por ela. Se no futuro essas tabelas passarem a ser expostas por +queries genéricas (ex.: um endpoint de admin que aceita filtros arbitrários), +revisitar essa decisão e considerar RLS ali também. + +## Platform Super Admin inicial (agente.md secao 199) + +O seed cria `admin@b2bcall.local` com senha aleatória de 24 bytes, salva uma +única vez em `FIRST_LOGIN.txt` (permissão 600, fora do Git) com +`mustChangePassword = true`. Rodar de novo o seed não recria o admin se já +existir um usuário com role `platform_super_admin`. + +## O que falta (fica para quando existir `apps/api`) + +Tudo abaixo depende de uma camada HTTP (NestJS/Fastify) que ainda não existe: + +- Rate limiting de login por IP/usuário (agente.md secao 149) — plugin + `@fastify/rate-limit` ou equivalente, não implementável em `packages/auth` + isoladamente. +- Endpoints REST (`POST /auth/login`, `/auth/refresh`, `/auth/logout`, + `/auth/select-tenant`) e guards HTTP que traduzem `InvalidCredentialsError`/ + `NotATenantMemberError` em 401/403. +- Password reset (link expirável por e-mail) — precisa de um provedor de + e-mail/SMTP, fora do escopo desta fase. +- Reuse detection de refresh token roubado (família de tokens) — não + implementado; a rotação simples (secao 148) já está feita, a detecção de + reuso é um hardening adicional a avaliar depois. diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 0000000..d53c496 --- /dev/null +++ b/packages/auth/package.json @@ -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" + } +} diff --git a/packages/auth/src/__tests__/auth.test.ts b/packages/auth/src/__tests__/auth.test.ts new file mode 100644 index 0000000..2f01c9b --- /dev/null +++ b/packages/auth/src/__tests__/auth.test.ts @@ -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, 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); +}); diff --git a/packages/auth/src/audit.ts b/packages/auth/src/audit.ts new file mode 100644 index 0000000..1d9f68d --- /dev/null +++ b/packages/auth/src/audit.ts @@ -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 { + 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, + }, + }); +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts new file mode 100644 index 0000000..ccb65b2 --- /dev/null +++ b/packages/auth/src/index.ts @@ -0,0 +1,5 @@ +export * from "./password"; +export * from "./tokens"; +export * from "./audit"; +export * from "./session"; +export * from "./permissions"; diff --git a/packages/auth/src/password.ts b/packages/auth/src/password.ts new file mode 100644 index 0000000..3040a94 --- /dev/null +++ b/packages/auth/src/password.ts @@ -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 { + return hash(plainPassword, ARGON2_OPTIONS); +} + +export function verifyPassword(passwordHash: string, plainPassword: string): Promise { + return verify(passwordHash, plainPassword); +} diff --git a/packages/auth/src/permissions.ts b/packages/auth/src/permissions.ts new file mode 100644 index 0000000..f5fa203 --- /dev/null +++ b/packages/auth/src/permissions.ts @@ -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 { + 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 { + const prisma = getPrismaClient(); + const count = await prisma.userRole.count({ + where: { userId, tenantId: null, role: { scope: "PLATFORM" } }, + }); + return count > 0; +} diff --git a/packages/auth/src/seed.ts b/packages/auth/src/seed.ts new file mode 100644 index 0000000..ba41c10 --- /dev/null +++ b/packages/auth/src/seed.ts @@ -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 = { + 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); +}); diff --git a/packages/auth/src/session.ts b/packages/auth/src/session.ts new file mode 100644 index 0000000..f271be9 --- /dev/null +++ b/packages/auth/src/session.ts @@ -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 { + 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 { + 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 { + 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! }); +} diff --git a/packages/auth/src/tokens.ts b/packages/auth/src/tokens.ts new file mode 100644 index 0000000..60427b5 --- /dev/null +++ b/packages/auth/src/tokens.ts @@ -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, +): Promise { + return new SignJWT(claims) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime(ACCESS_TOKEN_TTL) + .sign(getAccessTokenSecret()); +} + +export async function verifyAccessToken(token: string): Promise { + 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"); +} diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/database/prisma/migrations/20260828085245_auth_and_rbac/migration.sql b/packages/database/prisma/migrations/20260828085245_auth_and_rbac/migration.sql new file mode 100644 index 0000000..6893189 --- /dev/null +++ b/packages/database/prisma/migrations/20260828085245_auth_and_rbac/migration.sql @@ -0,0 +1,139 @@ +-- CreateEnum +CREATE TYPE "role_scope" AS ENUM ('PLATFORM', 'TENANT'); + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "must_change_password" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "roles" ( + "id" UUID NOT NULL, + "key" TEXT NOT NULL, + "name" TEXT NOT NULL, + "scope" "role_scope" NOT NULL, + "is_system" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "roles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "permissions" ( + "id" UUID NOT NULL, + "key" TEXT NOT NULL, + "description" TEXT, + + CONSTRAINT "permissions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "role_permissions" ( + "role_id" UUID NOT NULL, + "permission_id" UUID NOT NULL, + + CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("role_id","permission_id") +); + +-- CreateTable +CREATE TABLE "user_roles" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "role_id" UUID NOT NULL, + "tenant_id" UUID, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "refresh_token_hash" TEXT NOT NULL, + "active_tenant_id" UUID, + "user_agent" TEXT, + "ip_address" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "revoked_at" TIMESTAMP(3), + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "audit_logs" ( + "id" UUID NOT NULL, + "tenant_id" UUID, + "user_id" UUID, + "action" TEXT NOT NULL, + "entity_type" TEXT, + "entity_id" TEXT, + "before" JSONB, + "after" JSONB, + "ip_address" TEXT, + "user_agent" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "roles_key_key" ON "roles"("key"); + +-- CreateIndex +CREATE UNIQUE INDEX "permissions_key_key" ON "permissions"("key"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_roles_user_id_role_id_tenant_id_key" ON "user_roles"("user_id", "role_id", "tenant_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_refresh_token_hash_key" ON "sessions"("refresh_token_hash"); + +-- CreateIndex +CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id"); + +-- CreateIndex +CREATE INDEX "audit_logs_tenant_id_created_at_idx" ON "audit_logs"("tenant_id", "created_at"); + +-- CreateIndex +CREATE INDEX "audit_logs_user_id_idx" ON "audit_logs"("user_id"); + +-- AddForeignKey +ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_permission_id_fkey" FOREIGN KEY ("permission_id") REFERENCES "permissions"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- The login flow needs to discover which tenants a user belongs to BEFORE a +-- tenant has been chosen (chicken-and-egg: RLS normally requires +-- app.current_tenant_id to already be set). Extend the tenant_isolation +-- policy so a session can also see its OWN memberships via +-- app.current_user_id, without ever exposing other users' memberships or +-- other tenants' data. See docs/AUTHENTICATION.md. +DROP POLICY "tenant_isolation" ON "tenant_memberships"; + +CREATE POLICY "tenant_isolation" ON "tenant_memberships" + USING ( + tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid + OR user_id = NULLIF(current_setting('app.current_user_id', true), '')::uuid + ); + +-- roles / permissions / role_permissions / user_roles / sessions / audit_logs +-- are intentionally NOT protected by RLS. Unlike tenant business data +-- (extensions, agents, campaigns, calls, ...), these are auth-internal +-- tables touched only by the trusted packages/auth service layer, which +-- applies its own explicit WHERE clauses and RBAC checks (defense-in-depth +-- per agente.md secao 32: RBAC + object authorization + tenant repositories +-- + RLS are complementary layers, not all mandatory for every table). diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 0756043..787af3f 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -33,6 +33,7 @@ model Tenant { deletedAt DateTime? @map("deleted_at") memberships TenantMembership[] + userRoles UserRole[] @@map("tenants") } @@ -47,20 +48,124 @@ enum UserStatus { // Identidade global do usuário. NUNCA carrega tenant_id diretamente — o tenant // é sempre resolvido via TenantMembership (agente.md secao 31). model User { - id String @id @default(uuid()) @db.Uuid - email String @unique - passwordHash String @map("password_hash") - name String - status UserStatus @default(ACTIVE) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") + id String @id @default(uuid()) @db.Uuid + email String @unique + passwordHash String @map("password_hash") + name String + status UserStatus @default(ACTIVE) + mustChangePassword Boolean @default(false) @map("must_change_password") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") memberships TenantMembership[] + userRoles UserRole[] + sessions Session[] @@map("users") } +enum RoleScope { + PLATFORM + TENANT + + @@map("role_scope") +} + +// Definições de role. Roles de sistema (isSystem=true) são criadas pelo seed +// (agente.md secao 142) e não podem ser removidas via API. +model Role { + id String @id @default(uuid()) @db.Uuid + key String @unique + name String + scope RoleScope + isSystem Boolean @default(false) @map("is_system") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + rolePermissions RolePermission[] + userRoles UserRole[] + + @@map("roles") +} + +model Permission { + id String @id @default(uuid()) @db.Uuid + key String @unique + description String? + + rolePermissions RolePermission[] + + @@map("permissions") +} + +model RolePermission { + roleId String @map("role_id") @db.Uuid + permissionId String @map("permission_id") @db.Uuid + + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade) + + @@id([roleId, permissionId]) + @@map("role_permissions") +} + +// tenantId é obrigatório quando role.scope == TENANT e deve ser nulo quando +// role.scope == PLATFORM (invariante aplicado em packages/auth, não no banco — +// ver docs/AUTHENTICATION.md). +model UserRole { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + roleId String @map("role_id") @db.Uuid + tenantId String? @map("tenant_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade) + + @@unique([userId, roleId, tenantId]) + @@map("user_roles") +} + +// Refresh-token de longa duração (rotacionado a cada uso). Nunca guardamos o +// token em texto puro — só o hash (SHA-256) usado para lookup/comparação. +model Session { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + refreshTokenHash String @unique @map("refresh_token_hash") + activeTenantId String? @map("active_tenant_id") @db.Uuid + userAgent String? @map("user_agent") + ipAddress String? @map("ip_address") + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") + revokedAt DateTime? @map("revoked_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("sessions") +} + +// tenantId nulo = evento de escopo plataforma (agente.md secao 150). +model AuditLog { + id String @id @default(uuid()) @db.Uuid + tenantId String? @map("tenant_id") @db.Uuid + userId String? @map("user_id") @db.Uuid + action String + entityType String? @map("entity_type") + entityId String? @map("entity_id") + before Json? + after Json? + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") + createdAt DateTime @default(now()) @map("created_at") + + @@index([tenantId, createdAt]) + @@index([userId]) + @@map("audit_logs") +} + // Tabela tenant-scoped protegida por Row Level Security (ver migration // 'tenant_isolation' e docs/TENANT_ISOLATION.md). model TenantMembership { diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 04324f5..fece0f8 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -41,3 +41,22 @@ export async function withTenantContext( return fn(tx); }); } + +/** + * Like withTenantContext, but sets `app.current_user_id` instead of a + * tenant. Used only during login/tenant-resolution, when the caller needs to + * see a user's OWN tenant_memberships before any tenant has been chosen. The + * RLS policy on tenant_memberships allows this narrow self-lookup without + * exposing other users' memberships or other tenants' data (see + * docs/AUTHENTICATION.md). + */ +export async function withUserContext( + client: PrismaClient, + userId: string, + fn: (tx: Prisma.TransactionClient) => Promise, +): Promise { + return client.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.current_user_id', ${userId}, true)`; + return fn(tx); + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f16a685..7289a0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,25 @@ importers: specifier: ^5.9.3 version: 5.9.3 + packages/auth: + dependencies: + '@b2bcall/database': + specifier: workspace:* + version: link:../database + '@node-rs/argon2': + specifier: ^2.1.0 + version: 2.1.0 + jose: + specifier: ^6.2.10 + version: 6.2.10 + devDependencies: + tsx: + specifier: ^4.23.12 + version: 4.23.12 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + packages/database: dependencies: '@prisma/adapter-pg': @@ -239,6 +258,92 @@ packages: cpu: [x64] os: [win32] + '@node-rs/argon2-android-arm-eabi@2.1.0': + resolution: {integrity: sha512-hdWo5kb4eFRbHjdu4O6dVlRPI/CR1vbkJpe3Z9kF2s0Kp42428wg8AxI+8Cv4mygdW309BpUBf3sZaqXBNpdpw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@node-rs/argon2-android-arm64@2.1.0': + resolution: {integrity: sha512-MHby1n9UzlVma3GiEYWNNqRhn2Z/90QwF2PFckGwucdz0HfdVX+lSKEcyfwcbvS5sJ9GAzhop3hXlUPxUYe72Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@node-rs/argon2-darwin-arm64@2.1.0': + resolution: {integrity: sha512-5nayvL9wepOvTO5JmdaiDZ6bgHEsI/vksnXphM3UTy/cflARr9MoD1pBrMPs921hx2otURJhG0avg9hN0Q0KPw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@node-rs/argon2-darwin-x64@2.1.0': + resolution: {integrity: sha512-7bFf3qH/PLQ5kU5GkxMF+dMz/tiT+0yFXILpOx4fgSFjw3wdXpGZt3wH4i6dHMURDcwYlNap0hJaX4h2E3JGng==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@node-rs/argon2-freebsd-x64@2.1.0': + resolution: {integrity: sha512-01mVdl+7bjSW0to5xcEzueq7BuYbz3476/dmLMXziJf8fhykzr723YRNcGjOcHSU5fxNrElY9uVeUoow16vBrQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@node-rs/argon2-linux-arm-gnueabihf@2.1.0': + resolution: {integrity: sha512-NxZILF+Hqav6yzJTUFuZXTUC1VagTO4Vfd2HSlzkwankUa1B7goo0NLY2tUPw6MdqtLojkqVG5MZ26AaF49tHw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@node-rs/argon2-linux-arm64-gnu@2.1.0': + resolution: {integrity: sha512-jpx2VwJLyAF/cM64HRigFBk67GwtQBcssYyVhvHqfmCEZz/CP//SV9bxE+uJOGIPKJo7wDg/5f2k3JY0PNMROg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@node-rs/argon2-linux-arm64-musl@2.1.0': + resolution: {integrity: sha512-xXAaPUlnPx/44qwaSekTG/g6/h5QCyrTayPLmvewJNyYz17vyBRmFKWLgv9Q3+NJwSvV88nVnCu/xkQbEPQaog==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@node-rs/argon2-linux-x64-gnu@2.1.0': + resolution: {integrity: sha512-ICnfyFaxZzr8OdAEwTPsAjx6gpNla1sI3miZJWAVCtZlw98Wo3Wd4H7tvW6S2NRJ1BIOf4l8Z7aX7h3fimXn5w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@node-rs/argon2-linux-x64-musl@2.1.0': + resolution: {integrity: sha512-f56jehb/IPTGBWWrvMjDGZWlQm/hKkH2VK/MxJ9u3kUR9PCacdtLFZmX4sL+qKeo4wsyhlD1EvlZF6nV1FSi3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@node-rs/argon2-win32-arm64-msvc@2.1.0': + resolution: {integrity: sha512-KH8lBdjoZLUiVG1GjJQIQPKhmddJmEbJbL+e3S7BKVq1wKl1mx+ClmhGtMtYp8ZtEIw8/BYIFUIjL3sBuWGEuQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@node-rs/argon2-win32-ia32-msvc@2.1.0': + resolution: {integrity: sha512-zZ7NDHoKTgrjoUehNN7xvP+dA/M9krOp42l0MKns6TnMkwrp5nes9agEZsUy+0t0JoR8OQ8D7EUCNLh5BEn++w==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@node-rs/argon2-win32-x64-msvc@2.1.0': + resolution: {integrity: sha512-JN/yNiX7u8Cw9XXTbRrjnlgzFjCiEQe6x97wJnMIsJ3vty89q0SAg504Otd9qy2ofQS2F5CCWvrOXhm/oyHaAQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@node-rs/argon2@2.1.0': + resolution: {integrity: sha512-VBOWfM2u58/to3DFqTGJ2U5cJKQwmjN2zxzsQNZ5a2o8Z6aUrhvqQh8NdgotIF1Y0tMsBNtzOBDBdfvvkwJDSQ==} + engines: {node: '>= 10'} + '@prisma/adapter-pg@7.10.0': resolution: {integrity: sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==} @@ -654,6 +759,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.10: + resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -988,6 +1096,61 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true + '@node-rs/argon2-android-arm-eabi@2.1.0': + optional: true + + '@node-rs/argon2-android-arm64@2.1.0': + optional: true + + '@node-rs/argon2-darwin-arm64@2.1.0': + optional: true + + '@node-rs/argon2-darwin-x64@2.1.0': + optional: true + + '@node-rs/argon2-freebsd-x64@2.1.0': + optional: true + + '@node-rs/argon2-linux-arm-gnueabihf@2.1.0': + optional: true + + '@node-rs/argon2-linux-arm64-gnu@2.1.0': + optional: true + + '@node-rs/argon2-linux-arm64-musl@2.1.0': + optional: true + + '@node-rs/argon2-linux-x64-gnu@2.1.0': + optional: true + + '@node-rs/argon2-linux-x64-musl@2.1.0': + optional: true + + '@node-rs/argon2-win32-arm64-msvc@2.1.0': + optional: true + + '@node-rs/argon2-win32-ia32-msvc@2.1.0': + optional: true + + '@node-rs/argon2-win32-x64-msvc@2.1.0': + optional: true + + '@node-rs/argon2@2.1.0': + optionalDependencies: + '@node-rs/argon2-android-arm-eabi': 2.1.0 + '@node-rs/argon2-android-arm64': 2.1.0 + '@node-rs/argon2-darwin-arm64': 2.1.0 + '@node-rs/argon2-darwin-x64': 2.1.0 + '@node-rs/argon2-freebsd-x64': 2.1.0 + '@node-rs/argon2-linux-arm-gnueabihf': 2.1.0 + '@node-rs/argon2-linux-arm64-gnu': 2.1.0 + '@node-rs/argon2-linux-arm64-musl': 2.1.0 + '@node-rs/argon2-linux-x64-gnu': 2.1.0 + '@node-rs/argon2-linux-x64-musl': 2.1.0 + '@node-rs/argon2-win32-arm64-msvc': 2.1.0 + '@node-rs/argon2-win32-ia32-msvc': 2.1.0 + '@node-rs/argon2-win32-x64-msvc': 2.1.0 + '@prisma/adapter-pg@7.10.0': dependencies: '@prisma/driver-adapter-utils': 7.10.0 @@ -1475,6 +1638,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.10: {} + json-schema-traverse@1.0.0: {} lodash@4.17.21: {}