@devoven/authn
v0.1.5
Published
AuthN module for NestJS — hexagonal architecture
Readme
@devoven/authn
Authentication module for NestJS. Covers registration, login, JWT token issuance and refresh, email verification, and password reset.
Installation
npm install @devoven/authn
# or
pnpm add @devoven/authnbcrypt is a regular dependency and is installed automatically.
Peer dependencies
This package requires the standard NestJS peer dependencies plus @nestjs/jwt, class-validator, and class-transformer. If you do not already have them:
npm install @nestjs/jwt class-validator class-transformerQuick Start
import { Module } from '@nestjs/common';
import { AuthNModule } from '@devoven/authn';
// Your implementations of the four required repository ports:
import { PrismaUserRepository } from './auth/prisma-user.repository';
import { PrismaRefreshTokenRepository } from './auth/prisma-refresh-token.repository';
import { PrismaVerificationTokenRepository } from './auth/prisma-verification-token.repository';
import { PrismaResetTokenRepository } from './auth/prisma-reset-token.repository';
@Module({
imports: [
AuthNModule.register({
jwtSecret: process.env.JWT_SECRET,
userRepository: PrismaUserRepository,
refreshTokenRepository: PrismaRefreshTokenRepository,
verificationTokenRepository: PrismaVerificationTokenRepository,
resetTokenRepository: PrismaResetTokenRepository,
}),
],
})
export class AppModule {}The four repository options are required. All other options are optional with sensible defaults.
Module Options
Options split into adapters (AuthNAdapterOptions) and config
(AuthNModuleOptions). For sync register you pass both as one flat object;
for registerAsync the config is what useFactory returns, while adapters sit
at the top level of the call (see Async Registration).
// Adapters — wired statically so class adapters get full Nest DI
interface AuthNAdapterOptions {
// Required
userRepository: Type<UserRepositoryPort> | UserRepositoryPort;
refreshTokenRepository: Type<RefreshTokenRepositoryPort> | RefreshTokenRepositoryPort;
verificationTokenRepository: Type<VerificationTokenRepositoryPort> | VerificationTokenRepositoryPort;
resetTokenRepository: Type<ResetTokenRepositoryPort> | ResetTokenRepositoryPort;
// Optional
tokenProvider?: Type<TokenProviderPort> | TokenProviderPort;
passwordHasher?: Type<PasswordHasherPort> | PasswordHasherPort;
}
// Config — resolved by `useFactory` in async registration
interface AuthNModuleOptions {
jwtSecret?: string;
// TTL overrides (seconds)
accessTokenTtlSeconds?: number;
refreshTokenTtlSeconds?: number;
emailVerificationTokenTtlSeconds?: number;
passwordResetTokenTtlSeconds?: number;
}| Option | Type | Default | Description |
|--------|------|---------|-------------|
| userRepository | Class or instance | — | Required. Persistence adapter for users |
| refreshTokenRepository | Class or instance | — | Required. Persistence adapter for refresh tokens |
| verificationTokenRepository | Class or instance | — | Required. Persistence adapter for email verification tokens |
| resetTokenRepository | Class or instance | — | Required. Persistence adapter for password reset tokens |
| jwtSecret | string | — | Required when using the default JwtTokenProvider |
| tokenProvider | Class or instance | JwtTokenProvider | Override JWT signing/verification |
| passwordHasher | Class or instance | BcryptPasswordHasher | Override password hashing |
| accessTokenTtlSeconds | number | 900 (15 min) | Access token lifetime |
| refreshTokenTtlSeconds | number | 604800 (7 days) | Refresh token lifetime |
| emailVerificationTokenTtlSeconds | number | 86400 (24 hours) | Email verification token lifetime |
| passwordResetTokenTtlSeconds | number | 1800 (30 min) | Password reset token lifetime |
Async Registration
Use registerAsync when configuration values depend on injected services. The
adapters are passed as classes alongside useFactory (not inside it), so
Nest's injector constructs them with full DI — their own constructor
dependencies (e.g. a PrismaService) resolve normally. The factory returns
config only (jwtSecret, TTLs):
import { AuthNModule } from '@devoven/authn';
import { ConfigService } from '@nestjs/config';
@Module({
imports: [
AuthNModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
jwtSecret: config.getOrThrow('JWT_SECRET'),
accessTokenTtlSeconds: config.get('ACCESS_TOKEN_TTL'),
}),
// Adapters — passed as classes, wired via Nest DI. No manual
// `new PrismaUserRepository(prisma)`; deps resolve from the injector.
userRepository: PrismaUserRepository,
refreshTokenRepository: PrismaRefreshTokenRepository,
verificationTokenRepository: PrismaVerificationTokenRepository,
resetTokenRepository: PrismaResetTokenRepository,
}),
],
})
export class AppModule {}The adapter classes' dependencies must be resolvable in the module's injection context — either exported by a
@Global()module (e.g. a globalPrismaModule) or provided via theimportsarray ofregisterAsync.
REST API
All endpoints are served under the /auth prefix. The controller applies AuthGuard globally — routes marked @Public() skip token validation.
| Method | Path | Auth | Description | Status |
|--------|------|------|-------------|--------|
| POST | /auth/register | Public | Register a new user | 204 |
| POST | /auth/login | Public | Log in and receive tokens | 200 |
| POST | /auth/verify-email | Public | Verify email using a token | 204 |
| POST | /auth/reset-password | Public | Set a new password using a reset token | 204 |
| POST | /auth/refresh | Public | Exchange a refresh token for a new token pair | 200 |
| POST | /auth/change-password | Bearer | Change password for the authenticated user | 204 |
| POST | /auth/revoke | Bearer | Revoke a specific refresh token | 204 |
POST /auth/register
Request body:
{
"identifier": "[email protected]",
"password": "secret123",
"metadata": { "name": "Alice" }
}| Field | Type | Validation |
|-------|------|------------|
| identifier | string | Required |
| password | string | Required, min 8 characters |
| metadata | object | Optional |
Response 204 No Content.
POST /auth/login
Request body:
{
"identifier": "[email protected]",
"password": "secret123"
}Response 200 OK:
{
"accessToken": "<jwt>",
"refreshToken": "<uuid>"
}POST /auth/verify-email
Request body:
{ "token": "<verification-token>" }Response 204 No Content.
POST /auth/reset-password
Request body:
{
"token": "<reset-token>",
"newPassword": "newSecret123"
}Response 204 No Content.
POST /auth/refresh
Request body:
{ "refreshToken": "<uuid>" }Response 200 OK: same shape as /auth/login.
POST /auth/change-password
Requires Authorization: Bearer <accessToken>.
Request body:
{
"currentPassword": "secret123",
"newPassword": "newSecret123"
}Response 204 No Content.
POST /auth/revoke
Requires Authorization: Bearer <accessToken>.
Request body:
{ "refreshToken": "<uuid>" }Response 204 No Content.
Guards and Decorators
AuthGuard
AuthGuard validates the Authorization: Bearer <token> header on every request. On success it populates request.user with the decoded token payload (ITokenPayload, which always includes sub: string).
Apply it globally or per-controller:
import { AuthGuard } from '@devoven/authn';
// Global
app.useGlobalGuards(app.get(AuthGuard));
// Per-controller
@UseGuards(AuthGuard)
@Controller('orders')
export class OrdersController {}AuthGuard is exported from the module and can be injected directly.
@Public()
Skip AuthGuard for a specific route or controller:
import { Public } from '@devoven/authn';
@Public()
@Get('health')
healthCheck() { ... }@CurrentUser()
Inject the authenticated token payload into a route handler parameter:
import { CurrentUser } from '@devoven/authn';
import type { ITokenPayload } from '@devoven/authn';
@Get('profile')
getProfile(@CurrentUser() user: ITokenPayload) {
return user.sub; // user ID
}ITokenPayload is { sub: string; [key: string]: unknown }. Additional claims added during token signing are accessible as extra keys.
Architecture
The module follows hexagonal architecture. authn.module.ts is the composition root — the only place concrete adapters are wired to port tokens.
Driving Port / Token / Use Case Mapping
These are the inbound ports used by the controller and exported for injection in consuming apps:
| Port Interface | DI Token (TOKENS.*) | Use Case |
|----------------|----------------------|----------|
| RegisterPort | RegisterPort | RegisterUseCase |
| LoginPort | LoginPort | LoginUseCase |
| IssueTokenPairPort | IssueTokenPairPort | IssueTokenPairUseCase |
| RefreshTokenPairPort | RefreshTokenPairPort | RefreshTokenPairUseCase |
| RevokeRefreshTokenPort | RevokeRefreshTokenPort | RevokeRefreshTokenUseCase |
| ValidateAccessTokenPort | ValidateAccessTokenPort | ValidateAccessTokenUseCase |
| RequestEmailVerificationPort | RequestEmailVerificationPort | RequestEmailVerificationUseCase |
| RequestPasswordResetPort | RequestPasswordResetPort | RequestPasswordResetUseCase |
| ResetPasswordPort | ResetPasswordPort | ResetPasswordUseCase |
| VerifyEmailPort | VerifyEmailPort | VerifyEmailUseCase |
| ChangePasswordPort | ChangePasswordPort | ChangePasswordUseCase |
| LinkAccountPort | LinkAccountPort | LinkAccountUseCase |
Driven Port / Token Mapping
These are the outbound ports that consuming apps must implement:
| Port Interface | DI Token (TOKENS.*) | Default |
|----------------|----------------------|---------|
| UserRepositoryPort | UserRepositoryPort | — (required) |
| RefreshTokenRepositoryPort | RefreshTokenRepositoryPort | — (required) |
| VerificationTokenRepositoryPort | VerificationTokenRepositoryPort | — (required) |
| ResetTokenRepositoryPort | ResetTokenRepositoryPort | — (required) |
| TokenProviderPort | TokenProviderPort | JwtTokenProvider |
| PasswordHasherPort | PasswordHasherPort | BcryptPasswordHasher |
Import TOKENS from @devoven/authn to inject any of these by token string.
Custom Adapters
Implementing a User Repository
import { Injectable } from '@nestjs/common';
import { UserRepositoryPort, AuthUserEntity } from '@devoven/authn';
@Injectable()
export class PrismaUserRepository implements UserRepositoryPort {
constructor(private readonly prisma: PrismaService) {}
async save(user: AuthUserEntity<Record<string, unknown>>): Promise<void> {
await this.prisma.user.upsert({ /* ... */ });
}
async findByIdentifier(identifier: string) {
const row = await this.prisma.user.findUnique({ where: { email: identifier } });
if (!row) return null;
return AuthUserEntity.reconstitute(
row.id,
row.email,
row.passwordHash,
row.metadata,
[],
row.emailVerifiedAt,
row.createdAt,
);
}
async findById(id: string) { /* ... */ }
async findByProvider(provider: string, providerId: string) { /* ... */ }
async existsByIdentifier(identifier: string) { /* ... */ }
async existsByProvider(provider: string, providerId: string) { /* ... */ }
}Use AuthUserEntity.reconstitute(...) to rebuild entities from persistence. Never construct the entity directly.
RefreshTokenRepositoryPort
RefreshTokenRepositoryPort<T> is generic. The type parameter T represents optional extra metadata the consumer can attach to each refresh token (for example, device name, IP address, or any application-specific payload). If you do not need extra metadata, use RefreshTokenRepositoryPort<never> or RefreshTokenRepositoryPort<Record<string, unknown>>.
interface RefreshTokenRepositoryPort<T> {
save(token: RefreshTokenEntity<T>): Promise<void>;
findByToken(token: string): Promise<RefreshTokenEntity<T> | null>;
revokeByToken(token: string): Promise<void>;
revokeAllByUserId(userId: string): Promise<void>;
}VerificationTokenRepositoryPort
interface VerificationTokenRepositoryPort {
save(token: EmailVerificationTokenEntity): Promise<void>;
findByToken(token: string): Promise<EmailVerificationTokenEntity | null>;
markUsed(token: string): Promise<void>;
}ResetTokenRepositoryPort
interface ResetTokenRepositoryPort {
save(token: PasswordResetTokenEntity): Promise<void>;
findByToken(token: string): Promise<PasswordResetTokenEntity | null>;
markUsed(token: string): Promise<void>;
}PasswordHasherPort
Override the default BcryptPasswordHasher by implementing this interface and passing it via passwordHasher in module options:
interface PasswordHasherPort {
hash(plaintext: string): Promise<string>;
compare(plaintext: string, hashed: string): Promise<boolean>;
}Implementing a Token Provider
Replace JWT with any signing mechanism by implementing TokenProviderPort:
interface TokenProviderPort {
sign(payload: ITokenPayload, expiresInSeconds: number): Promise<string>;
verify(token: string): Promise<ITokenPayload>;
}Example:
import { Injectable } from '@nestjs/common';
import { TokenProviderPort, ITokenPayload } from '@devoven/authn';
@Injectable()
export class CustomTokenProvider implements TokenProviderPort {
async sign(payload: ITokenPayload, expiresInSeconds: number): Promise<string> { /* ... */ }
async verify(token: string): Promise<ITokenPayload> { /* ... */ }
}Pass it via module options:
AuthNModule.register({
tokenProvider: CustomTokenProvider,
// jwtSecret is not needed when tokenProvider is overridden
userRepository: ...,
/* ... */
})In-memory repositories (InMemoryUserRepository, InMemoryRefreshTokenRepository, InMemoryVerificationTokenRepository, InMemoryResetTokenRepository) are exported for use in tests.
