@helix/shared/guards
Shared NestJS guards for authentication and authorization using WorkOS RBAC.
Overview
Provides four security guards:
- JwtAuthGuard - Validates authentication via CLS
- RolesGuard - Enforces role-based access (from WorkOS JWT)
- ScopesGuard - Validates permission scopes (from WorkOS JWT)
- TenantAccessGuard - Enforces tenant isolation
CRITICAL: All guards use CLS to access context. NO database queries for authorization.
Installation
import {
JwtAuthGuard,
RolesGuard,
Roles,
ScopesGuard,
RequireScopes,
TenantAccessGuard,
} from '@helix/shared/guards';
Guards
1. JwtAuthGuard
Validates that authentication context exists in CLS.
@UseGuards(JwtAuthGuard)
@Get('profile')
async getProfile() {
const auth = this.cls.get('auth');
return auth.user;
}
What it checks:
- CLS has 'auth' context (set by AuthenticationMiddleware)
- Auth context contains valid userId
When to use:
- Every authenticated route
- Usually combined with other guards
2. RolesGuard
Enforces role-based access control using WorkOS roles.
@Roles('tenant_admin', 'product_admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Put('tenant/:id')
async updateTenant() {
// Only tenant_admin OR product_admin can access
}
What it checks:
- User's role from JWT matches required role(s)
- Role extracted from CLS:
cls.get('auth').role
NO DATABASE QUERIES - Role comes from WorkOS JWT token.
3. ScopesGuard
Validates permission scopes from WorkOS.
@RequireScopes('tenant:write', 'user:write')
@UseGuards(JwtAuthGuard, ScopesGuard)
@Post('users')
async createUser() {
// User must have BOTH scopes
}
What it checks:
- ALL required scopes present in JWT
- Scopes extracted from CLS:
cls.get('auth').scopes
NO DATABASE QUERIES - Scopes come from WorkOS JWT token.
4. TenantAccessGuard
Enforces tenant isolation - prevents cross-tenant access.
@UseGuards(JwtAuthGuard, TenantAccessGuard)
@Get('resources/:id')
async getResource(@Param('id') id: string) {
// Automatically validates tenant context
}
What it checks:
- Tenant context initialized in CLS
- Resource tenantId (if in params) matches user's current tenant
Guard Combinations
Pattern 1: Authentication Only
@UseGuards(JwtAuthGuard)
@Get('data')
async getData() {
// Any authenticated user
}
Pattern 2: Authentication + Role
@Roles('tenant_admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Delete('tenant/:id')
async deleteTenant() {
// Only tenant admins
}
Pattern 3: Authentication + Scope
@RequireScopes('file:write')
@UseGuards(JwtAuthGuard, ScopesGuard)
@Post('files')
async uploadFile() {
// Users with file:write permission
}
Pattern 4: Full Stack
@Roles('product_admin')
@RequireScopes('product:write', 'user:write')
@UseGuards(JwtAuthGuard, RolesGuard, ScopesGuard, TenantAccessGuard)
@Post('products/:productId/users')
async assignUserToProduct() {
// product_admin role
// + product:write scope
// + user:write scope
// + tenant isolation enforced
}
Guard Order
ALWAYS apply in this order:
@UseGuards(
JwtAuthGuard, // 1. Validate authentication
RolesGuard, // 2. Check role
ScopesGuard, // 3. Check scopes
TenantAccessGuard, // 4. Enforce tenant isolation
)
Why? Each guard depends on checks from previous guards.
WorkOS RBAC Integration
JWT Token Structure
{
"sub": "user_01HXYZ",
"org_id": "org_01ABC",
"role": "member",
"roles": ["member"],
"permissions": ["posts:read", "posts:write"],
"sid": "session_01HQ...",
"exp": 1709193857
}
Session Object (from WorkOS API)
{
"id": "session_01HQ...",
"user_id": "user_01HXYZ",
"organization_id": "org_01ABC", // ← Current organization
"status": "active",
"expires_at": "2025-07-23T15:00:00.000Z"
}
How Guards Extract Data
// AuthenticationMiddleware sets CLS
cls.set('auth', {
userId: 'user_01HXYZ',
workosOrganizationId: session.organization_id, // From session
role: jwt.role, // From JWT
scopes: jwt.permissions, // From JWT
user: { email, firstName, ... } // From WorkOS API
});
// RolesGuard reads from CLS
const auth = cls.get('auth');
const userRole = auth.role; // 'tenant_admin'
// ScopesGuard reads from CLS
const auth = cls.get('auth');
const userScopes = auth.scopes; // ['tenant:write', 'user:read']
NO DATABASE QUERIES - Everything from WorkOS JWT + API.
Organization Switching
WorkOS handles organization switching via sessions:
// User switches to different organization in WorkOS UI
// New session created with different organization_id
// Old session
{
"organization_id": "org_acme",
"user_id": "user_123"
}
// New session (different org)
{
"organization_id": "org_widget", // Different org
"user_id": "user_123" // Same user
}
// Our middleware reads session.organization_id
// TenantContextMiddleware finds tenant by workosOrganizationId
// User now in different tenant context
Error Messages
// JwtAuthGuard
{
statusCode: 401,
message: 'Authentication required'
}
// RolesGuard
{
statusCode: 403,
message: 'Required role: tenant_admin or product_admin. User role: user'
}
// ScopesGuard
{
statusCode: 403,
message: 'Missing required scopes: tenant:write, user:delete'
}
// TenantAccessGuard
{
statusCode: 403,
message: 'Access denied: Resource belongs to different tenant'
}
Global Guards
Apply guards globally with route-level opt-out:
// app.module.ts
@Module({
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard, // All routes require auth
},
{
provide: APP_GUARD,
useClass: TenantAccessGuard, // All routes enforce tenant isolation
},
],
})
export class AppModule {}
Opt-out specific routes:
@Public() // Custom decorator to skip JwtAuthGuard
@Get('health')
async healthCheck() {
// Public endpoint
}
Testing
describe('RolesGuard', () => {
it('should allow access with correct role from JWT', () => {
// Mock CLS with auth context
cls.set('auth', {
userId: 'user_123',
role: 'tenant_admin',
scopes: []
});
// Test guard
const canActivate = guard.canActivate(context);
expect(canActivate).toBe(true);
});
it('should block access with incorrect role', () => {
cls.set('auth', {
userId: 'user_123',
role: 'read_only',
scopes: []
});
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
});
});
Related Libraries
@helix/shared/middleware- Sets CLS context@helix/shared/types- Type definitions@helix/shared/interfaces- Interface definitions
Development
# Build the library
nx build shared-guards
# Lint the library
nx lint shared-guards
# Test the library
nx test shared-guards
License
Proprietary - CleverChain Limited