@elchinabilov/nestjs-auth
v2.0.0
Published
Modular, configurable and production-ready authentication system for NestJS: cookie, bearer/JWT, session, API key, OAuth, OTP, magic link, 2FA and RBAC.
Maintainers
Readme
@elchinabilov/nestjs-auth
A modular, configurable and production-ready authentication system for NestJS. One package, every common strategy — cookie, bearer/JWT, server-side sessions, API keys, OAuth, OTP, reverse OTP (inbound WhatsApp / Telegram), magic links, TOTP 2FA — plus a full RBAC/permissions layer and multi-device session management.
The library is ORM-agnostic and adapter-driven: you plug in your own database, cache, mail and SMS implementations. It ships no controllers and no routes — you stay in control of your API surface and call the provided services and guards from your own code.
Table of contents
- Install
- Quick start
- Configuration
- Adapters
- Authentication methods
- Authorization (RBAC + permissions)
- Device & session management
- Guards, decorators & services reference
- Error handling
- Testing
Install
npm install @elchinabilov/nestjs-auth
# peer deps (you very likely already have these)
npm install @nestjs/common @nestjs/core @nestjs/jwt reflect-metadata rxjsFor cookie auth, enable cookie-parser in your bootstrap:
import * as cookieParser from 'cookie-parser';
app.use(cookieParser(process.env.COOKIE_SECRET)); // secret only needed for signed cookiesNo native dependencies. Password hashing (scrypt), TOTP (HMAC-SHA1) and all token/crypto primitives use Node's built-in
crypto.
Quick start
// auth.config.ts
import { AuthModule } from '@elchinabilov/nestjs-auth';
import { Module } from '@nestjs/common';
import { PrismaUserAdapter } from './prisma-user.adapter';
@Module({
imports: [
AuthModule.forRoot({
global: true,
jwt: {
secret: process.env.JWT_SECRET!,
accessToken: { expiresIn: '15m' },
refreshToken: { expiresIn: '7d' },
},
cookie: { enabled: true, secure: true, sameSite: 'lax' },
session: { enabled: true, ttl: 60 * 60 * 24 * 7, maxPerUser: 5 },
userAdapter: PrismaUserAdapter,
}),
],
})
export class AppModule {}// auth.controller.ts
import { Controller, Post, Body, Req, Res, UseGuards } from '@nestjs/common';
import {
AuthService,
AuthGuard,
CurrentUser,
Public,
AuthUser,
} from '@elchinabilov/nestjs-auth';
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Public()
@Post('login')
async login(@Body() dto: LoginDto, @Req() req, @Res({ passthrough: true }) res) {
const { user, tokens } = await this.auth.loginWithCredentials(
dto.email,
dto.password,
{ request: req, response: res }, // sets HttpOnly cookies automatically
);
return { user: { id: user.id, email: user.email }, tokens };
}
@UseGuards(AuthGuard)
@Post('logout')
async logout(@CurrentUser() user: AuthUser, @Req() req, @Res({ passthrough: true }) res) {
await this.auth.logout({ sessionId: req.user.sessionId, response: res });
return { ok: true };
}
}Configuration
forRoot
Every section is optional except jwt. Defaults shown below are applied
automatically by AuthConfigService.
AuthModule.forRoot({
global: false, // register guards/services app-wide
jwt: {
secret: process.env.JWT_SECRET!, // or privateKey/publicKey for RS256
issuer: 'my-app',
audience: 'my-app-clients',
accessToken: { expiresIn: '15m' },// default 15m
refreshToken: { expiresIn: '7d' }, // default 7d, can use a separate secret
},
cookie: {
enabled: true,
accessTokenName: 'access_token',
refreshTokenName: 'refresh_token',
httpOnly: true, // default true
secure: true, // default true
sameSite: 'lax', // 'lax' | 'strict' | 'none'
domain: '.example.com',
path: '/',
signed: false,
},
csrf: {
enabled: true, // double-submit-cookie protection
cookieName: 'csrf_token',
headerName: 'x-csrf-token',
protectedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
},
session: {
enabled: true,
ttl: 60 * 60 * 24 * 7, // seconds
rolling: true, // refresh expiry on activity
maxPerUser: 5, // 0 = unlimited; evicts oldest beyond cap
},
apiKey: {
enabled: true,
header: 'x-api-key',
prefix: 'sk_',
keys: { 'sk_live_123': { name: 'billing-svc', roles: ['service'] } },
// or: validate: async (key) => db.apiKeys.findActive(key),
},
otp: { length: 6, ttl: 300, maxRetries: 5, resendCooldown: 60, alphanumeric: false },
// Reverse / inbound OTP — the user sends a server-issued code BACK over a
// chat channel. Inert unless `enabled`; register an adapter per channel.
reverseOtp: { enabled: true, length: 6, ttl: 300, alphanumeric: false },
inboundAdapters: [
new TelegramInboundAdapter({ secretToken: process.env.TG_WEBHOOK_SECRET }),
new MyTwilioWhatsAppAdapter(), // your subclass of WhatsAppInboundAdapter
],
magicLink: { ttl: 900, baseUrl: 'https://app.com/auth/magic', tokenParam: 'token' },
twoFactor: { issuer: 'My App', window: 1, backupCodeCount: 10 },
oauth: {
google: { clientId: '...', clientSecret: '...', callbackUrl: 'https://app.com/auth/google/callback' },
github: { clientId: '...', clientSecret: '...', callbackUrl: 'https://app.com/auth/github/callback' },
},
// Adapters (class or ready instance)
userAdapter: MyUserAdapter,
cache: new RedisCacheAdapter(redis), // defaults to in-memory if omitted
mail: MyMailAdapter,
sms: MyTwilioAdapter,
});forRootAsync with ConfigModule
AuthModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
jwt: { secret: config.getOrThrow('JWT_SECRET') },
cookie: { enabled: true, secure: config.get('NODE_ENV') === 'production' },
session: { enabled: true },
}),
});useClass / useExisting factories implementing AuthOptionsFactory are also
supported. When using forRootAsync, prefer passing adapter instances (not
classes) so they are wired exactly as you construct them.
Adapters
Adapters are how the package talks to your infrastructure. Only UserAdapter is
needed for stateful flows; the cache adapter defaults to in-memory.
| Token | Interface | Required for |
| --- | --- | --- |
| userAdapter | UserAdapter | credentials login, OAuth linking, 2FA persistence |
| cache | CacheAdapter | sessions, OTP, magic links, OAuth state (Redis in prod) |
| mail | MailAdapter | email OTP, magic links |
| sms | SmsAdapter | SMS OTP |
| inboundAdapters | InboundChannelAdapter[] | reverse OTP (inbound WhatsApp / Telegram) |
// Example: Prisma user adapter
import { Injectable } from '@nestjs/common';
import { UserAdapter, AuthUser, PasswordService } from '@elchinabilov/nestjs-auth';
@Injectable()
export class PrismaUserAdapter implements UserAdapter {
constructor(private prisma: PrismaService, private passwords: PasswordService) {}
async findById(id: string) {
return this.prisma.user.findUnique({ where: { id } }) as Promise<AuthUser | null>;
}
async validateCredentials(email: string, password: string) {
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user || !(await this.passwords.verify(password, user.passwordHash))) return null;
return user as AuthUser;
}
async upsertOAuthUser(profile) {
return this.prisma.user.upsert({
where: { email: profile.email! },
update: {},
create: { email: profile.email!, name: profile.displayName },
}) as Promise<AuthUser>;
}
}A RedisCacheAdapter is ~20 lines on top of ioredis implementing the
CacheAdapter interface (get/set/del/incr/keys).
Authentication methods
1. Cookie-based authentication
Enable cookie options and pass the response to login — HttpOnly access &
refresh cookies are set with your configured Secure/SameSite/domain. The
AuthGuard reads them automatically.
await this.auth.login(user, { request: req, response: res }); // sets cookies
await this.auth.refresh(req.cookies.refresh_token, { response: res }); // rotates
await this.auth.logout({ sessionId: req.user.sessionId, response: res }); // clearsRefresh token rotation is built in (see sessions).
CSRF: enable csrf and issue a token with CsrfService.issue(res); the
guard validates the header against the cookie on state-changing requests.
2. Bearer / JWT authentication
@UseGuards(AuthGuard)
@Get('me')
me(@CurrentUser() user: AuthUser) { return user; }Authorization: Bearer <token> is validated against the access-token secret.
Access and refresh tokens may use separate secrets and lifetimes; the type
claim prevents using one as the other. Restrict a route to specific strategies
with @AuthMethods(AuthMethod.BEARER).
3. Session-based authentication
When session.enabled, login creates a server-side session (in your cache
adapter) and embeds its id (sid) in the JWT. Every request re-validates the
session, so you can revoke tokens server-side — true logout, not just an
expiring JWT. Supports multi-device, rolling expiry, maxPerUser caps and
refresh-token rotation with reuse detection (a replayed refresh token kills
the session).
4. API key authentication
@UseGuards(ApiKeyGuard) // x-api-key header
@Get('internal/metrics')
metrics() {}
@UseGuards(ApiKeyOrAuthGuard) // accept a key OR a user token
@Get('data')
data() {}Keys resolve to a principal carrying roles/permissions, so RBAC guards work
for services too. Use a static keys map or an async validate callback.
5. OAuth authentication
@Public() @Get('google')
async google(@Res() res) {
const { url } = await this.oauth.getAuthorizationUrl('google', {
redirectUrl: '/dashboard',
});
res.redirect(url);
}
@Public() @Get('google/callback')
async callback(@Query('code') code, @Query('state') state, @Req() req, @Res({ passthrough: true }) res) {
const { profile, redirectUrl } = await this.oauth.handleCallback('google', code, state);
const user = await this.userAdapter.upsertOAuthUser(profile);
return this.auth.login(user, { request: req, response: res });
}Built-in Google and GitHub providers; the state nonce is stored in the
cache for CSRF protection. Add any provider by implementing OAuthProvider and
registering it (config under oauth.<name>, then oauthService.registerProvider).
6. OTP authentication
await this.otp.send({ channel: OtpChannel.EMAIL, destination: '[email protected]' });
await this.otp.verify('[email protected]', code, OtpPurpose.LOGIN); // throws on bad/expiredNumeric or alphanumeric codes, configurable length/TTL, per-code retry limit,
resend cooldown, single-use, hashed at rest. Email works out of the box;
SMS is an optional adapter (sms).
7. Reverse OTP authentication
Reverse (inbound) OTP flips the usual flow: instead of the server delivering a code, the server issues a code and the user sends it back from their own WhatsApp or Telegram. That inbound message proves the user controls the account/number. The package mints + verifies the code and tracks the session; it never sends anything outbound and stays provider-agnostic — provider logic lives entirely in your inbound adapters.
// 1. Create a verification session and show the code + target to the user.
// e.g. "Send TELEGRAM the code 482913 to @my_login_bot"
const { sessionId, code } = await this.reverseOtp.createSession({
channel: ReverseOtpChannel.TELEGRAM,
identifier: pendingUserId, // optional app-level binding
});
// 2. In your webhook route, hand the raw request to the service. The matching
// adapter authenticates + normalizes it, then any code is matched.
@Public() @Post('webhooks/telegram')
async telegram(@Body() body, @Headers() headers) {
await this.reverseOtp.handleInbound(ReverseOtpChannel.TELEGRAM, { body, headers });
return { ok: true };
}
// 3. Poll (or react) on the session status to complete your login flow.
const status = await this.reverseOtp.getStatus(sessionId); // pending | approved | denied | expired
if (status === ReverseOtpStatus.APPROVED) {
const session = await this.reverseOtp.getSession(sessionId);
// session.verifiedSender → the proven phone number / Telegram user id
}State lives entirely in the cache adapter (no database), codes are
single-use and hashed at rest, and the feature is inert unless
reverseOtp.enabled and an adapter for the channel is registered.
Telegram works out of the box via TelegramInboundAdapter, which normalizes
the official Bot API Update payload and (optionally) verifies the
X-Telegram-Bot-Api-Secret-Token header. WhatsApp is provider-agnostic:
extend WhatsAppInboundAdapter for Twilio, Meta Cloud API, Infobip, 360dialog,
etc.
// Provider-specific WhatsApp adapter (example: Twilio)
import { WhatsAppInboundAdapter, InboundWebhookRequest } from '@elchinabilov/nestjs-auth';
export class MyTwilioWhatsAppAdapter extends WhatsAppInboundAdapter {
parse(req: InboundWebhookRequest) {
const b = req.body as { From?: string; Body?: string; MessageSid?: string };
if (!b?.From || !b?.Body) return [];
return [this.message({
from: b.From.replace(/^whatsapp:/, ''),
text: b.Body,
messageId: b.MessageSid,
raw: b,
})];
}
// optional: override verify(req) to check the X-Twilio-Signature header
}Every adapter returns the same normalized InboundMessage
(channel, from, text, messageId?, senderName?, timestamp?, raw?),
so the core never depends on a provider. A webhook may yield several messages;
handleInbound returns one result per message. Register adapters at startup via
inboundAdapters, or at runtime with reverseOtp.registerAdapter(adapter).
Custom code extraction from message text is configurable via
reverseOtp.extractCodes.
8. Magic link authentication
await this.magicLink.sendToEmail('[email protected]', { redirectUrl: '/welcome' });
// on click:
const { identifier, redirectUrl } = await this.magicLink.verify(token);
const user = await this.userAdapter.findByEmail!(identifier);
await this.auth.login(user, { request: req, response: res });Tokens are one-time-use, time-limited, hashed at rest, with optional redirect URL support.
9. Two-factor authentication
// enable
const e = this.twoFactor.generateEnrollment(user.email);
await this.userAdapter.saveTwoFactorSecret!(user.id, e.secret, e.hashedBackupCodes);
return { otpauthUrl: e.otpauthUrl, backupCodes: e.backupCodes }; // show once
// verify (Google Authenticator / Authy / 1Password compatible)
const ok = this.twoFactor.verifyToken(secret, code)
|| !!this.twoFactor.verifyBackupCode(code, hashedBackupCodes);Protect sensitive routes with @RequireTwoFactor() + TwoFactorGuard. Mark a
session 2FA-verified via sessionService.markTwoFactorVerified(sid) or by
passing twoFactorVerified: true to auth.login.
Authorization
@UseGuards(AuthGuard, RolesGuard, PermissionsGuard)
@Roles('admin', 'editor') // any of these roles
@Permissions('posts:write') // all listed permissions
@Post('posts')
create() {}
@AnyPermission('posts:read', 'posts:write') // at least one
@Get('posts')
list() {}@Roles(...)→RolesGuard(RBAC, any-of)@Permissions(...)→PermissionsGuard(all-of),@AnyPermission(...)(any-of)@Public()→ bypass auth on a route even under a global guard@CurrentUser()/@CurrentUser('id')→ inject the user / a field@AuthContext()→ inject{ user, sessionId, method, device, twoFactorVerified }
Register globally with APP_GUARD for secure-by-default:
{ provide: APP_GUARD, useClass: AuthGuard }Device & session management
this.sessionService.listForUser(userId); // active sessions (multi-device)
this.sessionService.revoke(sessionId); // sign out one device
this.sessionService.revokeAllForUser(userId, keep); // sign out all (except current)
this.deviceService.extract(req); // { ip, userAgent, browser, os, deviceType, fingerprint }IP and user-agent are tracked per session (best-effort parsing, no extra deps).
API reference
Services: AuthService, TokenService, PasswordService, CookieService,
CsrfService, SessionService, ApiKeyService, OtpService,
ReverseOtpService, MagicLinkService, TwoFactorService, DeviceService,
OAuthService, AuthUserService, AuthConfigService.
Guards: AuthGuard, ApiKeyGuard, ApiKeyOrAuthGuard, RolesGuard,
PermissionsGuard, TwoFactorGuard.
Decorators: @Public, @Roles, @Permissions, @AnyPermission,
@CurrentUser, @AuthContext, @AuthMethods, @RequireTwoFactor.
All are exported from the package root with full TypeScript types.
Error handling
Failures throw typed exceptions carrying a stable machine code and the right
HTTP status — never leaking secrets or stack traces:
InvalidCredentialsException, InvalidTokenException, TokenExpiredException,
MissingTokenException, SessionExpiredException, InvalidApiKeyException,
OtpException, ReverseOtpException, TwoFactorRequiredException,
InvalidCsrfTokenException, InsufficientPermissionsException,
AuthConfigurationError.
// example 401 body
{ "statusCode": 401, "message": "Token has expired", "code": "token_expired", "error": "Unauthorized" }Testing
Every service is a plain injectable with explicit dependencies — trivial to unit-test. The package itself ships unit tests plus a DI-graph integration test:
npm testLicense
MIT © Elchin Abilov
