HazelJS Auth Package

npm downloads

@hazeljs/​auth provides authentication, authorization, role-based access control, and multi-tenant isolation for HazelJS applications.

Quick Reference

  • Purpose: @hazeljs/​auth provides 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/​auth when 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 via AsyncLocalStorage), 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 → use RoleGuard('admin') for role-based access.
  • Common mistakes: Not setting JWT_SECRET in environment variables; applying JwtAuthGuard globally without excluding public routes; forgetting to add TenantGuard for multi-tenant applications; not using TenantContext in repositories (bypasses tenant isolation).

Features

  • JWT Management: Sign and verify tokens with JwtService; configured once with JwtModule.forRoot()
  • Route Protection: JwtAuthGuard implements the standard CanActivate interface and works with @UseGuards()
  • Role Hierarchy: RoleGuard('manager') passes for manager, admin, and superadmin — no need to list every permitted role
  • Tenant Isolation (HTTP layer): TenantGuard compares the user's tenantId from the JWT against a URL param, header, or query string and returns 403 on mismatch
  • Tenant Isolation (database layer): TenantContext propagates the current tenant ID through the full async call chain via AsyncLocalStorage so 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 HazelJS AuthUser

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

  1. JwtModule: forRoot() configures JwtService with secret and expiry
  2. JwtService: sign(payload) and verify(token) — synchronous, no promises needed
  3. AuthService: verifyToken(token) — higher-level helper used by JwtAuthGuard
  4. JwtAuthGuard: CanActivate guard — validates Bearer token and attaches req.user
  5. RoleGuard(role): Guard factory — checks req.user.role against a role hierarchy
  6. TenantGuard(options): Guard factory — HTTP-layer tenant isolation + seeds TenantContext
  7. TenantContext: AsyncLocalStorage-backed service — propagates tenantId to repositories
  8. @CurrentUser(field?): Parameter decorator — injects the authenticated user into controllers
  9. @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

OptionDefaultDescription
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, or email
  • tenant id from tenantId, tenant_id, organizationId, or orgId
  • username from username, email, or name

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

  1. Always use JwtAuthGuard before RoleGuard or TenantGuard — both depend on req.user being populated first.
  2. Apply TenantGuard at the controller level, not per-method — this ensures no route in the controller can accidentally skip the tenant check.
  3. Use tenantCtx.requireId() in repositories, not getId() — it throws early and clearly if the context is missing.
  4. Prefer role hierarchy over flat role lists — RoleGuard('manager') is simpler and safer than RoleGuard('manager', 'admin', 'superadmin').
  5. Set short JWT expiry in production and implement refresh token rotation.
  6. 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: '...' }
  }
}