HazelJS Auth Package
@hazeljs/auth provides authentication, authorization, role-based access control, and multi-tenant isolation for HazelJS applications.
Quick Reference
- Purpose:
@hazeljs/authprovides JWT management, route protection guards, role-based access control with hierarchy, multi-tenant isolation (HTTP + database layer), and user injection for HazelJS applications. - When to use: Use
@hazeljs/authwhen a HazelJS application needs JWT authentication, role-based authorization, multi-tenant data isolation, or normalized identities from OAuth/SAML providers. - Key concepts:
JwtModule.forRoot(),JwtService(sign/verify tokens),JwtAuthGuard(route protection),RoleGuard(role hierarchy),TenantGuard(HTTP tenant isolation),TenantContext(database tenant isolation viaAsyncLocalStorage),AuthService.normalizeIdentity(),@CurrentUser()parameter decorator. - Dependencies:
@hazeljs/core. - Common patterns: Register
JwtModule.forRoot({ secret })→ apply@UseGuards(JwtAuthGuard)on protected routes → use@CurrentUser()to access the authenticated user → useRoleGuard('admin')for role-based access. - Common mistakes: Not setting
JWT_SECRETin environment variables; applyingJwtAuthGuardglobally without excluding public routes; forgetting to addTenantGuardfor multi-tenant applications; not usingTenantContextin repositories (bypasses tenant isolation).
Features
- 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 - Identity Normalization:
AuthService.normalizeIdentity()maps external claims (OAuth/SAML/IdP) into HazelJSAuthUser
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 };
}
}
AuthService.normalizeIdentity
normalizeIdentity() helps when users authenticate through external identity systems (OAuth or SAML). It maps heterogeneous claim shapes into HazelJS AuthUser, so your guards and role/tenant checks keep working with a consistent payload.
import { Injectable } from '@hazeljs/core';
import { AuthService } from '@hazeljs/auth';
@Injectable()
export class IdentityBridgeService {
constructor(private readonly authService: AuthService) {}
mapExternalIdentity(externalClaims: Record<string, unknown>) {
return this.authService.normalizeIdentity(externalClaims);
// -> { id, username, role, tenantId?, ...claims }
}
}
Common mappings supported by default include:
- user id from
sub,id,userId,externalId, oremail - tenant id from
tenantId,tenant_id,organizationId, ororgId - username from
username,email, orname
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.
What's Next?
- Learn about OAuth for social login (Google, GitHub, Microsoft)
- Explore Config for managing secrets
- Check out TypeORM for tenant-scoped repositories
Recipes
Recipe: JWT-Protected CRUD Controller
// File: src/users/users.controller.ts
import { Controller, Get, Post, Body, UseGuards, Req } from '@hazeljs/core';
import { AuthGuard, RoleGuard, Roles } from '@hazeljs/auth';
@Controller('users')
@UseGuards(AuthGuard)
export class UsersController {
@Get('me')
getProfile(@Req() req: any) {
return req.user;
}
@Post('admin-action')
@UseGuards(RoleGuard)
@Roles('admin')
adminOnly(@Body() body: any) {
return { message: 'Admin action executed', data: body };
}
}
Recipe: Register and Login Endpoints
// File: src/auth/auth.controller.ts
import { Controller, Post, Body } from '@hazeljs/core';
import { AuthService } from '@hazeljs/auth';
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post('register')
async register(@Body() body: { email: string; password: string }) {
return this.auth.register(body.email, body.password);
}
@Post('login')
async login(@Body() body: { email: string; password: string }) {
const user = await this.auth.validateUser(body.email, body.password);
return this.auth.login(user);
// Returns { accessToken: '...', refreshToken: '...' }
}
}
Related Resources
- OAuth Package – Social login integration
- CASL Package – Authorization and permissions
- Audit Package – Security audit logging
- Config Package – Secret management
- Guardrails Package – Content safety for authenticated routes