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 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
- ### Password Reset Tokens
695
-
696
- ⚠️ **Current behavior:** Reset tokens use JWT expiry (default 1h) for single-use validation. To add database-backed single-use tokens:
697
-
698
- ```typescript
699
- // In AuthService.resetPassword():
700
- async resetPassword(token: string, newPassword: string) {
701
- const userId = this.tokenService.verifyResetToken(token);
702
- // ... update password ...
703
- // Blacklist the reset token to prevent reuse
704
- await this.tokenService.blacklistCurrentToken(token);
705
- }
706
- ```
707
-
708
- ### Session Management
709
-
710
- - 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
711
- - A stale refresh token presented after the 120-second rotation grace window revokes only that session's family as reuse protection
712
- - The signed session cookie is accepted for up to its configured TTL (5 minutes by default) without a database or revocation-cache read
713
- - Use `@RateLimit` on logout for DDoS protection
714
-
715
- ### Token Blacklist
716
-
717
- - Built-in cache-based blacklist for immediate revocation
718
- - Supports Redis via `cache()` plugin configuration
719
- - Default: in-memory store (development/single-process only; entries are lost on restart)
720
- - Use Redis in production when immediate revocation must survive restarts or propagate across instances
721
- - Session-version revocation keys are cache-backed and TTL-bound to active access tokens
722
-
723
- ### Timing Attack Prevention
724
-
725
- - Dummy hash used for missing users in login
726
- - Constant-time password comparison
727
- - Same response for forgot-password (prevents email enumeration)
728
-
729
- ---
730
-
731
- ## Testing
732
-
733
- ```bash
734
- bun run test # Run all tests
735
- bun run test:auth # Run auth tests only
736
- ```
737
-
738
- Test files include:
739
- - `schema.test.ts` Schema exports validation
740
- - `auth.test.ts` Authentication flow
741
- - `user.test.ts` User CRUD
742
- - `role.test.ts` Role management
743
- - `permission.test.ts` Permission guards
744
- - `guards.test.ts` — Guard composability
745
- - `ownership.test.ts` Row-level scoping
746
- - `integration.test.ts` — Multi-role scenarios
747
-
748
- ---
749
-
750
- ## Production Checklist
751
-
752
- - ✅ Use strong JWT secrets (32+ chars, generated with `openssl rand -base64 32`)
753
- - ✅ Set `FRONTEND_URL` environment variable
754
- - ✅ Enable HTTPS in production
755
- - ✅ Store secrets in environment variables (never in code)
756
- - Use Redis for token blacklist/session-version revocation in production and distributed systems
757
- - Trust forwarded IP headers only behind a known proxy; otherwise provide a custom rate-limit key generator
758
- - ✅ Enable rate limiting on all auth routes
759
- - ✅ Log authentication events for audit trails
760
- - ✅ Test ownership scoping rules with multi-user scenarios
761
- - Run full test suite before deploying
762
-
763
- ---
764
-
765
- ## Migration Guide
766
-
767
- ### From v1.0 to v1.1
768
-
769
- - `FRONTEND_URL` now part of `AuthPluginConfig` (falls back to env var)
770
- - New: Rate limiting on `/auth/logout` and `/auth/me`
771
- - New: `configureOwnership()` for advanced scoping
772
- - New: `@Policy` and `@Owned` decorators
773
-
774
- ---
775
-
776
- ## Support & Contributing
777
-
778
- For issues, feature requests, or contributions, please refer to the main Najm repository: https://github.com/najm/najm-api
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