@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/LICENSE +192 -21
- package/README.md +348 -332
- package/dist/auth.service.js +1 -1
- package/dist/auth.test.d.ts +2 -0
- package/dist/auth.test.d.ts.map +1 -0
- package/dist/auth.test.js +682 -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/jwt/jwt.service.js +1 -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 +11 -5
package/README.md
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
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
|
-
[](https://www.npmjs.com/package/@hazeljs/auth)
|
|
9
|
+
[](https://www.apache.org/licenses/LICENSE-2.0)
|
|
9
10
|
|
|
10
11
|
## Features
|
|
11
12
|
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Setup
|
|
28
31
|
|
|
29
|
-
### 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.
|
|
30
35
|
|
|
31
36
|
```typescript
|
|
32
37
|
import { HazelModule } from '@hazeljs/core';
|
|
33
|
-
import {
|
|
38
|
+
import { JwtModule } from '@hazeljs/auth';
|
|
34
39
|
|
|
35
40
|
@HazelModule({
|
|
36
41
|
imports: [
|
|
37
|
-
|
|
38
|
-
secret: process.env.JWT_SECRET
|
|
39
|
-
expiresIn: '1h',
|
|
40
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
import { AuthService } from '@hazeljs/auth';
|
|
53
|
+
```env
|
|
54
|
+
JWT_SECRET=change-me-in-production
|
|
55
|
+
JWT_EXPIRES_IN=1h
|
|
56
|
+
```
|
|
52
57
|
|
|
53
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
80
|
+
---
|
|
97
81
|
|
|
98
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return { user };
|
|
113
|
-
}
|
|
84
|
+
All guards are resolved from the DI container, so they can inject services.
|
|
85
|
+
|
|
86
|
+
### `JwtAuthGuard`
|
|
114
87
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
105
|
+
Errors thrown:
|
|
125
106
|
|
|
126
|
-
|
|
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
|
-
@
|
|
130
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
132
|
+
```
|
|
133
|
+
superadmin
|
|
134
|
+
└─ admin
|
|
135
|
+
└─ manager
|
|
136
|
+
└─ user
|
|
151
137
|
```
|
|
152
138
|
|
|
153
|
-
|
|
139
|
+
So `RoleGuard('user')` passes for **every** role, and `RoleGuard('admin')` only passes for `admin` and `superadmin`.
|
|
154
140
|
|
|
155
141
|
```typescript
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
152
|
+
#### Custom hierarchy
|
|
173
153
|
|
|
174
154
|
```typescript
|
|
175
|
-
@
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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 {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
200
|
+
#### HTTP header
|
|
206
201
|
|
|
207
202
|
```typescript
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
238
|
+
All options:
|
|
252
239
|
|
|
253
|
-
|
|
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
|
-
|
|
247
|
+
Errors thrown:
|
|
256
248
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
265
|
-
@Get('/email')
|
|
266
|
-
@UseGuard(AuthGuard)
|
|
267
|
-
getEmail(@CurrentUser('email') email: string) {
|
|
268
|
-
return { email };
|
|
269
|
-
}
|
|
270
|
-
```
|
|
256
|
+
---
|
|
271
257
|
|
|
272
|
-
###
|
|
258
|
+
### Database-level tenant isolation
|
|
273
259
|
|
|
274
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
@
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
Shorthand for role-based access:
|
|
290
|
+
The route setup:
|
|
298
291
|
|
|
299
292
|
```typescript
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
@
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
306
|
+
The two layers together:
|
|
313
307
|
|
|
314
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
349
|
+
---
|
|
338
350
|
|
|
339
|
-
|
|
340
|
-
const payload = authService.decodeToken(token);
|
|
341
|
-
console.log(payload);
|
|
342
|
-
```
|
|
351
|
+
## `@CurrentUser()` decorator
|
|
343
352
|
|
|
344
|
-
|
|
353
|
+
Injects the authenticated user (or a specific field from it) directly into the controller parameter.
|
|
345
354
|
|
|
346
355
|
```typescript
|
|
347
|
-
|
|
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
|
-
|
|
350
|
-
|
|
370
|
+
@Get('/tenant')
|
|
371
|
+
getTenant(@CurrentUser('tenantId') tenantId: string) {
|
|
372
|
+
return { tenantId };
|
|
373
|
+
}
|
|
351
374
|
```
|
|
352
375
|
|
|
353
|
-
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
## `@Auth()` decorator (method-level shorthand)
|
|
354
379
|
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
385
|
+
@Controller('/admin')
|
|
386
|
+
export class AdminController {
|
|
387
|
+
@Auth() // JWT check only
|
|
388
|
+
@Get('/dashboard')
|
|
389
|
+
getDashboard() { ... }
|
|
386
390
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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 {
|
|
404
|
+
import { JwtService } from '@hazeljs/auth';
|
|
397
405
|
|
|
398
|
-
//
|
|
399
|
-
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
|
|
400
409
|
|
|
401
|
-
//
|
|
402
|
-
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
|
|
403
418
|
```
|
|
404
419
|
|
|
405
|
-
|
|
420
|
+
---
|
|
406
421
|
|
|
407
|
-
|
|
408
|
-
import { Guard, GuardContext } from '@hazeljs/core';
|
|
409
|
-
import { Injectable } from '@hazeljs/core';
|
|
422
|
+
## `AuthService` API
|
|
410
423
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
438
|
+
---
|
|
433
439
|
|
|
434
|
-
|
|
440
|
+
## `RoleHierarchy` API
|
|
435
441
|
|
|
436
442
|
```typescript
|
|
437
|
-
|
|
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
|
-
|
|
445
|
+
const h = new RoleHierarchy(DEFAULT_ROLE_HIERARCHY);
|
|
449
446
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
452
|
+
---
|
|
455
453
|
|
|
456
|
-
|
|
457
|
-
- `@Public()` - Skip authentication
|
|
458
|
-
- `@Roles(...roles: string[])` - Require specific roles
|
|
454
|
+
## Custom guards
|
|
459
455
|
|
|
460
|
-
|
|
456
|
+
Implement `CanActivate` from `@hazeljs/core` for fully custom logic:
|
|
461
457
|
|
|
462
|
-
|
|
458
|
+
```typescript
|
|
459
|
+
import { Injectable, CanActivate, ExecutionContext } from '@hazeljs/core';
|
|
463
460
|
|
|
464
|
-
|
|
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
|
-
|
|
467
|
-
|
|
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
|
-
|
|
481
|
+
---
|
|
471
482
|
|
|
472
|
-
|
|
483
|
+
## Environment variables
|
|
473
484
|
|
|
474
|
-
|
|
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
|
-
|
|
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/
|
|
483
|
-
- [Discord](https://discord.
|
|
498
|
+
- [Issues](https://github.com/hazel-js/hazeljs/issues)
|
|
499
|
+
- [Discord](https://discord.com/channels/1448263814238965833/1448263814859456575)
|