@hazeljs/auth 0.2.0-beta.58 → 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 +344 -329
- package/dist/auth.test.js +351 -0
- package/dist/decorators/current-user.decorator.d.ts +26 -0
- package/dist/decorators/current-user.decorator.d.ts.map +1 -0
- package/dist/decorators/current-user.decorator.js +39 -0
- package/dist/guards/jwt-auth.guard.d.ts +24 -0
- package/dist/guards/jwt-auth.guard.d.ts.map +1 -0
- package/dist/guards/jwt-auth.guard.js +61 -0
- package/dist/guards/role.guard.d.ts +36 -0
- package/dist/guards/role.guard.d.ts.map +1 -0
- package/dist/guards/role.guard.js +66 -0
- package/dist/guards/tenant.guard.d.ts +54 -0
- package/dist/guards/tenant.guard.d.ts.map +1 -0
- package/dist/guards/tenant.guard.js +96 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -1
- package/dist/tenant/tenant-context.d.ts +81 -0
- package/dist/tenant/tenant-context.d.ts.map +1 -0
- package/dist/tenant/tenant-context.js +108 -0
- package/dist/utils/role-hierarchy.d.ts +42 -0
- package/dist/utils/role-hierarchy.d.ts.map +1 -0
- package/dist/utils/role-hierarchy.js +57 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @hazeljs/auth
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**JWT authentication, role-based access control, and tenant isolation — in one line of decorators.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
No Passport config, no middleware soup. `@UseGuards(JwtAuthGuard)` on a controller and you're done.
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/@hazeljs/auth)
|
|
8
8
|
[](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
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Setup
|
|
29
31
|
|
|
30
|
-
### 1.
|
|
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 {
|
|
38
|
+
import { JwtModule } from '@hazeljs/auth';
|
|
35
39
|
|
|
36
40
|
@HazelModule({
|
|
37
41
|
imports: [
|
|
38
|
-
|
|
39
|
-
secret: process.env.JWT_SECRET
|
|
40
|
-
expiresIn: '1h',
|
|
41
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
import { AuthService } from '@hazeljs/auth';
|
|
53
|
+
```env
|
|
54
|
+
JWT_SECRET=change-me-in-production
|
|
55
|
+
JWT_EXPIRES_IN=1h
|
|
56
|
+
```
|
|
53
57
|
|
|
54
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
80
|
+
---
|
|
98
81
|
|
|
99
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return { user };
|
|
114
|
-
}
|
|
84
|
+
All guards are resolved from the DI container, so they can inject services.
|
|
85
|
+
|
|
86
|
+
### `JwtAuthGuard`
|
|
115
87
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
105
|
+
Errors thrown:
|
|
126
106
|
|
|
127
|
-
|
|
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
|
-
@
|
|
131
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
132
|
+
```
|
|
133
|
+
superadmin
|
|
134
|
+
└─ admin
|
|
135
|
+
└─ manager
|
|
136
|
+
└─ user
|
|
152
137
|
```
|
|
153
138
|
|
|
154
|
-
|
|
139
|
+
So `RoleGuard('user')` passes for **every** role, and `RoleGuard('admin')` only passes for `admin` and `superadmin`.
|
|
155
140
|
|
|
156
141
|
```typescript
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
152
|
+
#### Custom hierarchy
|
|
174
153
|
|
|
175
154
|
```typescript
|
|
176
|
-
@
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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 {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
200
|
+
#### HTTP header
|
|
207
201
|
|
|
208
202
|
```typescript
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
238
|
+
All options:
|
|
253
239
|
|
|
254
|
-
|
|
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
|
-
|
|
247
|
+
Errors thrown:
|
|
257
248
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
266
|
-
@Get('/email')
|
|
267
|
-
@UseGuard(AuthGuard)
|
|
268
|
-
getEmail(@CurrentUser('email') email: string) {
|
|
269
|
-
return { email };
|
|
270
|
-
}
|
|
271
|
-
```
|
|
256
|
+
---
|
|
272
257
|
|
|
273
|
-
###
|
|
258
|
+
### Database-level tenant isolation
|
|
274
259
|
|
|
275
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
@
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
Shorthand for role-based access:
|
|
290
|
+
The route setup:
|
|
299
291
|
|
|
300
292
|
```typescript
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
@
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
306
|
+
The two layers together:
|
|
314
307
|
|
|
315
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
349
|
+
---
|
|
339
350
|
|
|
340
|
-
|
|
341
|
-
const payload = authService.decodeToken(token);
|
|
342
|
-
console.log(payload);
|
|
343
|
-
```
|
|
351
|
+
## `@CurrentUser()` decorator
|
|
344
352
|
|
|
345
|
-
|
|
353
|
+
Injects the authenticated user (or a specific field from it) directly into the controller parameter.
|
|
346
354
|
|
|
347
355
|
```typescript
|
|
348
|
-
|
|
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
|
-
|
|
351
|
-
|
|
370
|
+
@Get('/tenant')
|
|
371
|
+
getTenant(@CurrentUser('tenantId') tenantId: string) {
|
|
372
|
+
return { tenantId };
|
|
373
|
+
}
|
|
352
374
|
```
|
|
353
375
|
|
|
354
|
-
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
## `@Auth()` decorator (method-level shorthand)
|
|
355
379
|
|
|
356
|
-
|
|
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
|
-
|
|
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
|
-
|
|
385
|
+
@Controller('/admin')
|
|
386
|
+
export class AdminController {
|
|
387
|
+
@Auth() // JWT check only
|
|
388
|
+
@Get('/dashboard')
|
|
389
|
+
getDashboard() { ... }
|
|
387
390
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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 {
|
|
404
|
+
import { JwtService } from '@hazeljs/auth';
|
|
398
405
|
|
|
399
|
-
//
|
|
400
|
-
const
|
|
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
|
-
//
|
|
403
|
-
const
|
|
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
|
-
|
|
420
|
+
---
|
|
407
421
|
|
|
408
|
-
|
|
409
|
-
import { Guard, GuardContext } from '@hazeljs/core';
|
|
410
|
-
import { Injectable } from '@hazeljs/core';
|
|
422
|
+
## `AuthService` API
|
|
411
423
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
438
|
+
---
|
|
434
439
|
|
|
435
|
-
|
|
440
|
+
## `RoleHierarchy` API
|
|
436
441
|
|
|
437
442
|
```typescript
|
|
438
|
-
|
|
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
|
-
|
|
445
|
+
const h = new RoleHierarchy(DEFAULT_ROLE_HIERARCHY);
|
|
450
446
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
452
|
+
---
|
|
456
453
|
|
|
457
|
-
|
|
458
|
-
- `@Public()` - Skip authentication
|
|
459
|
-
- `@Roles(...roles: string[])` - Require specific roles
|
|
454
|
+
## Custom guards
|
|
460
455
|
|
|
461
|
-
|
|
456
|
+
Implement `CanActivate` from `@hazeljs/core` for fully custom logic:
|
|
462
457
|
|
|
463
|
-
|
|
458
|
+
```typescript
|
|
459
|
+
import { Injectable, CanActivate, ExecutionContext } from '@hazeljs/core';
|
|
464
460
|
|
|
465
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
|
|
481
|
+
---
|
|
472
482
|
|
|
473
|
-
|
|
483
|
+
## Environment variables
|
|
474
484
|
|
|
475
|
-
|
|
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
|
-
|
|
492
|
+
---
|
|
478
493
|
|
|
479
494
|
## Links
|
|
480
495
|
|