@hazeljs/auth 0.2.0-beta.57 → 0.2.0-beta.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @hazeljs/auth
2
2
 
3
- **Auth that stays out of your way.**
3
+ **JWT authentication, role-based access control, and tenant isolation — in one line of decorators.**
4
4
 
5
- JWT, guards, roles, refresh tokens — all with decorators. Protect routes in one line. No passport config, no middleware soup. Just `@UseGuard(AuthGuard)` and ship.
5
+ No Passport config, no middleware soup. `@UseGuards(JwtAuthGuard)` on a controller and you're done.
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/@hazeljs/auth.svg)](https://www.npmjs.com/package/@hazeljs/auth)
8
8
  [![npm downloads](https://img.shields.io/npm/dm/@hazeljs/auth)](https://www.npmjs.com/package/@hazeljs/auth)
@@ -10,14 +10,14 @@ JWT, guards, roles, refresh tokens — all with decorators. Protect routes in on
10
10
 
11
11
  ## Features
12
12
 
13
- - 🔐 **JWT Authentication** - Secure token-based authentication
14
- - 🛡️ **Auth Guards** - Protect routes with decorators
15
- - 👤 **User Extraction** - Get current user from request
16
- - 🔑 **Token Management** - Generate, verify, and refresh tokens
17
- - **Token Expiration** - Configurable expiration times
18
- - 🎯 **Role-Based Access** - Role and permission guards
19
- - 🔄 **Refresh Tokens** - Long-lived refresh token support
20
- - 📊 **Token Blacklisting** - Revoke tokens when needed
13
+ - **JWT signing & verification** via `JwtService` (backed by `jsonwebtoken`)
14
+ - **`JwtAuthGuard`** `@UseGuards`-compatible guard that verifies Bearer tokens and attaches `req.user`
15
+ - **`RoleGuard`** — configurable role check with **inherited role hierarchy** (admin satisfies manager checks automatically)
16
+ - **`TenantGuard`** tenant-level isolation; compares the tenant ID on the JWT against a URL param, header, or query string
17
+ - **`@CurrentUser()`** parameter decorator that injects the authenticated user into controller methods
18
+ - **`@Auth()`** — all-in-one method decorator for JWT + optional role check without `@UseGuards`
19
+
20
+ ---
21
21
 
22
22
  ## Installation
23
23
 
@@ -25,456 +25,471 @@ JWT, guards, roles, refresh tokens — all with decorators. Protect routes in on
25
25
  npm install @hazeljs/auth
26
26
  ```
27
27
 
28
- ## Quick Start
28
+ ---
29
+
30
+ ## Setup
29
31
 
30
- ### 1. Configure Auth Module
32
+ ### 1. Register `JwtModule`
33
+
34
+ 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.
31
35
 
32
36
  ```typescript
33
37
  import { HazelModule } from '@hazeljs/core';
34
- import { AuthModule } from '@hazeljs/auth';
38
+ import { JwtModule } from '@hazeljs/auth';
35
39
 
36
40
  @HazelModule({
37
41
  imports: [
38
- AuthModule.forRoot({
39
- secret: process.env.JWT_SECRET || 'your-secret-key',
40
- expiresIn: '1h',
41
- refreshExpiresIn: '7d',
42
+ JwtModule.forRoot({
43
+ secret: process.env.JWT_SECRET, // or set JWT_SECRET env var
44
+ expiresIn: '1h', // or set JWT_EXPIRES_IN env var
45
+ issuer: 'my-app', // optional
46
+ audience: 'my-users', // optional
42
47
  }),
43
48
  ],
44
49
  })
45
50
  export class AppModule {}
46
51
  ```
47
52
 
48
- ### 2. Create Auth Service
49
-
50
- ```typescript
51
- import { Injectable } from '@hazeljs/core';
52
- import { AuthService } from '@hazeljs/auth';
53
+ ```env
54
+ JWT_SECRET=change-me-in-production
55
+ JWT_EXPIRES_IN=1h
56
+ ```
53
57
 
54
- @Injectable()
55
- export class UserAuthService {
56
- constructor(private authService: AuthService) {}
57
-
58
- async login(email: string, password: string) {
59
- // Validate credentials
60
- const user = await this.validateUser(email, password);
61
-
62
- if (!user) {
63
- throw new Error('Invalid credentials');
64
- }
65
-
66
- // Generate tokens
67
- const accessToken = await this.authService.generateToken({
68
- sub: user.id,
69
- email: user.email,
70
- roles: user.roles,
71
- });
58
+ ### 2. Issue tokens at login
72
59
 
73
- const refreshToken = await this.authService.generateRefreshToken({
74
- sub: user.id,
60
+ ```typescript
61
+ import { Service } from '@hazeljs/core';
62
+ import { JwtService } from '@hazeljs/auth';
63
+
64
+ @Service()
65
+ export class AuthLoginService {
66
+ constructor(private readonly jwt: JwtService) {}
67
+
68
+ async login(userId: string, role: string, tenantId?: string) {
69
+ const token = this.jwt.sign({
70
+ sub: userId,
71
+ role,
72
+ tenantId, // include for TenantGuard
75
73
  });
76
74
 
77
- return {
78
- accessToken,
79
- refreshToken,
80
- user,
81
- };
82
- }
83
-
84
- async validateUser(email: string, password: string) {
85
- // Your user validation logic
86
- const user = await this.userService.findByEmail(email);
87
-
88
- if (user && await this.comparePasswords(password, user.password)) {
89
- return user;
90
- }
91
-
92
- return null;
75
+ return { accessToken: token };
93
76
  }
94
77
  }
95
78
  ```
96
79
 
97
- ### 3. Protect Routes with Guards
80
+ ---
98
81
 
99
- ```typescript
100
- import { Controller, Get, Post, Body } from '@hazeljs/core';
101
- import { UseGuard, AuthGuard, CurrentUser } from '@hazeljs/auth';
102
-
103
- @Controller('/api')
104
- export class ApiController {
105
- @Post('/login')
106
- async login(@Body() credentials: LoginDto) {
107
- return this.authService.login(credentials.email, credentials.password);
108
- }
82
+ ## Guards
109
83
 
110
- @Get('/profile')
111
- @UseGuard(AuthGuard)
112
- getProfile(@CurrentUser() user: any) {
113
- return { user };
114
- }
84
+ All guards are resolved from the DI container, so they can inject services.
85
+
86
+ ### `JwtAuthGuard`
115
87
 
116
- @Get('/admin')
117
- @UseGuard(AuthGuard)
118
- @UseGuard(RoleGuard(['admin']))
119
- getAdminData(@CurrentUser() user: any) {
120
- return { message: 'Admin only data', user };
88
+ Verifies the `Authorization: Bearer <token>` header. On success it attaches the decoded payload to `req.user` so downstream guards and `@CurrentUser()` can read it.
89
+
90
+ ```typescript
91
+ import { Controller, Get } from '@hazeljs/core';
92
+ import { UseGuards } from '@hazeljs/core';
93
+ import { JwtAuthGuard, CurrentUser, AuthUser } from '@hazeljs/auth';
94
+
95
+ @UseGuards(JwtAuthGuard) // protects every route in this controller
96
+ @Controller('/profile')
97
+ export class ProfileController {
98
+ @Get('/')
99
+ getProfile(@CurrentUser() user: AuthUser) {
100
+ return user;
121
101
  }
122
102
  }
123
103
  ```
124
104
 
125
- ## Authentication Flow
105
+ Errors thrown:
126
106
 
127
- ### Login
107
+ | Condition | Status |
108
+ |---|---|
109
+ | No `Authorization` header | 400 |
110
+ | Header not in `Bearer <token>` format | 400 |
111
+ | Token invalid or expired | 401 |
112
+
113
+ ---
114
+
115
+ ### `RoleGuard`
116
+
117
+ Checks the authenticated user's `role` against a list of allowed roles. Uses the **role hierarchy** so higher roles automatically satisfy lower-level checks.
128
118
 
129
119
  ```typescript
130
- @Post('/auth/login')
131
- async login(@Body() loginDto: LoginDto) {
132
- const user = await this.authService.validateUser(
133
- loginDto.email,
134
- loginDto.password
135
- );
136
-
137
- if (!user) {
138
- throw new UnauthorizedException('Invalid credentials');
139
- }
120
+ import { UseGuards } from '@hazeljs/core';
121
+ import { JwtAuthGuard, RoleGuard } from '@hazeljs/auth';
140
122
 
141
- const payload = {
142
- sub: user.id,
143
- email: user.email,
144
- roles: user.roles,
145
- };
123
+ @UseGuards(JwtAuthGuard, RoleGuard('manager')) // manager, admin, superadmin can access
124
+ @Controller('/reports')
125
+ export class ReportsController {}
126
+ ```
127
+
128
+ #### Role hierarchy
129
+
130
+ The default hierarchy is `superadmin → admin → manager → user`.
146
131
 
147
- return {
148
- accessToken: await this.authService.generateToken(payload),
149
- refreshToken: await this.authService.generateRefreshToken(payload),
150
- };
151
- }
132
+ ```
133
+ superadmin
134
+ └─ admin
135
+ └─ manager
136
+ └─ user
152
137
  ```
153
138
 
154
- ### Refresh Token
139
+ So `RoleGuard('user')` passes for **every** role, and `RoleGuard('admin')` only passes for `admin` and `superadmin`.
155
140
 
156
141
  ```typescript
157
- @Post('/auth/refresh')
158
- async refresh(@Body() refreshDto: RefreshDto) {
159
- const payload = await this.authService.verifyRefreshToken(
160
- refreshDto.refreshToken
161
- );
162
-
163
- return {
164
- accessToken: await this.authService.generateToken({
165
- sub: payload.sub,
166
- email: payload.email,
167
- roles: payload.roles,
168
- }),
169
- };
170
- }
142
+ // Only superadmin and admin can call this:
143
+ @UseGuards(JwtAuthGuard, RoleGuard('admin'))
144
+
145
+ // Everyone authenticated can call this:
146
+ @UseGuards(JwtAuthGuard, RoleGuard('user'))
147
+
148
+ // Either role (admin OR moderator, each with their inherited roles):
149
+ @UseGuards(JwtAuthGuard, RoleGuard('admin', 'moderator'))
171
150
  ```
172
151
 
173
- ### Logout
152
+ #### Custom hierarchy
174
153
 
175
154
  ```typescript
176
- @Post('/auth/logout')
177
- @UseGuard(AuthGuard)
178
- async logout(@CurrentUser() user: any, @Headers('authorization') token: string) {
179
- // Extract token from "Bearer <token>"
180
- const jwt = token.split(' ')[1];
181
-
182
- // Blacklist the token
183
- await this.authService.blacklistToken(jwt);
184
-
185
- return { message: 'Logged out successfully' };
186
- }
155
+ import { RoleGuard, RoleHierarchy } from '@hazeljs/auth';
156
+
157
+ const hierarchy = new RoleHierarchy({
158
+ owner: ['editor'],
159
+ editor: ['viewer'],
160
+ viewer: [],
161
+ });
162
+
163
+ @UseGuards(JwtAuthGuard, RoleGuard('editor', { hierarchy }))
164
+ // owner and editor pass; viewer does not
187
165
  ```
188
166
 
189
- ## Guards
167
+ #### Disable hierarchy
168
+
169
+ ```typescript
170
+ @UseGuards(JwtAuthGuard, RoleGuard('admin', { hierarchy: {} }))
171
+ // Only exact 'admin' role passes — no inheritance
172
+ ```
173
+
174
+ Errors thrown:
175
+
176
+ | Condition | Status |
177
+ |---|---|
178
+ | No `req.user` (guard order wrong) | 401 |
179
+ | User role not in allowed set | 403 |
180
+
181
+ ---
190
182
 
191
- ### Auth Guard
183
+ ### `TenantGuard`
184
+
185
+ Enforces tenant-level isolation. Compares `req.user.tenantId` (from the JWT) against the tenant ID found in the request.
186
+
187
+ **Requires `JwtAuthGuard` to run first** so `req.user` is populated.
188
+
189
+ #### URL param (default)
192
190
 
193
191
  ```typescript
194
- import { AuthGuard } from '@hazeljs/auth';
195
-
196
- @Controller('/protected')
197
- export class ProtectedController {
198
- @Get()
199
- @UseGuard(AuthGuard)
200
- getData(@CurrentUser() user: any) {
201
- return { data: 'protected', user };
202
- }
203
- }
192
+ import { JwtAuthGuard, TenantGuard } from '@hazeljs/auth';
193
+
194
+ // Route: GET /orgs/:tenantId/invoices
195
+ @UseGuards(JwtAuthGuard, TenantGuard())
196
+ @Controller('/orgs/:tenantId/invoices')
197
+ export class InvoicesController {}
204
198
  ```
205
199
 
206
- ### Role Guard
200
+ #### HTTP header
207
201
 
208
202
  ```typescript
209
- import { RoleGuard } from '@hazeljs/auth';
203
+ // Client sends: X-Org-ID: acme
204
+ @UseGuards(JwtAuthGuard, TenantGuard({ source: 'header', key: 'x-org-id' }))
205
+ @Controller('/invoices')
206
+ export class InvoicesController {}
207
+ ```
210
208
 
211
- @Controller('/admin')
212
- export class AdminController {
213
- @Get('/users')
214
- @UseGuard(AuthGuard)
215
- @UseGuard(RoleGuard(['admin']))
216
- getAllUsers() {
217
- return { users: [] };
218
- }
209
+ #### Query string
219
210
 
220
- @Get('/settings')
221
- @UseGuard(AuthGuard)
222
- @UseGuard(RoleGuard(['admin', 'superadmin']))
223
- getSettings() {
224
- return { settings: {} };
225
- }
226
- }
211
+ ```typescript
212
+ // Client sends: GET /invoices?org=acme
213
+ @UseGuards(JwtAuthGuard, TenantGuard({ source: 'query', key: 'org' }))
214
+ @Controller('/invoices')
215
+ export class InvoicesController {}
227
216
  ```
228
217
 
229
- ### Permission Guard
218
+ #### Bypass for privileged roles
219
+
220
+ Superadmins often need to manage any tenant. Use `bypassRoles` to skip the check for them:
230
221
 
231
222
  ```typescript
232
- import { PermissionGuard } from '@hazeljs/auth';
233
-
234
- @Controller('/posts')
235
- export class PostController {
236
- @Post()
237
- @UseGuard(AuthGuard)
238
- @UseGuard(PermissionGuard(['posts:create']))
239
- createPost(@Body() createPostDto: CreatePostDto) {
240
- return this.postService.create(createPostDto);
241
- }
223
+ @UseGuards(
224
+ JwtAuthGuard,
225
+ TenantGuard({ bypassRoles: ['superadmin'] })
226
+ )
227
+ @Controller('/orgs/:tenantId/settings')
228
+ export class OrgSettingsController {}
229
+ ```
242
230
 
243
- @Delete('/:id')
244
- @UseGuard(AuthGuard)
245
- @UseGuard(PermissionGuard(['posts:delete']))
246
- deletePost(@Param('id') id: string) {
247
- return this.postService.delete(id);
248
- }
249
- }
231
+ #### Custom user field
232
+
233
+ ```typescript
234
+ // JWT payload uses 'orgId' instead of 'tenantId'
235
+ @UseGuards(JwtAuthGuard, TenantGuard({ userField: 'orgId' }))
250
236
  ```
251
237
 
252
- ## Decorators
238
+ All options:
253
239
 
254
- ### @CurrentUser()
240
+ | Option | Type | Default | Description |
241
+ |---|---|---|---|
242
+ | `source` | `'param' \| 'header' \| 'query'` | `'param'` | Where to read the tenant ID from the request |
243
+ | `key` | `string` | `'tenantId'` | Param name / header name / query key |
244
+ | `userField` | `string` | `'tenantId'` | Field on `req.user` holding the user's tenant |
245
+ | `bypassRoles` | `string[]` | `[]` | Roles that skip the check entirely |
255
246
 
256
- Extract the authenticated user from the request:
247
+ Errors thrown:
257
248
 
258
- ```typescript
259
- @Get('/me')
260
- @UseGuard(AuthGuard)
261
- getMe(@CurrentUser() user: any) {
262
- return user;
263
- }
249
+ | Condition | Status |
250
+ |---|---|
251
+ | No `req.user` | 401 |
252
+ | `req.user` has no tenant field | 403 |
253
+ | Tenant ID absent from request | 400 |
254
+ | Tenant IDs do not match | 403 |
264
255
 
265
- // With specific property
266
- @Get('/email')
267
- @UseGuard(AuthGuard)
268
- getEmail(@CurrentUser('email') email: string) {
269
- return { email };
270
- }
271
- ```
256
+ ---
272
257
 
273
- ### @Public()
258
+ ### Database-level tenant isolation
274
259
 
275
- Mark routes as public (skip authentication):
260
+ `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`.
261
+
262
+ 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.
276
263
 
277
264
  ```typescript
278
- import { Public } from '@hazeljs/auth';
279
-
280
- @Controller('/api')
281
- @UseGuard(AuthGuard) // Applied to all routes
282
- export class ApiController {
283
- @Get('/public')
284
- @Public() // This route skips authentication
285
- getPublicData() {
286
- return { data: 'public' };
265
+ // src/orders/orders.repository.ts
266
+ import { Service } from '@hazeljs/core';
267
+ import { TenantContext } from '@hazeljs/auth';
268
+
269
+ @Service()
270
+ export class OrdersRepository {
271
+ constructor(private readonly tenantCtx: TenantContext) {}
272
+
273
+ findAll() {
274
+ const tenantId = this.tenantCtx.requireId();
275
+ // Scoped automatically — no tenantId parameter needed
276
+ return db.query('SELECT * FROM orders WHERE tenant_id = $1', [tenantId]);
287
277
  }
288
278
 
289
- @Get('/private')
290
- getPrivateData(@CurrentUser() user: any) {
291
- return { data: 'private', user };
279
+ findById(id: string) {
280
+ const tenantId = this.tenantCtx.requireId();
281
+ // Even direct ID lookup is tenant-scoped — prevents IDOR attacks
282
+ return db.query(
283
+ 'SELECT * FROM orders WHERE id = $1 AND tenant_id = $2',
284
+ [id, tenantId]
285
+ );
292
286
  }
293
287
  }
294
288
  ```
295
289
 
296
- ### @Roles()
297
-
298
- Shorthand for role-based access:
290
+ The route setup:
299
291
 
300
292
  ```typescript
301
- import { Roles } from '@hazeljs/auth';
302
-
303
- @Controller('/admin')
304
- export class AdminController {
305
- @Get('/dashboard')
306
- @Roles('admin', 'superadmin')
307
- getDashboard() {
308
- return { dashboard: 'data' };
293
+ @UseGuards(JwtAuthGuard, TenantGuard())
294
+ @Controller('/orgs/:tenantId/orders')
295
+ export class OrdersController {
296
+ constructor(private readonly repo: OrdersRepository) {}
297
+
298
+ @Get('/')
299
+ list() {
300
+ // TenantContext is already seeded by TenantGuard — no need to pass tenantId
301
+ return this.repo.findAll();
309
302
  }
310
303
  }
311
304
  ```
312
305
 
313
- ## Token Management
306
+ The two layers together:
314
307
 
315
- ### Generate Token
308
+ | Layer | What it does | What it catches |
309
+ |---|---|---|
310
+ | `TenantGuard` | Rejects requests where `req.user.tenantId !== :tenantId` | Unauthenticated cross-tenant requests |
311
+ | `TenantContext` | Scopes every DB query via AsyncLocalStorage | Bugs, missing guard on a route, IDOR attempts |
312
+
313
+ For background jobs or tests, you can run code in a specific tenant context explicitly:
316
314
 
317
315
  ```typescript
318
- const token = await authService.generateToken({
319
- sub: user.id,
320
- email: user.email,
321
- roles: ['user'],
322
- customClaim: 'value',
316
+ // Background job no HTTP request involved
317
+ await TenantContext.run('acme', async () => {
318
+ await ordersService.processPendingOrders();
323
319
  });
324
320
  ```
325
321
 
326
- ### Verify Token
322
+ `requireId()` throws with a 500 if called outside any tenant context (guard missing), giving you a clear error instead of silently querying all tenants.
323
+
324
+ ---
325
+
326
+ ### Combining guards
327
+
328
+ Guards run left-to-right. Always put `JwtAuthGuard` first.
327
329
 
328
330
  ```typescript
329
- try {
330
- const payload = await authService.verifyToken(token);
331
- console.log(payload.sub); // user.id
332
- console.log(payload.email);
333
- } catch (error) {
334
- console.error('Invalid token:', error.message);
331
+ @UseGuards(JwtAuthGuard, RoleGuard('manager'), TenantGuard())
332
+ @Controller('/orgs/:tenantId/orders')
333
+ export class OrdersController {
334
+
335
+ @Get('/')
336
+ listOrders(@CurrentUser() user: AuthUser) {
337
+ return this.ordersService.findAll(user.tenantId!);
338
+ }
339
+
340
+ // Stricter restriction on a single route — only admin (and above) can delete:
341
+ @UseGuards(RoleGuard('admin'))
342
+ @Delete('/:id')
343
+ deleteOrder(@Param('id') id: string) {
344
+ return this.ordersService.remove(id);
345
+ }
335
346
  }
336
347
  ```
337
348
 
338
- ### Decode Token (without verification)
349
+ ---
339
350
 
340
- ```typescript
341
- const payload = authService.decodeToken(token);
342
- console.log(payload);
343
- ```
351
+ ## `@CurrentUser()` decorator
344
352
 
345
- ### Blacklist Token
353
+ Injects the authenticated user (or a specific field from it) directly into the controller parameter.
346
354
 
347
355
  ```typescript
348
- await authService.blacklistToken(token);
356
+ import { CurrentUser, AuthUser } from '@hazeljs/auth';
357
+
358
+ @UseGuards(JwtAuthGuard)
359
+ @Get('/me')
360
+ getMe(@CurrentUser() user: AuthUser) {
361
+ return user;
362
+ // { id: 'u1', username: 'alice', role: 'admin', tenantId: 'acme' }
363
+ }
364
+
365
+ @Get('/role')
366
+ getRole(@CurrentUser('role') role: string) {
367
+ return { role };
368
+ }
349
369
 
350
- // Check if blacklisted
351
- const isBlacklisted = await authService.isTokenBlacklisted(token);
370
+ @Get('/tenant')
371
+ getTenant(@CurrentUser('tenantId') tenantId: string) {
372
+ return { tenantId };
373
+ }
352
374
  ```
353
375
 
354
- ## Configuration
376
+ ---
377
+
378
+ ## `@Auth()` decorator (method-level shorthand)
355
379
 
356
- ### Module Configuration
380
+ 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.
357
381
 
358
382
  ```typescript
359
- AuthModule.forRoot({
360
- // JWT secret key
361
- secret: process.env.JWT_SECRET,
362
-
363
- // Access token expiration
364
- expiresIn: '15m',
365
-
366
- // Refresh token expiration
367
- refreshExpiresIn: '7d',
368
-
369
- // Token issuer
370
- issuer: 'hazeljs-app',
371
-
372
- // Token audience
373
- audience: 'hazeljs-users',
374
-
375
- // Algorithm
376
- algorithm: 'HS256',
377
-
378
- // Token blacklist (requires Redis)
379
- blacklist: {
380
- enabled: true,
381
- redis: redisClient,
382
- },
383
- })
384
- ```
383
+ import { Auth } from '@hazeljs/auth';
385
384
 
386
- ### Environment Variables
385
+ @Controller('/admin')
386
+ export class AdminController {
387
+ @Auth() // JWT check only
388
+ @Get('/dashboard')
389
+ getDashboard() { ... }
387
390
 
388
- ```env
389
- JWT_SECRET=your-super-secret-key-change-in-production
390
- JWT_EXPIRES_IN=1h
391
- JWT_REFRESH_EXPIRES_IN=7d
391
+ @Auth({ roles: ['admin'] }) // JWT + role check (no hierarchy)
392
+ @Delete('/user/:id')
393
+ deleteUser(@Param('id') id: string) { ... }
394
+ }
392
395
  ```
393
396
 
394
- ## Password Hashing
397
+ > **Note:** `@Auth()` does not use the role hierarchy. Use `@UseGuards(JwtAuthGuard, RoleGuard('admin'))` when hierarchy matters.
398
+
399
+ ---
400
+
401
+ ## `JwtService` API
395
402
 
396
403
  ```typescript
397
- import { hash, compare } from '@hazeljs/auth';
404
+ import { JwtService } from '@hazeljs/auth';
398
405
 
399
- // Hash password
400
- const hashedPassword = await hash('user-password', 10);
406
+ // Sign a token
407
+ const token = jwtService.sign({ sub: userId, role: 'admin', tenantId: 'acme' });
408
+ const token = jwtService.sign({ sub: userId }, { expiresIn: '15m' }); // custom expiry
401
409
 
402
- // Compare password
403
- const isValid = await compare('user-password', hashedPassword);
410
+ // Verify and decode
411
+ const payload = jwtService.verify(token); // throws on invalid/expired
412
+ payload.sub // string
413
+ payload.role // string
414
+ payload.tenantId // string | undefined
415
+
416
+ // Decode without verification (e.g. to read exp before refreshing)
417
+ const payload = jwtService.decode(token); // returns null on malformed
404
418
  ```
405
419
 
406
- ## Custom Guards
420
+ ---
407
421
 
408
- ```typescript
409
- import { Guard, GuardContext } from '@hazeljs/core';
410
- import { Injectable } from '@hazeljs/core';
422
+ ## `AuthService` API
411
423
 
412
- @Injectable()
413
- export class CustomAuthGuard implements Guard {
414
- async canActivate(context: GuardContext): Promise<boolean> {
415
- const request = context.request;
416
- const token = request.headers.authorization?.split(' ')[1];
417
-
418
- if (!token) {
419
- return false;
420
- }
421
-
422
- try {
423
- const payload = await this.authService.verifyToken(token);
424
- request.user = payload;
425
- return true;
426
- } catch {
427
- return false;
428
- }
429
- }
424
+ `AuthService` wraps `JwtService` and returns a typed `AuthUser` object:
425
+
426
+ ```typescript
427
+ interface AuthUser {
428
+ id: string;
429
+ username?: string;
430
+ role: string;
431
+ [key: string]: unknown; // all other JWT claims pass through
430
432
  }
433
+
434
+ const user = await authService.verifyToken(token);
435
+ // Returns AuthUser | null (null when token is invalid — never throws)
431
436
  ```
432
437
 
433
- ## API Reference
438
+ ---
434
439
 
435
- ### AuthService
440
+ ## `RoleHierarchy` API
436
441
 
437
442
  ```typescript
438
- class AuthService {
439
- generateToken(payload: any, options?: SignOptions): Promise<string>;
440
- generateRefreshToken(payload: any): Promise<string>;
441
- verifyToken(token: string): Promise<any>;
442
- verifyRefreshToken(token: string): Promise<any>;
443
- decodeToken(token: string): any;
444
- blacklistToken(token: string): Promise<void>;
445
- isTokenBlacklisted(token: string): Promise<boolean>;
446
- }
447
- ```
443
+ import { RoleHierarchy, DEFAULT_ROLE_HIERARCHY } from '@hazeljs/auth';
448
444
 
449
- ### Guards
445
+ const h = new RoleHierarchy(DEFAULT_ROLE_HIERARCHY);
450
446
 
451
- - `AuthGuard` - Validates JWT token
452
- - `RoleGuard(roles: string[])` - Checks user roles
453
- - `PermissionGuard(permissions: string[])` - Checks user permissions
447
+ h.satisfies('superadmin', 'user') // true — full chain
448
+ h.satisfies('manager', 'admin') // false no upward inheritance
449
+ h.resolve('admin') // Set { 'admin', 'manager', 'user' }
450
+ ```
454
451
 
455
- ### Decorators
452
+ ---
456
453
 
457
- - `@CurrentUser(property?: string)` - Extract user from request
458
- - `@Public()` - Skip authentication
459
- - `@Roles(...roles: string[])` - Require specific roles
454
+ ## Custom guards
460
455
 
461
- ## Examples
456
+ Implement `CanActivate` from `@hazeljs/core` for fully custom logic:
462
457
 
463
- See the [examples](../../example/src/auth) directory for complete working examples.
458
+ ```typescript
459
+ import { Injectable, CanActivate, ExecutionContext } from '@hazeljs/core';
464
460
 
465
- ## Testing
461
+ @Injectable()
462
+ export class ApiKeyGuard implements CanActivate {
463
+ canActivate(context: ExecutionContext): boolean {
464
+ const req = context.switchToHttp().getRequest() as { headers: Record<string, string> };
465
+ return req.headers['x-api-key'] === process.env.API_KEY;
466
+ }
467
+ }
468
+ ```
466
469
 
467
- ```bash
468
- npm test
470
+ The `ExecutionContext` also exposes the fully parsed `RequestContext` (params, query, headers, body, user):
471
+
472
+ ```typescript
473
+ canActivate(context: ExecutionContext): boolean {
474
+ const ctx = context.switchToHttp().getContext();
475
+ const orgId = ctx.params['orgId'];
476
+ const user = ctx.user;
477
+ // ...
478
+ }
469
479
  ```
470
480
 
471
- ## Contributing
481
+ ---
472
482
 
473
- Contributions are welcome! Please read our [Contributing Guide](../../CONTRIBUTING.md) for details.
483
+ ## Environment variables
474
484
 
475
- ## License
485
+ | Variable | Default | Description |
486
+ |---|---|---|
487
+ | `JWT_SECRET` | *(required)* | Secret used to sign and verify tokens |
488
+ | `JWT_EXPIRES_IN` | `1h` | Default token lifetime |
489
+ | `JWT_ISSUER` | — | Optional `iss` claim |
490
+ | `JWT_AUDIENCE` | — | Optional `aud` claim |
476
491
 
477
- Apache 2.0 © [HazelJS](https://hazeljs.com)
492
+ ---
478
493
 
479
494
  ## Links
480
495