@aranzatech/aranza-auth
v0.3.2
Published
Módulo de autenticación extensible para NestJS — JWT, refresh tokens, register/login y adapters MongoDB/Prisma/Cosmos/DynamoDB
Maintainers
Readme
@aranzatech/aranza-auth
Módulo de autenticación extensible para NestJS. JWT, refresh tokens, register/login y adapters MongoDB/Prisma/Cosmos/DynamoDB — sin acoplar tu dominio (org, roles, users, etc.).
Ideal para reutilizar auth en todos tus proyectos AranzaTech: instalas, configuras, extiendes el modelo y listo.
Tabla de contenidos
- Instalación
- Inicio rápido (5 minutos)
- Variables de entorno
- Configuración del módulo
- Feature flags
- Endpoints
- Proteger rutas propias
- Rate limiting (producción)
- Swagger / OpenAPI
- Seguridad en producción
- Extender con AuthHooks
- Ejemplos Nest reales
- Extender el schema MongoDB
- Matriz de adapters
- Adapter Prisma
- Adapter Cosmos DB
- Adapter DynamoDB
- Flujos opcionales (email y password)
- Multi-session / multi-device
- Exports públicos
- Estabilidad de API pública
- Auditoría y eventos
- Identity scope
- Identity service roadmap
- Identity Node client
- Requisitos del proyecto consumidor
- Limitaciones conocidas (v0.4.x)
- Licencia
Instalación
npm install @aranzatech/aranza-authPeer dependencies (instalar en tu app Nest):
npm install @nestjs/common @nestjs/core @nestjs/jwt @nestjs/passport \
passport passport-jwt bcryptjs \
class-validator class-transformer reflect-metadata @nestjs/swaggerPara MongoDB agrega @nestjs/mongoose mongoose. Para Prisma agrega @prisma/client y tu PrismaService. Para Cosmos agrega @azure/cosmos. Para DynamoDB agrega @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb.
NestJS 11+ recomendado.
Inicio rápido (5 minutos)
1. Variables de entorno
MONGODB_URI=mongodb://localhost:27017/myapp
JWT_SECRET=your-access-secret-min-32-chars
JWT_REFRESH_SECRET=your-refresh-secret-min-32-chars2. Conectar el módulo
// app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { MongooseModule } from "@nestjs/mongoose";
import { AuthModule } from "@aranzatech/aranza-auth";
import { MongoAuthModule } from "@aranzatech/aranza-auth/mongo";
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
MongooseModule.forRoot(process.env.MONGODB_URI!),
AuthModule.forRootAsync({
imports: [ConfigModule, MongoAuthModule.forFeature()],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow("JWT_SECRET"),
refreshSecret: config.getOrThrow("JWT_REFRESH_SECRET"),
expiresIn: "1h",
refreshExpiresIn: "7d",
identifierField: "email",
}),
}),
],
})
export class AppModule {}3. Habilitar validación global (requerido)
Los DTOs usan class-validator. Activa el pipe global en main.ts:
import { ValidationPipe } from "@nestjs/common";
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);4. Probar
# Register
curl -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"SecurePass123!"}'
# Login
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"SecurePass123!"}'
# Me (usa el accessToken del login)
curl http://localhost:3000/auth/me \
-H "Authorization: Bearer <accessToken>"Con eso ya tienes auth funcional para un POC.
Variables de entorno
| Variable | Requerida | Descripción |
|----------|-----------|-------------|
| MONGODB_URI | Según adapter | Connection string de MongoDB |
| DATABASE_URL | Según adapter | Connection string de Prisma/SQL |
| JWT_SECRET | Sí | Secret para access tokens |
| JWT_REFRESH_SECRET | Sí | Secret para refresh tokens (distinto al anterior) |
| JWT_EXPIRES_IN | No | TTL access token (default 1h si lo mapeas en config) |
| JWT_REFRESH_EXPIRES_IN | No | TTL refresh token (default 7d) |
| JWT_ISSUER / JWT_AUDIENCE | No | Solo si configuras jwtIssuer / jwtAudience |
| FRONTEND_URL | No | Base URL para links en emails (verify/reset) |
Ver .env.example en el repo como plantilla para apps consumidoras (la lib no lee .env directamente).
Configuración del módulo
AuthModule.forRootAsync() acepta un objeto AuthModuleOptions:
AuthModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
// ── JWT (requerido) ──────────────────────────────────
secret: config.getOrThrow("JWT_SECRET"),
refreshSecret: config.getOrThrow("JWT_REFRESH_SECRET"),
expiresIn: "1h", // access token (default: "1h")
refreshExpiresIn: "7d", // refresh token (default: "7d")
// ── Identificador de login ───────────────────────────
identifierField: "email", // "email" | "username" (default: "email")
// ── Features opcionales ──────────────────────────────
features: {
emailVerification: false, // default: false
passwordReset: false, // default: false
refreshTokenRotation: true, // default: true
accountLockout: false, // default: false
},
// ── Lockout (si accountLockout: true) ────────────────
lockout: {
maxAttempts: 5, // default: 5
lockoutDurationMs: 15 * 60_000, // default: 15 min
},
// ── JWT issuer/audience (opcional) ───────────────────
jwtIssuer: "my-api",
jwtAudience: "my-app",
// ── Performance (opcional) ───────────────────────────
jwtValidationCacheTtlMs: 0, // 0 = siempre validar en DB; max 300000 (5 min)
// ── Ruta del controller ──────────────────────────────
routePrefix: "auth", // default: "auth" → /auth/login
// ── Password policy ──────────────────────────────────
bcryptRounds: 10, // 10–14, default 10
passwordComplexity: false, // upper + lower + digit
// ── TTL de tokens de email/reset ─────────────────────
emailVerificationTokenTtlMs: 24 * 60 * 60 * 1000, // 24h
passwordResetTokenTtlMs: 15 * 60 * 1000, // 15min
// ── Hooks personalizados ─────────────────────────────
hooks: AppAuthHooks, // default: DefaultAuthHooks (Nest DI vía ModuleRef)
// hooksProvider: { ... }, // alternativa: provider Nest completo
}),
// routePrefix y hooks también pueden ir aquí (nivel forRootAsync):
// routePrefix: "auth",
// hooks: AppAuthHooks,
}),AuthModule.forRoot(options) acepta las mismas opciones de forma síncrona (sin imports/inject).
Orden de imports
El adapter de persistencia debe importarse dentro de AuthModule.forRootAsync:
AuthModule.forRootAsync({
imports: [ConfigModule, MongoAuthModule.forFeature()],
// ...
}),Con Prisma:
import { PrismaService } from "./prisma.service";
import { PrismaAuthModule } from "@aranzatech/aranza-auth/prisma";
AuthModule.forRootAsync({
imports: [
ConfigModule,
PrismaAuthModule.forFeature({
prismaServiceToken: PrismaService,
modelName: "authAccount",
}),
],
// ...
}),imports: [
MongooseModule.forRoot(...),
AuthModule.forRootAsync({
imports: [MongoAuthModule.forFeature(), ConfigModule],
// ...
}),
]Feature flags
Todo está desactivado por defecto. Sin declarar features, obtienes el mínimo para POCs.
| Flag | Default | Qué hace |
|------|---------|----------|
| emailVerification | false | Envía email al register, bloquea login hasta verificar |
| passwordReset | false | Habilita forgot-password y reset-password |
| refreshTokenRotation | true | Guarda hash del refresh token en DB y lo rota |
| accountLockout | false | Bloquea cuenta tras N intentos fallidos de login |
Modo POC (solo login/register)
// No declares features — o déjalo vacío:
AuthModule.forRootAsync({
useFactory: () => ({
secret: "...",
refreshSecret: "...",
}),
}),- Register crea cuenta con
emailVerified: true→ login inmediato. - Endpoints
verify-email,resend-verification,forgot-password,reset-passwordresponden 404.
Modo producción
features: {
emailVerification: true,
passwordReset: true,
refreshTokenRotation: true,
},
hooks: AppAuthHooks, // debe implementar sendEmailSi activas
emailVerificationopasswordReset, debes implementarAuthHooks.sendEmail. Si no, register/forgot fallará con un error claro.
Endpoints
| Método | Ruta | Auth | Feature | Body | Respuesta |
|--------|------|------|---------|------|-----------|
| POST | /auth/register | — | — | { email?, username?, password } | { registered: true } |
| POST | /auth/login | — | — | { email?, username?, password } | { accessToken, refreshToken } (HTTP 200) |
| POST | /auth/refresh | — | — | { refreshToken } | { accessToken, refreshToken } |
| POST | /auth/logout | Bearer | — | — | { loggedOut: true } |
| GET | /auth/me | Bearer | — | — | objeto enriquecido via hooks |
| POST | /auth/verify-email | — | emailVerification | { token } | { verified: true } |
| POST | /auth/resend-verification | — | emailVerification | { email } | { sent: true } |
| POST | /auth/forgot-password | — | passwordReset | { email } | { sent: true } |
| POST | /auth/reset-password | — | passwordReset | { token, newPassword } | { reset: true } |
| POST | /auth/change-password | Bearer | — | { currentPassword, newPassword } | { changed: true } |
Rutas usan
routePrefix(defaultauth). Ej:routePrefix: "v1/auth"→/v1/auth/login.
Errores comunes
| Código | Mensaje | Causa |
|--------|---------|-------|
| 401 | INVALID_CREDENTIALS | Email/username o password incorrectos |
| 401 | EMAIL_NOT_VERIFIED | Feature emailVerification activa y email sin verificar |
| 401 | ACCOUNT_DISABLED | Cuenta desactivada |
| 401 | ACCOUNT_LOCKED | Demasiados intentos fallidos (features.accountLockout) |
| 401 | PASSWORD_CHANGED | Access token emitido antes del último cambio de contraseña |
| 401 | REFRESH_TOKEN_REUSE | Refresh reutilizado; sesiones revocadas |
| 401 | INVALID_CURRENT_PASSWORD | Contraseña actual incorrecta en change-password |
| 401 | INVALID_REFRESH_TOKEN | Refresh expirado, revocado o inválido |
| 401 | UNAUTHORIZED | Sin Bearer token o token inválido en ruta protegida |
| 404 | — | Feature desactivada (endpoint no disponible) |
| 400 | TOKEN_INVALID_OR_EXPIRED | Token de verify/reset inválido o expirado |
| 400 | PASSWORD_UNCHANGED | Nueva contraseña igual a la actual |
Proteger rutas propias
Usa el guard y el decorador exportados por la lib:
import { Controller, Get, UseGuards } from "@nestjs/common";
import {
JwtAuthGuard,
CurrentUser,
type AuthJwtPayload,
} from "@aranzatech/aranza-auth";
@Controller("projects")
export class ProjectsController {
@Get()
@UseGuards(JwtAuthGuard)
list(@CurrentUser() user: AuthJwtPayload) {
// user.sub → ID de la cuenta auth
// user.email, user.orgId, etc. → lo que agregues en buildJwtPayload
return { ownerId: user.sub };
}
}También puedes registrar JwtAuthGuard como guard global:
{ provide: APP_GUARD, useClass: JwtAuthGuard }Rate limiting (producción)
La librería no incluye throttling interno — debes aplicarlo en tu app con @nestjs/throttler:
npm install @nestjs/throttlerimport { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler";
import { APP_GUARD } from "@nestjs/core";
import { AUTH_RATE_LIMIT_PRESETS } from "@aranzatech/aranza-auth";
@Module({
imports: [
ThrottlerModule.forRoot([
AUTH_RATE_LIMIT_PRESETS.default,
AUTH_RATE_LIMIT_PRESETS.credentials,
AUTH_RATE_LIMIT_PRESETS.passwordReset,
]),
// ...
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}Presets exportados:
| Preset | Uso recomendado | Límite |
|--------|-----------------|--------|
| default | Rutas auth generales | 10 req/min |
| credentials | /auth/login, /auth/register, /auth/refresh | 5 req/min |
| passwordReset | /auth/forgot-password, /auth/reset-password, /auth/resend-verification | 3 req/min |
Mapa por ruta (AUTH_RATE_LIMIT_ROUTES):
import { AUTH_RATE_LIMIT_ROUTES } from "@aranzatech/aranza-auth";
// AUTH_RATE_LIMIT_ROUTES.login → preset credentials
// AUTH_RATE_LIMIT_ROUTES["resend-verification"] → preset passwordResetAplica
@Throttle()por controlador o ruta según tu política de seguridad.
Refresh token en cookie (opcional)
import { buildRefreshTokenCookie } from "@aranzatech/aranza-auth";
@Post("login")
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const tokens = await this.authService.login(dto);
res.setHeader("Set-Cookie", buildRefreshTokenCookie(tokens.refreshToken));
return { accessToken: tokens.accessToken };
}Swagger / OpenAPI
Instala @nestjs/swagger en tu app (peer dependency):
npm install @nestjs/swaggerEn main.ts:
import { setupAuthSwagger } from "@aranzatech/aranza-auth";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
setupAuthSwagger(app, {
title: "Mi API",
description: "Documentación OpenAPI",
path: "api", // → http://localhost:3000/api
version: "1.0",
features: {
emailVerification: true,
passwordReset: true,
accountLockout: true,
},
exportPath: "./openapi.json", // opcional: escribe el spec al arrancar
});
await app.listen(3000);
}Los endpoints /auth/* aparecen bajo el tag auth. Rutas protegidas usan Bearer JWT (access-token).
Para probar en Swagger UI:
POST /auth/login→ copiaaccessToken- Click Authorize → pega el token
- Llama
GET /auth/meo tus rutas con@ApiBearerAuth('access-token')
Seguridad en producción
Desde v0.2.0 la lib valida configuración al arrancar (secrets ≥32 chars, access ≠ refresh).
Opciones recomendadas
AuthModule.forRootAsync({
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow("JWT_SECRET"),
refreshSecret: config.getOrThrow("JWT_REFRESH_SECRET"),
expiresIn: "30m", // access corto en prod
refreshExpiresIn: "7d",
bcryptRounds: 12, // opcional, default 10
passwordComplexity: true, // upper + lower + digit
features: {
emailVerification: true,
passwordReset: true,
refreshTokenRotation: true,
accountLockout: true,
},
lockout: {
maxAttempts: 5,
lockoutDurationMs: 15 * 60_000,
},
}),
});Refresh tokens en cookies (recomendado)
Usa los helpers exportados (HttpOnly, Secure, SameSite=strict):
import { buildRefreshTokenCookie, buildClearRefreshTokenCookie } from "@aranzatech/aranza-auth";
res.setHeader("Set-Cookie", buildRefreshTokenCookie(tokens.refreshToken));
// logout:
res.setHeader("Set-Cookie", buildClearRefreshTokenCookie());Alternativa manual:
res.cookie("refreshToken", tokens.refreshToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000,
});Códigos de error
import { AuthErrorCode } from "@aranzatech/aranza-auth";
// AuthErrorCode.REFRESH_TOKEN_REUSE → posible robo de token; sesiones revocadasVer SECURITY.md para tradeoffs (logout vs access JWT) y reporte de vulnerabilidades.
Extender con AuthHooks
La lib maneja auth genérico. Tu dominio (org, roles, users) va en hooks:
// app-auth.hooks.ts
import { Injectable } from "@nestjs/common";
import type { AuthHooks, BaseAuthAccount } from "@aranzatech/aranza-auth";
interface MyAuthAccount extends BaseAuthAccount {
orgId: string;
roleId: string;
}
@Injectable()
export class AppAuthHooks implements AuthHooks<MyAuthAccount> {
constructor(private readonly orgService: OrgService) {}
/** Campos extra en el JWT (no incluyas `sub` — lo añade la lib) */
async buildJwtPayload(account: MyAuthAccount) {
return {
orgId: account.orgId,
roleId: account.roleId,
};
}
/** Respuesta de GET /auth/me */
async enrichMe(account: MyAuthAccount) {
const org = await this.orgService.findById(account.orgId);
return {
id: account.id,
email: account.email,
org: { id: org.id, name: org.name },
};
}
/** Validaciones antes de crear cuenta */
async onBeforeRegister(input) {
// ej: verificar invitación, orgId, etc.
}
/** Envío de emails (requerido si activas emailVerification o passwordReset) */
async sendEmail(type: "verify" | "reset", to: string, token: string) {
const base = process.env.FRONTEND_URL ?? "http://localhost:3001";
const path = type === "verify" ? "verify-email" : "reset-password";
const url = `${base}/auth/${path}?token=${token}`;
// await this.mailer.send({ to, subject: "...", html: url });
}
/** Eventos estables para auditoría, métricas o webhooks */
async onAuthEvent(event) {
// await this.audit.write(event);
}
}Registra los hooks en la config:
AuthModule.forRootAsync({
useFactory: () => ({
secret: "...",
refreshSecret: "...",
hooks: AppAuthHooks,
}),
}),Métodos disponibles en AuthHooks
| Método | Cuándo se ejecuta | Requerido |
|--------|-------------------|-----------|
| buildJwtPayload | Login, refresh | Sí |
| enrichMe | GET /auth/me | No (usa default) |
| onBeforeRegister | Antes de crear cuenta | No |
| onAfterRegister | Después de crear cuenta | No |
| onAfterLogin | Después de login exitoso | No |
| onAuthEvent | Register/login/refresh/logout/email/password | No |
| sendEmail | Register (verify) o forgot (reset) | Sí si features de email activas |
onAuthEvent emite eventos estables como login.succeeded, login.failed, refresh.reuse_detected, logout.succeeded, email_verification.requested, password_reset.succeeded y password_change.succeeded. La librería no los persiste: tu app decide si van a logs, métricas, webhooks o una futura API Identity.
Cuando usas el controller incluido, cada evento puede traer context.ipAddress, context.userAgent y context.requestId extraídos del request HTTP. Si usas AuthService directamente, puedes pasar ese contexto como parámetro opcional.
Ejemplos Nest reales
La fase de adopción vive en ejemplos pequeños y copiables:
| Ejemplo | Backend | Ruta |
|---------|---------|------|
| Nest + Mongo | Mongoose/MongoDB | examples/nest-mongo |
| Nest + Prisma/Postgres | Prisma + PostgreSQL | examples/nest-prisma-postgres |
La guía completa para integrar la librería en apps Nest reales está en docs/NEST_APP_GUIDE.md.
Extender el schema MongoDB
El schema base incluye: email, username, passwordHash, refreshTokenHash, emailVerified, disabled y campos de tokens.
Agregar campos de dominio
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose";
import { baseAuthAccountSchema } from "@aranzatech/aranza-auth/mongo";
@Schema()
export class AuthAccount {
@Prop({ type: Types.ObjectId, ref: "Organization", required: true })
orgId!: Types.ObjectId;
@Prop({ type: Types.ObjectId, ref: "Role", required: true })
roleId!: Types.ObjectId;
}
export const AuthAccountSchema = SchemaFactory.createForClass(AuthAccount);
AuthAccountSchema.add(baseAuthAccountSchema);Registrar schema extendido
import { AuthAccount, AuthAccountSchema } from "./schemas/auth-account.schema";
MongoAuthModule.forFeature({
name: AuthAccount.name,
schema: AuthAccountSchema,
identifierField: "username", // si login es por username
}),
identifierFieldenMongoAuthModule.forFeature()debe coincidir con el deAuthModule.
Métodos extra en MongoAuthRepository
Útiles para admin o scripts (inyecta MongoAuthRepository o implementa en tu propio repo):
| Método | Uso |
|--------|-----|
| setAccountDisabled(id, disabled) | Deshabilitar/habilitar cuenta |
| findUnverifiedByEmail(email) | Buscar cuenta pendiente de verificación |
Índice compuesto { email: 1, disabled: 1 } incluido para lookups frecuentes.
Matriz de adapters
Los cuatro adapters oficiales implementan IAuthRepository. La diferencia real está en cómo cada base garantiza unicidad, consultas secundarias y updates condicionales.
| Capacidad | Mongo | Prisma/SQL | Cosmos DB | DynamoDB |
|-----------|-------|------------|-----------|----------|
| Register/login por email | Sí | Sí | Sí | Sí |
| Register/login por username | Sí | Sí | Sí | Sí |
| Refresh token rotation atómica | Sí | Sí | Sí, con _etag | Sí, con condition expression |
| Email verification | Sí | Sí | Sí | Sí |
| Password reset | Sí | Sí | Sí | Sí |
| Account lockout | Sí | Sí | Sí | Sí |
| Disable/enable account | Sí | Sí | Sí | Sí |
| Multi-session | Sí | Sí | Sí | Sí |
| Unicidad de email/username | Índices unique | @unique | Unique key policy recomendada | Sentinels transaccionales |
| Infra creada por la librería | Schema Mongoose | No | No | No |
Requisitos mínimos por adapter:
| Adapter | Infra requerida |
|---------|-----------------|
| Mongo | MongooseModule.forRoot(...) y MongoAuthModule.forFeature(...) |
| Prisma | Modelos compatibles, PrismaService y delegates configurados |
| Cosmos DB | Container existente con partición consistente; unique keys recomendadas |
| DynamoDB | Tabla, GSIs de lookup, GSI de sesiones por cuenta y DynamoDBDocumentClient |
Adapter Prisma
El adapter Prisma implementa el mismo IAuthRepository que Mongo. Tu app aporta el PrismaService; la librería usa el delegate configurado, por defecto prisma.authAccount.
Modelo sugerido
model AuthAccount {
id String @id @default(cuid())
email String? @unique
username String? @unique
passwordHash String
refreshTokenHash String?
emailVerified Boolean @default(false)
disabled Boolean @default(false)
emailVerificationTokenHash String?
emailVerificationExpiresAt DateTime?
resetTokenHash String?
resetTokenExpiresAt DateTime?
failedLoginAttempts Int @default(0)
lockedUntil DateTime?
lastLoginAt DateTime?
passwordChangedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email, disabled])
@@index([emailVerificationTokenHash])
@@index([resetTokenHash])
@@index([emailVerificationExpiresAt])
@@index([resetTokenExpiresAt])
}
model AuthSession {
sessionId String @id
accountId String
refreshTokenHash String
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([accountId, revokedAt])
@@index([expiresAt])
}Registro en Nest
import { Module } from "@nestjs/common";
import { AuthModule } from "@aranzatech/aranza-auth";
import { PrismaAuthModule } from "@aranzatech/aranza-auth/prisma";
import { PrismaService } from "./prisma.service";
@Module({
providers: [PrismaService],
imports: [
AuthModule.forRootAsync({
imports: [
PrismaAuthModule.forFeature({
prismaServiceToken: PrismaService,
modelName: "authAccount",
}),
],
useFactory: () => ({
secret: process.env.JWT_SECRET!,
refreshSecret: process.env.JWT_REFRESH_SECRET!,
identifierField: "email",
}),
}),
],
})
export class AppModule {}Si tu modelo Prisma tiene otro nombre, cambia modelName para que coincida con el delegate generado (userAuth → prisma.userAuth).
Si usas features.multiSession, el adapter usa por defecto el delegate prisma.authSession; puedes cambiarlo con sessionModelName.
Adapter Cosmos DB
El adapter Cosmos usa un Container compatible con @azure/cosmos. La app consumidora crea el cliente, database y container; la librería solo recibe el provider del container.
Documento sugerido
{
"kind": "auth_account",
"id": "auth-id",
"email": "[email protected]",
"username": null,
"passwordHash": "...",
"refreshTokenHash": null,
"emailVerified": false,
"disabled": false,
"emailVerificationTokenHash": null,
"emailVerificationExpiresAt": null,
"resetTokenHash": null,
"resetTokenExpiresAt": null,
"failedLoginAttempts": 0,
"lockedUntil": null,
"lastLoginAt": null,
"passwordChangedAt": null,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
}Para features.multiSession, configura un container de sesiones. Puede ser el mismo container si tu uniqueKeyPolicy no bloquea documentos sin email/username; en producción suele ser más limpio usar un container dedicado y pasarlo con sessionContainerToken.
{
"kind": "auth_session",
"id": "session-id",
"sessionId": "session-id",
"accountId": "auth-id",
"refreshTokenHash": "...",
"expiresAt": "2026-01-08T00:00:00.000Z",
"revokedAt": null,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
}Usa partition key /id para el modo simple. Si particionas por otro campo, pásalo como partitionKeyField.
Para producción, crea el container con unique keys para el identificador que uses:
{
"uniqueKeyPolicy": {
"uniqueKeys": [
{ "paths": ["/email"] },
{ "paths": ["/username"] }
]
}
}La librería hace un preflight anti-duplicado, pero la unique key policy es la barrera que cierra carreras concurrentes.
Registro en Nest
import { CosmosClient } from "@azure/cosmos";
import { CosmosAuthModule } from "@aranzatech/aranza-auth/cosmos";
const COSMOS_AUTH_CONTAINER = "CosmosAuthContainer";
@Module({
providers: [
{
provide: COSMOS_AUTH_CONTAINER,
useFactory: () => {
const client = new CosmosClient(process.env.COSMOS_CONNECTION_STRING!);
return client.database("app").container("auth_accounts");
},
},
],
imports: [
AuthModule.forRootAsync({
imports: [
CosmosAuthModule.forFeature({
containerToken: COSMOS_AUTH_CONTAINER,
sessionContainerToken: COSMOS_AUTH_CONTAINER,
partitionKeyField: "id",
}),
],
useFactory: () => ({
secret: process.env.JWT_SECRET!,
refreshSecret: process.env.JWT_REFRESH_SECRET!,
}),
}),
],
})
export class AppModule {}Adapter DynamoDB
El adapter DynamoDB usa DynamoDBDocumentClient y commands de @aws-sdk/lib-dynamodb, pasados por provider para mantener el SDK como dependencia opcional.
Tabla sugerida
- Partition key:
id(String) - GSI
email-index: partition keyemail - GSI
username-index: partition keyusernamesi usasidentifierField: "username" - GSI
email-verification-token-index: partition keyemailVerificationTokenHash - GSI
reset-token-index: partition keyresetTokenHash - GSI
session-account-index: partition keyaccountIdpara revocar todas las sesiones de una cuenta cuandofeatures.multiSessionestá activo
Las fechas se guardan como strings ISO para que las comparaciones de expiración sean ordenables.
Registro en Nest
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
DynamoDBDocumentClient,
GetCommand,
PutCommand,
QueryCommand,
TransactWriteCommand,
UpdateCommand,
} from "@aws-sdk/lib-dynamodb";
import { DynamoAuthModule } from "@aranzatech/aranza-auth/dynamo";
const DYNAMO_AUTH_CLIENT = "DynamoAuthDocumentClient";
const DYNAMO_AUTH_COMMANDS = "DynamoAuthCommands";
@Module({
providers: [
{
provide: DYNAMO_AUTH_CLIENT,
useFactory: () => DynamoDBDocumentClient.from(new DynamoDBClient({})),
},
{
provide: DYNAMO_AUTH_COMMANDS,
useValue: {
GetCommand,
PutCommand,
QueryCommand,
TransactWriteCommand,
UpdateCommand,
},
},
],
imports: [
AuthModule.forRootAsync({
imports: [
DynamoAuthModule.forFeature({
clientToken: DYNAMO_AUTH_CLIENT,
commandsToken: DYNAMO_AUTH_COMMANDS,
tableName: "auth_accounts",
emailIndexName: "email-index",
usernameIndexName: "username-index",
emailVerificationTokenHashIndexName:
"email-verification-token-index",
resetTokenHashIndexName: "reset-token-index",
sessionAccountIdIndexName: "session-account-index",
}),
],
useFactory: () => ({
secret: process.env.JWT_SECRET!,
refreshSecret: process.env.JWT_REFRESH_SECRET!,
}),
}),
],
})
export class AppModule {}Flujos opcionales (email y password)
Verificación de email
- Activa
features.emailVerification: true - Implementa
sendEmailen hooks - Register envía email con token → usuario visita link →
POST /auth/verify-emailcon{ token } - Si el usuario no recibió el email:
POST /auth/resend-verificationcon{ email }→ siempre{ sent: true } - Login bloqueado hasta
emailVerified: true
Si usas identifierField: "username", el register debe incluir email además del username.
Forgot / Reset password
- Activa
features.passwordReset: true - Implementa
sendEmailen hooks POST /auth/forgot-passwordcon{ email }→ siempre responde{ sent: true }(no revela si el email existe)- Usuario recibe link →
POST /auth/reset-passwordcon{ token, newPassword } - Reset invalida refresh tokens activos
Multi-session / multi-device
Desde 0.4.0, la librería incluye el contrato IAuthSessionRepository y el feature flag features.multiSession.
Cuando multiSession está apagado, la librería conserva el comportamiento histórico: una sola sesión refresh por cuenta usando refreshTokenHash en el account.
Cuando multiSession está activo:
- cada login crea una sesión independiente
- el refresh JWT incluye
sid - refresh rota el hash de esa sesión
- logout revoca solo la sesión actual si el access token trae
sid - reset/change password revocan todas las sesiones de la cuenta
- refresh token reuse revoca todas las sesiones de la cuenta
Los adapters oficiales registran AUTH_SESSION_REPOSITORY desde su forFeature() cuando tienen la infraestructura necesaria:
| Adapter | Requisito multi-session |
|---------|--------------------------|
| Mongo | MongoAuthModule.forFeature() registra schema y repository automáticamente |
| Prisma | Modelo/delegate AuthSession, por defecto prisma.authSession |
| Cosmos DB | Container configurado; por defecto usa el mismo containerToken |
| DynamoDB | sessionAccountIdIndexName para revocar todas las sesiones de una cuenta |
AuthModule.forRootAsync({
imports: [MongoAuthModule.forFeature()],
useFactory: () => ({
secret: process.env.JWT_SECRET!,
refreshSecret: process.env.JWT_REFRESH_SECRET!,
features: {
refreshTokenRotation: true,
multiSession: true,
},
}),
});Para adapters propios, registra un provider para AUTH_SESSION_REPOSITORY que implemente IAuthSessionRepository.
Exports públicos
| Import | Contenido |
|--------|-----------|
| @aranzatech/aranza-auth | AuthModule, AuthService, guards, DTOs, setupAuthSwagger, AuthErrorCode, cookie helpers, rate-limit presets |
| @aranzatech/aranza-auth/cosmos | CosmosAuthModule, CosmosAuthRepository, CosmosAuthSessionRepository, tipos estructurales del adapter |
| @aranzatech/aranza-auth/dynamo | DynamoAuthModule, DynamoAuthRepository, DynamoAuthSessionRepository, tipos estructurales del adapter |
| @aranzatech/aranza-auth/mongo | MongoAuthModule, MongoAuthRepository, MongoAuthSessionRepository, baseAuthAccountSchema, authSessionSchema |
| @aranzatech/aranza-auth/prisma | PrismaAuthModule, PrismaAuthRepository, PrismaAuthSessionRepository, tipos estructurales del adapter |
Principales exports:
| Símbolo | Uso |
|---------|-----|
| AuthErrorCode | Códigos de error (INVALID_CREDENTIALS, UNAUTHORIZED, …) |
| AUTH_RATE_LIMIT_PRESETS / AUTH_RATE_LIMIT_ROUTES | Throttling con @nestjs/throttler |
| buildRefreshTokenCookie / buildClearRefreshTokenCookie | Cookies HttpOnly para refresh |
| IAuthRepository / CreateAccountData | Contrato estable para adapters propios |
| IAuthSessionRepository / AuthSession | Contrato de sesiones multi-device |
| AuthHooks / AuthEvent / BaseAuthAccount / AuthTokens | Contratos estables para extensión de dominio y auditoría |
| MeResponseDto / ResendVerificationDto | Swagger + tipos |
| AuthHooksConstructor | Tipado de hooks custom con Nest DI |
| setupAuthSwagger | Swagger UI + Bearer JWT |
| buildIdentityScopeClaims / readIdentityScopeFromClaims | Claims estándar para tenantId, clientId, organizationId |
| createAuthEventForwarder | Forwarder HTTP opcional para enviar AuthEvent a un endpoint externo |
Tokens de inyección
import {
AUTH_MODULE_OPTIONS,
AUTH_HOOKS,
AUTH_REPOSITORY,
AUTH_SESSION_REPOSITORY,
} from "@aranzatech/aranza-auth";Útiles si necesitas acceder a config o reemplazar el repository manualmente. AUTH_SESSION_REPOSITORY se usa cuando features.multiSession está activo.
Estabilidad de API pública
Desde 0.3.0, la librería separa explícitamente lo estable de lo interno para poder crecer hacia más adapters y, luego, hacia Identity-as-a-Service.
Estable para apps consumidoras
Estos exports forman parte del contrato público:
| Área | Contrato |
|------|----------|
| Módulo Nest | AuthModule, AuthModuleOptions, AuthModuleAsyncOptions |
| Flujos auth | DTOs públicos, AuthService, TokenService, JwtAuthGuard, CurrentUser |
| Extensión | AuthHooks, AuthHooksConstructor, AuthEvent, BaseAuthAccount, AuthTokens, RegisterInput |
| Persistencia | IAuthRepository, CreateAccountData, AUTH_REPOSITORY |
| Sesiones | IAuthSessionRepository, AuthSession, AUTH_SESSION_REPOSITORY |
| Errores y seguridad | AuthErrorCode, cookie helpers, rate-limit presets/routes |
| Adapters | MongoAuthModule, PrismaAuthModule, CosmosAuthModule, DynamoAuthModule |
Contrato de adapters
Todos los adapters deben implementar IAuthRepository. Eso significa que Mongo, Prisma, Cosmos, Dynamo y cualquier adapter futuro deben soportar las mismas capacidades base:
- register/login por identificador
- refresh token y rotación atómica
- cambio y reset de password
- verificación de email
- lockout por intentos fallidos
- enable/disable de cuentas
- lecturas públicas sin secretos y lecturas internas con hashes
Los tipos estructurales como PrismaAuthDelegate, CosmosAuthContainer o DynamoAuthDocumentClient son públicos porque permiten integrar infraestructura real de la app consumidora, pero son contratos de bajo nivel del adapter. Si cambia el SDK externo de Prisma, Azure o AWS, podrían necesitar ajustes en una versión minor o major según el impacto.
Interno, no garantizado
No se considera API pública:
- archivos dentro de
__tests__/ - fixtures, harnesses in-memory y smoke tests
- estructura interna de servicios, estrategias o utilidades no exportadas
- campos privados de documentos/tablas que no estén representados en
BaseAuthAccountoAuthAccountWithSecrets
Política semver
| Versión | Qué puede cambiar |
|---------|-------------------|
| Patch | Fixes, documentación, tests, mejoras internas sin romper tipos públicos |
| Minor | Nuevos adapters, nuevos exports, nuevos campos opcionales, nuevas features apagadas por defecto |
| Major | Cambios incompatibles en DTOs, endpoints, IAuthRepository, AuthHooks, errores o comportamiento por defecto |
Mientras estemos en 0.x, vamos a tratar IAuthRepository, IAuthSessionRepository, AuthHooks, DTOs públicos y entrypoints de adapters como contratos estables. Si necesitamos romperlos para multi-tenant, OAuth/OIDC o Identity-as-a-Service, lo correcto será planearlo como minor o major según el impacto real.
Auditoría y eventos
La guía completa está en docs/AUDIT_EVENTS.md.
Reglas rápidas:
- usa
AuthHooks.onAuthEventpara logs, métricas, webhooks o forwarding hacia Identity - usa
createAuthEventForwardersi quieres enviar eventos a un endpoint HTTP externo - no guardes passwords, refresh tokens, verification tokens ni reset tokens
- trata
identifiercomo dato personal porque puede ser email o username - usa
context.requestIdpara correlación entre logs - monitorea
refresh.reuse_detectedcomo señal de riesgo alto
El forwarder es fail-open por defecto: si el endpoint externo cae, el login/refresh no se bloquea. Puedes usar failOpen: false cuando tu modelo de cumplimiento requiera bloquear el flujo si no se pudo auditar.
Identity scope
La guía completa está en docs/IDENTITY_SCOPE.md.
La librería define un vocabulario opcional para preparar multi-tenant / multi-app sin imponerlo:
| Campo | Uso |
|-------|-----|
| tenantId | Frontera de tenant/customer |
| clientId | App/cliente que consume auth |
| organizationId | Organización/workspace dentro de un tenant |
Puedes usar buildIdentityScopeClaims(account) dentro de AuthHooks.buildJwtPayload y readIdentityScopeFromClaims(user) dentro de guards propios.
Los headers x-tenant-id, x-client-id y x-organization-id se copian a AuthEvent.context.scope como metadata de auditoría. No deben usarse solos para autorizar acceso.
Identity service roadmap
La librería Nest sigue siendo integración local. El futuro Identity service debe vivir como producto HTTP separado y OpenAPI-first.
Documentos actuales:
| Documento | Uso |
|-----------|-----|
| docs/IDENTITY_SERVICE_OPENAPI.md | Mapa de recursos, endpoints, versionado y milestones |
| docs/identity-service.openapi.yaml | Draft OpenAPI 3.1 para tenants, clients, users, auth, sessions, audit y discovery |
| docs/SDK_GENERATION.md | Estrategia para SDKs Node/Python/etc. generados desde OpenAPI |
| docs/IDENTITY_HTTP_CONTRACT.md | Reglas HTTP para errores, paginacion, scope headers y request IDs |
| docs/IDENTITY_SERVICE_STARTER.md | Starter del futuro servicio separado AranzaIdentity |
| docs/IDENTITY_SERVICE_HANDOFF.md | Handoff hacia el proyecto AranzaIdentity |
Este contrato reutiliza AuthEvent, AuthIdentityScope, sesiones y tokens como vocabulario común para futuros SDKs Node/Python/etc.
Identity Node client
La guía completa está en docs/IDENTITY_NODE_CLIENT.md.
La librería incluye un cliente mínimo para prototipos y primeros consumidores del futuro Identity service:
import { createIdentityClient } from "@aranzatech/aranza-auth";
const identity = createIdentityClient({
baseUrl: "https://identity.example.com",
serviceToken: process.env.IDENTITY_SERVICE_TOKEN,
});
await identity.ingestAuthEvent(event);Este cliente es un puente de fase 5, no un SDK generado final. Está alineado con docs/identity-service.openapi.yaml.
Requisitos del proyecto consumidor
- [ ] NestJS 11+
- [ ]
ValidationPipeglobal activo - [ ]
reflect-metadataimportado al inicio demain.ts - [ ] Un adapter configurado: Mongo, Prisma, Cosmos o Dynamo
- [ ] La base correspondiente conectada en la app consumidora
- [ ] Secrets JWT distintos para access y refresh
- [ ]
@nestjs/throttleren rutas/auth/*(ver Rate limiting) - [ ] Access token TTL corto (15–60 min) en producción
- [ ] Si usas features de email: implementar
AuthHooks.sendEmail
Limitaciones conocidas (v0.4.x)
| Limitación | Workaround / versión futura |
|------------|----------------------------|
| Adapters Prisma/Cosmos/Dynamo estructurales; schema/infra viven en la app consumidora | Usar los modelos sugeridos o implementar IAuthRepository propio |
| Sin OAuth (Google, GitHub, etc.) | Roadmap futuro |
| Multi-session es opt-in | Activar features.multiSession y configurar la infraestructura de sesiones del adapter |
| jwtValidationCacheTtlMs > 0 retrasa revoke/disable en access tokens | Usar 0 si necesitas revocación inmediata |
Migración desde ≤0.2.1
- Actualiza a
@aranzatech/[email protected]. - Re-login obligatorio: refresh JWT y hashes almacenados cambiaron (payload mínimo + HMAC-SHA256).
- Los clientes deben parsear
AuthErrorCodeenmessage(no strings legibles). - Activa
forbidNonWhitelisted: trueenValidationPipe. - Opcional:
jwtIssuer/jwtAudience, cookies conbuildRefreshTokenCookie().
Desarrollo
npm install
npm run ci # lint + tests (unit + e2e) + coverage gates + audit + build
npm run ci:local # alias local de ci
npm run release:check # ci local + npm pack dry-run
npm run test:identity:openapi # valida el draft OpenAPI de Identity
npm run test:identity:service-blueprint # valida el starter del servicio Identity separado
npm run test:identity:python # valida el cliente Python de referencia
npm test # solo testsEl plan de estabilización vive en docs/STABILIZATION.md.
Adapter contract tests
Los adapters deben cumplir el mismo contrato IAuthRepository. La suite reusable vive en:
__tests__/helpers/auth-repository-contract.tsHoy corre contra Mongo, Prisma, Cosmos y Dynamo con harnesses in-memory:
__tests__/mongo-auth.repository.contract.test.ts
__tests__/prisma-auth.repository.contract.test.ts
__tests__/cosmos-auth.repository.contract.test.ts
__tests__/dynamo-auth.repository.contract.test.tsPara nuevos adapters, crea un harness que devuelva un repositorio fresco y llama:
describeAuthRepositoryContract("MyAuthRepository", {
createRepository: () => new MyAuthRepository(...),
});Validaciones locales y emuladas
La suite rápida usa harnesses in-memory. Para validar adapters contra engines reales/emulados, corre estos comandos localmente antes de publicar o cuando cambies un adapter:
npm run test:integration:local # MongoMemoryServer + Prisma SQLite
npm run test:integration:mongo # Mongo real en memoria
npm run test:integration:prisma # Prisma Client + SQLite
npm run test:integration:dynamo # requiere DynamoDB Local en DYNAMODB_LOCAL_ENDPOINT
npm run test:integration:cosmos # requiere COSMOS_ENDPOINT + COSMOS_KEYEl workflow de GitHub Actions queda intencionalmente liviano para no gastar minutos:
test: lint, coverage, threshold, audit y build.
Las integraciones Mongo/Prisma/Dynamo/Cosmos son locales/manuales. Nota: la imagen oficial de Cosmos DB emulator publica linux/amd64; en Macs Apple Silicon puede terminar con Exited (139) bajo carga del SDK. Para validación local estable en Mac usa un recurso Cosmos de prueba con COSMOS_ENDPOINT/COSMOS_KEY.
Smoke tests reales
Los smoke tests apuntan a infraestructura real y están apagados por defecto. Úsalos solo contra recursos temporales o dedicados de prueba:
npm run test:smoke:mongo # requiere MONGO_SMOKE_URI
npm run test:smoke:dynamo # requiere credenciales AWS o DYNAMODB_SMOKE_ENDPOINT
npm run test:smoke:cosmos # requiere COSMOS_SMOKE_ENDPOINT + COSMOS_SMOKE_KEY
npm run test:smoke:postgres # requiere PRISMA_SMOKE_DATABASE_URL + PRISMA_SMOKE_ALLOW_SCHEMA_PUSH=1Variables útiles:
| Smoke | Variables |
|-------|-----------|
| Mongo | MONGO_SMOKE_URI, opcional MONGO_SMOKE_DATABASE |
| DynamoDB | AWS_REGION/credenciales AWS, opcional DYNAMODB_SMOKE_ENDPOINT, DYNAMODB_SMOKE_TABLE |
| Cosmos DB | COSMOS_SMOKE_ENDPOINT, COSMOS_SMOKE_KEY, opcional COSMOS_SMOKE_DATABASE_ID, COSMOS_SMOKE_CONTAINER_ID, COSMOS_SMOKE_KEEP_DATABASE=1 |
| PostgreSQL/Prisma | PRISMA_SMOKE_DATABASE_URL, PRISMA_SMOKE_ALLOW_SCHEMA_PUSH=1 |
Los smoke tests crean recursos con prefijo aranza-auth-smoke-* cuando pueden y los eliminan al terminar. Para PostgreSQL usa una base dedicada: el script ejecuta prisma db push sobre PRISMA_SMOKE_DATABASE_URL.
Licencia
MIT © AranzaTech
