Skip to main content

@helix/shared/guards

Shared NestJS guards for authentication and authorization using WorkOS RBAC.

Overview

Provides four security guards:

  1. JwtAuthGuard - Validates authentication via CLS
  2. RolesGuard - Enforces role-based access (from WorkOS JWT)
  3. ScopesGuard - Validates permission scopes (from WorkOS JWT)
  4. 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);
});
});


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