@hazeljs/auth 0.2.0-beta.8 → 0.2.0-beta.81

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,22 +1,23 @@
1
1
  # @hazeljs/auth
2
2
 
3
- **Authentication and JWT Module for HazelJS**
3
+ **JWT authentication, role-based access control, and tenant isolation in one line of decorators.**
4
4
 
5
- Secure your HazelJS applications with JWT-based authentication, guards, and decorators.
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
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+ [![npm downloads](https://img.shields.io/npm/dm/@hazeljs/auth)](https://www.npmjs.com/package/@hazeljs/auth)
9
+ [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
9
10
 
10
11
  ## Features
11
12
 
12
- - 🔐 **JWT Authentication** - Secure token-based authentication
13
- - 🛡️ **Auth Guards** - Protect routes with decorators
14
- - 👤 **User Extraction** - Get current user from request
15
- - 🔑 **Token Management** - Generate, verify, and refresh tokens
16
- - **Token Expiration** - Configurable expiration times
17
- - 🎯 **Role-Based Access** - Role and permission guards
18
- - 🔄 **Refresh Tokens** - Long-lived refresh token support
19
- - 📊 **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
+ ---
20
21
 
21
22
  ## Installation
22
23
 
@@ -24,460 +25,475 @@ Secure your HazelJS applications with JWT-based authentication, guards, and deco
24
25
  npm install @hazeljs/auth
25
26
  ```
26
27
 
27
- ## Quick Start
28
+ ---
29
+
30
+ ## Setup
28
31
 
29
- ### 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.
30
35
 
31
36
  ```typescript
32
37
  import { HazelModule } from '@hazeljs/core';
33
- import { AuthModule } from '@hazeljs/auth';
38
+ import { JwtModule } from '@hazeljs/auth';
34
39
 
35
40
  @HazelModule({
36
41
  imports: [
37
- AuthModule.forRoot({
38
- secret: process.env.JWT_SECRET || 'your-secret-key',
39
- expiresIn: '1h',
40
- 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
41
47
  }),
42
48
  ],
43
49
  })
44
50
  export class AppModule {}
45
51
  ```
46
52
 
47
- ### 2. Create Auth Service
48
-
49
- ```typescript
50
- import { Injectable } from '@hazeljs/core';
51
- import { AuthService } from '@hazeljs/auth';
53
+ ```env
54
+ JWT_SECRET=change-me-in-production
55
+ JWT_EXPIRES_IN=1h
56
+ ```
52
57
 
53
- @Injectable()
54
- export class UserAuthService {
55
- constructor(private authService: AuthService) {}
56
-
57
- async login(email: string, password: string) {
58
- // Validate credentials
59
- const user = await this.validateUser(email, password);
60
-
61
- if (!user) {
62
- throw new Error('Invalid credentials');
63
- }
64
-
65
- // Generate tokens
66
- const accessToken = await this.authService.generateToken({
67
- sub: user.id,
68
- email: user.email,
69
- roles: user.roles,
70
- });
58
+ ### 2. Issue tokens at login
71
59
 
72
- const refreshToken = await this.authService.generateRefreshToken({
73
- 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
74
73
  });
75
74
 
76
- return {
77
- accessToken,
78
- refreshToken,
79
- user,
80
- };
81
- }
82
-
83
- async validateUser(email: string, password: string) {
84
- // Your user validation logic
85
- const user = await this.userService.findByEmail(email);
86
-
87
- if (user && await this.comparePasswords(password, user.password)) {
88
- return user;
89
- }
90
-
91
- return null;
75
+ return { accessToken: token };
92
76
  }
93
77
  }
94
78
  ```
95
79
 
96
- ### 3. Protect Routes with Guards
80
+ ---
97
81
 
98
- ```typescript
99
- import { Controller, Get, Post, Body } from '@hazeljs/core';
100
- import { UseGuard, AuthGuard, CurrentUser } from '@hazeljs/auth';
101
-
102
- @Controller('/api')
103
- export class ApiController {
104
- @Post('/login')
105
- async login(@Body() credentials: LoginDto) {
106
- return this.authService.login(credentials.email, credentials.password);
107
- }
82
+ ## Guards
108
83
 
109
- @Get('/profile')
110
- @UseGuard(AuthGuard)
111
- getProfile(@CurrentUser() user: any) {
112
- return { user };
113
- }
84
+ All guards are resolved from the DI container, so they can inject services.
85
+
86
+ ### `JwtAuthGuard`
114
87
 
115
- @Get('/admin')
116
- @UseGuard(AuthGuard)
117
- @UseGuard(RoleGuard(['admin']))
118
- getAdminData(@CurrentUser() user: any) {
119
- 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;
120
101
  }
121
102
  }
122
103
  ```
123
104
 
124
- ## Authentication Flow
105
+ Errors thrown:
125
106
 
126
- ### 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.
127
118
 
128
119
  ```typescript
129
- @Post('/auth/login')
130
- async login(@Body() loginDto: LoginDto) {
131
- const user = await this.authService.validateUser(
132
- loginDto.email,
133
- loginDto.password
134
- );
135
-
136
- if (!user) {
137
- throw new UnauthorizedException('Invalid credentials');
138
- }
120
+ import { UseGuards } from '@hazeljs/core';
121
+ import { JwtAuthGuard, RoleGuard } from '@hazeljs/auth';
139
122
 
140
- const payload = {
141
- sub: user.id,
142
- email: user.email,
143
- roles: user.roles,
144
- };
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`.
145
131
 
146
- return {
147
- accessToken: await this.authService.generateToken(payload),
148
- refreshToken: await this.authService.generateRefreshToken(payload),
149
- };
150
- }
132
+ ```
133
+ superadmin
134
+ └─ admin
135
+ └─ manager
136
+ └─ user
151
137
  ```
152
138
 
153
- ### Refresh Token
139
+ So `RoleGuard('user')` passes for **every** role, and `RoleGuard('admin')` only passes for `admin` and `superadmin`.
154
140
 
155
141
  ```typescript
156
- @Post('/auth/refresh')
157
- async refresh(@Body() refreshDto: RefreshDto) {
158
- const payload = await this.authService.verifyRefreshToken(
159
- refreshDto.refreshToken
160
- );
161
-
162
- return {
163
- accessToken: await this.authService.generateToken({
164
- sub: payload.sub,
165
- email: payload.email,
166
- roles: payload.roles,
167
- }),
168
- };
169
- }
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'))
170
150
  ```
171
151
 
172
- ### Logout
152
+ #### Custom hierarchy
173
153
 
174
154
  ```typescript
175
- @Post('/auth/logout')
176
- @UseGuard(AuthGuard)
177
- async logout(@CurrentUser() user: any, @Headers('authorization') token: string) {
178
- // Extract token from "Bearer <token>"
179
- const jwt = token.split(' ')[1];
180
-
181
- // Blacklist the token
182
- await this.authService.blacklistToken(jwt);
183
-
184
- return { message: 'Logged out successfully' };
185
- }
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
186
165
  ```
187
166
 
188
- ## 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
+ ---
189
182
 
190
- ### 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)
191
190
 
192
191
  ```typescript
193
- import { AuthGuard } from '@hazeljs/auth';
194
-
195
- @Controller('/protected')
196
- export class ProtectedController {
197
- @Get()
198
- @UseGuard(AuthGuard)
199
- getData(@CurrentUser() user: any) {
200
- return { data: 'protected', user };
201
- }
202
- }
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 {}
203
198
  ```
204
199
 
205
- ### Role Guard
200
+ #### HTTP header
206
201
 
207
202
  ```typescript
208
- 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
+ ```
209
208
 
210
- @Controller('/admin')
211
- export class AdminController {
212
- @Get('/users')
213
- @UseGuard(AuthGuard)
214
- @UseGuard(RoleGuard(['admin']))
215
- getAllUsers() {
216
- return { users: [] };
217
- }
209
+ #### Query string
218
210
 
219
- @Get('/settings')
220
- @UseGuard(AuthGuard)
221
- @UseGuard(RoleGuard(['admin', 'superadmin']))
222
- getSettings() {
223
- return { settings: {} };
224
- }
225
- }
211
+ ```typescript
212
+ // Client sends: GET /invoices?org=acme
213
+ @UseGuards(JwtAuthGuard, TenantGuard({ source: 'query', key: 'org' }))
214
+ @Controller('/invoices')
215
+ export class InvoicesController {}
226
216
  ```
227
217
 
228
- ### Permission Guard
218
+ #### Bypass for privileged roles
219
+
220
+ Superadmins often need to manage any tenant. Use `bypassRoles` to skip the check for them:
229
221
 
230
222
  ```typescript
231
- import { PermissionGuard } from '@hazeljs/auth';
232
-
233
- @Controller('/posts')
234
- export class PostController {
235
- @Post()
236
- @UseGuard(AuthGuard)
237
- @UseGuard(PermissionGuard(['posts:create']))
238
- createPost(@Body() createPostDto: CreatePostDto) {
239
- return this.postService.create(createPostDto);
240
- }
223
+ @UseGuards(
224
+ JwtAuthGuard,
225
+ TenantGuard({ bypassRoles: ['superadmin'] })
226
+ )
227
+ @Controller('/orgs/:tenantId/settings')
228
+ export class OrgSettingsController {}
229
+ ```
241
230
 
242
- @Delete('/:id')
243
- @UseGuard(AuthGuard)
244
- @UseGuard(PermissionGuard(['posts:delete']))
245
- deletePost(@Param('id') id: string) {
246
- return this.postService.delete(id);
247
- }
248
- }
231
+ #### Custom user field
232
+
233
+ ```typescript
234
+ // JWT payload uses 'orgId' instead of 'tenantId'
235
+ @UseGuards(JwtAuthGuard, TenantGuard({ userField: 'orgId' }))
249
236
  ```
250
237
 
251
- ## Decorators
238
+ All options:
252
239
 
253
- ### @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 |
254
246
 
255
- Extract the authenticated user from the request:
247
+ Errors thrown:
256
248
 
257
- ```typescript
258
- @Get('/me')
259
- @UseGuard(AuthGuard)
260
- getMe(@CurrentUser() user: any) {
261
- return user;
262
- }
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 |
263
255
 
264
- // With specific property
265
- @Get('/email')
266
- @UseGuard(AuthGuard)
267
- getEmail(@CurrentUser('email') email: string) {
268
- return { email };
269
- }
270
- ```
256
+ ---
271
257
 
272
- ### @Public()
258
+ ### Database-level tenant isolation
273
259
 
274
- 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.
275
263
 
276
264
  ```typescript
277
- import { Public } from '@hazeljs/auth';
278
-
279
- @Controller('/api')
280
- @UseGuard(AuthGuard) // Applied to all routes
281
- export class ApiController {
282
- @Get('/public')
283
- @Public() // This route skips authentication
284
- getPublicData() {
285
- 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]);
286
277
  }
287
278
 
288
- @Get('/private')
289
- getPrivateData(@CurrentUser() user: any) {
290
- 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
+ );
291
286
  }
292
287
  }
293
288
  ```
294
289
 
295
- ### @Roles()
296
-
297
- Shorthand for role-based access:
290
+ The route setup:
298
291
 
299
292
  ```typescript
300
- import { Roles } from '@hazeljs/auth';
301
-
302
- @Controller('/admin')
303
- export class AdminController {
304
- @Get('/dashboard')
305
- @Roles('admin', 'superadmin')
306
- getDashboard() {
307
- 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();
308
302
  }
309
303
  }
310
304
  ```
311
305
 
312
- ## Token Management
306
+ The two layers together:
313
307
 
314
- ### 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:
315
314
 
316
315
  ```typescript
317
- const token = await authService.generateToken({
318
- sub: user.id,
319
- email: user.email,
320
- roles: ['user'],
321
- customClaim: 'value',
316
+ // Background job no HTTP request involved
317
+ await TenantContext.run('acme', async () => {
318
+ await ordersService.processPendingOrders();
322
319
  });
323
320
  ```
324
321
 
325
- ### 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.
326
329
 
327
330
  ```typescript
328
- try {
329
- const payload = await authService.verifyToken(token);
330
- console.log(payload.sub); // user.id
331
- console.log(payload.email);
332
- } catch (error) {
333
- 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
+ }
334
346
  }
335
347
  ```
336
348
 
337
- ### Decode Token (without verification)
349
+ ---
338
350
 
339
- ```typescript
340
- const payload = authService.decodeToken(token);
341
- console.log(payload);
342
- ```
351
+ ## `@CurrentUser()` decorator
343
352
 
344
- ### Blacklist Token
353
+ Injects the authenticated user (or a specific field from it) directly into the controller parameter.
345
354
 
346
355
  ```typescript
347
- 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
+ }
348
369
 
349
- // Check if blacklisted
350
- const isBlacklisted = await authService.isTokenBlacklisted(token);
370
+ @Get('/tenant')
371
+ getTenant(@CurrentUser('tenantId') tenantId: string) {
372
+ return { tenantId };
373
+ }
351
374
  ```
352
375
 
353
- ## Configuration
376
+ ---
377
+
378
+ ## `@Auth()` decorator (method-level shorthand)
354
379
 
355
- ### 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.
356
381
 
357
382
  ```typescript
358
- AuthModule.forRoot({
359
- // JWT secret key
360
- secret: process.env.JWT_SECRET,
361
-
362
- // Access token expiration
363
- expiresIn: '15m',
364
-
365
- // Refresh token expiration
366
- refreshExpiresIn: '7d',
367
-
368
- // Token issuer
369
- issuer: 'hazeljs-app',
370
-
371
- // Token audience
372
- audience: 'hazeljs-users',
373
-
374
- // Algorithm
375
- algorithm: 'HS256',
376
-
377
- // Token blacklist (requires Redis)
378
- blacklist: {
379
- enabled: true,
380
- redis: redisClient,
381
- },
382
- })
383
- ```
383
+ import { Auth } from '@hazeljs/auth';
384
384
 
385
- ### Environment Variables
385
+ @Controller('/admin')
386
+ export class AdminController {
387
+ @Auth() // JWT check only
388
+ @Get('/dashboard')
389
+ getDashboard() { ... }
386
390
 
387
- ```env
388
- JWT_SECRET=your-super-secret-key-change-in-production
389
- JWT_EXPIRES_IN=1h
390
- 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
+ }
391
395
  ```
392
396
 
393
- ## 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
394
402
 
395
403
  ```typescript
396
- import { hash, compare } from '@hazeljs/auth';
404
+ import { JwtService } from '@hazeljs/auth';
397
405
 
398
- // Hash password
399
- 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
400
409
 
401
- // Compare password
402
- 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
403
418
  ```
404
419
 
405
- ## Custom Guards
420
+ ---
406
421
 
407
- ```typescript
408
- import { Guard, GuardContext } from '@hazeljs/core';
409
- import { Injectable } from '@hazeljs/core';
422
+ ## `AuthService` API
410
423
 
411
- @Injectable()
412
- export class CustomAuthGuard implements Guard {
413
- async canActivate(context: GuardContext): Promise<boolean> {
414
- const request = context.request;
415
- const token = request.headers.authorization?.split(' ')[1];
416
-
417
- if (!token) {
418
- return false;
419
- }
420
-
421
- try {
422
- const payload = await this.authService.verifyToken(token);
423
- request.user = payload;
424
- return true;
425
- } catch {
426
- return false;
427
- }
428
- }
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
429
432
  }
433
+
434
+ const user = await authService.verifyToken(token);
435
+ // Returns AuthUser | null (null when token is invalid — never throws)
430
436
  ```
431
437
 
432
- ## API Reference
438
+ ---
433
439
 
434
- ### AuthService
440
+ ## `RoleHierarchy` API
435
441
 
436
442
  ```typescript
437
- class AuthService {
438
- generateToken(payload: any, options?: SignOptions): Promise<string>;
439
- generateRefreshToken(payload: any): Promise<string>;
440
- verifyToken(token: string): Promise<any>;
441
- verifyRefreshToken(token: string): Promise<any>;
442
- decodeToken(token: string): any;
443
- blacklistToken(token: string): Promise<void>;
444
- isTokenBlacklisted(token: string): Promise<boolean>;
445
- }
446
- ```
443
+ import { RoleHierarchy, DEFAULT_ROLE_HIERARCHY } from '@hazeljs/auth';
447
444
 
448
- ### Guards
445
+ const h = new RoleHierarchy(DEFAULT_ROLE_HIERARCHY);
449
446
 
450
- - `AuthGuard` - Validates JWT token
451
- - `RoleGuard(roles: string[])` - Checks user roles
452
- - `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
+ ```
453
451
 
454
- ### Decorators
452
+ ---
455
453
 
456
- - `@CurrentUser(property?: string)` - Extract user from request
457
- - `@Public()` - Skip authentication
458
- - `@Roles(...roles: string[])` - Require specific roles
454
+ ## Custom guards
459
455
 
460
- ## Examples
456
+ Implement `CanActivate` from `@hazeljs/core` for fully custom logic:
461
457
 
462
- See the [examples](../../example/src/auth) directory for complete working examples.
458
+ ```typescript
459
+ import { Injectable, CanActivate, ExecutionContext } from '@hazeljs/core';
463
460
 
464
- ## 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
+ ```
465
469
 
466
- ```bash
467
- 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
+ }
468
479
  ```
469
480
 
470
- ## Contributing
481
+ ---
471
482
 
472
- Contributions are welcome! Please read our [Contributing Guide](../../CONTRIBUTING.md) for details.
483
+ ## Environment variables
473
484
 
474
- ## 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 |
475
491
 
476
- MIT © [HazelJS](https://hazeljs.com)
492
+ ---
477
493
 
478
494
  ## Links
479
495
 
480
496
  - [Documentation](https://hazeljs.com/docs/packages/auth)
481
497
  - [GitHub](https://github.com/hazel-js/hazeljs)
482
- - [Issues](https://github.com/hazeljs/hazel-js/issues)
483
- - [Discord](https://discord.gg/hazeljs)
498
+ - [Issues](https://github.com/hazel-js/hazeljs/issues)
499
+ - [Discord](https://discord.com/channels/1448263814238965833/1448263814859456575)