@ambushsoftworks/nestjs-auth-graphql
v0.6.7
Published
Production-grade authentication package for NestJS with GraphQL, supporting JWT, OAuth, email/SMS verification, and biometric auth
Maintainers
Readme
@ambushsoftworks/nestjs-auth-graphql
Production-grade authentication module for NestJS GraphQL + REST APIs. JWT, cookie auth, multi-tenancy, OAuth, email verification, and more -- with zero database coupling.
Table of Contents
- Design Principles
- Installation
- Quick Start
- Why BaseAuthResolver?
- Features
- Configuration Reference
- Decorators
- Guards
- Utilities
- Security Features
- License
Design Principles
- Interface-driven persistence -- All storage is behind interfaces (
IUserRepository,IRefreshTokenRepository,ITenantRepository, etc.). Bring your own database. - Instance-based DI -- Repositories and services are injected as instances via
useFactory, not as classes. - Consumer owns GraphQL types -- The package provides
BaseAuthResolver<T>withprotected perform*()methods. You create your own resolver with@Mutation()decorators (see Why BaseAuthResolver?). - Guards are exported, not auto-registered -- You register guards in your own
APP_GUARDchain to control execution order. - NoOp fallbacks -- Every optional dependency has a no-op implementation, so the module works with minimal config.
Installation
npm install @ambushsoftworks/nestjs-auth-graphqlPeer Dependencies
npm install @nestjs/common @nestjs/core @nestjs/graphql @nestjs/jwt @nestjs/passport @nestjs/throttler graphql passport passport-jwt reflect-metadata rxjsresend is an optional peer dependency -- install it only if using the Resend email sender.
Quick Start
Minimal setup: JWT authentication with email/password login.
1. Implement the required repositories
// users.repository.ts
import { Injectable } from '@nestjs/common';
import { IUserRepositoryCore, CreateUserData } from '@ambushsoftworks/nestjs-auth-graphql';
@Injectable()
export class UsersRepository implements IUserRepositoryCore<User> {
async findByEmail(email: string): Promise<User | null> { /* ... */ }
async findById(id: string): Promise<User | null> { /* ... */ }
async create(data: CreateUserData): Promise<User> { /* ... */ }
// ... implement remaining IUserRepositoryCore methods
}// refresh-token.repository.ts
import { Injectable } from '@nestjs/common';
import { IRefreshTokenRepository } from '@ambushsoftworks/nestjs-auth-graphql';
@Injectable()
export class RefreshTokenRepository implements IRefreshTokenRepository {
async create(data: {
userId: string;
token: string;
hashedToken: string;
expiresAt: Date;
deviceInfo?: string | null;
}): Promise<any> { /* ... */ }
async findByHashedToken(hashedToken: string): Promise<any | null> { /* ... */ }
async deleteByUserId(userId: string): Promise<number> { /* ... */ }
// ... implement remaining methods
}2. Register the module
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuthModule } from '@ambushsoftworks/nestjs-auth-graphql';
@Module({
imports: [
AuthModule.forRootAsync({
imports: [ConfigModule, DatabaseModule],
inject: [UsersRepository, RefreshTokenRepository, ConfigService],
useFactory: (usersRepo, tokenRepo, config) => ({
userRepositoryInstance: usersRepo,
refreshTokenRepositoryInstance: tokenRepo,
jwtSecret: config.get('JWT_SECRET'),
}),
}),
],
providers: [UsersRepository, RefreshTokenRepository],
})
export class AppModule {}3. Create your auth resolver
import { Resolver, Mutation, Args, Context } from '@nestjs/graphql';
import { Inject } from '@nestjs/common';
import {
BaseAuthResolver,
CurrentUser,
AuthService,
BruteForceProtectionService,
USER_REPOSITORY,
AUTH_LOGGER,
} from '@ambushsoftworks/nestjs-auth-graphql';
@Resolver()
export class AuthResolver extends BaseAuthResolver<User> {
constructor(
authService: AuthService,
bruteForceProtection: BruteForceProtectionService,
@Inject(USER_REPOSITORY) userRepository: any,
@Inject(AUTH_LOGGER) authLogger: any,
) {
super(authService, bruteForceProtection, userRepository, authLogger);
}
@Mutation(() => AuthResponse)
async signup(@Args('input') input: SignupInput, @Context() ctx: any) {
return this.performSignup(input, ctx);
}
@Mutation(() => AuthResponse)
async login(@Args('input') input: LoginInput, @Context() ctx: any) {
return this.performLogin(input, ctx);
}
@Mutation(() => AuthResponse)
async refreshToken(@Args('input') input: RefreshTokenInput, @Context() ctx: any) {
return this.performRefreshToken(input, ctx);
}
@Mutation(() => LogoutResponse)
async logout(@Args('input') input: LogoutInput, @CurrentUser() user: User) {
return this.performLogout(input, user);
}
}4. Set up the guard chain
import { APP_GUARD } from '@nestjs/core';
import { createAuthGuard } from '@ambushsoftworks/nestjs-auth-graphql';
@Module({
providers: [
{
provide: APP_GUARD,
useClass: createAuthGuard(['jwt'], { allowPublic: true }),
},
],
})
export class AppModule {}Mark public routes with @Public():
import { Public } from '@ambushsoftworks/nestjs-auth-graphql';
@Public()
@Mutation(() => AuthResponse)
async login() { /* ... */ }Why BaseAuthResolver?
TypeScript decorator metadata is not preserved when importing from compiled npm packages. If the package exported a resolver with @Mutation(() => AuthResponse), NestJS would throw "Cannot determine GraphQL output type" at runtime.
BaseAuthResolver<T> solves this by providing only business logic via protected perform*() methods. You add the GraphQL decorators in your own code, where metadata resolution works correctly.
When cookie auth is enabled, pass the GraphQL context so tokens are set as HttpOnly cookies. The response body will contain empty strings for accessToken/refreshToken.
Available perform*() methods
Authentication:
| Method | Purpose |
|--------|---------|
| performSignup(input, context?) | Create account |
| performLogin(input, context) | Authenticate |
| performRefreshToken(input, context?) | Rotate tokens |
| performLogout(input, user, context?) | Invalidate token |
| performLogoutAll(user, context?) | Invalidate all tokens |
| performGetCurrentUser(userId) | Get current user |
Verification:
| Method | Purpose |
|--------|---------|
| performVerifyEmail(input, context?) | Email verification |
| performResendVerificationEmail(email) | Resend verification email |
| performSendPhoneVerification(input, user) | Send SMS verification code |
| performVerifyPhone(input, user) | Verify phone with SMS code |
| performResendPhoneVerification(phoneNumber, user) | Resend phone verification |
| performRemovePhoneNumber(user) | Remove phone number from account |
| performPhoneVerificationStatus(user) | Get phone verification status |
Password:
| Method | Purpose |
|--------|---------|
| performRequestPasswordReset(input, context?) | Send reset code/token |
| performResetPassword(input, context?) | Reset password |
| performChangePassword(userId, current, new) | Change password |
Other:
| Method | Purpose |
|--------|---------|
| performCheckAccountLockStatus(email) | Check brute force lock status |
| performCompleteFacebookSignUp(input) | Facebook email fallback |
Features
Cookie Authentication
Enable features.cookieAuth to deliver tokens via HttpOnly cookies instead of response bodies.
AuthModule.forRootAsync({
useFactory: () => ({
// ...required options
features: { cookieAuth: true },
cookie: {
httpOnly: true, // default
secure: true, // default
sameSite: 'lax', // default
domain: undefined, // browser uses request domain
useHostPrefix: true, // prefix names with __Host- for enhanced security
},
}),
})When useHostPrefix: true, cookie names become __Host-access_token and __Host-refresh_token. The module validates at startup that secure is true, path is /, and domain is unset (as required by the __Host- spec).
You can also customize cookie names and max ages -- see Cookie Options.
The JWT strategy automatically reads from cookies first, then falls back to Authorization: Bearer headers.
CSRF Protection
With cookie auth, register CsrfGuard to protect mutations. It validates that a configurable header (default: X-Requested-With) is present when the request is authenticated via cookies.
import { APP_GUARD } from '@nestjs/core';
import { createAuthGuard, CsrfGuard } from '@ambushsoftworks/nestjs-auth-graphql';
@Module({
providers: [
{ provide: APP_GUARD, useClass: createAuthGuard(['jwt'], { allowPublic: true }) },
{ provide: APP_GUARD, useClass: CsrfGuard },
],
})Configure via csrf options:
csrf: {
headerName: 'X-Requested-With', // default
requireInProduction: true, // default
exemptOperations: ['refreshToken'], // skip CSRF for specific operations
}The guard automatically skips when:
- The request uses
Authorizationheader (Bearer/API key) - The route has
@Public() - Cookie auth is not enabled
requireInProductionis false and not in production- The GraphQL operation is in
exemptOperations
Multi-Tenancy
Enable tenant resolution and permission checking across requests.
AuthModule.forRootAsync({
useFactory: (tenantRepo, tenantExtractor) => ({
// ...required options
features: { multiTenancy: true },
tenantRepositoryInstance: tenantRepo,
tenantExtractorInstance: tenantExtractor,
}),
})Guard chain (order matters):
providers: [
{ provide: APP_GUARD, useClass: createAuthGuard(['jwt'], { allowPublic: true }) },
{ provide: APP_GUARD, useClass: TenantGuard },
{ provide: APP_GUARD, useClass: PermissionGuard },
]ITenantRepository resolves whether a user has access to a tenant:
@Injectable()
class TenantRepository implements ITenantRepository {
async resolveTenant(userId: string, tenantId: string): Promise<ITenantContext | null> {
const membership = await this.db.membership.find({ userId, tenantId });
if (!membership) return null;
return {
tenantId,
permissions: membership.role.permissions,
metadata: { accountId: membership.accountId },
};
}
}ITenantExtractor pulls the tenant ID from the request. Use the built-in HeaderTenantExtractor (reads x-tenant-id header) or implement your own:
import { HeaderTenantExtractor } from '@ambushsoftworks/nestjs-auth-graphql';
// Default: reads x-tenant-id header
const extractor = new HeaderTenantExtractor();
// Custom header name
const extractor = new HeaderTenantExtractor('x-org-id');Access tenant context in resolvers:
@Query(() => [Item])
async items(@CurrentTenant() tenant: ITenantContext) {
return this.service.findByTenant(tenant.tenantId);
}Skip tenant resolution for routes that don't need it:
@SkipTenant()
@Query(() => User)
async me(@CurrentUser() user: User) { /* ... */ }Permission checking with @RequirePermissions():
@RequirePermissions('clients:read')
@Query(() => [Client])
async clients(@CurrentTenant() tenant: ITenantContext) { /* ... */ }For resource-scoped permissions, combine with @ResourceScope() and provide an IResourcePermissionRepository:
@RequirePermissions('clients:write')
@ResourceScope('client', 'clientId')
@Mutation(() => Client)
async updateClient(@Args('clientId') clientId: string) { /* ... */ }API Key Authentication
For machine-to-machine auth, provide an IApiKeyRepository:
AuthModule.forRootAsync({
useFactory: (apiKeyRepo) => ({
// ...required options
apiKeyRepositoryInstance: apiKeyRepo,
}),
})Then use a multi-strategy guard:
{ provide: APP_GUARD, useClass: createAuthGuard(['api-key', 'jwt'], { allowPublic: true }) }The ApiKeyStrategy hashes the bearer token with SHA-256 and calls findByKeyHash() on your repository. API keys and JWT tokens use the same Authorization: Bearer header -- strategies are tried in the order specified. When no bearer token is present (e.g. cookie auth), the API key strategy gracefully falls through to the next strategy.
Email System
The email system uses a composable architecture: a sender (transport) and a template renderer (HTML generation).
import { SendGridEmailSender } from '@ambushsoftworks/nestjs-auth-graphql';
AuthModule.forRootAsync({
useFactory: (config) => ({
// ...required options
email: {
sender: new SendGridEmailSender(config.get('SENDGRID_API_KEY')),
from: { email: '[email protected]', name: 'MyApp' },
branding: {
appName: 'MyApp',
primaryColor: '#1976D2',
logoUrl: 'https://example.com/logo.png',
companyName: 'My Company',
supportEmail: '[email protected]',
},
},
}),
})email.branding is required when using the default template renderer. If you provide a custom email.templateRenderer, branding can be omitted.
Built-in senders: SendGridEmailSender, ResendEmailSender, NoOpEmailSender
Custom sender: Implement IEmailSender:
class SmtpEmailSender implements IEmailSender {
async send(params: {
to: string;
from: { email: string; name?: string };
subject: string;
html: string;
text?: string;
}) {
// your SMTP logic
}
}Custom template renderer: Override DefaultEmailTemplateRenderer by providing email.templateRenderer:
email: {
sender: new SendGridEmailSender(apiKey),
from: { email: '[email protected]' },
templateRenderer: new MyCustomRenderer(),
}Verification Modes
Two modes for email verification and password reset:
'code'(default) -- 6-digit numeric codes'token'-- URL-based tokens with configurable base URL
features: { verificationMode: 'token' },
verification: {
baseUrl: 'https://app.example.com',
tokenLength: 64, // bytes, default: 64
tokenExpiresInMinutes: 60, // default: 60
},OAuth
Google and Facebook OAuth with encrypted token storage (AES-256-GCM).
AuthModule.forRootAsync({
useFactory: (config) => ({
// ...required options
encryptionKey: config.get('ENCRYPTION_KEY'), // 32-byte hex string
oauth: {
google: {
clientId: config.get('GOOGLE_CLIENT_ID'),
clientSecret: config.get('GOOGLE_CLIENT_SECRET'),
callbackUrl: config.get('GOOGLE_CALLBACK_URL'),
},
facebook: {
clientId: config.get('FACEBOOK_CLIENT_ID'),
clientSecret: config.get('FACEBOOK_CLIENT_SECRET'),
callbackUrl: config.get('FACEBOOK_CALLBACK_URL'),
},
},
}),
})The OAuthController is automatically registered and handles OAuth callback routes. OAuth strategies gracefully degrade to no-ops when credentials are not configured.
Brute Force Protection
When features.bruteForceProtection is enabled and a bruteForceRepositoryInstance is provided, the module tracks failed login attempts and temporarily locks accounts. The lockout policy (attempt thresholds, lockout duration) is determined by your IBruteForceRepository implementation.
Password Policy
passwordPolicy: {
minLength: 12, // default: 8
maxLength: 72, // bcrypt limit
requireUppercase: true, // default: true
requireLowercase: true, // default: true
requireNumber: true, // default: true
requireSpecialChar: true, // default: false
customValidator: async (password) => {
// Optional: dictionary checks, leaked password detection, etc.
const isCommon = await checkLeakedPasswords(password);
return {
isValid: !isCommon,
errors: isCommon ? ['Password found in data breaches'] : [],
};
},
}Lifecycle Hooks
React to auth events without modifying core logic:
lifecycleHooksInstance: {
async onSignup(user) { /* create default settings, send analytics */ },
async onLogin(user) { /* update metrics */ },
async onLogout(user) { /* cleanup */ },
async onEmailVerified(user) { /* unlock features */ },
async onPhoneVerified(user) { /* enable 2FA */ },
async onOAuthAccountLinked(user, provider) { /* sync data */ },
async onPasswordReset(user) { /* send security alert */ },
async onPasswordChanged(user) { /* notify user */ },
async onAccountDelete(user) { /* cleanup user data */ },
onAuthFailure(email, ip, reason) { /* security monitoring */ },
}All hooks are optional. Async hooks are awaited but failures are logged and do not block the auth operation. onAuthFailure is synchronous (fire-and-forget).
JWT Validation Modes
'full'(default) -- CallsuserRepository.findById()on every request to verify the user still exists.'payload-only'-- Returns{ id, email }from the JWT payload without a DB lookup. Faster, but does not detect deleted/disabled users until token expiry.
jwtValidation: 'payload-only',JWT Payload Factory
Customize JWT claims by providing an IJwtPayloadFactory:
jwtPayloadFactoryInstance: {
createPayload(user) {
return { sub: user.id, email: user.email, role: user.role };
},
extractUserId(payload) {
return payload.sub;
},
}Configuration Reference
Required Options
| Option | Type | Description |
|--------|------|-------------|
| userRepositoryInstance | IUserRepository | User persistence |
| refreshTokenRepositoryInstance | IRefreshTokenRepository | Token persistence |
| jwtSecret | string | JWT signing secret |
Common Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| jwtExpiresIn | string | '15m' | Access token TTL |
| refreshTokenExpiresIn | string | '30d' | Refresh token TTL |
| jwtValidation | 'full' \| 'payload-only' | 'full' | JWT validation strategy |
| bcryptRounds | number | 12 | Password hashing cost |
| nodeEnv | string | 'production' | Environment identifier |
| passwordPolicy | PasswordPolicyConfig | See Password Policy | Password strength rules |
| encryptionKey | string | -- | 32-byte hex string for AES-256-GCM (OAuth token encryption) |
Feature Flags (features)
| Flag | Default | Description |
|------|---------|-------------|
| cookieAuth | false | Token delivery via HttpOnly cookies |
| multiTenancy | false | Enable TenantGuard + PermissionGuard |
| bruteForceProtection | false | Account lockout on failed logins |
| preventEnumerationOnSignup | false | Return synthetic success for duplicate emails |
| verificationMode | 'code' | 'code' for 6-digit codes, 'token' for URL tokens |
| emailVerification | false | Enable email verification flow |
| smsVerification | false | Enable SMS verification flow |
| googleOAuth | false | Enable Google OAuth |
| facebookOAuth | false | Enable Facebook OAuth |
| biometricAuth | false | Enable biometric authentication |
Security Secrets
Separate HMAC secrets for security isolation. All fall back to jwtSecret if not provided.
| Option | Type | Description |
|--------|------|-------------|
| refreshTokenSecret | string | HMAC secret for refresh token hashing |
| verificationCodeSecret | string | HMAC secret for verification code hashing |
| oauthStateSecret | string | JWT secret for OAuth CSRF state tokens |
| oauthRedirectWhitelist | string | Comma-separated allowed OAuth redirect URLs |
Cookie Options (cookie)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| httpOnly | boolean | true | HttpOnly flag |
| secure | boolean | true | Secure flag |
| sameSite | 'lax' \| 'strict' \| 'none' | 'lax' | SameSite attribute |
| domain | string | undefined | Cookie domain |
| path | string | '/' | Cookie path |
| accessTokenName | string | 'access_token' | Access token cookie name |
| refreshTokenName | string | 'refresh_token' | Refresh token cookie name |
| accessTokenMaxAge | number | derived from jwtExpiresIn | Access token cookie max age (ms) |
| refreshTokenMaxAge | number | derived from refreshTokenExpiresIn | Refresh token cookie max age (ms) |
| useHostPrefix | boolean | false | Prefix names with __Host- |
CSRF Options (csrf)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| headerName | string | 'X-Requested-With' | Header to check |
| requireInProduction | boolean | true | Require CSRF header in production |
| exemptOperations | string[] | [] | GraphQL operations to skip |
Composable Email (email)
| Option | Type | Description |
|--------|------|-------------|
| email.sender | IEmailSender | Transport (SendGrid, Resend, etc.) -- required |
| email.from | { email, name? } | Sender address -- required |
| email.branding | EmailBrandingConfig | App name, colors, logo, etc. -- required unless templateRenderer is provided |
| email.templateRenderer | IEmailTemplateRenderer | Override default HTML renderer |
When email is provided, it takes precedence over emailServiceInstance.
Verification Options (verification)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| tokenLength | number | 64 | Token length in bytes (token mode) |
| tokenExpiresInMinutes | number | 60 | Token expiration (token mode) |
| baseUrl | string | -- | Base URL for verification/reset URLs |
Optional Instance Options
| Option | Interface | Fallback |
|--------|-----------|----------|
| emailServiceInstance | IEmailService | NoOpEmailService |
| smsServiceInstance | ISmsService | NoOpSmsService |
| lifecycleHooksInstance | IAuthLifecycleHooks | {} (no-op) |
| verificationRepositoryInstance | IVerificationRepository | NoOpVerificationRepository |
| bruteForceRepositoryInstance | IBruteForceRepository | NoOpBruteForceRepository |
| biometricRepositoryInstance | IBiometricRepository | NoOpBiometricRepository |
| authLoggerInstance | IAuthLogger | ConsoleAuthLogger |
| tenantRepositoryInstance | ITenantRepository | null |
| tenantExtractorInstance | ITenantExtractor | null |
| resourcePermissionRepositoryInstance | IResourcePermissionRepository | null |
| jwtPayloadFactoryInstance | IJwtPayloadFactory | DefaultJwtPayloadFactory |
| apiKeyRepositoryInstance | IApiKeyRepository | null |
| rateLimiterInstance | IRateLimiter | InMemoryRateLimiterService |
Decorators
| Decorator | Target | Description |
|-----------|--------|-------------|
| @Public() | Method/Class | Skip authentication |
| @PublicEndpoint() | Method/Class | Skip authentication + tenant resolution (combines @Public() + @SkipTenant()) |
| @SkipTenant() | Method/Class | Skip tenant resolution |
| @RequirePermissions('p1', 'p2') | Method/Class | Require specific permissions |
| @CurrentUser() | Parameter | Inject authenticated user |
| @CurrentTenant() | Parameter | Inject resolved tenant context |
| @ResourceScope('resourceType', 'argName') | Method | Resource-level permission scoping |
Guards
| Guard | Description |
|-------|-------------|
| createAuthGuard(strategies, options) | Factory for multi-strategy auth guards |
| CsrfGuard | CSRF header validation for cookie auth |
| TenantGuard | Multi-tenant context resolution |
| PermissionGuard | Permission checking against tenant context |
| JwtAuthGuard | Basic JWT guard (prefer createAuthGuard) |
Recommended Guard Chains
// JWT only
{ provide: APP_GUARD, useClass: createAuthGuard(['jwt'], { allowPublic: true }) }
// JWT + API keys
{ provide: APP_GUARD, useClass: createAuthGuard(['jwt', 'api-key'], { allowPublic: true }) }
// Full stack with cookie auth + multi-tenancy
{ provide: APP_GUARD, useClass: CsrfGuard },
{ provide: APP_GUARD, useClass: createAuthGuard(['jwt', 'api-key'], { allowPublic: true }) },
{ provide: APP_GUARD, useClass: TenantGuard },
{ provide: APP_GUARD, useClass: PermissionGuard },Utilities
Helper functions exported for use in custom guards and middleware:
| Function | Description |
|----------|-------------|
| getRequestFromContext(context) | Extract request from ExecutionContext (handles GraphQL + HTTP) |
| extractIpAddress(request) | Extract client IP (checks clientIp, req.ip, falls back to 'unknown') |
import { getRequestFromContext, extractIpAddress } from '@ambushsoftworks/nestjs-auth-graphql';
@Injectable()
export class CustomGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = getRequestFromContext(context);
const ip = extractIpAddress(request);
// your logic
return true;
}
}Security Features
- Refresh token rotation with HMAC-SHA256 hashing and idempotent 10-second grace period
- Bcrypt password hashing with configurable rounds (default: 12)
- AES-256-GCM encryption for OAuth tokens at rest
- Constant-time comparison for verification codes
- Account enumeration prevention on signup (optional)
- Stateless CSRF protection for OAuth flows via
OAuthStateService __Host-cookie prefix support for enhanced cookie security- Separate HMAC secrets for refresh tokens, verification codes, and OAuth state
License
MIT -- see LICENSE.
