Authentication Pattern
Overview
The Wrkbelt authentication system is a robust, session-based authentication framework built on NestJS that handles user authentication, permission management, and multi-tenancy. It provides a secure and stateful approach to authentication using Redis-backed sessions rather than stateless JWT tokens.
Authentication Architecture
The authentication system consists of several interconnected components that work together to provide a comprehensive security solution.
Core Components
- AuthModule: The central module that coordinates all authentication functionality
- AuthService: Provides authentication business logic and user validation
- PasswordService: Handles password hashing, validation, and reset functionality
- Guards: Protect routes and enforce permissions (SessionAuthGuard, PermissionsGuard)
- Session Management: Redis-backed session store for stateful authentication
- Decorators: Custom decorators for route protection (@Public) and permission requirements (@RequirePermissions)
- Passport Integration: Local strategy for credential validation
Core Components Diagram
Design Principles
The authentication system follows these key principles:
- Stateful Session Management: Uses server-side sessions stored in Redis rather than stateless JWT tokens
- Least Privilege Access: Routes are protected by default and explicitly marked as public when needed
- Separation of Concerns: Authentication, authorization, and user management are kept separate
- Type Safety: Strong typing throughout the authentication pipeline
- Multi-Tenancy: Support for users to belong to multiple organizations with different permissions
Module Integration
The AuthModule imports several dependencies and exports key services:
@Module({
imports: [
PassportModule,
forwardRef(() => UserModule),
OrganizationModule,
SharedModule,
EmailModule,
TemporaryLinksModule,
ConfigModule,
],
providers: [
AuthService,
PasswordService,
LocalStrategy,
SessionSerializer,
RedisSessionStore,
{
provide: APP_GUARD,
useClass: SessionAuthGuard,
},
{
provide: APP_GUARD,
useClass: PermissionsGuard,
},
],
exports: [AuthService, RedisSessionStore, PasswordService],
controllers: [AuthController],
})
export class AuthModule {}
This setup globally applies the SessionAuthGuard and PermissionsGuard to protect all routes by default.
Session Management
Wrkbelt uses Redis-backed sessions for authentication state management. This approach offers several advantages over JWT tokens:
- Immediate Session Revocation: Sessions can be terminated server-side
- Reduced Attack Surface: No token signatures to verify or decode
- Lower Bandwidth: Session IDs are smaller than JWTs with claims
- Centralized State Management: Session data is stored on the server
Redis Session Store
We implement a custom RedisSessionStore that extends the Express Session Store:
@Injectable()
export class RedisSessionStore extends Store {
private logger: Logger = new Logger('RedisSessionStore');
private prefix: string;
private ttl: number;
constructor(private readonly redisService: RedisService) {
super();
const sessionConfig = getAuthConfig()['session'];
this.ttl = sessionConfig.expirationInSec;
this.prefix = sessionConfig.storePrefix;
}
// Methods for get, set, destroy, touch
// ...
}
Session Serialization
User information is serialized into the session using Passport's serialization mechanism:
@Injectable()
export class SessionSerializer extends PassportSerializer {
constructor(private readonly usersService: UserService) {
super();
}
serializeUser(user: User, done: CallableFunction) {
done(null, user._id);
}
async deserializeUser(userId: string, done: CallableFunction) {
try {
const user = await this.usersService.findOneById(userId).catch(() => null);
if (!user) {
return done(null, false);
}
done(null, user);
} catch (error) {
console.error('Session deserialize error:', error);
done(null, false);
}
}
}
Note how we only store the user ID in the session, with the full user object retrieved on each request. This ensures that user data is always up-to-date.
Session Initialization
Sessions are initialized at the application bootstrap phase:
export const initializeSessionAuthentication = (
app: INestApplication
): void => {
const redisService = app.get(RedisService);
const initializer = new SessionInitializer(app, redisService);
initializer.initialize();
};
The initializer configures cookie settings based on the environment:
private getCookieConfig(): SessionCookieConfig {
const domain = this.getCookieDomain();
const isLocal = this.env === Environment.LOCAL;
const config: SessionCookieConfig = {
secure: !isLocal, // Only use secure cookies in non-development environments
httpOnly: true,
maxAge: getAuthConfig()['session'].maxAge,
sameSite: isLocal ? 'lax' : 'strict', // More permissive in development
path: '/',
domain,
};
return config;
}
Authentication Flow
Login Process
- Credential Submission: User submits email and password
- Validation:
LocalStrategyvalidates credentials viaAuthService.validateUser() - Session Creation: Upon successful validation, a session is created and stored in Redis
- Response: User data and session information are returned to the client
@Public()
@UseGuards(LogInWithCredentialsGuard)
@Post(API_ACTIONS.AUTH.LOGIN)
@ApiBody({ type: LoginDto })
async login(@Request() req: AuthenticatedRequest): Promise<SignInResponse> {
return this.authService.login(req);
}
The login method in AuthService establishes the session:
async login(req: AuthenticatedRequest): Promise<SignInResponse> {
const { user } = req;
req.session.user_metadata = {
email: user.email,
};
// Save and reload session changes
await new Promise((resolve, reject) => {
req.session.save((err) => (err ? reject(err) : resolve(true)));
});
await new Promise((resolve, reject) => {
req.session.reload((err) => (err ? reject(err) : resolve(true)));
});
return {
user: {
_id: user._id.toString(),
first_name: user.first_name as string,
last_name: user.last_name as string,
email: user.email as string,
},
authSession: this.constructAuthSession(req),
};
}
Request Authentication
For authenticated routes:
- Session Check:
SessionAuthGuardvalidates the session exists and is valid - User Retrieval: User is deserialized from the session ID
- Permission Check:
PermissionsGuardchecks required permissions (if specified) - Route Handler: If all checks pass, the route handler executes
Password Reset Flow
- Initiation: User requests password reset via email
- Token Creation: A temporary link with a token is created
- Email Delivery: Reset link is sent to user's email
- Token Validation: When user accesses the link, token is validated
- Password Update: User sets a new password
- Confirmation: Success confirmation and notification email sent
Permission-Based Authorization
Wrkbelt implements a flexible permission system for fine-grained access control.
Permission Decorator
Routes can specify required permissions using the RequirePermissions decorator:
@Get()
@RequirePermissions(AllPermissions.VIEW_PERMISSIONS)
async findAll() {
return this.permissionService.findAll();
}
Permissions Guard
The PermissionsGuard enforces these permission requirements:
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector, private userService: UserService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredPermissions = this.reflector.get<AllPermissions[]>(
'permissions',
context.getHandler()
);
if (!requiredPermissions) {
return true; // If no permissions are required, allow the request.
}
const request = context.switchToHttp().getRequest() as AuthenticatedRequest;
const user = request.user;
const userPermissionsDocs = await this.userService.getUserPermissions(
user._id
);
const userPermissions = userPermissionsDocs.map(
(permission) => permission.name
);
return requiredPermissions.every((permission) =>
hasPermission(userPermissions, permission)
);
}
}
Public Routes
Routes that should be accessible without authentication can be marked with the @Public() decorator:
@Public()
@Post(API_ACTIONS.AUTH.FORGOT_PASSWORD)
@HttpCode(HttpStatus.OK)
async forgotPassword(
@Body() dto: ForgotPasswordDto
): Promise<ForgotPasswordResponse> {
await this.authService.initiatePasswordReset(dto);
return {
message:
'If an account exists with this email, you will receive password reset instructions',
};
}
Password Management
The PasswordService provides comprehensive password management:
@Injectable()
export class PasswordService {
private readonly logger = new Logger(PasswordService.name);
private readonly saltRounds = 10;
constructor(
@Inject(forwardRef(() => UserService))
private readonly userService: UserService,
private readonly emailService: EmailService,
private readonly emailTemplateProvider: EmailTemplateProvider
) {}
// Password management methods...
}
Password Hashing
We use bcrypt for secure password hashing:
async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, this.saltRounds);
}
async comparePasswords(
plainTextPassword: string,
hashedPassword: string
): Promise<boolean> {
return bcrypt.compare(plainTextPassword, hashedPassword);
}
Password Validation
Passwords are validated against complexity requirements:
validatePasswordComplexity(password: string): {
isValid: boolean;
errors: string[];
} {
const errors: string[] = [];
Object.entries(passwordRequirements).forEach(([_, requirement]) => {
if (!requirement.test(password)) {
errors.push(
`Password must contain ${requirement.message.toLowerCase()}`
);
}
});
return {
isValid: errors.length === 0,
errors,
};
}
Multi-Tenancy Support
The authentication system supports multi-tenancy through organization selection.
Organization Selection
Users can select an active organization context:
@Post(API_ACTIONS.AUTH.SELECT_ORGANIZATION)
@ApiBody({ type: OrganizationSelectionDto })
@ApiResponse({
status: 200,
description: 'Organization selected successfully',
})
async selectOrganization(
@Request() req: AuthenticatedRequest,
@Body() body: OrganizationSelectionDto
) {
return this.authService.setActiveOrganization(req, body.organizationId);
}
The setActiveOrganization method in AuthService updates the session with the active organization context:
async setActiveOrganization(
req: AuthenticatedRequest,
organizationId: string
): Promise<SelectActiveOrganizationResponse> {
const organization =
await this.organizationService.validateUserOrganizationAccess(
req.user._id,
organizationId
);
// Update session with active organization
req.session.activeOrganization = {
_id: organizationId,
tenant_subdomain: organization.tenant_subdomain,
};
// Save session changes...
return {
authSession: this.constructAuthSession(req),
activeOrganization
};
}
Security Considerations
Session Security
- HTTP-Only Cookies: Prevent JavaScript access to session cookies
- Secure Flag: Only transmit cookies over HTTPS in production
- Same-Site Policy: Prevent CSRF attacks
- Expiration: Sessions have a configured maximum age
Password Security
- Bcrypt Hashing: Industry-standard password hashing
- Complexity Requirements: Enforced password strength
- Secure Reset Flow: Secure token-based password reset
Anti-Enumeration
We prevent user enumeration in security-sensitive flows:
async initiatePasswordReset(dto: ForgotPasswordDto): Promise<void> {
// Always return void to prevent email enumeration
try {
const user = await this.userService.findOneByEmail(dto.email);
if (!user) {
this.logger.debug(
`Password reset requested for non-existent email: ${dto.email}`
);
return;
}
// Process password reset...
} catch (error) {
// Re-throw internal errors that aren't related to email sending
if (!(error instanceof Error && error.message.includes('email'))) {
throw error;
}
// Otherwise swallow the error to prevent email enumeration
}
}
Implementation Examples
Securing a Controller
@ApiTags(capitalizeFirstCharacter(API_RESOURCES.PERMISSIONS))
@ApiCookieAuth()
@Controller(API_RESOURCES.PERMISSIONS)
export class PermissionController {
constructor(private readonly permissionService: PermissionService) {}
@Get()
@RequirePermissions(AllPermissions.VIEW_PERMISSIONS)
async findAll() {
return this.permissionService.findAll();
}
@Post()
@RequirePermissions(AllPermissions.CREATE_PERMISSION)
async create(@Body() createPermissionDto: CreatePermissionDto) {
return this.permissionService.create(createPermissionDto);
}
}
Accessing the Authenticated User
@Get('profile')
async getProfile(@Request() req: AuthenticatedRequest) {
// req.user contains the authenticated user
return {
userId: req.user._id,
email: req.user.email,
firstName: req.user.first_name,
lastName: req.user.last_name
};
}
Creating Public Endpoints
@Public()
@Get('public-info')
async getPublicInfo() {
return {
version: '1.0.0',
apiName: 'Wrkbelt API',
status: 'operational'
};
}
Best Practices
- Always Use
RequirePermissions: Even if you think a route is obvious, explicitly declare required permissions - Minimize Public Routes: Use the
@Public()decorator sparingly - Type Safety: Use the
AuthenticatedRequesttype for route handlers that need the authenticated user - No Session Data Leaks: Never return sensitive session data to clients
- Validate Organization Access: Always check organization access before performing organization-specific operations
- Consistent Error Handling: Use appropriate HTTP status codes for authentication failures
- Test Authentication Logic: Write tests for authentication flows and guard behavior
Troubleshooting
Common Authentication Issues
-
Session Not Recognized:
- Check Redis connection
- Verify cookie settings match domain configuration
- Clear browser cookies and try again
-
Permission Denied Errors:
- Verify user has the required permissions
- Check for typos in permission strings
- Ensure permission assignment in database
-
Login Failures:
- Ensure email is in correct format
- Verify password meets complexity requirements
- Check for account status issues
Debugging Sessions
To debug session-related issues:
// Get detailed session information
@Get('debug-session')
@RequirePermissions(AllPermissions.VIEW_SESSION_DEBUG)
async debugSession(@Request() req: AuthenticatedRequest) {
return {
sessionId: req.session.id,
expires: req.session.cookie.expires,
activeOrganization: req.session.activeOrganization,
userMetadata: req.session.user_metadata
};
}
Related Patterns
- Repository Pattern: How user data is accessed
- Resource Module Pattern: How authentication fits into the overall module structure
- Shared Type System: Understanding the type system used for authentication