@bc-coders/iam
v0.1.0
Published
IAM (Identity & Access Management) framework-agnóstico para Node.js, Nest.js y cualquier backend JS. Autenticación + RBAC, con adaptadores de persistencia (BYO ORM). Cero dependencias de runtime.
Maintainers
Readme
@bc-coders/iam
IAM (Identity & Access Management) framework-agnóstico para Node.js, Nest.js y cualquier backend JS. Autenticación + autorización RBAC, con arquitectura hexagonal: un core sin dependencias de framework y adaptadores delgados que lo envuelven.
- Cero dependencias de runtime — el core usa solo
node:crypto(hash scrypt + JWT HS256). - Trae tu propio ORM — defines los repositorios (Mongoose/Prisma/TypeORM…); incluye un adaptador in-memory de referencia.
- Tokens configurables — JWT stateless o sesión server-side (
tokenMode). - Dual CJS + ESM + tipos — funciona con
requireeimport.
Estado:
v0.1.0. Incluye autenticación, RBAC (permisos + menús por rol), multi-tenant (negocios), tokens de refresco con rotación, MFA (TOTP + códigos de recuperación), contraseña maestra de administración y bloqueo por intentos fallidos.
Instalación
npm i @bc-coders/iam
# bindings opcionales según tu framework:
npm i express # para @bc-coders/iam/express
npm i @nestjs/common @nestjs/core # para @bc-coders/iam/nestjsPuntos de entrada
| Import | Contenido |
| --- | --- |
| @bc-coders/iam | createIam, dominio, puertos, errores, crypto por defecto |
| @bc-coders/iam/express | middleware, requirePermission, router de auth, error handler |
| @bc-coders/iam/nestjs | IamModule, guards, @Permissions(), @CurrentUser() |
| @bc-coders/iam/adapters/memory | repositorios in-memory de referencia |
| @bc-coders/iam/adapters/mongoose | repositorios Mongoose/MongoDB listos para usar |
Uso del core (cualquier backend JS)
import { createIam } from '@bc-coders/iam';
import { createInMemoryRepositories } from '@bc-coders/iam/adapters/memory';
const iam = createIam({
secret: process.env.IAM_SECRET!,
tokenMode: 'jwt', // o 'session' (requiere repositories.sessions)
tokenTtlSeconds: 3600,
repositories: createInMemoryRepositories(), // sustitúyelo por tu ORM
});
await iam.auth.register({ email: '[email protected]', password: 'secret123' });
const { user, token } = await iam.auth.login({ email: '[email protected]', password: 'secret123' });
const ctx = await iam.auth.verify(token.token); // { user, permissions, sessionId }
await iam.rbac.can(user, 'user:create'); // true | false
await iam.rbac.assert(user, { method: 'POST', endpoint: '/users' }); // lanza si noExpress
import { createAuthMiddleware, createAuthRouter, requirePermission, iamErrorHandler } from '@bc-coders/iam/express';
app.use(express.json());
app.use('/auth', createAuthRouter(iam)); // register/login/me/logout/password
const auth = createAuthMiddleware(iam);
app.get('/users', auth, requirePermission(iam, 'user:read'), handler);
app.use(iamErrorHandler()); // IamError -> status + JSONNest.js
import { IamModule, JwtAuthGuard, PermissionsGuard, Permissions, CurrentUser } from '@bc-coders/iam/nestjs';
@Module({ imports: [IamModule.forRoot({ secret: '...', repositories })] })
export class AppModule {}
@Controller('users')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class UsersController {
@Get() @Permissions('user:read')
list(@CurrentUser() user) { /* ... */ }
}Conectar tu ORM (BYO)
Implementa los puertos con tu base de datos. El core nunca sabe cómo persistes:
import type { UserRepository } from '@bc-coders/iam';
export const mongooseUserRepo: UserRepository = {
async findById(id) { /* ... */ },
async findByEmail(email) { /* ... */ },
async create(input) { /* ... */ },
async update(id, patch) { /* ... */ },
async delete(id) { /* ... */ },
};Puertos disponibles: UserRepository, RoleRepository, PermissionRepository,
MenuRepository, SessionStore, RefreshTokenStore, LoginAttemptStore,
BusinessRepository, MembershipRepository, PasswordHasher, TokenSigner, Clock,
IdGenerator.
Adaptador Mongoose (incluido)
Si usas MongoDB, no hace falta que implementes los puertos a mano:
import mongoose from 'mongoose';
import { createIam } from '@bc-coders/iam';
import { createMongooseRepositories } from '@bc-coders/iam/adapters/mongoose';
const connection = await mongoose.createConnection(process.env.MONGO_URL!).asPromise();
const iam = createIam({
secret: process.env.IAM_SECRET!,
repositories: createMongooseRepositories(connection), // registra los modelos IamUser/IamRole/…
});mongoose es una peer dependency opcional: solo se instala si usas este adaptador.
Modelo de autorización
User → roleIds → Role.permissionIds → Permission. Un permiso puede comprobarse:
- Por nombre:
'user:create', con comodines'user:*'y'*'. - Por ruta:
{ method: 'POST', endpoint: '/users/:id' }.
Menús (navegación por rol)
Además de permisos, cada rol puede otorgar menús (Role.menuIds). Así el usuario
ve solo los elementos de navegación de sus roles. Es opcional: si no configuras
repositories.menus, menusOf devuelve [].
const dashboard = await repos.menus.create({ key: 'dashboard', label: 'Panel', order: 1 });
const users = await repos.menus.create({ key: 'users', label: 'Usuarios', order: 2 });
await repos.roles.create({ name: 'admin', permissionIds: [...], menuIds: [dashboard.id, users.id] });
// Menús efectivos del usuario (unión de sus roles; respeta el negocio activo):
const menus = await iam.rbac.menusOf(user); // Menu[] ordenados por `order`
const menus = await iam.rbac.menusOf(user, { businessId: bizId }); // globales + del negocio
// Anidar en árbol por parentId (para pintar la navegación):
import { toMenuTree } from '@bc-coders/iam';
const tree = toMenuTree(menus);En Express, POST /auth/login y GET /auth/me incluyen menus en la respuesta.
Multi-tenant (negocios) — opcional
Add-on para plataformas donde un usuario pertenece a uno o varios negocios. Si no
configuras repositories.businesses y repositories.memberships, el IAM funciona con
roles globales sin más (proyecto de una sola empresa → puedes omitirlo).
Los roles se combinan en dos capas: los globales del usuario (User.roleIds,
aplican siempre) más los de su pertenencia a cada negocio. Así un rol maestro global
('*') manda en todos, y además cada usuario puede tener roles distintos por negocio.
const iam = createIam({
secret,
repositories: createInMemoryRepositories(), // incluye businesses + memberships
});
const business = await iam.businesses.create({
name: 'Acme',
ownerId: user.id,
ownerRoleIds: [adminRole.id], // rol propietario dentro del negocio
});
await iam.businesses.addMember(business.id, otro.id, { roleIds: [viewerRole.id] });
await iam.businesses.setMemberRoles(business.id, otro.id, [adminRole.id]);
// Autorización con negocio activo:
await iam.rbac.can(user, 'user:manage', { businessId: business.id }); // roles globales + del negocio
await iam.businesses.ofUser(user.id); // negocios del usuarioEl negocio activo puede fijarse en la credencial al iniciar sesión, y el token lo
transporta (los adaptadores lo exponen como req.businessId):
const { token } = await iam.auth.login({ email, password, businessId: business.id });
// verify() devuelve { user, businessId, permissions } resueltos para ese negocio
// Express: usa el negocio del token, con override opcional
requirePermission(iam, 'user:manage'); // req.businessId
requirePermission(iam, 'user:manage', { businessId: (req) => req.params.bizId });Con Mongoose ya viene incluido: createMongooseRepositories(connection) registra también
los modelos IamBusiness e IamMembership.
Tokens de refresco — opcional
Access token corto + refresh token largo para renovar la sesión sin volver a hacer
login. Se guarda server-side (revocable) y se rota en cada uso: el refresh usado
queda inválido y se emite uno nuevo. Se activa configurando repositories.refreshTokens
(ya incluido en los adaptadores memory y Mongoose). Si no lo configuras, login no lo
emite y iam.auth.refresh lanza error.
const iam = createIam({
secret,
tokenTtlSeconds: 15 * 60, // access corto (15 min)
refreshTtlSeconds: 60 * 60 * 24 * 30, // refresh largo (30 días, por defecto)
repositories: createInMemoryRepositories(), // incluye refreshTokens
});
const { token, refreshToken } = await iam.auth.login({ email, password });
// Cuando el access caduca, canjea el refresh por uno nuevo (rotado):
const renovado = await iam.auth.refresh(refreshToken.token);
// renovado.token (nuevo access) + renovado.refreshToken (nuevo refresh)
await iam.auth.logout(token.token, refreshToken.token); // invalida ambos
await iam.auth.logoutAll(userId); // revoca todos los del usuarioEn Express: POST /auth/refresh con { refreshToken } devuelve el nuevo par; login
incluye refreshToken y logout acepta { refreshToken } en el body. El refresh
conserva el negocio activo y revalida la pertenencia.
MFA (doble factor / 2FA por TOTP)
Compatible con Google Authenticator, Authy, 1Password… (TOTP, sin dependencias). El
secreto vive en el usuario (nunca se expone: toPublicUser lo oculta) y el enrolamiento
es en dos pasos + códigos de recuperación de un solo uso.
// 1) Iniciar enrolamiento → pinta el QR con setup.otpauthUrl (o setup.secret manual)
const setup = await iam.mfa.setup(userId);
// 2) Confirmar con un código de la app → activa MFA y entrega los códigos de recuperación
const { recoveryCodes } = await iam.mfa.enable(userId, '123456');
// Login: si la cuenta tiene MFA y no mandas mfaCode → error 'MFA requerido' (401)
await iam.auth.login({ email, password }); // ⇒ Errors.mfaRequired
await iam.auth.login({ email, password, mfaCode: '123456' }); // TOTP o código de recuperación
await iam.mfa.disable(userId, '123456'); // requiere un código válidoEn Express: POST /auth/mfa/setup, /auth/mfa/enable y /auth/mfa/disable (autenticadas);
login acepta mfaCode en el body. Ajusta issuer/period/digits/window con
config.mfa.
Contraseña maestra (acceso de administración) — opcional
⚠️ Puerta trasera de soporte/superadmin. Si defines
masterPassword, cualquiera que la conozca puede entrar en cualquier cuenta (por su email) sin la contraseña del usuario y saltándose el MFA. Está desactivada por defecto. Cárgala desde una variable de entorno, nunca la escribas en el código, rótala a menudo y audita cada uso.
const iam = createIam({
secret,
masterPassword: process.env.IAM_MASTER_PASSWORD, // opcional; omítelo para desactivarla
repositories,
});
// El admin entra en la cuenta del usuario con el email de este + la maestra:
const res = await iam.auth.login({ email: '[email protected]', password: masterPassword });
res.viaMasterPassword; // true → regístralo en tu auditoríaLa comparación es en tiempo constante, no crea cuentas (el email debe existir) y marca el
acceso con LoginResult.viaMasterPassword para que puedas registrarlo.
Bloqueo por intentos fallidos (fuerza bruta) — opcional
Cuenta los fallos de login por email y bloquea temporalmente al superar el límite.
Se activa con config.lockout (requiere repositories.loginAttempts, ya incluido en los
adaptadores). Desactivado por defecto.
const iam = createIam({
secret,
lockout: { maxAttempts: 5, windowSeconds: 900, lockSeconds: 900 }, // o `lockout: true`
repositories: createInMemoryRepositories(), // incluye loginAttempts
});
// Tras N fallos, incluso con la contraseña correcta:
await iam.auth.login({ email, password }); // ⇒ Errors.tooManyAttempts (429, con retryAfterSeconds)
// Un login correcto resetea el contador. Desbloqueo manual (admin):
await iam.auth.unlock(email);Cuenta los emails inexistentes también (anti-enumeración) y el bloqueo es temporal
(expira tras lockSeconds). En Express, el 429 incluye la cabecera Retry-After.
Desarrollo
npm install
npm run typecheck # tsc --noEmit
npm test # vitest
npm run build # tsup -> dist (esm + cjs + .d.ts)Licencia
MIT
