@hazeljs/auth
v1.0.6
Published
Authentication and JWT module for HazelJS framework
Maintainers
Readme
@hazeljs/auth
JWT authentication, role-based access control, and tenant isolation — in one line of decorators.
No Passport config, no middleware soup. @UseGuards(JwtAuthGuard) on a controller and you're done.
Features
- JWT signing & verification via
JwtService(backed byjsonwebtoken) JwtAuthGuard—@UseGuards-compatible guard that verifies Bearer tokens and attachesreq.userRoleGuard— configurable role check with inherited role hierarchy (admin satisfies manager checks automatically)TenantGuard— tenant-level isolation; compares the tenant ID on the JWT against a URL param, header, or query string@CurrentUser()— parameter decorator that injects the authenticated user into controller methods@Auth()— all-in-one method decorator for JWT + optional role check without@UseGuards
Installation
npm install @hazeljs/authSetup
1. Register JwtModule
Configure the JWT secret once in your root module. Picks up JWT_SECRET and JWT_EXPIRES_IN env vars automatically when no options are passed.
import { HazelModule } from '@hazeljs/core';
import { JwtModule } from '@hazeljs/auth';
@HazelModule({
imports: [
JwtModule.forRoot({
secret: process.env.JWT_SECRET, // or set JWT_SECRET env var
expiresIn: '1h', // or set JWT_EXPIRES_IN env var
issuer: 'my-app', // optional
audience: 'my-users', // optional
}),
],
})
export class AppModule {}JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN=1h2. Issue tokens at login
import { Service } from '@hazeljs/core';
import { JwtService } from '@hazeljs/auth';
@Service()
export class AuthLoginService {
constructor(private readonly jwt: JwtService) {}
async login(userId: string, role: string, tenantId?: string) {
const token = this.jwt.sign({
sub: userId,
role,
tenantId, // include for TenantGuard
});
return { accessToken: token };
}
}Guards
All guards are resolved from the DI container, so they can inject services.
JwtAuthGuard
Verifies the Authorization: Bearer <token> header. On success it attaches the decoded payload to req.user so downstream guards and @CurrentUser() can read it.
import { Controller, Get } from '@hazeljs/core';
import { UseGuards } from '@hazeljs/core';
import { JwtAuthGuard, CurrentUser, AuthUser } from '@hazeljs/auth';
@UseGuards(JwtAuthGuard) // protects every route in this controller
@Controller('/profile')
export class ProfileController {
@Get('/')
getProfile(@CurrentUser() user: AuthUser) {
return user;
}
}Errors thrown:
| Condition | Status |
| ------------------------------------- | ------ |
| No Authorization header | 400 |
| Header not in Bearer <token> format | 400 |
| Token invalid or expired | 401 |
RoleGuard
Checks the authenticated user's role against a list of allowed roles. Uses the role hierarchy so higher roles automatically satisfy lower-level checks.
import { UseGuards } from '@hazeljs/core';
import { JwtAuthGuard, RoleGuard } from '@hazeljs/auth';
@UseGuards(JwtAuthGuard, RoleGuard('manager')) // manager, admin, superadmin can access
@Controller('/reports')
export class ReportsController {}Role hierarchy
The default hierarchy is superadmin → admin → manager → user.
superadmin
└─ admin
└─ manager
└─ userSo RoleGuard('user') passes for every role, and RoleGuard('admin') only passes for admin and superadmin.
// Only superadmin and admin can call this:
@UseGuards(JwtAuthGuard, RoleGuard('admin'))
// Everyone authenticated can call this:
@UseGuards(JwtAuthGuard, RoleGuard('user'))
// Either role (admin OR moderator, each with their inherited roles):
@UseGuards(JwtAuthGuard, RoleGuard('admin', 'moderator'))Custom hierarchy
import { RoleGuard, RoleHierarchy } from '@hazeljs/auth';
const hierarchy = new RoleHierarchy({
owner: ['editor'],
editor: ['viewer'],
viewer: [],
});
@UseGuards(JwtAuthGuard, RoleGuard('editor', { hierarchy }))
// owner and editor pass; viewer does notDisable hierarchy
@UseGuards(JwtAuthGuard, RoleGuard('admin', { hierarchy: {} }))
// Only exact 'admin' role passes — no inheritanceErrors thrown:
| Condition | Status |
| --------------------------------- | ------ |
| No req.user (guard order wrong) | 401 |
| User role not in allowed set | 403 |
TenantGuard
Enforces tenant-level isolation. Compares req.user.tenantId (from the JWT) against the tenant ID found in the request.
Requires JwtAuthGuard to run first so req.user is populated.
URL param (default)
import { JwtAuthGuard, TenantGuard } from '@hazeljs/auth';
// Route: GET /orgs/:tenantId/invoices
@UseGuards(JwtAuthGuard, TenantGuard())
@Controller('/orgs/:tenantId/invoices')
export class InvoicesController {}HTTP header
// Client sends: X-Org-ID: acme
@UseGuards(JwtAuthGuard, TenantGuard({ source: 'header', key: 'x-org-id' }))
@Controller('/invoices')
export class InvoicesController {}Query string
// Client sends: GET /invoices?org=acme
@UseGuards(JwtAuthGuard, TenantGuard({ source: 'query', key: 'org' }))
@Controller('/invoices')
export class InvoicesController {}Bypass for privileged roles
Superadmins often need to manage any tenant. Use bypassRoles to skip the check for them:
@UseGuards(JwtAuthGuard, TenantGuard({ bypassRoles: ['superadmin'] }))
@Controller('/orgs/:tenantId/settings')
export class OrgSettingsController {}Custom user field
// JWT payload uses 'orgId' instead of 'tenantId'
@UseGuards(JwtAuthGuard, TenantGuard({ userField: 'orgId' }))All options:
| Option | Type | Default | Description |
| ------------- | -------------------------------- | ------------ | --------------------------------------------- |
| source | 'param' \| 'header' \| 'query' | 'param' | Where to read the tenant ID from the request |
| key | string | 'tenantId' | Param name / header name / query key |
| userField | string | 'tenantId' | Field on req.user holding the user's tenant |
| bypassRoles | string[] | [] | Roles that skip the check entirely |
Errors thrown:
| Condition | Status |
| ------------------------------ | ------ |
| No req.user | 401 |
| req.user has no tenant field | 403 |
| Tenant ID absent from request | 400 |
| Tenant IDs do not match | 403 |
Database-level tenant isolation
TenantGuard blocks cross-tenant HTTP requests, but that alone isn't enough — a bug in service code could still return another tenant's rows. TenantContext closes that gap by enforcing isolation at the query level using Node.js AsyncLocalStorage.
After TenantGuard validates the request it calls TenantContext.enterWith(tenantId), which seeds the tenant ID into the current async execution chain. Every service and repository downstream can then call tenantCtx.requireId() to get the current tenant without it being passed through every function signature.
// src/orders/orders.repository.ts
import { Service } from '@hazeljs/core';
import { TenantContext } from '@hazeljs/auth';
@Service()
export class OrdersRepository {
constructor(private readonly tenantCtx: TenantContext) {}
findAll() {
const tenantId = this.tenantCtx.requireId();
// Scoped automatically — no tenantId parameter needed
return db.query('SELECT * FROM orders WHERE tenant_id = $1', [tenantId]);
}
findById(id: string) {
const tenantId = this.tenantCtx.requireId();
// Even direct ID lookup is tenant-scoped — prevents IDOR attacks
return db.query('SELECT * FROM orders WHERE id = $1 AND tenant_id = $2', [id, tenantId]);
}
}The route setup:
@UseGuards(JwtAuthGuard, TenantGuard())
@Controller('/orgs/:tenantId/orders')
export class OrdersController {
constructor(private readonly repo: OrdersRepository) {}
@Get('/')
list() {
// TenantContext is already seeded by TenantGuard — no need to pass tenantId
return this.repo.findAll();
}
}The two layers together:
| Layer | What it does | What it catches |
| --------------- | -------------------------------------------------------- | --------------------------------------------- |
| TenantGuard | Rejects requests where req.user.tenantId !== :tenantId | Unauthenticated cross-tenant requests |
| TenantContext | Scopes every DB query via AsyncLocalStorage | Bugs, missing guard on a route, IDOR attempts |
For background jobs or tests, you can run code in a specific tenant context explicitly:
// Background job — no HTTP request involved
await TenantContext.run('acme', async () => {
await ordersService.processPendingOrders();
});requireId() throws with a 500 if called outside any tenant context (guard missing), giving you a clear error instead of silently querying all tenants.
Combining guards
Guards run left-to-right. Always put JwtAuthGuard first.
@UseGuards(JwtAuthGuard, RoleGuard('manager'), TenantGuard())
@Controller('/orgs/:tenantId/orders')
export class OrdersController {
@Get('/')
listOrders(@CurrentUser() user: AuthUser) {
return this.ordersService.findAll(user.tenantId!);
}
// Stricter restriction on a single route — only admin (and above) can delete:
@UseGuards(RoleGuard('admin'))
@Delete('/:id')
deleteOrder(@Param('id') id: string) {
return this.ordersService.remove(id);
}
}@CurrentUser() decorator
Injects the authenticated user (or a specific field from it) directly into the controller parameter.
import { CurrentUser, AuthUser } from '@hazeljs/auth';
@UseGuards(JwtAuthGuard)
@Get('/me')
getMe(@CurrentUser() user: AuthUser) {
return user;
// { id: 'u1', username: 'alice', role: 'admin', tenantId: 'acme' }
}
@Get('/role')
getRole(@CurrentUser('role') role: string) {
return { role };
}
@Get('/tenant')
getTenant(@CurrentUser('tenantId') tenantId: string) {
return { tenantId };
}@Auth() decorator (method-level shorthand)
A lower-level alternative that wraps the handler directly instead of using the @UseGuards metadata system. Useful for one-off routes or when you prefer explicit colocation.
import { Auth } from '@hazeljs/auth';
@Controller('/admin')
export class AdminController {
@Auth() // JWT check only
@Get('/dashboard')
getDashboard() { ... }
@Auth({ roles: ['admin'] }) // JWT + role check (no hierarchy)
@Delete('/user/:id')
deleteUser(@Param('id') id: string) { ... }
}Note:
@Auth()does not use the role hierarchy. Use@UseGuards(JwtAuthGuard, RoleGuard('admin'))when hierarchy matters.
JwtService API
import { JwtService } from '@hazeljs/auth';
// Sign a token
const token = jwtService.sign({ sub: userId, role: 'admin', tenantId: 'acme' });
const token = jwtService.sign({ sub: userId }, { expiresIn: '15m' }); // custom expiry
// Verify and decode
const payload = jwtService.verify(token); // throws on invalid/expired
payload.sub; // string
payload.role; // string
payload.tenantId; // string | undefined
// Decode without verification (e.g. to read exp before refreshing)
const payload = jwtService.decode(token); // returns null on malformedAuthService API
AuthService wraps JwtService and returns a typed AuthUser object:
interface AuthUser {
id: string;
username?: string;
role: string;
[key: string]: unknown; // all other JWT claims pass through
}
const user = await authService.verifyToken(token);
// Returns AuthUser | null (null when token is invalid — never throws)Identity normalization for OAuth/SAML callbacks
When upstream identity providers emit non-uniform claims (sub, externalId, tenant_id, etc.),
normalize them before issuing app tokens:
const normalized = authService.normalizeIdentity({
externalId: samlResult.nameId,
email: samlResult.user.email,
role: 'user',
tenant_id: 'acme',
});normalizeIdentity() maps these shapes to a consistent AuthUser used by JwtAuthGuard, RoleGuard, and TenantGuard.
RoleHierarchy API
import { RoleHierarchy, DEFAULT_ROLE_HIERARCHY } from '@hazeljs/auth';
const h = new RoleHierarchy(DEFAULT_ROLE_HIERARCHY);
h.satisfies('superadmin', 'user'); // true — full chain
h.satisfies('manager', 'admin'); // false — no upward inheritance
h.resolve('admin'); // Set { 'admin', 'manager', 'user' }Custom guards
Implement CanActivate from @hazeljs/core for fully custom logic:
import { Injectable, CanActivate, ExecutionContext } from '@hazeljs/core';
@Injectable()
export class ApiKeyGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest() as { headers: Record<string, string> };
return req.headers['x-api-key'] === process.env.API_KEY;
}
}The ExecutionContext also exposes the fully parsed RequestContext (params, query, headers, body, user):
canActivate(context: ExecutionContext): boolean {
const ctx = context.switchToHttp().getContext();
const orgId = ctx.params['orgId'];
const user = ctx.user;
// ...
}Environment variables
| Variable | Default | Description |
| ---------------- | ------------ | ------------------------------------- |
| JWT_SECRET | (required) | Secret used to sign and verify tokens |
| JWT_EXPIRES_IN | 1h | Default token lifetime |
| JWT_ISSUER | — | Optional iss claim |
| JWT_AUDIENCE | — | Optional aud claim |
