Auth Package
The @hazeljs/auth package provides a complete authentication and authorization system for HazelJS applications. It covers JWT issuance and verification, route protection via CanActivate guards, role-based access control with an inheritance hierarchy, and two-layer multi-tenant isolation backed by AsyncLocalStorage.
Purpose
- JWT Management: Sign and verify tokens with
JwtService; configured once withJwtModule.forRoot() - Route Protection:
JwtAuthGuardimplements the standardCanActivateinterface and works with@UseGuards() - Role Hierarchy:
RoleGuard('manager')passes formanager,admin, andsuperadmin— no need to list every permitted role - Tenant Isolation (HTTP layer):
TenantGuardcompares the user'stenantIdfrom the JWT against a URL param, header, or query string and returns 403 on mismatch - Tenant Isolation (database layer):
TenantContextpropagates the current tenant ID through the full async call chain viaAsyncLocalStorageso repositories are automatically scoped - User Injection:
@CurrentUser()injects the authenticated user (or a single field) directly into controller method parameters
Architecture
graph TD A["Incoming Request"] --> B["JwtAuthGuard<br/>(CanActivate)"] B --> C["Verifies Bearer token<br/>attaches req.user"] C --> D["TenantGuard<br/>(optional)"] D --> E["Compare user.tenantId<br/>vs :orgId param"] E --> F["TenantContext.enterWith<br/>(AsyncLocalStorage)"] F --> G["RoleGuard<br/>(optional)"] G --> H["Role Hierarchy check<br/>manager ≥ user ✓"] H --> I["Controller Method"] I --> J["Repository<br/>tenantCtx.requireId()"] style A fill:#3b82f6,stroke:#60a5fa,stroke-width:2px,color:#fff style B fill:#8b5cf6,stroke:#a78bfa,stroke-width:2px,color:#fff style D fill:#8b5cf6,stroke:#a78bfa,stroke-width:2px,color:#fff style G fill:#8b5cf6,stroke:#a78bfa,stroke-width:2px,color:#fff style I fill:#10b981,stroke:#34d399,stroke-width:2px,color:#fff style J fill:#10b981,stroke:#34d399,stroke-width:2px,color:#fff
Key Components
- JwtModule:
forRoot()configuresJwtServicewith secret and expiry - JwtService:
sign(payload)andverify(token)— synchronous, no promises needed - AuthService:
verifyToken(token)— higher-level helper used byJwtAuthGuard - JwtAuthGuard:
CanActivateguard — validates Bearer token and attachesreq.user - RoleGuard(role): Guard factory — checks
req.user.roleagainst a role hierarchy - TenantGuard(options): Guard factory — HTTP-layer tenant isolation + seeds
TenantContext - TenantContext:
AsyncLocalStorage-backed service — propagatestenantIdto repositories - @CurrentUser(field?): Parameter decorator — injects the authenticated user into controllers
- @Auth(): Simplified all-in-one decorator for basic JWT + role checks
Installation
npm install @hazeljs/auth
Quick Start
1. Register JwtModule
import { HazelModule } from '@hazeljs/core';
import { JwtModule } from '@hazeljs/auth';
@HazelModule({
imports: [
JwtModule.forRoot({
secret: process.env.JWT_SECRET,
expiresIn: '8h',
}),
],
})
export class AppModule {}
JWT secret and expiry can also be set via environment variables (JWT_SECRET, JWT_EXPIRES_IN) — passing {} to forRoot is enough when they are set.
2. Issue tokens
import { Injectable } from '@hazeljs/core';
import { JwtService } from '@hazeljs/auth';
@Injectable()
export class AuthService {
constructor(private readonly jwtService: JwtService) {}
login(user: { id: string; email: string; role: string; organizationId: string }) {
const token = this.jwtService.sign({
sub: user.id,
email: user.email,
role: user.role,
tenantId: user.organizationId, // consumed by TenantGuard
});
return { token };
}
}
3. Protect routes
import { Controller, Get, UseGuards } from '@hazeljs/core';
import { JwtAuthGuard, CurrentUser } from '@hazeljs/auth';
@UseGuards(JwtAuthGuard)
@Controller('/profile')
export class ProfileController {
@Get('/')
me(@CurrentUser() user: unknown) {
return user; // decoded JWT payload
}
}
JwtAuthGuard
JwtAuthGuard implements CanActivate. Apply it with @UseGuards() at the controller or method level.
import { Controller, Get, UseGuards } from '@hazeljs/core';
import { JwtAuthGuard, CurrentUser } from '@hazeljs/auth';
// Applied to every route in this controller
@UseGuards(JwtAuthGuard)
@Controller('/orders')
export class OrdersController {
@Get('/')
list(@CurrentUser('sub') userId: string) {
return this.ordersService.findByUser(userId);
}
}
On success, JwtAuthGuard attaches the decoded payload to req.user and populates context.user so @CurrentUser() can read it. On failure it throws 401.
RoleGuard
RoleGuard is a factory that returns a CanActivate class. Pass one or more role strings; the default hierarchy means higher roles inherit all lower-role permissions.
import { Controller, Get, Delete, UseGuards } from '@hazeljs/core';
import { JwtAuthGuard, RoleGuard } from '@hazeljs/auth';
@UseGuards(JwtAuthGuard)
@Controller('/admin/posts')
export class AdminPostsController {
@UseGuards(RoleGuard('user')) // user, manager, admin, superadmin all pass
@Get('/')
list() { ... }
@UseGuards(RoleGuard('manager')) // manager, admin, superadmin pass; user → 403
@Delete('/:id')
remove() { ... }
}
Default role hierarchy
superadmin
└── admin
└── manager
└── user
Custom hierarchy
import { RoleGuard } from '@hazeljs/auth';
const CONTENT_HIERARCHY = {
owner: ['editor'],
editor: ['viewer'],
viewer: [],
};
@UseGuards(JwtAuthGuard, RoleGuard('editor', { hierarchy: CONTENT_HIERARCHY }))
@Patch('/:id')
update() { ... }
Shared hierarchy instance
import { RoleHierarchy } from '@hazeljs/auth';
export const AppHierarchy = new RoleHierarchy({
owner: ['editor', 'viewer'],
editor: ['viewer'],
viewer: [],
});
@UseGuards(JwtAuthGuard, RoleGuard('viewer', { hierarchy: AppHierarchy }))
TenantGuard
TenantGuard enforces tenant isolation at the HTTP layer. It compares the tenantId claim from the JWT against a value extracted from the request (URL param by default), and returns 403 if they do not match.
After a successful check it calls TenantContext.enterWith(tenantId), which seeds the AsyncLocalStorage context so any repository running in the same async call chain can call tenantCtx.requireId() without needing the ID passed as a parameter.
import { Controller, Get, Post, Delete, UseGuards } from '@hazeljs/core';
import { JwtAuthGuard, RoleGuard, TenantGuard } from '@hazeljs/auth';
// Both guards apply to every route in this controller:
// 1. JwtAuthGuard — verifies token, attaches req.user
// 2. TenantGuard — checks user.tenantId === :orgId param
@UseGuards(JwtAuthGuard, TenantGuard({ source: 'param', key: 'orgId' }))
@Controller('/orgs/:orgId/tasks')
export class TasksController {
@UseGuards(RoleGuard('user'))
@Get('/')
list() { ... }
@UseGuards(RoleGuard('manager'))
@Post('/')
create() { ... }
@UseGuards(RoleGuard('admin'))
@Delete('/:id')
remove() { ... }
}
TenantGuard options
| Option | Default | Description |
|---|---|---|
source | 'param' | Where to read the tenant ID from the request: 'param', 'header', or 'query' |
key | 'tenantId' | Name of the param / header / query key |
userField | 'tenantId' | Field on the JWT payload that holds the user's tenant ID |
bypassRoles | [] | Roles that skip the tenant check (e.g. ['superadmin']) |
// From a header:
TenantGuard({ source: 'header', key: 'x-org-id' })
// Superadmins skip the check entirely:
TenantGuard({ source: 'param', key: 'orgId', bypassRoles: ['superadmin'] })
TenantContext
TenantContext is a service backed by AsyncLocalStorage. Once TenantGuard calls TenantContext.enterWith(tenantId) for a request, any code running in the same async context (services, repositories, nested calls) can read the tenant ID without threading it through every function argument.
import { Injectable } from '@hazeljs/core';
import { TenantContext } from '@hazeljs/auth';
import { BaseRepository, TypeOrmService } from '@hazeljs/typeorm';
import { Task } from './task.entity';
@Injectable()
export class TasksRepository extends BaseRepository<Task> {
constructor(
typeOrm: TypeOrmService,
private readonly tenantCtx: TenantContext,
) {
super(typeOrm, Task);
}
findAll() {
// No parameter needed — reads from AsyncLocalStorage
return this.find({ where: { organizationId: this.tenantCtx.requireId() } });
}
findById(id: string) {
return this.findOne({ where: { id, organizationId: this.tenantCtx.requireId() } });
}
}
tenantCtx.getId() returns string | undefined. tenantCtx.requireId() throws if the context is not set (useful to catch requests that bypassed the guard).
@CurrentUser decorator
Inject the authenticated user (or a single field from it) as a method parameter:
import { Controller, Get, UseGuards } from '@hazeljs/core';
import { JwtAuthGuard, CurrentUser } from '@hazeljs/auth';
@UseGuards(JwtAuthGuard)
@Controller('/auth')
export class AuthController {
// Inject entire decoded payload
@Get('/me')
me(@CurrentUser() user: unknown) {
return user;
}
// Inject a single field — @CurrentUser('sub') → the user's ID string
@Get('/me/id')
myId(@CurrentUser('sub') userId: string) {
return { userId };
}
}
Full guard stack example
@UseGuards(
JwtAuthGuard, // 1. verify token
TenantGuard({ source: 'param', key: 'orgId', // 2. tenant isolation
bypassRoles: ['superadmin'] }),
RoleGuard('manager'), // 3. role check
)
@Controller('/orgs/:orgId/reports')
export class ReportsController {
@Get('/')
list(@CurrentUser('sub') userId: string) {
// TenantContext is already seeded — repositories are auto-scoped
return this.reportsService.findAll();
}
}
@Auth decorator (simplified)
For simpler use cases where you don't need the full guard stack, @Auth() is a method-level shorthand:
import { Controller, Get } from '@hazeljs/core';
import { Auth } from '@hazeljs/auth';
@Controller('/dashboard')
export class DashboardController {
@Auth() // any authenticated user
@Get('/')
home() { ... }
@Auth({ roles: ['admin'] }) // admin role only (no hierarchy)
@Get('/settings')
settings() { ... }
}
@Auth() is best for simple per-method checks. For multi-tenant or hierarchical roles use the explicit guard stack.
Environment Variables
JWT_SECRET=your-secret-key-here
JWT_EXPIRES_IN=8h
JWT_ISSUER=https://yourapp.com # optional
JWT_AUDIENCE=yourapp # optional
Best Practices
- Always use
JwtAuthGuardbeforeRoleGuardorTenantGuard— both depend onreq.userbeing populated first. - Apply
TenantGuardat the controller level, not per-method — this ensures no route in the controller can accidentally skip the tenant check. - Use
tenantCtx.requireId()in repositories, notgetId()— it throws early and clearly if the context is missing. - Prefer role hierarchy over flat role lists —
RoleGuard('manager')is simpler and safer thanRoleGuard('manager', 'admin', 'superadmin'). - Set short JWT expiry in production and implement refresh token rotation.
- Use HTTPS — tokens in transit must be encrypted.