najm-auth 1.1.4 → 1.1.6

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.
Files changed (4) hide show
  1. package/README.md +657 -0
  2. package/dist/index.d.ts +145 -121
  3. package/dist/index.js +410 -95
  4. package/package.json +10 -10
package/README.md ADDED
@@ -0,0 +1,657 @@
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, MySQL)
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' | 'mysql' // Default: 'pg'
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
+
127
+ // Frontend
128
+ frontendUrl?: string // Password reset link base URL
129
+
130
+ // Dependencies (forwarded to plugins)
131
+ validation?: ValidationPluginConfig
132
+ rateLimit?: RateLimitPluginConfig
133
+ })
134
+ ```
135
+
136
+ ---
137
+
138
+ ## Auto-Registered Routes
139
+
140
+ All routes are prefixed with `/auth` and auto-registered by the plugin.
141
+
142
+ ### Authentication Routes
143
+
144
+ | Method | Path | Description | Auth |
145
+ |--------|------|-------------|------|
146
+ | `POST` | `/auth/register` | Register new user | None |
147
+ | `POST` | `/auth/login` | Login with email/password | None |
148
+ | `GET` | `/auth/refresh` | Refresh access token (cookie) | None (uses refresh cookie) |
149
+ | `GET` | `/auth/logout` | Logout and revoke tokens | ✅ Required |
150
+ | `GET` | `/auth/me` | Get current user profile | ✅ Required |
151
+ | `POST` | `/auth/forgot-password` | Request password reset | None |
152
+ | `POST` | `/auth/reset-password` | Confirm password reset | None |
153
+
154
+ ### Admin Routes (all require `@isAdmin()`)
155
+
156
+ | Method | Path | Description |
157
+ |--------|------|-------------|
158
+ | `GET` | `/users` | List all users |
159
+ | `GET` | `/users/:id` | Get user by ID |
160
+ | `POST` | `/users` | Create new user |
161
+ | `PUT` | `/users/:id` | Update user |
162
+ | `DELETE` | `/users/:id` | Delete user |
163
+ | `GET` | `/roles` | List all roles |
164
+ | `GET` | `/roles/:id` | Get role by ID |
165
+ | `POST` | `/roles` | Create new role |
166
+ | `PUT` | `/roles/:id` | Update role |
167
+ | `DELETE` | `/roles/:id` | Delete role |
168
+ | `GET` | `/permissions` | List all permissions |
169
+ | `GET` | `/permissions/:id` | Get permission by ID |
170
+ | `POST` | `/permissions` | Create new permission |
171
+ | `PUT` | `/permissions/:id` | Update permission |
172
+ | `DELETE` | `/permissions/:id` | Delete permission |
173
+ | `POST` | `/permissions/assign/:roleId/:permissionId` | Assign permission to role |
174
+ | `DELETE` | `/permissions/remove/:roleId/:permissionId` | Remove permission from role |
175
+
176
+ ---
177
+
178
+ ## Guards Reference
179
+
180
+ ### Authentication Guard
181
+
182
+ ```typescript
183
+ import { isAuth } from 'najm-auth';
184
+
185
+ @Controller('/api/posts')
186
+ class PostController {
187
+ @Get('/') // Public
188
+ getAll() { }
189
+
190
+ @Post('/')
191
+ @isAuth() // Requires valid JWT
192
+ create(@Body() data: any) { }
193
+ }
194
+ ```
195
+
196
+ ### Role Guards
197
+
198
+ ```typescript
199
+ import { defineRoles } from 'najm-auth';
200
+
201
+ const roles = defineRoles({
202
+ ADMIN: 'admin',
203
+ MODERATOR: 'moderator',
204
+ USER: 'user',
205
+ });
206
+
207
+ export const { isAdmin, isModerator, isUser } = roles;
208
+
209
+ @Controller('/admin')
210
+ @isAdmin() // All methods require admin role
211
+ class AdminController {
212
+ @Get('/users')
213
+ getUsers() { }
214
+ }
215
+
216
+ @Controller('/api/posts')
217
+ class PostController {
218
+ @Delete('/:id')
219
+ @isModerator() // Method-level guard
220
+ deletePost() { }
221
+ }
222
+ ```
223
+
224
+ ### Permission Guards
225
+
226
+ ```typescript
227
+ import { Can, canRead, canCreate, canUpdate, canDelete } from 'najm-auth';
228
+
229
+ @Controller('/api/posts')
230
+ class PostController {
231
+ @Get('/')
232
+ @canRead('posts') // Requires 'read:posts' permission
233
+ getAll() { }
234
+
235
+ @Post('/')
236
+ @canCreate('posts') // Requires 'create:posts' permission
237
+ create(@Body() data: any) { }
238
+
239
+ @Put('/:id')
240
+ @canUpdate('posts') // Requires 'update:posts' permission
241
+ update() { }
242
+
243
+ @Delete('/:id')
244
+ @canDelete('posts') // Requires 'delete:posts' permission
245
+ delete() { }
246
+
247
+ @Post('/:id/publish')
248
+ @Can('publish:posts') // Custom permission
249
+ publish() { }
250
+ }
251
+ ```
252
+
253
+ **Permission Wildcards:**
254
+ - `*:*` — All actions on all resources
255
+ - `create:*` — Create action on any resource
256
+ - `*:posts` — Any action on posts
257
+
258
+ ### Combined Guards
259
+
260
+ ```typescript
261
+ @Controller('/admin/reports')
262
+ @isAdmin() // Require admin role
263
+ class ReportController {
264
+ @Get('/financial')
265
+ @Can('view:financial') // AND require financial view permission
266
+ getFinancial() { }
267
+ }
268
+ ```
269
+
270
+ ---
271
+
272
+ ## Ownership System
273
+
274
+ Control row-level access based on ownership (e.g., users see only their own data).
275
+
276
+ ### Declaring Ownership Rules
277
+
278
+ ```typescript
279
+ import { own, join, where } from 'najm-auth';
280
+ import { schema } from '../database/schema';
281
+
282
+ const { products, users } = schema;
283
+ const _users = alias(users, '_u');
284
+
285
+ export const Product = own(products)
286
+ .for('user',
287
+ join(products.userId, _users.id),
288
+ where(_users.id)
289
+ )
290
+ .writeBy(products.userId); // Enforce on create/update
291
+ ```
292
+
293
+ ### Using @Policy and @Owned
294
+
295
+ ```typescript
296
+ import { configureOwnership, Policy, CanList, CanRead, CanCreate, CanUpdate, CanDelete } from 'najm-auth';
297
+
298
+ const config = configureOwnership({
299
+ adminRoles: ['admin'],
300
+ rules: {
301
+ 'user': {
302
+ 'products': Product.getRules()['user']
303
+ }
304
+ }
305
+ });
306
+
307
+ @Policy(Product)
308
+ @Controller('/api/products')
309
+ export class ProductController {
310
+ @Get('/')
311
+ @CanList() // List only owned products
312
+ getAll(@GuardParams() filter: any) { }
313
+
314
+ @Get('/:id')
315
+ @CanRead() // Read only if owner
316
+ getOne() { }
317
+
318
+ @Post('/')
319
+ @CanCreate() // Create (ownership assigned automatically)
320
+ create(@Body() data: any) { }
321
+
322
+ @Put('/:id')
323
+ @CanUpdate() // Update only if owner
324
+ update(@Body() data: any) { }
325
+
326
+ @Delete('/:id')
327
+ @CanDelete() // Delete only if owner
328
+ delete() { }
329
+ }
330
+
331
+ @Repository('default')
332
+ @Owned(Product)
333
+ export class ProductRepository {
334
+ @DB() db!: Database;
335
+
336
+ // Auto-scoped to current user
337
+ async findMany(opts?: { where?: any; limit?: number }) {
338
+ return this.findMany(opts); // Only returns owned products
339
+ }
340
+
341
+ async findOne(opts: { where: any }) {
342
+ return this.findOne(opts); // Returns null if not owned
343
+ }
344
+
345
+ async scopedQuery() {
346
+ return this.scopedQuery(); // Raw scoped query builder
347
+ }
348
+ }
349
+ ```
350
+
351
+ ### Advanced Ownership: Multi-Role Scoping
352
+
353
+ ```typescript
354
+ const Grade = own(grades)
355
+ // Teachers see students' grades
356
+ .for('teacher',
357
+ join(grades.studentId, _s.id),
358
+ join(_s.id, _t.studentId),
359
+ where(_t.userId)
360
+ )
361
+ // Parents see only their child's grades
362
+ .for('parent',
363
+ join(grades.studentId, _s.id),
364
+ join(_s.id, _p.studentId),
365
+ where(_p.userId)
366
+ );
367
+ ```
368
+
369
+ ---
370
+
371
+ ## Database Schema
372
+
373
+ ### Tables
374
+
375
+ ```
376
+ users
377
+ ├── id (string, primary key)
378
+ ├── email (string, unique)
379
+ ├── password (string, hashed)
380
+ ├── emailVerified (boolean, default: false)
381
+ ├── image (string, nullable)
382
+ ├── status (enum: ACTIVE, INACTIVE)
383
+ ├── roleId (string, FK → roles.id)
384
+ ├── lastLogin (timestamp, nullable)
385
+ ├── createdAt (timestamp)
386
+ └── updatedAt (timestamp)
387
+
388
+ roles
389
+ ├── id (string, primary key)
390
+ ├── name (string, unique)
391
+ ├── description (string, nullable)
392
+ ├── createdAt (timestamp)
393
+ └── updatedAt (timestamp)
394
+
395
+ permissions
396
+ ├── id (string, primary key)
397
+ ├── name (string, unique)
398
+ ├── description (string, nullable)
399
+ ├── resource (string)
400
+ ├── action (string)
401
+ ├── createdAt (timestamp)
402
+ └── updatedAt (timestamp)
403
+
404
+ tokens
405
+ ├── id (string, primary key)
406
+ ├── userId (string, FK → users.id, unique)
407
+ ├── token (string, hashed)
408
+ ├── type (enum: REFRESH, RESET)
409
+ ├── status (enum: ACTIVE, REVOKED)
410
+ ├── expiresAt (timestamp)
411
+ ├── createdAt (timestamp)
412
+ └── updatedAt (timestamp)
413
+
414
+ role_permissions
415
+ ├── id (string, primary key)
416
+ ├── roleId (string, FK → roles.id)
417
+ ├── permissionId (string, FK → permissions.id)
418
+ ├── createdAt (timestamp)
419
+ └── updatedAt (timestamp)
420
+ ```
421
+
422
+ ### ID Strategy
423
+
424
+ Uses `nanoid` with short lengths for efficient storage:
425
+ - Users: 8 characters
426
+ - Roles: 5 characters
427
+ - Permissions: 5 characters
428
+ - Tokens: 10 characters
429
+
430
+ To use UUIDs instead, customize the schema:
431
+
432
+ ```typescript
433
+ import { customAlphabet } from 'nanoid';
434
+ import { uuid } from 'uuid';
435
+
436
+ // Use UUID for larger ID space
437
+ const customUsers = sqliteTable('users', {
438
+ id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
439
+ // ...
440
+ });
441
+ ```
442
+
443
+ ---
444
+
445
+ ## Seeding
446
+
447
+ ### Low-Level Seeding (authSeed)
448
+
449
+ ```typescript
450
+ import { authSeed } from 'najm-auth';
451
+ import { SeedService } from 'najm-database';
452
+
453
+ @Service()
454
+ class SetupService {
455
+ constructor(private seeder: SeedService) {}
456
+
457
+ async seed() {
458
+ const entries = authSeed({
459
+ adminEmail: 'admin@app.com',
460
+ adminPass: 'AdminPass123!',
461
+ roles: [
462
+ { name: 'editor', description: 'Can edit content' },
463
+ { name: 'viewer', description: 'Can view only' },
464
+ ],
465
+ permissions: [
466
+ { name: 'read:posts', resource: 'posts', action: 'read' },
467
+ { name: 'create:posts', resource: 'posts', action: 'create' },
468
+ ],
469
+ additionalUsers: [
470
+ { email: 'user@app.com', password: 'User123!', roleName: 'viewer' },
471
+ ]
472
+ });
473
+
474
+ await this.seeder.run(entries);
475
+ }
476
+ }
477
+ ```
478
+
479
+ ### High-Level Seeding (seedAuthData)
480
+
481
+ ```typescript
482
+ import { seedAuthData } from 'najm-auth';
483
+
484
+ await seedAuthData({
485
+ db,
486
+ adminEmail: process.env.ADMIN_EMAIL!,
487
+ adminPassword: process.env.ADMIN_PASSWORD!,
488
+ roles: [
489
+ { name: 'moderator', description: 'Content moderator' },
490
+ ],
491
+ users: [
492
+ { email: 'mod@app.com', password: 'Mod123!' , roleName: 'moderator' },
493
+ ],
494
+ verbose: true
495
+ });
496
+
497
+ // Note: Return type has empty users[] and roles[] arrays
498
+ // Query the database directly to retrieve inserted records
499
+ ```
500
+
501
+ ---
502
+
503
+ ## Rate Limiting
504
+
505
+ Auth routes have built-in rate limiting to prevent brute force attacks.
506
+
507
+ | Route | Limit | Window | Key Strategy |
508
+ |-------|-------|--------|--------------|
509
+ | `POST /auth/register` | 5 | 15 minutes | IP |
510
+ | `POST /auth/login` | 5 | 15 minutes | IP |
511
+ | `GET /auth/refresh` | 10 | 15 minutes | IP |
512
+ | `GET /auth/logout` | 10 | 15 minutes | User ID |
513
+ | `GET /auth/me` | 30 | 1 minute | User ID |
514
+ | `POST /auth/forgot-password` | 3 | 15 minutes | IP |
515
+ | `POST /auth/reset-password` | 5 | 15 minutes | IP |
516
+
517
+ ### Customizing Rate Limits
518
+
519
+ ```typescript
520
+ auth({
521
+ rateLimit: {
522
+ keyGenerator: 'ip', // or 'user', 'api-key', 'user+ip'
523
+ defaultWindow: '10m',
524
+ skip: (ctx) => ctx.path === '/health' // Skip for certain routes
525
+ }
526
+ })
527
+ ```
528
+
529
+ ---
530
+
531
+ ## TypeScript Types
532
+
533
+ ```typescript
534
+ import type {
535
+ AuthUser, // { id, email, name?, role?, permissions? }
536
+ TokenPair, // { accessToken, refreshToken, expiresAt? }
537
+ JwtPayload, // { userId, jti, exp?, iat? }
538
+ AuthConfig, // Full resolved config
539
+ AuthPluginConfig, // User-facing config
540
+ } from 'najm-auth';
541
+ ```
542
+
543
+ ---
544
+
545
+ ## Error Handling
546
+
547
+ All errors are i18n-based. Error messages are automatically localized.
548
+
549
+ ### Common Error Codes
550
+
551
+ | HTTP | Scenario |
552
+ |------|----------|
553
+ | 400 | Invalid input (bad email format, weak password) |
554
+ | 401 | Missing or invalid authentication (bad token, no header) |
555
+ | 403 | Forbidden (lacks required role/permission) |
556
+ | 409 | Conflict (email already registered) |
557
+ | 429 | Rate limited (too many requests) |
558
+ | 500 | Server error (email send failure, DB error) |
559
+
560
+ ### Examples
561
+
562
+ ```typescript
563
+ // Invalid credentials
564
+ throw new HttpError(401, 'Invalid email or password');
565
+
566
+ // User already exists
567
+ throw new HttpError(409, 'Email already registered');
568
+
569
+ // Insufficient permissions
570
+ throw new HttpError(403, 'Insufficient permissions for this action');
571
+ ```
572
+
573
+ ---
574
+
575
+ ## Security Considerations
576
+
577
+ ### Password Reset Tokens
578
+
579
+ ⚠️ **Current behavior:** Reset tokens use JWT expiry (default 1h) for single-use validation. To add database-backed single-use tokens:
580
+
581
+ ```typescript
582
+ // In AuthService.resetPassword():
583
+ async resetPassword(token: string, newPassword: string) {
584
+ const userId = this.tokenService.verifyResetToken(token);
585
+ // ... update password ...
586
+ // Blacklist the reset token to prevent reuse
587
+ await this.tokenService.blacklistCurrentToken(token);
588
+ }
589
+ ```
590
+
591
+ ### Session Management
592
+
593
+ - Single refresh token per user (upsert on login)
594
+ - Previous sessions invalidated on new login
595
+ - Use `@RateLimit` on logout for DDoS protection
596
+
597
+ ### Token Blacklist
598
+
599
+ - Built-in cache-based blacklist for immediate revocation
600
+ - Supports Redis via `cache()` plugin configuration
601
+ - Default: in-memory store (suitable for single-instance servers)
602
+
603
+ ### Timing Attack Prevention
604
+
605
+ - Dummy hash used for missing users in login
606
+ - Constant-time password comparison
607
+ - Same response for forgot-password (prevents email enumeration)
608
+
609
+ ---
610
+
611
+ ## Testing
612
+
613
+ ```bash
614
+ bun run test # Run all tests
615
+ bun run test:auth # Run auth tests only
616
+ ```
617
+
618
+ Test files include:
619
+ - `schema.test.ts` — Schema exports validation
620
+ - `auth.test.ts` — Authentication flow
621
+ - `user.test.ts` — User CRUD
622
+ - `role.test.ts` — Role management
623
+ - `permission.test.ts` — Permission guards
624
+ - `guards.test.ts` — Guard composability
625
+ - `ownership.test.ts` — Row-level scoping
626
+ - `integration.test.ts` — Multi-role scenarios
627
+
628
+ ---
629
+
630
+ ## Production Checklist
631
+
632
+ - ✅ Use strong JWT secrets (32+ chars, generated with `openssl rand -base64 32`)
633
+ - ✅ Set `FRONTEND_URL` environment variable
634
+ - ✅ Enable HTTPS in production
635
+ - ✅ Store secrets in environment variables (never in code)
636
+ - ✅ Use Redis for token blacklist in distributed systems
637
+ - ✅ Enable rate limiting on all auth routes
638
+ - ✅ Log authentication events for audit trails
639
+ - ✅ Test ownership scoping rules with multi-user scenarios
640
+ - ✅ Run full test suite before deploying
641
+
642
+ ---
643
+
644
+ ## Migration Guide
645
+
646
+ ### From v1.0 to v1.1
647
+
648
+ - `FRONTEND_URL` now part of `AuthPluginConfig` (falls back to env var)
649
+ - New: Rate limiting on `/auth/logout` and `/auth/me`
650
+ - New: `configureOwnership()` for advanced scoping
651
+ - New: `@Policy` and `@Owned` decorators
652
+
653
+ ---
654
+
655
+ ## Support & Contributing
656
+
657
+ For issues, feature requests, or contributions, please refer to the main Najm repository: https://github.com/najm/najm-api