najm-auth 1.1.44 → 2.0.2

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