Skip to main content

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

  1. AuthModule: The central module that coordinates all authentication functionality
  2. AuthService: Provides authentication business logic and user validation
  3. PasswordService: Handles password hashing, validation, and reset functionality
  4. Guards: Protect routes and enforce permissions (SessionAuthGuard, PermissionsGuard)
  5. Session Management: Redis-backed session store for stateful authentication
  6. Decorators: Custom decorators for route protection (@Public) and permission requirements (@RequirePermissions)
  7. Passport Integration: Local strategy for credential validation

Core Components Diagram

Design Principles

The authentication system follows these key principles:

  1. Stateful Session Management: Uses server-side sessions stored in Redis rather than stateless JWT tokens
  2. Least Privilege Access: Routes are protected by default and explicitly marked as public when needed
  3. Separation of Concerns: Authentication, authorization, and user management are kept separate
  4. Type Safety: Strong typing throughout the authentication pipeline
  5. 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 {}
info

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

  1. Credential Submission: User submits email and password
  2. Validation: LocalStrategy validates credentials via AuthService.validateUser()
  3. Session Creation: Upon successful validation, a session is created and stored in Redis
  4. 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:

  1. Session Check: SessionAuthGuard validates the session exists and is valid
  2. User Retrieval: User is deserialized from the session ID
  3. Permission Check: PermissionsGuard checks required permissions (if specified)
  4. Route Handler: If all checks pass, the route handler executes

Password Reset Flow

  1. Initiation: User requests password reset via email
  2. Token Creation: A temporary link with a token is created
  3. Email Delivery: Reset link is sent to user's email
  4. Token Validation: When user accesses the link, token is validated
  5. Password Update: User sets a new password
  6. 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

  1. Always Use RequirePermissions: Even if you think a route is obvious, explicitly declare required permissions
  2. Minimize Public Routes: Use the @Public() decorator sparingly
  3. Type Safety: Use the AuthenticatedRequest type for route handlers that need the authenticated user
  4. No Session Data Leaks: Never return sensitive session data to clients
  5. Validate Organization Access: Always check organization access before performing organization-specific operations
  6. Consistent Error Handling: Use appropriate HTTP status codes for authentication failures
  7. Test Authentication Logic: Write tests for authentication flows and guard behavior

Troubleshooting

Common Authentication Issues

  1. Session Not Recognized:

    • Check Redis connection
    • Verify cookie settings match domain configuration
    • Clear browser cookies and try again
  2. Permission Denied Errors:

    • Verify user has the required permissions
    • Check for typos in permission strings
    • Ensure permission assignment in database
  3. 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
};
}

References