Auth Package

npm downloads

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 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

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 };
  }
}

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 listsRoleGuard('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