najm-auth 2.0.9 → 2.0.10
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 +801 -778
- package/dist/client/edge.js +21 -12
- package/dist/client/server/index.js +21 -12
- package/dist/index.d.ts +22 -3
- package/dist/index.js +78 -36
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,778 +1,801 @@
|
|
|
1
|
-
# najm-auth
|
|
2
|
-
|
|
3
|
-
Production-ready authentication and authorization library for the Najm framework. Provides JWT-based authentication, role-based access control (RBAC), permission-based access control (PBAC), and row-level ownership scoping.
|
|
4
|
-
|
|
5
|
-
**Features:**
|
|
6
|
-
- ✅ JWT authentication (access + refresh token strategy)
|
|
7
|
-
- ✅ Automatic token rotation and blacklist-based revocation
|
|
8
|
-
- ✅ Role-based access control (RBAC) with hierarchies
|
|
9
|
-
- ✅ Permission-based access control (PBAC) with wildcards
|
|
10
|
-
- ✅ Row-level ownership scoping for multi-tenant apps
|
|
11
|
-
- ✅ Built-in password reset flow with email support
|
|
12
|
-
- ✅ Multi-dialect support (PostgreSQL, SQLite)
|
|
13
|
-
- ✅ Type-safe decorators with TypeScript
|
|
14
|
-
- ✅ Rate limiting on auth endpoints
|
|
15
|
-
- ✅ Internationalization (i18n) for all messages
|
|
16
|
-
- ✅ Google OpenID Connect sign-in with PKCE and explicit account linking
|
|
17
|
-
|
|
18
|
-
---
|
|
19
|
-
|
|
20
|
-
## Installation
|
|
21
|
-
|
|
22
|
-
```bash
|
|
23
|
-
bun add najm-auth
|
|
24
|
-
# Peer dependencies
|
|
25
|
-
bun add hono drizzle-orm reflect-metadata
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
---
|
|
29
|
-
|
|
30
|
-
## Quick Setup
|
|
31
|
-
|
|
32
|
-
### 1. Initialize Database
|
|
33
|
-
|
|
34
|
-
```typescript
|
|
35
|
-
// src/database/schema.ts
|
|
36
|
-
import { authSchema } from 'najm-auth';
|
|
37
|
-
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
|
38
|
-
|
|
39
|
-
// Your app tables
|
|
40
|
-
export const products = sqliteTable('products', {
|
|
41
|
-
id: text('id').primaryKey(),
|
|
42
|
-
name: text('name').notNull(),
|
|
43
|
-
userId: text('userId').notNull(),
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
// Combined schema (always include authSchema)
|
|
47
|
-
export const schema = {
|
|
48
|
-
...authSchema, // users, roles, permissions, tokens, rolePermissions
|
|
49
|
-
products,
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
// src/database/index.ts
|
|
53
|
-
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
|
54
|
-
import { Database } from 'bun:sqlite';
|
|
55
|
-
import { schema } from './schema';
|
|
56
|
-
|
|
57
|
-
const sqlite = new Database('./app.db');
|
|
58
|
-
export const db = drizzle(sqlite, { schema });
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
### 2. Configure Auth Plugin
|
|
62
|
-
|
|
63
|
-
```typescript
|
|
64
|
-
// src/main.ts
|
|
65
|
-
import 'reflect-metadata';
|
|
66
|
-
import { Server } from 'najm-core';
|
|
67
|
-
import { database } from 'najm-database';
|
|
68
|
-
import { auth } from 'najm-auth';
|
|
69
|
-
import { db } from './database';
|
|
70
|
-
|
|
71
|
-
const server = new Server()
|
|
72
|
-
.use(database({ default: db })) // Required: database must be registered first
|
|
73
|
-
.use(auth({
|
|
74
|
-
dialect: 'sqlite', // Auto-selects SQLite schema
|
|
75
|
-
jwt: {
|
|
76
|
-
accessSecret: process.env.JWT_ACCESS_SECRET!, // Required
|
|
77
|
-
refreshSecret: process.env.JWT_REFRESH_SECRET!, // Required
|
|
78
|
-
accessExpiresIn: '15m', // Optional, default: 1h
|
|
79
|
-
refreshExpiresIn: '7d', // Optional, default: 7d
|
|
80
|
-
},
|
|
81
|
-
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000', // For password reset links
|
|
82
|
-
}))
|
|
83
|
-
.load(/* your controllers and services */)
|
|
84
|
-
.listen(3000);
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
### 3. Set Environment Variables
|
|
88
|
-
|
|
89
|
-
```bash
|
|
90
|
-
# .env
|
|
91
|
-
JWT_ACCESS_SECRET=<32-character-minimum-secret>
|
|
92
|
-
JWT_REFRESH_SECRET=<32-character-minimum-secret>
|
|
93
|
-
FRONTEND_URL=https://app.example.com
|
|
94
|
-
# Optional Google sign-in
|
|
95
|
-
GOOGLE_CLIENT_ID=<google-web-client-id>
|
|
96
|
-
GOOGLE_CLIENT_SECRET=<google-web-client-secret>
|
|
97
|
-
# Optional for a split frontend/API deployment. Otherwise FRONTEND_URL is used.
|
|
98
|
-
GOOGLE_CALLBACK_URL=https://app.example.com/api/auth/oauth/google/callback
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
> ⚠️ **Security:** Generate secrets with `openssl rand -base64 32`
|
|
102
|
-
|
|
103
|
-
---
|
|
104
|
-
|
|
105
|
-
## Configuration Reference
|
|
106
|
-
|
|
107
|
-
### AuthPluginConfig
|
|
108
|
-
|
|
109
|
-
```typescript
|
|
110
|
-
auth({
|
|
111
|
-
// Database
|
|
112
|
-
dialect?: 'pg' | 'sqlite' // Default: 'pg' (RETURNING-capable engines only)
|
|
113
|
-
schema?: AuthSchema // Override dialect schema
|
|
114
|
-
|
|
115
|
-
// JWT
|
|
116
|
-
jwt?: {
|
|
117
|
-
accessSecret: string // Required, min 32 chars
|
|
118
|
-
accessExpiresIn?: string // Default: 1h
|
|
119
|
-
refreshSecret: string // Required, min 32 chars
|
|
120
|
-
refreshExpiresIn?: string // Default: 7d
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Cookies
|
|
124
|
-
refreshCookieName?: string // Default: 'refreshToken'
|
|
125
|
-
|
|
126
|
-
// Database
|
|
127
|
-
database?: string // Default: 'default'
|
|
128
|
-
blacklistPrefix?: string // Default: 'auth:blacklist:'
|
|
129
|
-
|
|
130
|
-
// Registration
|
|
131
|
-
defaultRole?: string | null // Auto-assign role to new users
|
|
132
|
-
bcryptRounds?: number // Default: 10 (valid: 4-31)
|
|
133
|
-
|
|
134
|
-
// Frontend
|
|
135
|
-
frontendUrl?: string // Password reset link base URL
|
|
136
|
-
|
|
137
|
-
// Optional Google OpenID Connect
|
|
138
|
-
oauth?: {
|
|
139
|
-
google?: true | {
|
|
140
|
-
clientId?: string // Or GOOGLE_CLIENT_ID
|
|
141
|
-
clientSecret?: string // Or GOOGLE_CLIENT_SECRET
|
|
142
|
-
callbackUrl?: string // Or GOOGLE_CALLBACK_URL; otherwise frontendUrl + /api/auth/oauth/google/callback
|
|
143
|
-
frontendCallbackPath?: string // Default: /auth/oauth/callback
|
|
144
|
-
errorRedirectPath?: string // Default: /login
|
|
145
|
-
allowSignup?: boolean // Default: true
|
|
146
|
-
autoLinkVerifiedEmail?: boolean // Default: false
|
|
147
|
-
allowedHostedDomains?: string[] // Validates the Google hd claim
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Dependencies (forwarded to plugins)
|
|
152
|
-
validation?: ValidationPluginConfig
|
|
153
|
-
rateLimit?: RateLimitPluginConfig
|
|
154
|
-
})
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
---
|
|
158
|
-
|
|
159
|
-
## Auto-Registered Routes
|
|
160
|
-
|
|
161
|
-
All routes are prefixed with `/auth` and auto-registered by the plugin.
|
|
162
|
-
|
|
163
|
-
### Authentication Routes
|
|
164
|
-
|
|
165
|
-
| Method | Path | Description | Auth |
|
|
166
|
-
|--------|------|-------------|------|
|
|
167
|
-
| `POST` | `/auth/register` | Register new user | None |
|
|
168
|
-
| `POST` | `/auth/login` | Login with email/password | None |
|
|
169
|
-
| `POST` | `/auth/refresh` | Refresh access token (cookie) | None (uses refresh cookie) |
|
|
170
|
-
| `POST` | `/auth/session/recover` | Reissue signed session without token rotation | Refresh cookie + recovery header |
|
|
171
|
-
| `POST` | `/auth/logout` | Logout and revoke tokens | ✅ Required |
|
|
172
|
-
| `GET` | `/auth/me` | Get current user profile | ✅ Required |
|
|
173
|
-
| `POST` | `/auth/forgot-password` | Request password reset | None |
|
|
174
|
-
| `POST` | `/auth/reset-password` | Confirm password reset | None |
|
|
175
|
-
| `GET` | `/auth/oauth/google/start` | Start Google sign-in | None |
|
|
176
|
-
| `GET` | `/auth/oauth/google/callback` | Verify Google callback and create Najm session | None |
|
|
177
|
-
| `POST` | `/auth/oauth/google/link` | Link Google to the current user | ✅ Required |
|
|
178
|
-
|
|
179
|
-
### Google Sign-In
|
|
180
|
-
|
|
181
|
-
Google sign-in uses the server-side OpenID Connect authorization-code flow.
|
|
182
|
-
Najm creates state, nonce, and PKCE values, verifies Google's signed ID token,
|
|
183
|
-
then issues the same Najm JWT, refresh token, and session cookie as password
|
|
184
|
-
login. Google tokens are discarded and are never stored.
|
|
185
|
-
|
|
186
|
-
```ts
|
|
187
|
-
auth({
|
|
188
|
-
dialect: 'pg',
|
|
189
|
-
frontendUrl: 'https://app.example.com',
|
|
190
|
-
oauth: { google: true },
|
|
191
|
-
})
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
With `google: true`, credentials come from `GOOGLE_CLIENT_ID` and
|
|
195
|
-
`GOOGLE_CLIENT_SECRET`; the callback defaults to
|
|
196
|
-
`${FRONTEND_URL}/api/auth/oauth/google/callback`. Register that value exactly
|
|
197
|
-
as an authorized redirect URI in Google Cloud. Set `GOOGLE_CALLBACK_URL` or
|
|
198
|
-
`google: { callbackUrl: '...' }` when the API runs on a different origin.
|
|
199
|
-
Production callback URLs must use HTTPS; HTTP is accepted only for localhost.
|
|
200
|
-
|
|
201
|
-
Mount the browser completion route configured by `frontendCallbackPath`:
|
|
202
|
-
|
|
203
|
-
```tsx
|
|
204
|
-
'use client';
|
|
205
|
-
|
|
206
|
-
import { OAuthCallback } from 'najm-auth/client/react';
|
|
207
|
-
|
|
208
|
-
export default function OAuthCallbackPage() {
|
|
209
|
-
return <OAuthCallback fallback={<p>Finishing sign-in...</p>} />;
|
|
210
|
-
}
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
Then use the headless button anywhere below `AuthProvider`:
|
|
214
|
-
|
|
215
|
-
```tsx
|
|
216
|
-
import { GoogleLoginButton } from 'najm-auth/client/react';
|
|
217
|
-
|
|
218
|
-
<GoogleLoginButton returnTo="/dashboard">
|
|
219
|
-
<button type="button">Continue with Google</button>
|
|
220
|
-
</GoogleLoginButton>
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
Google accounts are keyed by Google's stable `sub` claim. If an existing Najm
|
|
224
|
-
user has the same email but is not linked, sign-in fails with
|
|
225
|
-
`oauth_account_link_required` by default. After password login, call
|
|
226
|
-
`client.linkOAuthAccount('google')` to prove control of both accounts. Setting
|
|
227
|
-
`autoLinkVerifiedEmail: true` opts into verified-email linking.
|
|
228
|
-
|
|
229
|
-
### Admin Routes (all require `@isAdmin()`)
|
|
230
|
-
|
|
231
|
-
| Method | Path | Description |
|
|
232
|
-
|--------|------|-------------|
|
|
233
|
-
| `GET` | `/users?limit=50&offset=0` | List users (limit 1-100) |
|
|
234
|
-
| `GET` | `/users/:id` | Get user by ID |
|
|
235
|
-
| `POST` | `/users` | Create new user |
|
|
236
|
-
| `PUT` | `/users/:id` | Update user |
|
|
237
|
-
| `DELETE` | `/users/:id` | Delete user |
|
|
238
|
-
| `GET` | `/roles` | List all roles |
|
|
239
|
-
| `GET` | `/roles/:id` | Get role by ID |
|
|
240
|
-
| `POST` | `/roles` | Create new role |
|
|
241
|
-
| `PUT` | `/roles/:id` | Update role |
|
|
242
|
-
| `DELETE` | `/roles/:id` | Delete role |
|
|
243
|
-
| `GET` | `/permissions` | List all permissions |
|
|
244
|
-
| `GET` | `/permissions/:id` | Get permission by ID |
|
|
245
|
-
| `POST` | `/permissions` | Create new permission |
|
|
246
|
-
| `PUT` | `/permissions/:id` | Update permission |
|
|
247
|
-
| `DELETE` | `/permissions/:id` | Delete permission |
|
|
248
|
-
| `POST` | `/permissions/assign/:roleId/:permissionId` | Assign permission to role |
|
|
249
|
-
| `DELETE` | `/permissions/remove/:roleId/:permissionId` | Remove permission from role |
|
|
250
|
-
|
|
251
|
-
---
|
|
252
|
-
|
|
253
|
-
## Guards Reference
|
|
254
|
-
|
|
255
|
-
### Authentication Guard
|
|
256
|
-
|
|
257
|
-
```typescript
|
|
258
|
-
import { isAuth } from 'najm-auth';
|
|
259
|
-
|
|
260
|
-
@Controller('/api/posts')
|
|
261
|
-
class PostController {
|
|
262
|
-
@Get('/') // Public
|
|
263
|
-
getAll() { }
|
|
264
|
-
|
|
265
|
-
@Post('/')
|
|
266
|
-
@isAuth() // Requires valid JWT
|
|
267
|
-
create(@Body() data: any) { }
|
|
268
|
-
}
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
### Role Guards
|
|
272
|
-
|
|
273
|
-
```typescript
|
|
274
|
-
import { defineRoles } from 'najm-auth';
|
|
275
|
-
|
|
276
|
-
const roles = defineRoles({
|
|
277
|
-
ADMIN: 'admin',
|
|
278
|
-
MODERATOR: 'moderator',
|
|
279
|
-
USER: 'user',
|
|
280
|
-
}, {
|
|
281
|
-
superRoles: ['ADMIN'], // admin also passes moderator/user role guards
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
export const { isAdmin, isModerator, isUser } = roles;
|
|
285
|
-
|
|
286
|
-
@Controller('/admin')
|
|
287
|
-
@isAdmin() // All methods require admin role
|
|
288
|
-
class AdminController {
|
|
289
|
-
@Get('/users')
|
|
290
|
-
getUsers() { }
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
@Controller('/api/posts')
|
|
294
|
-
class PostController {
|
|
295
|
-
@Delete('/:id')
|
|
296
|
-
@isModerator() // Method-level guard
|
|
297
|
-
deletePost() { }
|
|
298
|
-
}
|
|
299
|
-
```
|
|
300
|
-
|
|
301
|
-
### Permission Guards
|
|
302
|
-
|
|
303
|
-
```typescript
|
|
304
|
-
import { Can, canRead, canCreate, canUpdate, canDelete } from 'najm-auth';
|
|
305
|
-
|
|
306
|
-
@Controller('/api/posts')
|
|
307
|
-
class PostController {
|
|
308
|
-
@Get('/')
|
|
309
|
-
@canRead('posts') // Requires 'read:posts' permission
|
|
310
|
-
getAll() { }
|
|
311
|
-
|
|
312
|
-
@Post('/')
|
|
313
|
-
@canCreate('posts') // Requires 'create:posts' permission
|
|
314
|
-
create(@Body() data: any) { }
|
|
315
|
-
|
|
316
|
-
@Put('/:id')
|
|
317
|
-
@canUpdate('posts') // Requires 'update:posts' permission
|
|
318
|
-
update() { }
|
|
319
|
-
|
|
320
|
-
@Delete('/:id')
|
|
321
|
-
@canDelete('posts') // Requires 'delete:posts' permission
|
|
322
|
-
delete() { }
|
|
323
|
-
|
|
324
|
-
@Post('/:id/publish')
|
|
325
|
-
@Can('publish:posts') // Custom permission
|
|
326
|
-
publish() { }
|
|
327
|
-
}
|
|
328
|
-
```
|
|
329
|
-
|
|
330
|
-
**Permission Wildcards:**
|
|
331
|
-
- `*:*` — All actions on all resources
|
|
332
|
-
- `create:*` — Create action on any resource
|
|
333
|
-
- `*:posts` — Any action on posts
|
|
334
|
-
|
|
335
|
-
### Combined Guards
|
|
336
|
-
|
|
337
|
-
```typescript
|
|
338
|
-
@Controller('/admin/reports')
|
|
339
|
-
@isAdmin() // Require admin role
|
|
340
|
-
class ReportController {
|
|
341
|
-
@Get('/financial')
|
|
342
|
-
@Can('view:financial') // AND require financial view permission
|
|
343
|
-
getFinancial() { }
|
|
344
|
-
}
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
---
|
|
348
|
-
|
|
349
|
-
## Ownership System
|
|
350
|
-
|
|
351
|
-
Control row-level access based on ownership (e.g., users see only their own data).
|
|
352
|
-
|
|
353
|
-
### Declaring Ownership Rules
|
|
354
|
-
|
|
355
|
-
```typescript
|
|
356
|
-
import { own, join, where } from 'najm-auth';
|
|
357
|
-
import { schema } from '../database/schema';
|
|
358
|
-
|
|
359
|
-
const { products, users } = schema;
|
|
360
|
-
const _users = alias(users, '_u');
|
|
361
|
-
|
|
362
|
-
export const Product = own(products)
|
|
363
|
-
.for('user',
|
|
364
|
-
join(products.userId, _users.id),
|
|
365
|
-
where(_users.id)
|
|
366
|
-
)
|
|
367
|
-
.writeBy(products.userId); // Enforce on create/update
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
### Using @Policy and @Owned
|
|
371
|
-
|
|
372
|
-
```typescript
|
|
373
|
-
import { configureOwnership, Policy, CanList, CanRead, CanCreate, CanUpdate, CanDelete } from 'najm-auth';
|
|
374
|
-
|
|
375
|
-
const config = configureOwnership({
|
|
376
|
-
adminRoles: ['admin'],
|
|
377
|
-
rules: {
|
|
378
|
-
'user': {
|
|
379
|
-
'products': Product.getRules()['user']
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
@Policy(Product)
|
|
385
|
-
@Controller('/api/products')
|
|
386
|
-
export class ProductController {
|
|
387
|
-
@Get('/')
|
|
388
|
-
@CanList() // List only owned products
|
|
389
|
-
getAll(@GuardParams() filter: any) { }
|
|
390
|
-
|
|
391
|
-
@Get('/:id')
|
|
392
|
-
@CanRead() // Read only if owner
|
|
393
|
-
getOne() { }
|
|
394
|
-
|
|
395
|
-
@Post('/')
|
|
396
|
-
@CanCreate() // Create (ownership assigned automatically)
|
|
397
|
-
create(@Body() data: any) { }
|
|
398
|
-
|
|
399
|
-
@Put('/:id')
|
|
400
|
-
@CanUpdate() // Update only if owner
|
|
401
|
-
update(@Body() data: any) { }
|
|
402
|
-
|
|
403
|
-
@Delete('/:id')
|
|
404
|
-
@CanDelete() // Delete only if owner
|
|
405
|
-
delete() { }
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
@Repository('default')
|
|
409
|
-
@Owned(Product)
|
|
410
|
-
export class ProductRepository {
|
|
411
|
-
@DB() db!: Database;
|
|
412
|
-
|
|
413
|
-
// Auto-scoped to current user
|
|
414
|
-
async findMany(opts?: { where?: any; limit?: number }) {
|
|
415
|
-
return this.findMany(opts); // Only returns owned products
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
async findOne(opts: { where: any }) {
|
|
419
|
-
return this.findOne(opts); // Returns null if not owned
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
async scopedQuery() {
|
|
423
|
-
return this.scopedQuery(); // Raw scoped query builder
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
```
|
|
427
|
-
|
|
428
|
-
### Advanced Ownership: Multi-Role Scoping
|
|
429
|
-
|
|
430
|
-
```typescript
|
|
431
|
-
const Grade = own(grades)
|
|
432
|
-
// Teachers see students' grades
|
|
433
|
-
.for('teacher',
|
|
434
|
-
join(grades.studentId, _s.id),
|
|
435
|
-
join(_s.id, _t.studentId),
|
|
436
|
-
where(_t.userId)
|
|
437
|
-
)
|
|
438
|
-
// Parents see only their child's grades
|
|
439
|
-
.for('parent',
|
|
440
|
-
join(grades.studentId, _s.id),
|
|
441
|
-
join(_s.id, _p.studentId),
|
|
442
|
-
where(_p.userId)
|
|
443
|
-
);
|
|
444
|
-
```
|
|
445
|
-
|
|
446
|
-
---
|
|
447
|
-
|
|
448
|
-
## Database Schema
|
|
449
|
-
|
|
450
|
-
### Tables
|
|
451
|
-
|
|
452
|
-
```
|
|
453
|
-
users
|
|
454
|
-
├── id (string, primary key)
|
|
455
|
-
├── email (string, unique)
|
|
456
|
-
├── password (string, hashed)
|
|
457
|
-
├── emailVerified (boolean, default: false)
|
|
458
|
-
├── image (string, nullable)
|
|
459
|
-
├── status (enum: ACTIVE, INACTIVE)
|
|
460
|
-
├── roleId (string, FK → roles.id)
|
|
461
|
-
├── lastLogin (timestamp, nullable)
|
|
462
|
-
├── createdAt (timestamp)
|
|
463
|
-
└── updatedAt (timestamp)
|
|
464
|
-
|
|
465
|
-
roles
|
|
466
|
-
├── id (string, primary key)
|
|
467
|
-
├── name (string, unique)
|
|
468
|
-
├── description (string, nullable)
|
|
469
|
-
├── createdAt (timestamp)
|
|
470
|
-
└── updatedAt (timestamp)
|
|
471
|
-
|
|
472
|
-
permissions
|
|
473
|
-
├── id (string, primary key)
|
|
474
|
-
├── name (string, unique)
|
|
475
|
-
├── description (string, nullable)
|
|
476
|
-
├── resource (string)
|
|
477
|
-
├── action (string)
|
|
478
|
-
├── createdAt (timestamp)
|
|
479
|
-
└── updatedAt (timestamp)
|
|
480
|
-
|
|
481
|
-
tokens
|
|
482
|
-
├── id (string, primary key)
|
|
483
|
-
├── userId (string, FK → users.id, unique)
|
|
484
|
-
├── token (string, hashed)
|
|
485
|
-
├── type (enum: REFRESH, RESET)
|
|
486
|
-
├── status (enum: ACTIVE, REVOKED)
|
|
487
|
-
├── expiresAt (timestamp)
|
|
488
|
-
├── createdAt (timestamp)
|
|
489
|
-
└── updatedAt (timestamp)
|
|
490
|
-
|
|
491
|
-
role_permissions
|
|
492
|
-
├── id (string, primary key)
|
|
493
|
-
├── roleId (string, FK → roles.id)
|
|
494
|
-
├── permissionId (string, FK → permissions.id)
|
|
495
|
-
├── createdAt (timestamp)
|
|
496
|
-
└── updatedAt (timestamp)
|
|
497
|
-
|
|
498
|
-
oauth_accounts
|
|
499
|
-
├── id (string, primary key)
|
|
500
|
-
├── userId (string, FK → users.id, cascade delete)
|
|
501
|
-
├── provider (string; `google` in this release)
|
|
502
|
-
├── providerAccountId (Google `sub`)
|
|
503
|
-
├── unique(provider, providerAccountId)
|
|
504
|
-
└── unique(userId, provider)
|
|
505
|
-
```
|
|
506
|
-
|
|
507
|
-
Existing databases must generate and run a migration after upgrading so the
|
|
508
|
-
new `oauth_accounts` table exists. Custom `AuthSchema` objects may omit
|
|
509
|
-
`oauthAccounts` while OAuth is disabled, but Google configuration fails fast
|
|
510
|
-
unless the custom schema supplies it.
|
|
511
|
-
|
|
512
|
-
### ID Strategy
|
|
513
|
-
|
|
514
|
-
Uses `nanoid` with short lengths for efficient storage:
|
|
515
|
-
- Users: 8 characters
|
|
516
|
-
- Roles: 5 characters
|
|
517
|
-
- Permissions: 5 characters
|
|
518
|
-
- Tokens: 10 characters
|
|
519
|
-
|
|
520
|
-
To use UUIDs instead, customize the schema:
|
|
521
|
-
|
|
522
|
-
```typescript
|
|
523
|
-
import { customAlphabet } from 'nanoid';
|
|
524
|
-
import { uuid } from 'uuid';
|
|
525
|
-
|
|
526
|
-
// Use UUID for larger ID space
|
|
527
|
-
const customUsers = sqliteTable('users', {
|
|
528
|
-
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
|
529
|
-
// ...
|
|
530
|
-
});
|
|
531
|
-
```
|
|
532
|
-
|
|
533
|
-
---
|
|
534
|
-
|
|
535
|
-
## Seeding
|
|
536
|
-
|
|
537
|
-
### Low-Level Seeding (authSeed)
|
|
538
|
-
|
|
539
|
-
```typescript
|
|
540
|
-
import { authSeed } from 'najm-auth';
|
|
541
|
-
import { SeedService } from 'najm-database';
|
|
542
|
-
|
|
543
|
-
@Service()
|
|
544
|
-
class SetupService {
|
|
545
|
-
constructor(private seeder: SeedService) {}
|
|
546
|
-
|
|
547
|
-
async seed() {
|
|
548
|
-
const entries = authSeed({
|
|
549
|
-
adminEmail: 'admin@app.com',
|
|
550
|
-
adminPass: 'AdminPass123!',
|
|
551
|
-
roles: [
|
|
552
|
-
{ name: 'editor', description: 'Can edit content' },
|
|
553
|
-
{ name: 'viewer', description: 'Can view only' },
|
|
554
|
-
],
|
|
555
|
-
permissions: [
|
|
556
|
-
{ name: 'read:posts', resource: 'posts', action: 'read' },
|
|
557
|
-
{ name: 'create:posts', resource: 'posts', action: 'create' },
|
|
558
|
-
],
|
|
559
|
-
additionalUsers: [
|
|
560
|
-
{ email: 'user@app.com', password: 'User123!', roleName: 'viewer' },
|
|
561
|
-
]
|
|
562
|
-
});
|
|
563
|
-
|
|
564
|
-
await this.seeder.run(entries);
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
```
|
|
568
|
-
|
|
569
|
-
### High-Level Seeding (seedAuthData)
|
|
570
|
-
|
|
571
|
-
```typescript
|
|
572
|
-
import { seedAuthData } from 'najm-auth';
|
|
573
|
-
|
|
574
|
-
await seedAuthData({
|
|
575
|
-
db,
|
|
576
|
-
adminEmail: process.env.ADMIN_EMAIL!,
|
|
577
|
-
adminPassword: process.env.ADMIN_PASSWORD!,
|
|
578
|
-
roles: [
|
|
579
|
-
{ name: 'moderator', description: 'Content moderator' },
|
|
580
|
-
],
|
|
581
|
-
users: [
|
|
582
|
-
{ email: 'mod@app.com', password: 'Mod123!' , roleName: 'moderator' },
|
|
583
|
-
],
|
|
584
|
-
verbose: true
|
|
585
|
-
});
|
|
586
|
-
|
|
587
|
-
// Note: Return type has empty users[] and roles[] arrays
|
|
588
|
-
// Query the database directly to retrieve inserted records
|
|
589
|
-
```
|
|
590
|
-
|
|
591
|
-
---
|
|
592
|
-
|
|
593
|
-
## Rate Limiting
|
|
594
|
-
|
|
595
|
-
Auth routes have built-in rate limiting to prevent brute force attacks.
|
|
596
|
-
The auth plugin registers `najm-rate` as a dependency, so these decorator-level
|
|
597
|
-
limits are active when `auth()` is registered.
|
|
598
|
-
|
|
599
|
-
| Route | Limit | Window | Key Strategy |
|
|
600
|
-
|-------|-------|--------|--------------|
|
|
601
|
-
| `POST /auth/register` | 5 | 15 minutes | IP |
|
|
602
|
-
| `POST /auth/login` | 5 | 15 minutes | IP |
|
|
603
|
-
| `POST /auth/refresh` | 15 | 15 minutes | Cookie fingerprint |
|
|
604
|
-
| `POST /auth/session/recover` | 120 | 1 minute | Cookie fingerprint |
|
|
605
|
-
| `POST /auth/logout` | 10 | 15 minutes | User ID |
|
|
606
|
-
| `GET /auth/me` | 30 | 1 minute | User ID |
|
|
607
|
-
| `POST /auth/forgot-password` | 3 | 15 minutes | IP |
|
|
608
|
-
| `POST /auth/reset-password` | 5 | 15 minutes | IP |
|
|
609
|
-
|
|
610
|
-
### Customizing Rate Limits
|
|
611
|
-
|
|
612
|
-
```typescript
|
|
613
|
-
auth({
|
|
614
|
-
rateLimit: {
|
|
615
|
-
keyGenerator: 'ip', // or 'user', 'api-key', 'user+ip'
|
|
616
|
-
defaultWindow: '10m',
|
|
617
|
-
skip: (ctx) => ctx.path === '/health' // Skip for certain routes
|
|
618
|
-
}
|
|
619
|
-
})
|
|
620
|
-
```
|
|
621
|
-
|
|
622
|
-
---
|
|
623
|
-
|
|
624
|
-
## TypeScript Types
|
|
625
|
-
|
|
626
|
-
```typescript
|
|
627
|
-
import type {
|
|
628
|
-
AuthUser, // { id, email, name?, role?, permissions? }
|
|
629
|
-
TokenPair, // { accessToken, refreshToken, expiresAt? }
|
|
630
|
-
JwtPayload, // { userId, jti, exp?, iat? }
|
|
631
|
-
AuthConfig, // Full resolved config
|
|
632
|
-
AuthPluginConfig, // User-facing config
|
|
633
|
-
} from 'najm-auth';
|
|
634
|
-
```
|
|
635
|
-
|
|
636
|
-
---
|
|
637
|
-
|
|
638
|
-
## Error Handling
|
|
639
|
-
|
|
640
|
-
All errors are i18n-based. Error messages are automatically localized.
|
|
641
|
-
|
|
642
|
-
### Common Error Codes
|
|
643
|
-
|
|
644
|
-
| HTTP | Scenario |
|
|
645
|
-
|------|----------|
|
|
646
|
-
| 400 | Invalid input (bad email format, weak password) |
|
|
647
|
-
| 401 | Missing or invalid authentication (bad token, no header) |
|
|
648
|
-
| 403 | Forbidden (lacks required role/permission) |
|
|
649
|
-
| 409 | Conflict (email already registered) |
|
|
650
|
-
| 429 | Rate limited (too many requests) |
|
|
651
|
-
| 500 | Server error (email send failure, DB error) |
|
|
652
|
-
|
|
653
|
-
### Examples
|
|
654
|
-
|
|
655
|
-
```typescript
|
|
656
|
-
// Invalid credentials
|
|
657
|
-
throw new HttpError(401, 'Invalid email or password');
|
|
658
|
-
|
|
659
|
-
// User already exists
|
|
660
|
-
throw new HttpError(409, 'Email already registered');
|
|
661
|
-
|
|
662
|
-
// Insufficient permissions
|
|
663
|
-
throw new HttpError(403, 'Insufficient permissions for this action');
|
|
664
|
-
```
|
|
665
|
-
|
|
666
|
-
---
|
|
667
|
-
|
|
668
|
-
## Security Considerations
|
|
669
|
-
|
|
670
|
-
### Security Defaults
|
|
671
|
-
|
|
672
|
-
- JWT access and refresh secrets are required and must pass minimum strength
|
|
673
|
-
checks.
|
|
674
|
-
- Refresh tokens rotate by session family and suspected family compromise does
|
|
675
|
-
not revoke unrelated user sessions.
|
|
676
|
-
- Password reset and password change revoke existing user sessions.
|
|
677
|
-
- Login uses a dummy password hash for missing users to reduce timing leaks.
|
|
678
|
-
- Forgot-password responses avoid email enumeration.
|
|
679
|
-
- Auth routes register `najm-rate` and ship route-level brute-force limits.
|
|
680
|
-
- Session cookies are signed and short-lived; server auth resolution checks
|
|
681
|
-
their session version.
|
|
682
|
-
- Expired signed sessions recover through authoritative, non-rotating refresh
|
|
683
|
-
validation; middleware verifies the reissued HMAC before using its claims.
|
|
684
|
-
- Server-side recovery sends only the configured refresh cookie and accepts
|
|
685
|
-
relative or exact same-origin endpoints. URL credentials and any
|
|
686
|
-
scheme/hostname/port change are rejected before the network request.
|
|
687
|
-
- Self-hosted apps may explicitly use a loopback-only `internalRecoveryURL`
|
|
688
|
-
when their public reverse-proxy origin is not reachable from the app process.
|
|
689
|
-
- `onRecoveryFailure` exposes structured, secret-free recovery diagnostics
|
|
690
|
-
without logging anything by default.
|
|
691
|
-
- `verifyAlways` forces that authoritative check on every protected request;
|
|
692
|
-
the default bounds cached role/status staleness to `session.maxAge`.
|
|
693
|
-
|
|
694
|
-
###
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
-
|
|
740
|
-
- `
|
|
741
|
-
-
|
|
742
|
-
-
|
|
743
|
-
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
1
|
+
# najm-auth
|
|
2
|
+
|
|
3
|
+
Production-ready authentication and authorization library for the Najm framework. Provides JWT-based authentication, role-based access control (RBAC), permission-based access control (PBAC), and row-level ownership scoping.
|
|
4
|
+
|
|
5
|
+
**Features:**
|
|
6
|
+
- ✅ JWT authentication (access + refresh token strategy)
|
|
7
|
+
- ✅ Automatic token rotation and blacklist-based revocation
|
|
8
|
+
- ✅ Role-based access control (RBAC) with hierarchies
|
|
9
|
+
- ✅ Permission-based access control (PBAC) with wildcards
|
|
10
|
+
- ✅ Row-level ownership scoping for multi-tenant apps
|
|
11
|
+
- ✅ Built-in password reset flow with email support
|
|
12
|
+
- ✅ Multi-dialect support (PostgreSQL, SQLite)
|
|
13
|
+
- ✅ Type-safe decorators with TypeScript
|
|
14
|
+
- ✅ Rate limiting on auth endpoints
|
|
15
|
+
- ✅ Internationalization (i18n) for all messages
|
|
16
|
+
- ✅ Google OpenID Connect sign-in with PKCE and explicit account linking
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
bun add najm-auth
|
|
24
|
+
# Peer dependencies
|
|
25
|
+
bun add hono drizzle-orm reflect-metadata
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Quick Setup
|
|
31
|
+
|
|
32
|
+
### 1. Initialize Database
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
// src/database/schema.ts
|
|
36
|
+
import { authSchema } from 'najm-auth';
|
|
37
|
+
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
|
38
|
+
|
|
39
|
+
// Your app tables
|
|
40
|
+
export const products = sqliteTable('products', {
|
|
41
|
+
id: text('id').primaryKey(),
|
|
42
|
+
name: text('name').notNull(),
|
|
43
|
+
userId: text('userId').notNull(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Combined schema (always include authSchema)
|
|
47
|
+
export const schema = {
|
|
48
|
+
...authSchema, // users, roles, permissions, tokens, rolePermissions
|
|
49
|
+
products,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// src/database/index.ts
|
|
53
|
+
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
|
54
|
+
import { Database } from 'bun:sqlite';
|
|
55
|
+
import { schema } from './schema';
|
|
56
|
+
|
|
57
|
+
const sqlite = new Database('./app.db');
|
|
58
|
+
export const db = drizzle(sqlite, { schema });
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 2. Configure Auth Plugin
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
// src/main.ts
|
|
65
|
+
import 'reflect-metadata';
|
|
66
|
+
import { Server } from 'najm-core';
|
|
67
|
+
import { database } from 'najm-database';
|
|
68
|
+
import { auth } from 'najm-auth';
|
|
69
|
+
import { db } from './database';
|
|
70
|
+
|
|
71
|
+
const server = new Server()
|
|
72
|
+
.use(database({ default: db })) // Required: database must be registered first
|
|
73
|
+
.use(auth({
|
|
74
|
+
dialect: 'sqlite', // Auto-selects SQLite schema
|
|
75
|
+
jwt: {
|
|
76
|
+
accessSecret: process.env.JWT_ACCESS_SECRET!, // Required
|
|
77
|
+
refreshSecret: process.env.JWT_REFRESH_SECRET!, // Required
|
|
78
|
+
accessExpiresIn: '15m', // Optional, default: 1h
|
|
79
|
+
refreshExpiresIn: '7d', // Optional, default: 7d
|
|
80
|
+
},
|
|
81
|
+
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000', // For password reset links
|
|
82
|
+
}))
|
|
83
|
+
.load(/* your controllers and services */)
|
|
84
|
+
.listen(3000);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 3. Set Environment Variables
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# .env
|
|
91
|
+
JWT_ACCESS_SECRET=<32-character-minimum-secret>
|
|
92
|
+
JWT_REFRESH_SECRET=<32-character-minimum-secret>
|
|
93
|
+
FRONTEND_URL=https://app.example.com
|
|
94
|
+
# Optional Google sign-in
|
|
95
|
+
GOOGLE_CLIENT_ID=<google-web-client-id>
|
|
96
|
+
GOOGLE_CLIENT_SECRET=<google-web-client-secret>
|
|
97
|
+
# Optional for a split frontend/API deployment. Otherwise FRONTEND_URL is used.
|
|
98
|
+
GOOGLE_CALLBACK_URL=https://app.example.com/api/auth/oauth/google/callback
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
> ⚠️ **Security:** Generate secrets with `openssl rand -base64 32`
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Configuration Reference
|
|
106
|
+
|
|
107
|
+
### AuthPluginConfig
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
auth({
|
|
111
|
+
// Database
|
|
112
|
+
dialect?: 'pg' | 'sqlite' // Default: 'pg' (RETURNING-capable engines only)
|
|
113
|
+
schema?: AuthSchema // Override dialect schema
|
|
114
|
+
|
|
115
|
+
// JWT
|
|
116
|
+
jwt?: {
|
|
117
|
+
accessSecret: string // Required, min 32 chars
|
|
118
|
+
accessExpiresIn?: string // Default: 1h
|
|
119
|
+
refreshSecret: string // Required, min 32 chars
|
|
120
|
+
refreshExpiresIn?: string // Default: 7d
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Cookies
|
|
124
|
+
refreshCookieName?: string // Default: 'refreshToken'
|
|
125
|
+
|
|
126
|
+
// Database
|
|
127
|
+
database?: string // Default: 'default'
|
|
128
|
+
blacklistPrefix?: string // Default: 'auth:blacklist:'
|
|
129
|
+
|
|
130
|
+
// Registration
|
|
131
|
+
defaultRole?: string | null // Auto-assign role to new users
|
|
132
|
+
bcryptRounds?: number // Default: 10 (valid: 4-31)
|
|
133
|
+
|
|
134
|
+
// Frontend
|
|
135
|
+
frontendUrl?: string // Password reset link base URL
|
|
136
|
+
|
|
137
|
+
// Optional Google OpenID Connect
|
|
138
|
+
oauth?: {
|
|
139
|
+
google?: true | {
|
|
140
|
+
clientId?: string // Or GOOGLE_CLIENT_ID
|
|
141
|
+
clientSecret?: string // Or GOOGLE_CLIENT_SECRET
|
|
142
|
+
callbackUrl?: string // Or GOOGLE_CALLBACK_URL; otherwise frontendUrl + /api/auth/oauth/google/callback
|
|
143
|
+
frontendCallbackPath?: string // Default: /auth/oauth/callback
|
|
144
|
+
errorRedirectPath?: string // Default: /login
|
|
145
|
+
allowSignup?: boolean // Default: true
|
|
146
|
+
autoLinkVerifiedEmail?: boolean // Default: false
|
|
147
|
+
allowedHostedDomains?: string[] // Validates the Google hd claim
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Dependencies (forwarded to plugins)
|
|
152
|
+
validation?: ValidationPluginConfig
|
|
153
|
+
rateLimit?: RateLimitPluginConfig
|
|
154
|
+
})
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Auto-Registered Routes
|
|
160
|
+
|
|
161
|
+
All routes are prefixed with `/auth` and auto-registered by the plugin.
|
|
162
|
+
|
|
163
|
+
### Authentication Routes
|
|
164
|
+
|
|
165
|
+
| Method | Path | Description | Auth |
|
|
166
|
+
|--------|------|-------------|------|
|
|
167
|
+
| `POST` | `/auth/register` | Register new user | None |
|
|
168
|
+
| `POST` | `/auth/login` | Login with email/password | None |
|
|
169
|
+
| `POST` | `/auth/refresh` | Refresh access token (cookie) | None (uses refresh cookie) |
|
|
170
|
+
| `POST` | `/auth/session/recover` | Reissue signed session without token rotation | Refresh cookie + recovery header |
|
|
171
|
+
| `POST` | `/auth/logout` | Logout and revoke tokens | ✅ Required |
|
|
172
|
+
| `GET` | `/auth/me` | Get current user profile | ✅ Required |
|
|
173
|
+
| `POST` | `/auth/forgot-password` | Request password reset | None |
|
|
174
|
+
| `POST` | `/auth/reset-password` | Confirm password reset | None |
|
|
175
|
+
| `GET` | `/auth/oauth/google/start` | Start Google sign-in | None |
|
|
176
|
+
| `GET` | `/auth/oauth/google/callback` | Verify Google callback and create Najm session | None |
|
|
177
|
+
| `POST` | `/auth/oauth/google/link` | Link Google to the current user | ✅ Required |
|
|
178
|
+
|
|
179
|
+
### Google Sign-In
|
|
180
|
+
|
|
181
|
+
Google sign-in uses the server-side OpenID Connect authorization-code flow.
|
|
182
|
+
Najm creates state, nonce, and PKCE values, verifies Google's signed ID token,
|
|
183
|
+
then issues the same Najm JWT, refresh token, and session cookie as password
|
|
184
|
+
login. Google tokens are discarded and are never stored.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
auth({
|
|
188
|
+
dialect: 'pg',
|
|
189
|
+
frontendUrl: 'https://app.example.com',
|
|
190
|
+
oauth: { google: true },
|
|
191
|
+
})
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
With `google: true`, credentials come from `GOOGLE_CLIENT_ID` and
|
|
195
|
+
`GOOGLE_CLIENT_SECRET`; the callback defaults to
|
|
196
|
+
`${FRONTEND_URL}/api/auth/oauth/google/callback`. Register that value exactly
|
|
197
|
+
as an authorized redirect URI in Google Cloud. Set `GOOGLE_CALLBACK_URL` or
|
|
198
|
+
`google: { callbackUrl: '...' }` when the API runs on a different origin.
|
|
199
|
+
Production callback URLs must use HTTPS; HTTP is accepted only for localhost.
|
|
200
|
+
|
|
201
|
+
Mount the browser completion route configured by `frontendCallbackPath`:
|
|
202
|
+
|
|
203
|
+
```tsx
|
|
204
|
+
'use client';
|
|
205
|
+
|
|
206
|
+
import { OAuthCallback } from 'najm-auth/client/react';
|
|
207
|
+
|
|
208
|
+
export default function OAuthCallbackPage() {
|
|
209
|
+
return <OAuthCallback fallback={<p>Finishing sign-in...</p>} />;
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Then use the headless button anywhere below `AuthProvider`:
|
|
214
|
+
|
|
215
|
+
```tsx
|
|
216
|
+
import { GoogleLoginButton } from 'najm-auth/client/react';
|
|
217
|
+
|
|
218
|
+
<GoogleLoginButton returnTo="/dashboard">
|
|
219
|
+
<button type="button">Continue with Google</button>
|
|
220
|
+
</GoogleLoginButton>
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Google accounts are keyed by Google's stable `sub` claim. If an existing Najm
|
|
224
|
+
user has the same email but is not linked, sign-in fails with
|
|
225
|
+
`oauth_account_link_required` by default. After password login, call
|
|
226
|
+
`client.linkOAuthAccount('google')` to prove control of both accounts. Setting
|
|
227
|
+
`autoLinkVerifiedEmail: true` opts into verified-email linking.
|
|
228
|
+
|
|
229
|
+
### Admin Routes (all require `@isAdmin()`)
|
|
230
|
+
|
|
231
|
+
| Method | Path | Description |
|
|
232
|
+
|--------|------|-------------|
|
|
233
|
+
| `GET` | `/users?limit=50&offset=0` | List users (limit 1-100) |
|
|
234
|
+
| `GET` | `/users/:id` | Get user by ID |
|
|
235
|
+
| `POST` | `/users` | Create new user |
|
|
236
|
+
| `PUT` | `/users/:id` | Update user |
|
|
237
|
+
| `DELETE` | `/users/:id` | Delete user |
|
|
238
|
+
| `GET` | `/roles` | List all roles |
|
|
239
|
+
| `GET` | `/roles/:id` | Get role by ID |
|
|
240
|
+
| `POST` | `/roles` | Create new role |
|
|
241
|
+
| `PUT` | `/roles/:id` | Update role |
|
|
242
|
+
| `DELETE` | `/roles/:id` | Delete role |
|
|
243
|
+
| `GET` | `/permissions` | List all permissions |
|
|
244
|
+
| `GET` | `/permissions/:id` | Get permission by ID |
|
|
245
|
+
| `POST` | `/permissions` | Create new permission |
|
|
246
|
+
| `PUT` | `/permissions/:id` | Update permission |
|
|
247
|
+
| `DELETE` | `/permissions/:id` | Delete permission |
|
|
248
|
+
| `POST` | `/permissions/assign/:roleId/:permissionId` | Assign permission to role |
|
|
249
|
+
| `DELETE` | `/permissions/remove/:roleId/:permissionId` | Remove permission from role |
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Guards Reference
|
|
254
|
+
|
|
255
|
+
### Authentication Guard
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
import { isAuth } from 'najm-auth';
|
|
259
|
+
|
|
260
|
+
@Controller('/api/posts')
|
|
261
|
+
class PostController {
|
|
262
|
+
@Get('/') // Public
|
|
263
|
+
getAll() { }
|
|
264
|
+
|
|
265
|
+
@Post('/')
|
|
266
|
+
@isAuth() // Requires valid JWT
|
|
267
|
+
create(@Body() data: any) { }
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Role Guards
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
import { defineRoles } from 'najm-auth';
|
|
275
|
+
|
|
276
|
+
const roles = defineRoles({
|
|
277
|
+
ADMIN: 'admin',
|
|
278
|
+
MODERATOR: 'moderator',
|
|
279
|
+
USER: 'user',
|
|
280
|
+
}, {
|
|
281
|
+
superRoles: ['ADMIN'], // admin also passes moderator/user role guards
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
export const { isAdmin, isModerator, isUser } = roles;
|
|
285
|
+
|
|
286
|
+
@Controller('/admin')
|
|
287
|
+
@isAdmin() // All methods require admin role
|
|
288
|
+
class AdminController {
|
|
289
|
+
@Get('/users')
|
|
290
|
+
getUsers() { }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
@Controller('/api/posts')
|
|
294
|
+
class PostController {
|
|
295
|
+
@Delete('/:id')
|
|
296
|
+
@isModerator() // Method-level guard
|
|
297
|
+
deletePost() { }
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### Permission Guards
|
|
302
|
+
|
|
303
|
+
```typescript
|
|
304
|
+
import { Can, canRead, canCreate, canUpdate, canDelete } from 'najm-auth';
|
|
305
|
+
|
|
306
|
+
@Controller('/api/posts')
|
|
307
|
+
class PostController {
|
|
308
|
+
@Get('/')
|
|
309
|
+
@canRead('posts') // Requires 'read:posts' permission
|
|
310
|
+
getAll() { }
|
|
311
|
+
|
|
312
|
+
@Post('/')
|
|
313
|
+
@canCreate('posts') // Requires 'create:posts' permission
|
|
314
|
+
create(@Body() data: any) { }
|
|
315
|
+
|
|
316
|
+
@Put('/:id')
|
|
317
|
+
@canUpdate('posts') // Requires 'update:posts' permission
|
|
318
|
+
update() { }
|
|
319
|
+
|
|
320
|
+
@Delete('/:id')
|
|
321
|
+
@canDelete('posts') // Requires 'delete:posts' permission
|
|
322
|
+
delete() { }
|
|
323
|
+
|
|
324
|
+
@Post('/:id/publish')
|
|
325
|
+
@Can('publish:posts') // Custom permission
|
|
326
|
+
publish() { }
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
**Permission Wildcards:**
|
|
331
|
+
- `*:*` — All actions on all resources
|
|
332
|
+
- `create:*` — Create action on any resource
|
|
333
|
+
- `*:posts` — Any action on posts
|
|
334
|
+
|
|
335
|
+
### Combined Guards
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
@Controller('/admin/reports')
|
|
339
|
+
@isAdmin() // Require admin role
|
|
340
|
+
class ReportController {
|
|
341
|
+
@Get('/financial')
|
|
342
|
+
@Can('view:financial') // AND require financial view permission
|
|
343
|
+
getFinancial() { }
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## Ownership System
|
|
350
|
+
|
|
351
|
+
Control row-level access based on ownership (e.g., users see only their own data).
|
|
352
|
+
|
|
353
|
+
### Declaring Ownership Rules
|
|
354
|
+
|
|
355
|
+
```typescript
|
|
356
|
+
import { own, join, where } from 'najm-auth';
|
|
357
|
+
import { schema } from '../database/schema';
|
|
358
|
+
|
|
359
|
+
const { products, users } = schema;
|
|
360
|
+
const _users = alias(users, '_u');
|
|
361
|
+
|
|
362
|
+
export const Product = own(products)
|
|
363
|
+
.for('user',
|
|
364
|
+
join(products.userId, _users.id),
|
|
365
|
+
where(_users.id)
|
|
366
|
+
)
|
|
367
|
+
.writeBy(products.userId); // Enforce on create/update
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Using @Policy and @Owned
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
import { configureOwnership, Policy, CanList, CanRead, CanCreate, CanUpdate, CanDelete } from 'najm-auth';
|
|
374
|
+
|
|
375
|
+
const config = configureOwnership({
|
|
376
|
+
adminRoles: ['admin'],
|
|
377
|
+
rules: {
|
|
378
|
+
'user': {
|
|
379
|
+
'products': Product.getRules()['user']
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
@Policy(Product)
|
|
385
|
+
@Controller('/api/products')
|
|
386
|
+
export class ProductController {
|
|
387
|
+
@Get('/')
|
|
388
|
+
@CanList() // List only owned products
|
|
389
|
+
getAll(@GuardParams() filter: any) { }
|
|
390
|
+
|
|
391
|
+
@Get('/:id')
|
|
392
|
+
@CanRead() // Read only if owner
|
|
393
|
+
getOne() { }
|
|
394
|
+
|
|
395
|
+
@Post('/')
|
|
396
|
+
@CanCreate() // Create (ownership assigned automatically)
|
|
397
|
+
create(@Body() data: any) { }
|
|
398
|
+
|
|
399
|
+
@Put('/:id')
|
|
400
|
+
@CanUpdate() // Update only if owner
|
|
401
|
+
update(@Body() data: any) { }
|
|
402
|
+
|
|
403
|
+
@Delete('/:id')
|
|
404
|
+
@CanDelete() // Delete only if owner
|
|
405
|
+
delete() { }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
@Repository('default')
|
|
409
|
+
@Owned(Product)
|
|
410
|
+
export class ProductRepository {
|
|
411
|
+
@DB() db!: Database;
|
|
412
|
+
|
|
413
|
+
// Auto-scoped to current user
|
|
414
|
+
async findMany(opts?: { where?: any; limit?: number }) {
|
|
415
|
+
return this.findMany(opts); // Only returns owned products
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async findOne(opts: { where: any }) {
|
|
419
|
+
return this.findOne(opts); // Returns null if not owned
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async scopedQuery() {
|
|
423
|
+
return this.scopedQuery(); // Raw scoped query builder
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
### Advanced Ownership: Multi-Role Scoping
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
const Grade = own(grades)
|
|
432
|
+
// Teachers see students' grades
|
|
433
|
+
.for('teacher',
|
|
434
|
+
join(grades.studentId, _s.id),
|
|
435
|
+
join(_s.id, _t.studentId),
|
|
436
|
+
where(_t.userId)
|
|
437
|
+
)
|
|
438
|
+
// Parents see only their child's grades
|
|
439
|
+
.for('parent',
|
|
440
|
+
join(grades.studentId, _s.id),
|
|
441
|
+
join(_s.id, _p.studentId),
|
|
442
|
+
where(_p.userId)
|
|
443
|
+
);
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## Database Schema
|
|
449
|
+
|
|
450
|
+
### Tables
|
|
451
|
+
|
|
452
|
+
```
|
|
453
|
+
users
|
|
454
|
+
├── id (string, primary key)
|
|
455
|
+
├── email (string, unique)
|
|
456
|
+
├── password (string, hashed)
|
|
457
|
+
├── emailVerified (boolean, default: false)
|
|
458
|
+
├── image (string, nullable)
|
|
459
|
+
├── status (enum: ACTIVE, INACTIVE)
|
|
460
|
+
├── roleId (string, FK → roles.id)
|
|
461
|
+
├── lastLogin (timestamp, nullable)
|
|
462
|
+
├── createdAt (timestamp)
|
|
463
|
+
└── updatedAt (timestamp)
|
|
464
|
+
|
|
465
|
+
roles
|
|
466
|
+
├── id (string, primary key)
|
|
467
|
+
├── name (string, unique)
|
|
468
|
+
├── description (string, nullable)
|
|
469
|
+
├── createdAt (timestamp)
|
|
470
|
+
└── updatedAt (timestamp)
|
|
471
|
+
|
|
472
|
+
permissions
|
|
473
|
+
├── id (string, primary key)
|
|
474
|
+
├── name (string, unique)
|
|
475
|
+
├── description (string, nullable)
|
|
476
|
+
├── resource (string)
|
|
477
|
+
├── action (string)
|
|
478
|
+
├── createdAt (timestamp)
|
|
479
|
+
└── updatedAt (timestamp)
|
|
480
|
+
|
|
481
|
+
tokens
|
|
482
|
+
├── id (string, primary key)
|
|
483
|
+
├── userId (string, FK → users.id, unique)
|
|
484
|
+
├── token (string, hashed)
|
|
485
|
+
├── type (enum: REFRESH, RESET)
|
|
486
|
+
├── status (enum: ACTIVE, REVOKED)
|
|
487
|
+
├── expiresAt (timestamp)
|
|
488
|
+
├── createdAt (timestamp)
|
|
489
|
+
└── updatedAt (timestamp)
|
|
490
|
+
|
|
491
|
+
role_permissions
|
|
492
|
+
├── id (string, primary key)
|
|
493
|
+
├── roleId (string, FK → roles.id)
|
|
494
|
+
├── permissionId (string, FK → permissions.id)
|
|
495
|
+
├── createdAt (timestamp)
|
|
496
|
+
└── updatedAt (timestamp)
|
|
497
|
+
|
|
498
|
+
oauth_accounts
|
|
499
|
+
├── id (string, primary key)
|
|
500
|
+
├── userId (string, FK → users.id, cascade delete)
|
|
501
|
+
├── provider (string; `google` in this release)
|
|
502
|
+
├── providerAccountId (Google `sub`)
|
|
503
|
+
├── unique(provider, providerAccountId)
|
|
504
|
+
└── unique(userId, provider)
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
Existing databases must generate and run a migration after upgrading so the
|
|
508
|
+
new `oauth_accounts` table exists. Custom `AuthSchema` objects may omit
|
|
509
|
+
`oauthAccounts` while OAuth is disabled, but Google configuration fails fast
|
|
510
|
+
unless the custom schema supplies it.
|
|
511
|
+
|
|
512
|
+
### ID Strategy
|
|
513
|
+
|
|
514
|
+
Uses `nanoid` with short lengths for efficient storage:
|
|
515
|
+
- Users: 8 characters
|
|
516
|
+
- Roles: 5 characters
|
|
517
|
+
- Permissions: 5 characters
|
|
518
|
+
- Tokens: 10 characters
|
|
519
|
+
|
|
520
|
+
To use UUIDs instead, customize the schema:
|
|
521
|
+
|
|
522
|
+
```typescript
|
|
523
|
+
import { customAlphabet } from 'nanoid';
|
|
524
|
+
import { uuid } from 'uuid';
|
|
525
|
+
|
|
526
|
+
// Use UUID for larger ID space
|
|
527
|
+
const customUsers = sqliteTable('users', {
|
|
528
|
+
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
|
529
|
+
// ...
|
|
530
|
+
});
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
---
|
|
534
|
+
|
|
535
|
+
## Seeding
|
|
536
|
+
|
|
537
|
+
### Low-Level Seeding (authSeed)
|
|
538
|
+
|
|
539
|
+
```typescript
|
|
540
|
+
import { authSeed } from 'najm-auth';
|
|
541
|
+
import { SeedService } from 'najm-database';
|
|
542
|
+
|
|
543
|
+
@Service()
|
|
544
|
+
class SetupService {
|
|
545
|
+
constructor(private seeder: SeedService) {}
|
|
546
|
+
|
|
547
|
+
async seed() {
|
|
548
|
+
const entries = authSeed({
|
|
549
|
+
adminEmail: 'admin@app.com',
|
|
550
|
+
adminPass: 'AdminPass123!',
|
|
551
|
+
roles: [
|
|
552
|
+
{ name: 'editor', description: 'Can edit content' },
|
|
553
|
+
{ name: 'viewer', description: 'Can view only' },
|
|
554
|
+
],
|
|
555
|
+
permissions: [
|
|
556
|
+
{ name: 'read:posts', resource: 'posts', action: 'read' },
|
|
557
|
+
{ name: 'create:posts', resource: 'posts', action: 'create' },
|
|
558
|
+
],
|
|
559
|
+
additionalUsers: [
|
|
560
|
+
{ email: 'user@app.com', password: 'User123!', roleName: 'viewer' },
|
|
561
|
+
]
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
await this.seeder.run(entries);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
### High-Level Seeding (seedAuthData)
|
|
570
|
+
|
|
571
|
+
```typescript
|
|
572
|
+
import { seedAuthData } from 'najm-auth';
|
|
573
|
+
|
|
574
|
+
await seedAuthData({
|
|
575
|
+
db,
|
|
576
|
+
adminEmail: process.env.ADMIN_EMAIL!,
|
|
577
|
+
adminPassword: process.env.ADMIN_PASSWORD!,
|
|
578
|
+
roles: [
|
|
579
|
+
{ name: 'moderator', description: 'Content moderator' },
|
|
580
|
+
],
|
|
581
|
+
users: [
|
|
582
|
+
{ email: 'mod@app.com', password: 'Mod123!' , roleName: 'moderator' },
|
|
583
|
+
],
|
|
584
|
+
verbose: true
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// Note: Return type has empty users[] and roles[] arrays
|
|
588
|
+
// Query the database directly to retrieve inserted records
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
---
|
|
592
|
+
|
|
593
|
+
## Rate Limiting
|
|
594
|
+
|
|
595
|
+
Auth routes have built-in rate limiting to prevent brute force attacks.
|
|
596
|
+
The auth plugin registers `najm-rate` as a dependency, so these decorator-level
|
|
597
|
+
limits are active when `auth()` is registered.
|
|
598
|
+
|
|
599
|
+
| Route | Limit | Window | Key Strategy |
|
|
600
|
+
|-------|-------|--------|--------------|
|
|
601
|
+
| `POST /auth/register` | 5 | 15 minutes | IP |
|
|
602
|
+
| `POST /auth/login` | 5 | 15 minutes | IP |
|
|
603
|
+
| `POST /auth/refresh` | 15 | 15 minutes | Cookie fingerprint |
|
|
604
|
+
| `POST /auth/session/recover` | 120 | 1 minute | Cookie fingerprint |
|
|
605
|
+
| `POST /auth/logout` | 10 | 15 minutes | User ID |
|
|
606
|
+
| `GET /auth/me` | 30 | 1 minute | User ID |
|
|
607
|
+
| `POST /auth/forgot-password` | 3 | 15 minutes | IP |
|
|
608
|
+
| `POST /auth/reset-password` | 5 | 15 minutes | IP |
|
|
609
|
+
|
|
610
|
+
### Customizing Rate Limits
|
|
611
|
+
|
|
612
|
+
```typescript
|
|
613
|
+
auth({
|
|
614
|
+
rateLimit: {
|
|
615
|
+
keyGenerator: 'ip', // or 'user', 'api-key', 'user+ip'
|
|
616
|
+
defaultWindow: '10m',
|
|
617
|
+
skip: (ctx) => ctx.path === '/health' // Skip for certain routes
|
|
618
|
+
}
|
|
619
|
+
})
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
---
|
|
623
|
+
|
|
624
|
+
## TypeScript Types
|
|
625
|
+
|
|
626
|
+
```typescript
|
|
627
|
+
import type {
|
|
628
|
+
AuthUser, // { id, email, name?, role?, permissions? }
|
|
629
|
+
TokenPair, // { accessToken, refreshToken, expiresAt? }
|
|
630
|
+
JwtPayload, // { userId, jti, exp?, iat? }
|
|
631
|
+
AuthConfig, // Full resolved config
|
|
632
|
+
AuthPluginConfig, // User-facing config
|
|
633
|
+
} from 'najm-auth';
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
---
|
|
637
|
+
|
|
638
|
+
## Error Handling
|
|
639
|
+
|
|
640
|
+
All errors are i18n-based. Error messages are automatically localized.
|
|
641
|
+
|
|
642
|
+
### Common Error Codes
|
|
643
|
+
|
|
644
|
+
| HTTP | Scenario |
|
|
645
|
+
|------|----------|
|
|
646
|
+
| 400 | Invalid input (bad email format, weak password) |
|
|
647
|
+
| 401 | Missing or invalid authentication (bad token, no header) |
|
|
648
|
+
| 403 | Forbidden (lacks required role/permission) |
|
|
649
|
+
| 409 | Conflict (email already registered) |
|
|
650
|
+
| 429 | Rate limited (too many requests) |
|
|
651
|
+
| 500 | Server error (email send failure, DB error) |
|
|
652
|
+
|
|
653
|
+
### Examples
|
|
654
|
+
|
|
655
|
+
```typescript
|
|
656
|
+
// Invalid credentials
|
|
657
|
+
throw new HttpError(401, 'Invalid email or password');
|
|
658
|
+
|
|
659
|
+
// User already exists
|
|
660
|
+
throw new HttpError(409, 'Email already registered');
|
|
661
|
+
|
|
662
|
+
// Insufficient permissions
|
|
663
|
+
throw new HttpError(403, 'Insufficient permissions for this action');
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
---
|
|
667
|
+
|
|
668
|
+
## Security Considerations
|
|
669
|
+
|
|
670
|
+
### Security Defaults
|
|
671
|
+
|
|
672
|
+
- JWT access and refresh secrets are required and must pass minimum strength
|
|
673
|
+
checks.
|
|
674
|
+
- Refresh tokens rotate by session family and suspected family compromise does
|
|
675
|
+
not revoke unrelated user sessions.
|
|
676
|
+
- Password reset and password change revoke existing user sessions.
|
|
677
|
+
- Login uses a dummy password hash for missing users to reduce timing leaks.
|
|
678
|
+
- Forgot-password responses avoid email enumeration.
|
|
679
|
+
- Auth routes register `najm-rate` and ship route-level brute-force limits.
|
|
680
|
+
- Session cookies are signed and short-lived; server auth resolution checks
|
|
681
|
+
their session version.
|
|
682
|
+
- Expired signed sessions recover through authoritative, non-rotating refresh
|
|
683
|
+
validation; middleware verifies the reissued HMAC before using its claims.
|
|
684
|
+
- Server-side recovery sends only the configured refresh cookie and accepts
|
|
685
|
+
relative or exact same-origin endpoints. URL credentials and any
|
|
686
|
+
scheme/hostname/port change are rejected before the network request.
|
|
687
|
+
- Self-hosted apps may explicitly use a loopback-only `internalRecoveryURL`
|
|
688
|
+
when their public reverse-proxy origin is not reachable from the app process.
|
|
689
|
+
- `onRecoveryFailure` exposes structured, secret-free recovery diagnostics
|
|
690
|
+
without logging anything by default.
|
|
691
|
+
- `verifyAlways` forces that authoritative check on every protected request;
|
|
692
|
+
the default bounds cached role/status staleness to `session.maxAge`.
|
|
693
|
+
|
|
694
|
+
### Next.js 16 Reverse-Proxy Recovery
|
|
695
|
+
|
|
696
|
+
When a self-hosted Next.js proxy cannot safely call its own public
|
|
697
|
+
reverse-proxy origin while handling that same request, configure the exact
|
|
698
|
+
loopback recovery endpoint:
|
|
699
|
+
|
|
700
|
+
```env
|
|
701
|
+
NAJM_AUTH_INTERNAL_URL=http://127.0.0.1:3000/api/auth/session/recover
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
`defineAuth()` reads this environment variable automatically. An explicit
|
|
705
|
+
`internalRecoveryURL` option takes precedence. The internal URL must use HTTP
|
|
706
|
+
or HTTPS, contain no URL credentials, and resolve to `localhost`, `127.0.0.1`,
|
|
707
|
+
or `::1`; Najm never guesses a loopback endpoint. Relative and exact
|
|
708
|
+
same-origin `recoveryURL` values remain supported.
|
|
709
|
+
|
|
710
|
+
The recovery request forwards only the configured refresh cookie, requires
|
|
711
|
+
`X-Najm-Session-Recovery: 1`, never rotates the refresh token, HMAC-verifies
|
|
712
|
+
the returned session cookie, and fails closed. `onRecoveryFailure` receives
|
|
713
|
+
only a structured reason and bounded, sanitized fetch-error metadata; callback
|
|
714
|
+
errors cannot change the authentication result.
|
|
715
|
+
|
|
716
|
+
### Password Reset Tokens
|
|
717
|
+
|
|
718
|
+
⚠️ **Current behavior:** Reset tokens use JWT expiry (default 1h) for single-use validation. To add database-backed single-use tokens:
|
|
719
|
+
|
|
720
|
+
```typescript
|
|
721
|
+
// In AuthService.resetPassword():
|
|
722
|
+
async resetPassword(token: string, newPassword: string) {
|
|
723
|
+
const userId = this.tokenService.verifyResetToken(token);
|
|
724
|
+
// ... update password ...
|
|
725
|
+
// Blacklist the reset token to prevent reuse
|
|
726
|
+
await this.tokenService.blacklistCurrentToken(token);
|
|
727
|
+
}
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
### Session Management
|
|
731
|
+
|
|
732
|
+
- Sessions are multi-device: the token table stores one refresh row per login session (keyed by a unique `tokenFamily`), so a user can stay logged in on several devices at once. Logout and rotation are scoped to the current session; password change/reset revoke every session
|
|
733
|
+
- A stale refresh token presented after the 120-second rotation grace window revokes only that session's family as reuse protection
|
|
734
|
+
- The signed session cookie is accepted for up to its configured TTL (5 minutes by default) without a database or revocation-cache read
|
|
735
|
+
- Use `@RateLimit` on logout for DDoS protection
|
|
736
|
+
|
|
737
|
+
### Token Blacklist
|
|
738
|
+
|
|
739
|
+
- Built-in cache-based blacklist for immediate revocation
|
|
740
|
+
- Supports Redis via `cache()` plugin configuration
|
|
741
|
+
- Default: in-memory store (development/single-process only; entries are lost on restart)
|
|
742
|
+
- Use Redis in production when immediate revocation must survive restarts or propagate across instances
|
|
743
|
+
- Session-version revocation keys are cache-backed and TTL-bound to active access tokens
|
|
744
|
+
|
|
745
|
+
### Timing Attack Prevention
|
|
746
|
+
|
|
747
|
+
- Dummy hash used for missing users in login
|
|
748
|
+
- Constant-time password comparison
|
|
749
|
+
- Same response for forgot-password (prevents email enumeration)
|
|
750
|
+
|
|
751
|
+
---
|
|
752
|
+
|
|
753
|
+
## Testing
|
|
754
|
+
|
|
755
|
+
```bash
|
|
756
|
+
bun run test # Run all tests
|
|
757
|
+
bun run test:auth # Run auth tests only
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
Test files include:
|
|
761
|
+
- `schema.test.ts` — Schema exports validation
|
|
762
|
+
- `auth.test.ts` — Authentication flow
|
|
763
|
+
- `user.test.ts` — User CRUD
|
|
764
|
+
- `role.test.ts` — Role management
|
|
765
|
+
- `permission.test.ts` — Permission guards
|
|
766
|
+
- `guards.test.ts` — Guard composability
|
|
767
|
+
- `ownership.test.ts` — Row-level scoping
|
|
768
|
+
- `integration.test.ts` — Multi-role scenarios
|
|
769
|
+
|
|
770
|
+
---
|
|
771
|
+
|
|
772
|
+
## Production Checklist
|
|
773
|
+
|
|
774
|
+
- ✅ Use strong JWT secrets (32+ chars, generated with `openssl rand -base64 32`)
|
|
775
|
+
- ✅ Set `FRONTEND_URL` environment variable
|
|
776
|
+
- ✅ Enable HTTPS in production
|
|
777
|
+
- ✅ Store secrets in environment variables (never in code)
|
|
778
|
+
- ✅ Use Redis for token blacklist/session-version revocation in production and distributed systems
|
|
779
|
+
- ✅ Trust forwarded IP headers only behind a known proxy; otherwise provide a custom rate-limit key generator
|
|
780
|
+
- ✅ Login/register rate keys hash normalized email or international-phone identifiers; passwords and request bodies never appear in cache keys
|
|
781
|
+
- ✅ Enable rate limiting on all auth routes
|
|
782
|
+
- ✅ Log authentication events for audit trails
|
|
783
|
+
- ✅ Test ownership scoping rules with multi-user scenarios
|
|
784
|
+
- ✅ Run full test suite before deploying
|
|
785
|
+
|
|
786
|
+
---
|
|
787
|
+
|
|
788
|
+
## Migration Guide
|
|
789
|
+
|
|
790
|
+
### From v1.0 to v1.1
|
|
791
|
+
|
|
792
|
+
- `FRONTEND_URL` now part of `AuthPluginConfig` (falls back to env var)
|
|
793
|
+
- New: Rate limiting on `/auth/logout` and `/auth/me`
|
|
794
|
+
- New: `configureOwnership()` for advanced scoping
|
|
795
|
+
- New: `@Policy` and `@Owned` decorators
|
|
796
|
+
|
|
797
|
+
---
|
|
798
|
+
|
|
799
|
+
## Support & Contributing
|
|
800
|
+
|
|
801
|
+
For issues, feature requests, or contributions, please refer to the main Najm repository: https://github.com/najm/najm-api
|