najm-auth 4.0.5 → 4.0.7

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,1316 +1,1316 @@
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
- - ✅ Forced first-login credential setup for provisioned accounts
13
- - ✅ Country identity presets so `06…` and `+2126…` resolve to one account
14
- - ✅ Multi-dialect support (PostgreSQL, SQLite)
15
- - ✅ Type-safe decorators with TypeScript
16
- - ✅ Rate limiting on auth endpoints
17
- - ✅ Internationalization (i18n) for all messages
18
- - ✅ Google OpenID Connect sign-in with PKCE and explicit account linking
19
-
20
- ---
21
-
22
- ## Installation
23
-
24
- ```bash
25
- bun add najm-auth
26
- # Peer dependencies
27
- bun add hono drizzle-orm reflect-metadata
28
- ```
29
-
30
- ---
31
-
32
- ## Quick Setup
33
-
34
- ### 1. Initialize Database
35
-
36
- ```typescript
37
- // src/database/schema.ts
38
- import { authSchema } from 'najm-auth';
39
- import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
40
-
41
- // Your app tables
42
- export const products = sqliteTable('products', {
43
- id: text('id').primaryKey(),
44
- name: text('name').notNull(),
45
- userId: text('userId').notNull(),
46
- });
47
-
48
- // Combined schema (always include authSchema)
49
- export const schema = {
50
- ...authSchema, // includes users, tokens, and credentialSetupSessions
51
- products,
52
- };
53
-
54
- // src/database/index.ts
55
- import { drizzle } from 'drizzle-orm/bun-sqlite';
56
- import { Database } from 'bun:sqlite';
57
- import { schema } from './schema';
58
-
59
- const sqlite = new Database('./app.db');
60
- export const db = drizzle(sqlite, { schema });
61
- ```
62
-
63
- ### 2. Configure Auth Plugin
64
-
65
- ```typescript
66
- // src/main.ts
67
- import 'reflect-metadata';
68
- import { Server } from 'najm-core';
69
- import { database } from 'najm-database';
70
- import { auth } from 'najm-auth';
71
- import { db } from './database';
72
-
73
- const server = new Server()
74
- .use(database({ default: db })) // Required: database must be registered first
75
- .use(auth({
76
- dialect: 'sqlite', // Auto-selects SQLite schema
77
- jwt: {
78
- accessSecret: process.env.JWT_ACCESS_SECRET!, // Required
79
- refreshSecret: process.env.JWT_REFRESH_SECRET!, // Required
80
- accessExpiresIn: '15m', // Optional, default: 1h
81
- refreshExpiresIn: '7d', // Optional, default: 7d
82
- },
83
- frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000', // For password reset links
84
- }))
85
- .load(/* your controllers and services */)
86
- .listen(3000);
87
- ```
88
-
89
- ### 3. Set Environment Variables
90
-
91
- ```bash
92
- # .env
93
- JWT_ACCESS_SECRET=<32-character-minimum-secret>
94
- JWT_REFRESH_SECRET=<32-character-minimum-secret>
95
- FRONTEND_URL=https://app.example.com
96
- # Optional Google sign-in
97
- GOOGLE_CLIENT_ID=<google-web-client-id>
98
- GOOGLE_CLIENT_SECRET=<google-web-client-secret>
99
- # Optional for a split frontend/API deployment. Otherwise FRONTEND_URL is used.
100
- GOOGLE_CALLBACK_URL=https://app.example.com/api/auth/oauth/google/callback
101
- # Optional GitHub sign-in
102
- GITHUB_CLIENT_ID=<github-oauth-app-client-id>
103
- GITHUB_CLIENT_SECRET=<github-oauth-app-client-secret>
104
- GITHUB_CALLBACK_URL=https://app.example.com/api/auth/oauth/github/callback
105
- ```
106
-
107
- > ⚠️ **Security:** Generate secrets with `openssl rand -base64 32`
108
-
109
- ---
110
-
111
- ## Configuration Reference
112
-
113
- ### AuthPluginConfig
114
-
115
- ```typescript
116
- auth({
117
- // Database
118
- dialect?: 'pg' | 'sqlite' // Default: 'pg' (RETURNING-capable engines only)
119
- schema?: AuthSchema // Override dialect schema
120
-
121
- // JWT
122
- jwt?: {
123
- accessSecret: string // Required, min 32 chars
124
- accessExpiresIn?: string // Default: 1h
125
- refreshSecret: string // Required, min 32 chars
126
- refreshExpiresIn?: string // Default: 7d
127
- }
128
-
129
- // Cookies
130
- refreshCookieName?: string // Default: 'refreshToken'
131
-
132
- // Database
133
- database?: string // Default: 'default'
134
- blacklistPrefix?: string // Default: 'auth:blacklist:'
135
-
136
- // Registration
137
- defaultRole?: string | null // Auto-assign role to new users
138
- publicRegistration?: boolean // Default: true; mounts POST /auth/register
139
- bcryptRounds?: number // Default: 10 (valid: 4-31)
140
-
141
- // Frontend
142
- frontendUrl?: string // Password reset link base URL
143
- appName?: string // Security email brand (default: 'Your app')
144
- accountInviteLogo?: { // Optional CID-backed inline mark
145
- alt?: string
146
- contentBase64: string
147
- contentType: string
148
- filename: string
149
- }
150
-
151
- // Login identifier normalization (see "Identity presets")
152
- identity?: {
153
- preset?: 'ma' | 'tn' | IdentityPreset | null // Default: 'ma'
154
- extend?: IdentityNormalizer[] // Runs before the preset
155
- }
156
-
157
- // Credential setup policy overrides (the flow itself is always on)
158
- credentialSetup?: {
159
- password?: {
160
- passwordSchema?: ZodType<string> // Default: 8-72 bytes, a letter and a digit
161
- ttlMs?: number // Default: 600000 (10 minutes)
162
- cookieName?: string // Default: 'najm.credential-setup'
163
- }
164
- }
165
-
166
- // Optional external identity providers
167
- oauth?: {
168
- google?: true | {
169
- clientId?: string // Or GOOGLE_CLIENT_ID
170
- clientSecret?: string // Or GOOGLE_CLIENT_SECRET
171
- callbackUrl?: string // Or GOOGLE_CALLBACK_URL; otherwise frontendUrl + /api/auth/oauth/google/callback
172
- frontendCallbackPath?: string // Default: /auth/oauth/callback
173
- errorRedirectPath?: string // Default: /login
174
- allowSignup?: boolean // Default: true
175
- autoLinkVerifiedEmail?: boolean // Default: false
176
- allowedHostedDomains?: string[] // Validates the Google hd claim
177
- }
178
- github?: true | {
179
- clientId?: string // Or GITHUB_CLIENT_ID
180
- clientSecret?: string // Or GITHUB_CLIENT_SECRET
181
- callbackUrl?: string // Or GITHUB_CALLBACK_URL
182
- frontendCallbackPath?: string // Default: /auth/oauth/callback
183
- errorRedirectPath?: string // Default: /login
184
- allowSignup?: boolean // Default: true
185
- autoLinkVerifiedEmail?: boolean // Default: false
186
- }
187
- }
188
-
189
- // Dependencies (forwarded to plugins)
190
- validation?: ValidationPluginConfig
191
- rateLimit?: RateLimitPluginConfig
192
- })
193
- ```
194
-
195
- ---
196
-
197
- ## Auto-Registered Routes
198
-
199
- All routes are prefixed with `/auth` and auto-registered by the plugin.
200
-
201
- ### Authentication Routes
202
-
203
- | Method | Path | Description | Auth |
204
- |--------|------|-------------|------|
205
- | `POST` | `/auth/register` | Register new user (omitted when `publicRegistration: false`) | None |
206
- | `POST` | `/auth/login` | Login with email/password | None |
207
- | `POST` | `/auth/refresh` | Refresh access token (cookie) | None (uses refresh cookie) |
208
- | `POST` | `/auth/session/recover` | Reissue signed session without token rotation | Refresh cookie + recovery header |
209
- | `POST` | `/auth/logout` | Logout and revoke tokens | ✅ Required |
210
- | `GET` | `/auth/me` | Get current user profile | ✅ Required |
211
- | `POST` | `/auth/forgot-password` | Request password reset | None |
212
- | `POST` | `/auth/reset-password` | Confirm password reset | None |
213
- | `GET` | `/auth/oauth/google/start` | Start Google sign-in | None |
214
- | `GET` | `/auth/oauth/google/callback` | Verify Google callback and create Najm session | None |
215
- | `POST` | `/auth/oauth/google/link` | Link Google to the current user | ✅ Required |
216
- | `GET` | `/auth/credential-setup/setup` | Read the pending setup session | Setup cookie |
217
- | `POST` | `/auth/credential-setup/change` | Replace the temporary credential | Setup cookie |
218
- | `POST` | `/auth/credential-setup/cancel` | Abandon the setup session | Setup cookie |
219
-
220
- Applications with an approval-owned onboarding flow should set
221
- `publicRegistration: false`. This removes the unauthenticated route while
222
- retaining `AuthService.registerUser()`, `provisionUser()`, and other internal
223
- account-management APIs for trusted application services.
224
-
225
- ### Identity presets
226
-
227
- Login lookup, lockout accounting, and rate-limit bucketing all normalize the
228
- submitted identifier the same way. The pipeline is: email (lowercased) →
229
- project extensions → the country preset → generic E.164.
230
-
231
- The resolved pipeline belongs to the specific `auth()` plugin/server instance.
232
- Multiple isolated Najm servers can therefore use different country presets in
233
- one process without replacing each other's login or rate-limit behavior.
234
-
235
- Morocco is the default, so `0612345678`, `212612345678`, and `+212612345678`
236
- all resolve to `+212612345678` with no configuration.
237
-
238
- ```typescript
239
- auth(); // preset: 'ma'
240
- auth({ identity: { extend: [employeeNumberNormalizer] } });
241
- auth({ identity: { preset: 'tn' } }); // replaces Morocco
242
- auth({ identity: { preset: null, extend: [custom] } }); // generic only
243
- ```
244
-
245
- Local numbers are country-ambiguous, so presets **replace** each other rather
246
- than stacking — two presets claiming `06…` would resolve one raw input to two
247
- different accounts.
248
-
249
- ### First-login credential setup
250
-
251
- Provision an account with a temporary credential and Najm refuses it a normal
252
- session until the holder replaces it:
253
-
254
- ```typescript
255
- import { moroccanCinTemporaryCredential } from 'najm-auth/identity/ma';
256
-
257
- await authService.provisionUser({
258
- email: guardian.email,
259
- phone: guardian.phone,
260
- role: 'family',
261
- temporaryCredential: moroccanCinTemporaryCredential(guardian.cin),
262
- requireCredentialSetup: 'password',
263
- });
264
- ```
265
-
266
- `temporaryCredential` also accepts a plain string, compared exactly and
267
- case-sensitively — enough for a student or registration number:
268
-
269
- ```typescript
270
- await authService.provisionUser({
271
- email: student.schoolEmail,
272
- role: 'student',
273
- temporaryCredential: student.registrationNumber,
274
- requireCredentialSetup: 'password',
275
- });
276
- ```
277
-
278
- Supplying both `password` and `temporaryCredential` is rejected, so an account
279
- can never hold a permanent password that something also treats as temporary.
280
- Typed helpers such as `moroccanCinTemporaryCredential()` validate their value,
281
- and every temporary credential remains limited to bcrypt's 72-byte boundary.
282
-
283
- Login then answers a discriminated result instead of a token pair:
284
-
285
- ```typescript
286
- const result = await auth.client.login({ identifier, password, rememberMe });
287
-
288
- if (result.nextStep === 'credential_setup') {
289
- router.push('/change-password'); // no tokens were issued
290
- } else {
291
- router.push('/dashboard');
292
- }
293
- ```
294
-
295
- The requirement is enforced at every session-establishment path — password
296
- login, `AuthSessionService.establish()`, Google OAuth (which redirects with
297
- `oauthError=oauth_credential_setup_required`), refresh, and signed-session
298
- recovery — so verified-email OAuth linking cannot skip it. Marking a new
299
- requirement also revokes the user's current sessions.
300
-
301
- `withAuthCookiePersistence` recognizes logout and setup boundaries on its own.
302
- After a successful logout it drops stale auth-cookie issuances and guarantees
303
- exactly one deletion for each configured auth cookie. It preserves a valid
304
- upstream deletion (including a custom cookie path), or synthesizes a canonical
305
- deletion when one is missing. A setup response gets the same auth-cookie
306
- deletions, clears the remembered preference, and leaves the opaque setup cookie
307
- alone.
308
-
309
- ### Google Sign-In
310
-
311
- Google sign-in uses the server-side OpenID Connect authorization-code flow.
312
- Najm creates state, nonce, and PKCE values, verifies Google's signed ID token,
313
- then issues the same Najm JWT, refresh token, and session cookie as password
314
- login. Google tokens are discarded and are never stored.
315
-
316
- ```ts
317
- auth({
318
- dialect: 'pg',
319
- frontendUrl: 'https://app.example.com',
320
- oauth: { google: true },
321
- })
322
- ```
323
-
324
- With `google: true`, credentials come from `GOOGLE_CLIENT_ID` and
325
- `GOOGLE_CLIENT_SECRET`; the callback defaults to
326
- `${FRONTEND_URL}/api/auth/oauth/google/callback`. Register that value exactly
327
- as an authorized redirect URI in Google Cloud. Set `GOOGLE_CALLBACK_URL` or
328
- `google: { callbackUrl: '...' }` when the API runs on a different origin.
329
- Production callback URLs must use HTTPS; HTTP is accepted only for localhost.
330
-
331
- Mount the browser completion route configured by `frontendCallbackPath`:
332
-
333
- ```tsx
334
- 'use client';
335
-
336
- import { OAuthCallback } from 'najm-auth/client/react';
337
-
338
- export default function OAuthCallbackPage() {
339
- return <OAuthCallback fallback={<p>Finishing sign-in...</p>} />;
340
- }
341
- ```
342
-
343
- Then use the headless button anywhere below `AuthProvider`:
344
-
345
- ```tsx
346
- import { GoogleLoginButton } from 'najm-auth/client/react';
347
-
348
- <GoogleLoginButton returnTo="/dashboard">
349
- <button type="button">Continue with Google</button>
350
- </GoogleLoginButton>
351
- ```
352
-
353
- Google accounts are keyed by Google's stable `sub` claim. If an existing Najm
354
- user has the same email but is not linked, sign-in fails with
355
- `oauth_account_link_required` by default. After password login, call
356
- `client.linkOAuthAccount('google')` to prove control of both accounts. Setting
357
- `autoLinkVerifiedEmail: true` opts into verified-email linking.
358
-
359
- ### GitHub Sign-In
360
-
361
- Enable GitHub with `oauth: { github: true }`. Credentials come from
362
- `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`; the callback defaults to
363
- `${FRONTEND_URL}/api/auth/oauth/github/callback`. Register that exact callback
364
- on the GitHub OAuth App, and set `GITHUB_CALLBACK_URL` only for a split-origin
365
- deployment. GitHub login uses authorization code plus PKCE, requests
366
- `user:email`, requires a verified primary email, and keys the durable provider
367
- link by GitHub's numeric user ID.
368
-
369
- The client exposes `loginWithGitHub()`, `useGitHubLogin()`, and the headless
370
- `GitHubLoginButton`; the generic `linkOAuthAccount('github')` method links an
371
- authenticated Najm user.
372
-
373
- ### Admin Routes (all require `@isAdmin()`)
374
-
375
- | Method | Path | Description |
376
- |--------|------|-------------|
377
- | `GET` | `/users?limit=50&offset=0` | List users (limit 1-100) |
378
- | `GET` | `/users/:id` | Get user by ID |
379
- | `POST` | `/users` | Create new user |
380
- | `PUT` | `/users/:id` | Update user |
381
- | `DELETE` | `/users/:id` | Delete user |
382
- | `GET` | `/roles` | List all roles |
383
- | `GET` | `/roles/:id` | Get role by ID |
384
- | `POST` | `/roles` | Create new role |
385
- | `PUT` | `/roles/:id` | Update role |
386
- | `DELETE` | `/roles/:id` | Delete role |
387
- | `GET` | `/permissions` | List all permissions |
388
- | `GET` | `/permissions/:id` | Get permission by ID |
389
- | `POST` | `/permissions` | Create new permission |
390
- | `PUT` | `/permissions/:id` | Update permission |
391
- | `DELETE` | `/permissions/:id` | Delete permission |
392
- | `POST` | `/permissions/assign/:roleId/:permissionId` | Assign permission to role |
393
- | `DELETE` | `/permissions/remove/:roleId/:permissionId` | Remove permission from role |
394
-
395
- ---
396
-
397
- ## Guards Reference
398
-
399
- ### Authentication Guard
400
-
401
- ```typescript
402
- import { isAuth } from 'najm-auth';
403
-
404
- @Controller('/api/posts')
405
- class PostController {
406
- @Get('/') // Public
407
- getAll() { }
408
-
409
- @Post('/')
410
- @isAuth() // Requires valid JWT
411
- create(@Body() data: any) { }
412
- }
413
- ```
414
-
415
- ### Role Guards
416
-
417
- ```typescript
418
- import { defineRoles } from 'najm-auth';
419
-
420
- const roles = defineRoles({
421
- ADMIN: 'admin',
422
- MODERATOR: 'moderator',
423
- USER: 'user',
424
- }, {
425
- superRoles: ['ADMIN'], // admin also passes moderator/user role guards
426
- });
427
-
428
- export const { isAdmin, isModerator, isUser } = roles;
429
-
430
- @Controller('/admin')
431
- @isAdmin() // All methods require admin role
432
- class AdminController {
433
- @Get('/users')
434
- getUsers() { }
435
- }
436
-
437
- @Controller('/api/posts')
438
- class PostController {
439
- @Delete('/:id')
440
- @isModerator() // Method-level guard
441
- deletePost() { }
442
- }
443
- ```
444
-
445
- ### Permission Guards
446
-
447
- ```typescript
448
- import { Can, canRead, canCreate, canUpdate, canDelete } from 'najm-auth';
449
-
450
- @Controller('/api/posts')
451
- class PostController {
452
- @Get('/')
453
- @canRead('posts') // Requires 'read:posts' permission
454
- getAll() { }
455
-
456
- @Post('/')
457
- @canCreate('posts') // Requires 'create:posts' permission
458
- create(@Body() data: any) { }
459
-
460
- @Put('/:id')
461
- @canUpdate('posts') // Requires 'update:posts' permission
462
- update() { }
463
-
464
- @Delete('/:id')
465
- @canDelete('posts') // Requires 'delete:posts' permission
466
- delete() { }
467
-
468
- @Post('/:id/publish')
469
- @Can('publish:posts') // Custom permission
470
- publish() { }
471
- }
472
- ```
473
-
474
- **Permission Wildcards:**
475
- - `*:*` — All actions on all resources
476
- - `create:*` — Create action on any resource
477
- - `*:posts` — Any action on posts
478
-
479
- ### Combined Guards
480
-
481
- ```typescript
482
- @Controller('/admin/reports')
483
- @isAdmin() // Require admin role
484
- class ReportController {
485
- @Get('/financial')
486
- @Can('view:financial') // AND require financial view permission
487
- getFinancial() { }
488
- }
489
- ```
490
-
491
- ---
492
-
493
- ## Ownership System
494
-
495
- Control row-level access based on ownership (e.g., users see only their own data).
496
-
497
- ### Declaring Ownership Rules
498
-
499
- ```typescript
500
- import { own, join, where } from 'najm-auth';
501
- import { schema } from '../database/schema';
502
-
503
- const { products, users } = schema;
504
- const _users = alias(users, '_u');
505
-
506
- export const Product = own(products)
507
- .for('user',
508
- join(products.userId, _users.id),
509
- where(_users.id)
510
- )
511
- .writeBy(products.userId); // Enforce on create/update
512
- ```
513
-
514
- ### Using @Policy and @Owned
515
-
516
- ```typescript
517
- import { configureOwnership, Policy, CanList, CanRead, CanCreate, CanUpdate, CanDelete } from 'najm-auth';
518
-
519
- const config = configureOwnership({
520
- adminRoles: ['admin'],
521
- rules: {
522
- 'user': {
523
- 'products': Product.getRules()['user']
524
- }
525
- }
526
- });
527
-
528
- @Policy(Product)
529
- @Controller('/api/products')
530
- export class ProductController {
531
- @Get('/')
532
- @CanList() // List only owned products
533
- getAll(@GuardParams() filter: any) { }
534
-
535
- @Get('/:id')
536
- @CanRead() // Read only if owner
537
- getOne() { }
538
-
539
- @Post('/')
540
- @CanCreate() // Create (ownership assigned automatically)
541
- create(@Body() data: any) { }
542
-
543
- @Put('/:id')
544
- @CanUpdate() // Update only if owner
545
- update(@Body() data: any) { }
546
-
547
- @Delete('/:id')
548
- @CanDelete() // Delete only if owner
549
- delete() { }
550
- }
551
-
552
- @Repository('default')
553
- @Owned(Product)
554
- export class ProductRepository {
555
- @DB() db!: Database;
556
-
557
- // Auto-scoped to current user
558
- async findMany(opts?: { where?: any; limit?: number }) {
559
- return this.findMany(opts); // Only returns owned products
560
- }
561
-
562
- async findOne(opts: { where: any }) {
563
- return this.findOne(opts); // Returns null if not owned
564
- }
565
-
566
- async scopedQuery() {
567
- return this.scopedQuery(); // Raw scoped query builder
568
- }
569
- }
570
- ```
571
-
572
- ### Advanced Ownership: Multi-Role Scoping
573
-
574
- ```typescript
575
- const Grade = own(grades)
576
- // Teachers see students' grades
577
- .for('teacher',
578
- join(grades.studentId, _s.id),
579
- join(_s.id, _t.studentId),
580
- where(_t.userId)
581
- )
582
- // Parents see only their child's grades
583
- .for('parent',
584
- join(grades.studentId, _s.id),
585
- join(_s.id, _p.studentId),
586
- where(_p.userId)
587
- );
588
- ```
589
-
590
- ---
591
-
592
- ## Database Schema
593
-
594
- ### Tables
595
-
596
- ```
597
- users
598
- ├── id (string, primary key)
599
- ├── email (string, unique)
600
- ├── password (string, hashed)
601
- ├── emailVerified (boolean, default: false)
602
- ├── image (string, nullable)
603
- ├── status (enum: ACTIVE, INACTIVE)
604
- ├── roleId (string, FK → roles.id)
605
- ├── lastLogin (timestamp, nullable)
606
- ├── createdAt (timestamp)
607
- └── updatedAt (timestamp)
608
-
609
- roles
610
- ├── id (string, primary key)
611
- ├── name (string, unique — `roles_name_unique` in both dialects)
612
- ├── description (string, nullable)
613
- ├── createdAt (timestamp)
614
- └── updatedAt (timestamp)
615
-
616
- permissions
617
- ├── id (string, primary key)
618
- ├── name (string, unique)
619
- ├── description (string, nullable)
620
- ├── resource (string)
621
- ├── action (string)
622
- ├── createdAt (timestamp)
623
- └── updatedAt (timestamp)
624
-
625
- tokens
626
- ├── id (string, primary key)
627
- ├── userId (string, FK → users.id, unique)
628
- ├── token (string, hashed)
629
- ├── type (enum: REFRESH, RESET)
630
- ├── status (enum: ACTIVE, REVOKED)
631
- ├── expiresAt (timestamp)
632
- ├── createdAt (timestamp)
633
- └── updatedAt (timestamp)
634
-
635
- role_permissions
636
- ├── id (string, primary key)
637
- ├── roleId (string, FK → roles.id)
638
- ├── permissionId (string, FK → permissions.id)
639
- ├── createdAt (timestamp)
640
- └── updatedAt (timestamp)
641
-
642
- oauth_accounts
643
- ├── id (string, primary key)
644
- ├── userId (string, FK → users.id, cascade delete)
645
- ├── provider (string; `google` in this release)
646
- ├── providerAccountId (Google `sub`)
647
- ├── unique(provider, providerAccountId)
648
- └── unique(userId, provider)
649
-
650
- credential_setup_sessions
651
- ├── id (string, primary key)
652
- ├── userId (string, FK → users.id, cascade delete)
653
- ├── purpose (string)
654
- ├── tokenHash (string, unique — SHA-256 of the browser cookie)
655
- ├── expiresAt (timestamp)
656
- ├── consumedAt (timestamp, nullable)
657
- └── revokedAt (timestamp, nullable)
658
-
659
- credential_setup_requirements
660
- ├── userId (string, FK → users.id, cascade delete)
661
- ├── purpose (string; `password` for the built-in flow)
662
- ├── temporaryCredentialKind (string, nullable; `exact` or `ma-cin`)
663
- ├── required (boolean, default: true)
664
- ├── completedAt (timestamp, nullable)
665
- └── primary key (userId, purpose)
666
- ```
667
-
668
- `credential_setup_requirements` is keyed on `(userId, purpose)` rather than
669
- `userId` alone, so one user can owe more than one future setup purpose.
670
-
671
- Existing databases must generate and run a migration after upgrading so the
672
- new `oauth_accounts`, `credential_setup_sessions`, and
673
- `credential_setup_requirements` tables exist. Custom `AuthSchema` objects may
674
- omit `oauthAccounts` while OAuth is disabled, but provider configuration fails
675
- fast unless the custom schema supplies it. Both credential-setup tables are
676
- required of a custom schema, because the setup flow is always mounted.
677
-
678
- ### ID Strategy
679
-
680
- Uses `nanoid` with short lengths for efficient storage:
681
- - Users: 8 characters
682
- - Roles: 5 characters
683
- - Permissions: 5 characters
684
- - Tokens: 10 characters
685
-
686
- To use UUIDs instead, customize the schema:
687
-
688
- ```typescript
689
- import { customAlphabet } from 'nanoid';
690
- import { uuid } from 'uuid';
691
-
692
- // Use UUID for larger ID space
693
- const customUsers = sqliteTable('users', {
694
- id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
695
- // ...
696
- });
697
- ```
698
-
699
- ---
700
-
701
- ## Seeding
702
-
703
- Role names are database-enforced identities. `authSeed()` reconciles the `roles`
704
- entry **by name**, not by the primary key it proposes, so a role that already
705
- exists under a legacy or randomly generated ID is reused rather than inserted a
706
- second time. Downstream resolvers receive the row the database actually holds,
707
- which keeps `users.role_id` and `role_permissions.role_id` pointing at the live
708
- ID. Repeat seeding is idempotent and never rewrites a role's primary key —
709
- `users.role_id` carries no `ON UPDATE CASCADE` contract.
710
-
711
- > **Adopting this from an earlier version:** the unique index is a persistence
712
- > invariant, not only a validation change. Consolidate any duplicate role names
713
- > **before** you apply a migration that adds it, or the migration fails. Consumer
714
- > migrations are application-owned; this package ships the schema declaration,
715
- > not your migration.
716
-
717
-
718
- ### Low-Level Seeding (authSeed)
719
-
720
- ```typescript
721
- import { authSeed } from 'najm-auth';
722
- import { SeedService } from 'najm-database';
723
-
724
- @Service()
725
- class SetupService {
726
- constructor(private seeder: SeedService) {}
727
-
728
- async seed() {
729
- const entries = authSeed({
730
- adminEmail: 'admin@app.com',
731
- adminPass: 'AdminPass123!',
732
- roles: [
733
- { name: 'editor', description: 'Can edit content' },
734
- { name: 'viewer', description: 'Can view only' },
735
- ],
736
- permissions: [
737
- { name: 'read:posts', resource: 'posts', action: 'read' },
738
- { name: 'create:posts', resource: 'posts', action: 'create' },
739
- ],
740
- additionalUsers: [
741
- { email: 'user@app.com', password: 'User123!', roleName: 'viewer' },
742
- ]
743
- });
744
-
745
- await this.seeder.run(entries);
746
- }
747
- }
748
- ```
749
-
750
- ### High-Level Seeding (seedAuthData)
751
-
752
- ```typescript
753
- import { seedAuthData } from 'najm-auth';
754
-
755
- await seedAuthData({
756
- db,
757
- adminEmail: process.env.ADMIN_EMAIL!,
758
- adminPassword: process.env.ADMIN_PASSWORD!,
759
- roles: [
760
- { name: 'moderator', description: 'Content moderator' },
761
- ],
762
- users: [
763
- { email: 'mod@app.com', password: 'Mod123!' , roleName: 'moderator' },
764
- ],
765
- verbose: true
766
- });
767
-
768
- // Note: Return type has empty users[] and roles[] arrays
769
- // Query the database directly to retrieve inserted records
770
- ```
771
-
772
- ---
773
-
774
- ## Rate Limiting
775
-
776
- Auth routes have built-in rate limiting to prevent brute force attacks.
777
- The auth plugin registers `najm-rate` as a dependency, so these decorator-level
778
- limits are active when `auth()` is registered.
779
-
780
- | Route | Limit | Window | Key Strategy |
781
- |-------|-------|--------|--------------|
782
- | `POST /auth/register` | 5 | 15 minutes | IP |
783
- | `POST /auth/login` | 8 | 10 minutes | IP + hashed normalized identity |
784
- | `POST /auth/refresh` | 15 | 15 minutes | Cookie fingerprint |
785
- | `POST /auth/session/recover` | 120 | 1 minute | Cookie fingerprint |
786
- | `POST /auth/logout` | 10 | 15 minutes | User ID |
787
- | `GET /auth/me` | 30 | 1 minute | User ID |
788
- | `POST /auth/forgot-password` | 3 | 15 minutes | IP |
789
- | `POST /auth/reset-password` | 5 | 15 minutes | IP |
790
-
791
- ### Customizing Rate Limits
792
-
793
- The login route has strict environment overrides. Values are read when the
794
- server imports `najm-auth`, so restart the process after changing them. Invalid
795
- values fail startup rather than silently weakening the limiter.
796
-
797
- ```bash
798
- NAJM_AUTH_LOGIN_RATE_LIMIT_ENABLED=true
799
- NAJM_AUTH_LOGIN_RATE_LIMIT=8
800
- NAJM_AUTH_LOGIN_RATE_WINDOW=10m
801
- ```
802
-
803
- `NAJM_AUTH_LOGIN_RATE_LIMIT_ENABLED=false` disables only the login-route
804
- limiter. Keep it enabled on public production deployments; a shorter window is
805
- the safer setting for a disposable production-built demo.
806
-
807
- The generic plugin configuration remains available for global limits and skip
808
- rules:
809
-
810
- ```typescript
811
- auth({
812
- rateLimit: {
813
- keyGenerator: 'ip', // or 'user', 'api-key', 'user+ip'
814
- defaultWindow: '10m',
815
- skip: (ctx) => ctx.path === '/health' // Skip for certain routes
816
- }
817
- })
818
- ```
819
-
820
- ---
821
-
822
- ## Next.js App Router Structure
823
-
824
- Every App Router application keeps the same four files. Copying more than this
825
- between apps means logic that belongs in the package has leaked into them.
826
-
827
- ```text
828
- src/lib/auth.ts defineAuth() configuration — browser, server, and proxy safe
829
- src/lib/session.ts one createReactServerAuth() instance for Server Components
830
- src/proxy.ts exports auth.proxy plus Next's required static matcher
831
- src/app/api/[...route]/route.ts binds the server through auth.routeHandlers()
832
- ```
833
-
834
- ```typescript
835
- // src/lib/auth.ts
836
- import { defineAuth } from 'najm-auth/client/server';
837
-
838
- export const auth = defineAuth({
839
- apiBaseURL: '/api',
840
- loginRoute: '/login',
841
- forbiddenRoute: '/forbidden',
842
- publicRoutes: ['/', '/login'],
843
- protectedRoutes: ['/dashboard/:path*', '/admin/:path*'],
844
- roleRoutes: { '/admin/:path*': ['admin'] },
845
- proxySessionMode: 'optimistic',
846
- });
847
- ```
848
-
849
- ```typescript
850
- // src/lib/session.ts
851
- import 'server-only';
852
-
853
- import { createReactServerAuth } from 'najm-auth/client/server/react';
854
-
855
- import { auth } from './auth';
856
-
857
- export const serverAuth = createReactServerAuth(auth);
858
- ```
859
-
860
- ```typescript
861
- // src/proxy.ts
862
- import { auth } from './lib/auth';
863
-
864
- export default auth.proxy;
865
- export const config = {
866
- matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
867
- };
868
- ```
869
-
870
- Next.js 16 requires the exported Proxy `config` to be a statically analyzable
871
- object literal. Turbopack rejects `export const config = auth.config`, so the
872
- matcher is the one integration value that cannot be composed at runtime.
873
-
874
- When a Proxy generates request-scoped headers for the downstream render, pass
875
- only the overrides as the optional second argument. Najm merges them over the
876
- incoming request and preserves them when session recovery replaces the cookie.
877
- The application still owns any matching response header:
878
-
879
- ```typescript
880
- export default async function proxy(request: Request) {
881
- const nonce = btoa(globalThis.crypto.randomUUID());
882
- const policy = `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`;
883
- const response = await auth.proxy(request, {
884
- requestHeaders: {
885
- 'content-security-policy': policy,
886
- 'x-nonce': nonce,
887
- },
888
- });
889
- response.headers.set('Content-Security-Policy', policy);
890
- return response;
891
- }
892
- ```
893
-
894
- Authentication reads the original request. `requestHeaders` controls only what
895
- the successful Next.js render receives. Attempts to override `cookie` or
896
- `authorization` fail closed; only Najm's validated recovery path may replace
897
- the cookie after it authorizes the recovered session.
898
-
899
- Speculative/prefetch requests (`next-router-prefetch`, `purpose: prefetch`,
900
- `sec-purpose` containing `prefetch`, `next-router-state-tree` metadata-only)
901
- receive the same treatment as direct navigation: a valid signed snapshot or
902
- non-rotating `/session/recover` validation is still required. Refresh-cookie
903
- presence is never authorization, so do not bypass `auth.proxy` when a refresh
904
- cookie is present on a prefetch. Recovery never rotates refresh tokens.
905
-
906
- ```typescript
907
- // src/app/api/[...route]/route.ts
908
- import { handle } from 'najm-core';
909
- import server from '@app/server';
910
-
911
- import { auth } from '../../../lib/auth';
912
-
913
- const handlers = auth.routeHandlers(handle(server));
914
- export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = handlers;
915
- ```
916
-
917
- `auth.routeHandlers()` applies the remember-me lifecycle to login, refresh,
918
- credential setup, and logout for every supported Next.js verb. It automatically
919
- uses the refresh and signed-session cookie names from `defineAuth()`; an app only
920
- passes an option when it intentionally customizes behavior, such as
921
- `{ rememberCookieName: 'school.remember' }`.
922
-
923
- ### Why `session.ts` exists
924
-
925
- A Next.js page is not one function. The root layout, each nested layout, and the
926
- page render separately, and each one that asks for the session pays for its own
927
- cookie verification and possibly its own recovery round trip. React's `cache()`
928
- collapses those into one — but only for callers that go through the *same*
929
- memoized function, which means the application has to own one module that
930
- creates it. `session.ts` is that module and nothing else; strictness, redirect
931
- targets, role fallback, and error classification all stay in the package.
932
-
933
- ```tsx
934
- // Root layout, nested layout, and page: one resolution between them.
935
- const session = await serverAuth.getSession(); // null when anonymous
936
- const session = await serverAuth.requireSession(); // redirects to loginRoute
937
- const session = await serverAuth.requireRole(['admin', 'operator']);
938
- ```
939
-
940
- - `requireSession()` redirects to `loginRoute` when the visitor is missing,
941
- invalid, or revoked. An unreachable recovery endpoint or an unset session
942
- secret is an operational fault, not an anonymous visitor: those stay visible
943
- errors instead of becoming a login redirect that hides the outage.
944
- - `requireRole()` redirects to `forbiddenRoute`, never to login — the visitor is
945
- already authenticated, so signing in again cannot change the answer.
946
- - `session.roles` is authoritative when present, with `user.role` as the
947
- single-role fallback.
948
-
949
- ### Scope and limits
950
-
951
- - **React Server Components only.** Route handlers, server actions, proxy/Edge
952
- code, and scripts keep using `auth.getSession()`, `auth.requireSession()`, and
953
- `auth.requireRole()`. Outside a render there is no request cache for `cache()`
954
- to write to, so the adapter would resolve the session again on every call.
955
- - **Call the factory once, at module scope.** Calling it inside a layout, page,
956
- or component builds a fresh memoized resolver per call and shares nothing.
957
- - **The snapshot is stable for one render.** Code that mutates authentication
958
- must redirect or refresh into a new render to observe the result.
959
- - **Requests never share.** The cache is React's per-request cache — no module
960
- map, no global, no Redis, no `unstable_cache`, no `"use cache"`.
961
- - **Requires React 18.3 or newer** (the first version exporting `cache()`); the
962
- factory throws a named error on older versions. The subpath is opt-in, so
963
- non-React consumers of `najm-auth` are unaffected. Importing it from a Client
964
- Component or the Edge runtime fails at build time.
965
-
966
- ### `auth.ts` and `session.ts` cannot be merged
967
-
968
- Two files looks like one too many until you try it. Both directions fail, for
969
- the same reason in mirror image:
970
-
971
- | Module | Must be reachable from | Must never be reachable from |
972
- |---|---|---|
973
- | the `defineAuth()` module | browser, Edge, server | — |
974
- | the `createReactServerAuth()` module | server only | browser, Edge |
975
-
976
- `auth.client` and `auth.api` are what Client Components call, and
977
- `auth.proxy` is what the Edge proxy calls, so the `defineAuth()` module is
978
- always in the browser and Edge graphs. The adapter must never be. Putting both
979
- in one file puts the adapter everywhere `auth` already is, and the `browser`
980
- export condition — which exists precisely to catch this — resolves to a module
981
- that throws:
982
-
983
- ```text
984
- The export createReactServerAuth was not found in module
985
- …/najm-auth/dist/client/server/reactClientGuard.js [app-client]
986
-
987
- Import traces:
988
- Middleware: ./src/lib/auth.ts → ./src/proxy.ts
989
- Client Component Browser: ./src/lib/auth.ts → … → ./src/app/dashboard/page.tsx
990
- Client Component SSR: ./src/lib/auth.ts → … → ./src/app/dashboard/page.tsx
991
- Server Component: ./src/lib/auth.ts → ./src/lib/session.ts → layout.tsx
992
- ```
993
-
994
- Renaming the files changes nothing; there simply have to be two. This is a
995
- property of the runtime boundary, not of the package.
996
-
997
- ### Protected trees must opt out of prerendering
998
-
999
- `requireSession()` reads a per-request cookie. A route Next.js tries to
1000
- prerender has no request, so the read fails and the guard reports a
1001
- configuration error — correct behavior, wrong context. Mark the protected
1002
- segment dynamic:
1003
-
1004
- ```tsx
1005
- // src/app/(dashboard)/layout.tsx
1006
- export const dynamic = 'force-dynamic';
1007
-
1008
- export default async function DashboardLayout({ children }) {
1009
- await serverAuth.requireSession();
1010
- return <Shell>{children}</Shell>;
1011
- }
1012
- ```
1013
-
1014
- `getSession()` needs no such opt-out — it returns `null` rather than throwing,
1015
- so a prerendered public page renders anonymous. Do not "fix" a prerender failure
1016
- by wrapping a strict guard in `.catch(() => null)`; that turns a real outage
1017
- into a silently anonymous page.
1018
-
1019
- ### What the app owns, what the package owns
1020
-
1021
- | App, via `defineAuth()` | Package |
1022
- |---|---|
1023
- | `loginRoute`, `forbiddenRoute`, route matchers, `roleRoutes` | when to redirect where |
1024
- | cookie names, `apiBaseURL`, `authPrefix`, recovery URL | request memoization |
1025
- | `refreshThreshold`, `tabSync`, `proxySessionMode` | strict vs optional semantics |
1026
- | — | `session.roles` / `user.role` fallback |
1027
- | — | error classification |
1028
-
1029
- If a new app has to copy anything beyond the four files above, that logic
1030
- belongs in the package instead.
1031
-
1032
- `proxySessionMode: 'optimistic'` is the default and locally verifies the signed
1033
- snapshot, matching Next.js guidance that Proxy is an optimistic routing boundary.
1034
- Use `'authoritative'` only when every protected navigation must also validate
1035
- refresh-session state. The older `verifyAlways` option and `auth.middleware`
1036
- property remain as deprecated compatibility aliases.
1037
-
1038
- ### What a new app must prove
1039
-
1040
- At its real Next.js production boundary, not with mocks:
1041
-
1042
- - two concurrent renders never observe each other's session;
1043
- - root layout, nested layout, and page resolve once per render — measurable by
1044
- counting recovery round trips;
1045
- - anonymous navigation to a protected route redirects to `loginRoute`;
1046
- - an authenticated role mismatch reaches `forbiddenRoute` without a login loop;
1047
- - an unset session secret or an unreachable recovery endpoint stays a visible
1048
- failure rather than a login redirect;
1049
- - the Edge/proxy bundle builds without pulling in React.
1050
-
1051
- ---
1052
-
1053
- ## TypeScript Types
1054
-
1055
- ```typescript
1056
- import type {
1057
- AuthUser, // { id, email, name?, role?, permissions? }
1058
- TokenPair, // { accessToken, refreshToken, expiresAt? }
1059
- JwtPayload, // { userId, jti, exp?, iat? }
1060
- AuthConfig, // Full resolved config
1061
- AuthPluginConfig, // User-facing config
1062
- } from 'najm-auth';
1063
- ```
1064
-
1065
- ---
1066
-
1067
- ## Error Handling
1068
-
1069
- All errors are i18n-based. Error messages are automatically localized.
1070
-
1071
- ### Common Error Codes
1072
-
1073
- | HTTP | Scenario |
1074
- |------|----------|
1075
- | 400 | Invalid input (bad email format, weak password) |
1076
- | 401 | Missing or invalid authentication (bad token, no header) |
1077
- | 403 | Forbidden (lacks required role/permission) |
1078
- | 409 | Conflict (email already registered) |
1079
- | 429 | Rate limited (too many requests) |
1080
- | 500 | Server error (email send failure, DB error) |
1081
-
1082
- ### Examples
1083
-
1084
- ```typescript
1085
- // Invalid credentials
1086
- throw new HttpError(401, 'Invalid email or password');
1087
-
1088
- // User already exists
1089
- throw new HttpError(409, 'Email already registered');
1090
-
1091
- // Insufficient permissions
1092
- throw new HttpError(403, 'Insufficient permissions for this action');
1093
- ```
1094
-
1095
- ---
1096
-
1097
- ## Security Considerations
1098
-
1099
- ### Security Defaults
1100
-
1101
- - JWT access and refresh secrets are required and must pass minimum strength
1102
- checks.
1103
- - Refresh tokens rotate by session family and suspected family compromise does
1104
- not revoke unrelated user sessions.
1105
- - Password reset and password change revoke existing user sessions.
1106
- - Login uses a dummy password hash for missing users to reduce timing leaks.
1107
- - Forgot-password responses avoid email enumeration.
1108
- - Auth routes register `najm-rate` and ship route-level brute-force limits.
1109
- - Session cookies are signed, short-lived, and bound to their refresh-token
1110
- family; server auth resolution checks both the session version and positive
1111
- family liveness.
1112
- - Expired signed sessions recover through authoritative, non-rotating refresh
1113
- validation; middleware verifies the reissued HMAC before using its claims.
1114
- - Server-side recovery sends only the configured refresh cookie and accepts
1115
- relative or exact same-origin endpoints. URL credentials and any
1116
- scheme/hostname/port change are rejected before the network request.
1117
- - Self-hosted apps may explicitly use a loopback-only `internalRecoveryURL`
1118
- when their public reverse-proxy origin is not reachable from the app process.
1119
- - `onRecoveryFailure` exposes structured, secret-free recovery diagnostics
1120
- without logging anything by default.
1121
- - `proxySessionMode: 'authoritative'` forces that check on every protected
1122
- request; the default `'optimistic'` mode bounds cached role/status staleness
1123
- to `session.maxAge`. The deprecated `verifyAlways` flag maps to the same
1124
- behavior for existing applications.
1125
-
1126
- ### Next.js 16 Reverse-Proxy Recovery
1127
-
1128
- When a self-hosted Next.js proxy cannot safely call its own public
1129
- reverse-proxy origin while handling that same request, configure the exact
1130
- loopback recovery endpoint:
1131
-
1132
- ```env
1133
- NAJM_AUTH_INTERNAL_URL=http://127.0.0.1:3000/api/auth/session/recover
1134
- ```
1135
-
1136
- `defineAuth()` reads this environment variable automatically. An explicit
1137
- `internalRecoveryURL` option takes precedence. The internal URL must use HTTP
1138
- or HTTPS, contain no URL credentials, and resolve to `localhost`, `127.0.0.1`,
1139
- or `::1`; Najm never guesses a loopback endpoint. Relative and exact
1140
- same-origin `recoveryURL` values remain supported.
1141
-
1142
- The recovery request forwards only the configured refresh cookie, requires
1143
- `X-Najm-Session-Recovery: 1`, never rotates the refresh token, HMAC-verifies
1144
- the returned session cookie, and fails closed. `onRecoveryFailure` receives
1145
- only a structured reason and bounded, sanitized fetch-error metadata; callback
1146
- errors cannot change the authentication result.
1147
-
1148
- ### Password Reset Tokens
1149
-
1150
- Reset and invite links are signed JWTs whose `jti` is stored in the configured
1151
- cache with the same expiry. `consumeSetPasswordToken()` atomically compares and
1152
- deletes that value while preserving whether the token was a reset or invite;
1153
- the backward-compatible `verifyResetToken()` returns only the user id. Exactly
1154
- one concurrent caller can consume a link, and a stale link cannot delete the
1155
- value for a newer one.
1156
-
1157
- `AuthService.resetPassword()` validates the replacement password before
1158
- consumption. Once consumed, a token stays consumed even if the later user
1159
- mutation fails; restoring it would make the link replayable, so the user must
1160
- request a new one.
1161
-
1162
- Accepting an account invitation also marks the destination email verified and
1163
- activates the account when its status is `pending`. An ordinary password reset
1164
- changes neither verification nor lifecycle status, and an explicitly inactive
1165
- invited account remains inactive.
1166
-
1167
- Set `appName` on `auth()` to brand the invitation subject and email card. The
1168
- provisioned role is presented as the account type, so a sponsor invitation can
1169
- say “Activate your sponsor account” without application-owned HTML. The shared
1170
- template uses inline critical styles for Gmail and keeps the raw token URL out
1171
- of visible fallback copy.
1172
-
1173
- For a branded mark that works in email clients without a public asset URL, set
1174
- `accountInviteLogo` to base64 content plus its MIME type and filename. Najm
1175
- attaches it inline and points the shared template at a stable CID; when omitted,
1176
- the template renders `appName` as text.
1177
-
1178
- The built-in memory and Redis drivers implement the required atomic primitive.
1179
- A custom cache driver may omit `compareAndDelete()` for compatibility with
1180
- unrelated cache usage, but reset and invite consumption then fails closed. Do
1181
- not emulate this operation with separate `get()` and `del()` calls.
1182
-
1183
- ### Purpose-Bound Credential Setup
1184
-
1185
- Use `CredentialSetupService` when valid credentials should open only a
1186
- short-lived setup flow, not a complete application session. The default auth
1187
- schema includes the durable `credential_setup_sessions` table for PostgreSQL
1188
- and SQLite; generate and apply a consumer migration after upgrading.
1189
-
1190
- ```typescript
1191
- import { AuthService, CredentialSetupService } from 'najm-auth';
1192
-
1193
- const options = {
1194
- purpose: 'password-setup',
1195
- cookieName: 'my-app.password-setup',
1196
- ttlMs: 10 * 60 * 1000,
1197
- };
1198
-
1199
- // Verify the password without minting access/refresh tokens.
1200
- const user = await authService.verifyCredentials({ identifier, password });
1201
-
1202
- // Or narrowly accept only an unverified pending account with one exact role.
1203
- const pendingSponsor = await authService.verifyPendingCredentials(
1204
- { identifier, password },
1205
- 'sponsor',
1206
- );
1207
-
1208
- if (await appRequiresPasswordSetup(user.id)) {
1209
- // Revokes normal sessions and writes only an HttpOnly, SameSite=Strict,
1210
- // browser-session cookie. The database stores only its SHA-256 hash.
1211
- return credentialSetup.begin(user.id, options);
1212
- }
1213
-
1214
- return authService.establishSession(user);
1215
-
1216
- // Complete an app-owned mutation in the same transaction as one-time
1217
- // consumption. If the callback fails, token consumption rolls back.
1218
- await credentialSetup.consume(options, async ({ userId }) => {
1219
- await replaceApplicationCredential(userId, newCredential);
1220
- });
1221
- ```
1222
-
1223
- Setup tokens are bound to a server-owned purpose, expire automatically, are
1224
- replaced when the same user starts that purpose again, and can be cancelled or
1225
- consumed exactly once. `require()` validates the current setup cookie without
1226
- consuming it; `cancel()` revokes it and clears the cookie.
1227
-
1228
- ### Session Management
1229
-
1230
- - 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
1231
- - Revocation changes the refresh row to a durable `revoked` tombstone until its original expiry. Active-session reads and rotations require `status = active`, so losing Redis cannot revive a logged-out database session; expired tombstones are removed by normal session cleanup
1232
- - A stale refresh token presented after the 120-second rotation grace window revokes only that session's family as reuse protection
1233
- - The signed session cookie is accepted on the fast path only while Redis positively identifies its family as live and owned by the same user; an unknown cache state falls back to authoritative refresh-row recovery
1234
- - Use `@RateLimit` on logout for DDoS protection
1235
-
1236
- ### Token Blacklist
1237
-
1238
- - Built-in cache-based blacklist for immediate revocation
1239
- - Supports Redis via `cache()` plugin configuration
1240
- - Default: in-memory store (development/single-process only; entries are lost on restart)
1241
- - Use Redis in production when immediate revocation must survive restarts or propagate across instances
1242
- - Session-version revocation keys are cache-backed and TTL-bound to active access tokens
1243
-
1244
- ### Timing Attack Prevention
1245
-
1246
- - Dummy hash used for missing users in login
1247
- - Constant-time password comparison
1248
- - Same response for forgot-password (prevents email enumeration)
1249
-
1250
- ---
1251
-
1252
- ## Testing
1253
-
1254
- ```bash
1255
- bun run test # Run all tests
1256
- bun run test:auth # Run auth tests only
1257
- bun run --cwd packages/najm-auth test:real-infra # Opt-in PostgreSQL + Redis races
1258
- bun packages/najm-auth/integration/mailpit-forgot-password/run.ts # Loopback Redis + Mailpit HTTP acceptance
1259
- ```
1260
-
1261
- The real-infrastructure suite runs only with `NAJM_AUTH_REAL_INFRA=1`. Supply
1262
- loopback-only `NAJM_AUTH_REAL_POSTGRES_URL` and `NAJM_AUTH_REAL_REDIS_URL` (or
1263
- the conventional `DATABASE_URL` and `REDIS_URL`). It creates and drops its own
1264
- randomly named PostgreSQL database and cleans only its unique Redis key prefix;
1265
- remote endpoints fail before either service is touched.
1266
-
1267
- The Mailpit acceptance runner requires Redis on `127.0.0.1:6399`, Mailpit SMTP
1268
- on `127.0.0.1:1025`, and the Mailpit API on `127.0.0.1:8025` by default. It
1269
- boots the real auth plugin over HTTP with an ephemeral SQLite fixture, proves
1270
- ignored fields and spoofed forwarding headers cannot buy more reset emails,
1271
- and removes only its run-specific messages and Redis keys. The three endpoints
1272
- can be changed with the `NAJM_AUTH_MAILPIT_*` variables, but non-loopback
1273
- values fail before the fixture is created.
1274
-
1275
- Test files include:
1276
- - `schema.test.ts` — Schema exports validation
1277
- - `auth.test.ts` — Authentication flow
1278
- - `user.test.ts` — User CRUD
1279
- - `role.test.ts` — Role management
1280
- - `permission.test.ts` — Permission guards
1281
- - `guards.test.ts` — Guard composability
1282
- - `ownership.test.ts` — Row-level scoping
1283
- - `integration.test.ts` — Multi-role scenarios
1284
-
1285
- ---
1286
-
1287
- ## Production Checklist
1288
-
1289
- - ✅ Use strong JWT secrets (32+ chars, generated with `openssl rand -base64 32`)
1290
- - ✅ Set `FRONTEND_URL` environment variable
1291
- - ✅ Enable HTTPS in production
1292
- - ✅ Store secrets in environment variables (never in code)
1293
- - ✅ Use Redis for token blacklist/session-version revocation in production and distributed systems
1294
- - ✅ Trust forwarded IP headers only behind a known proxy; otherwise provide a custom rate-limit key generator
1295
- - ✅ Login/register rate keys hash normalized email or international-phone identifiers; passwords and request bodies never appear in cache keys
1296
- - ✅ Enable rate limiting on all auth routes
1297
- - ✅ Log authentication events for audit trails
1298
- - ✅ Test ownership scoping rules with multi-user scenarios
1299
- - ✅ Run full test suite before deploying
1300
-
1301
- ---
1302
-
1303
- ## Migration Guide
1304
-
1305
- ### From v1.0 to v1.1
1306
-
1307
- - `FRONTEND_URL` now part of `AuthPluginConfig` (falls back to env var)
1308
- - New: Rate limiting on `/auth/logout` and `/auth/me`
1309
- - New: `configureOwnership()` for advanced scoping
1310
- - New: `@Policy` and `@Owned` decorators
1311
-
1312
- ---
1313
-
1314
- ## Support & Contributing
1315
-
1316
- 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
+ - ✅ Forced first-login credential setup for provisioned accounts
13
+ - ✅ Country identity presets so `06…` and `+2126…` resolve to one account
14
+ - ✅ Multi-dialect support (PostgreSQL, SQLite)
15
+ - ✅ Type-safe decorators with TypeScript
16
+ - ✅ Rate limiting on auth endpoints
17
+ - ✅ Internationalization (i18n) for all messages
18
+ - ✅ Google OpenID Connect sign-in with PKCE and explicit account linking
19
+
20
+ ---
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ bun add najm-auth
26
+ # Peer dependencies
27
+ bun add hono drizzle-orm reflect-metadata
28
+ ```
29
+
30
+ ---
31
+
32
+ ## Quick Setup
33
+
34
+ ### 1. Initialize Database
35
+
36
+ ```typescript
37
+ // src/database/schema.ts
38
+ import { authSchema } from 'najm-auth';
39
+ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
40
+
41
+ // Your app tables
42
+ export const products = sqliteTable('products', {
43
+ id: text('id').primaryKey(),
44
+ name: text('name').notNull(),
45
+ userId: text('userId').notNull(),
46
+ });
47
+
48
+ // Combined schema (always include authSchema)
49
+ export const schema = {
50
+ ...authSchema, // includes users, tokens, and credentialSetupSessions
51
+ products,
52
+ };
53
+
54
+ // src/database/index.ts
55
+ import { drizzle } from 'drizzle-orm/bun-sqlite';
56
+ import { Database } from 'bun:sqlite';
57
+ import { schema } from './schema';
58
+
59
+ const sqlite = new Database('./app.db');
60
+ export const db = drizzle(sqlite, { schema });
61
+ ```
62
+
63
+ ### 2. Configure Auth Plugin
64
+
65
+ ```typescript
66
+ // src/main.ts
67
+ import 'reflect-metadata';
68
+ import { Server } from 'najm-core';
69
+ import { database } from 'najm-database';
70
+ import { auth } from 'najm-auth';
71
+ import { db } from './database';
72
+
73
+ const server = new Server()
74
+ .use(database({ default: db })) // Required: database must be registered first
75
+ .use(auth({
76
+ dialect: 'sqlite', // Auto-selects SQLite schema
77
+ jwt: {
78
+ accessSecret: process.env.JWT_ACCESS_SECRET!, // Required
79
+ refreshSecret: process.env.JWT_REFRESH_SECRET!, // Required
80
+ accessExpiresIn: '15m', // Optional, default: 1h
81
+ refreshExpiresIn: '7d', // Optional, default: 7d
82
+ },
83
+ frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000', // For password reset links
84
+ }))
85
+ .load(/* your controllers and services */)
86
+ .listen(3000);
87
+ ```
88
+
89
+ ### 3. Set Environment Variables
90
+
91
+ ```bash
92
+ # .env
93
+ JWT_ACCESS_SECRET=<32-character-minimum-secret>
94
+ JWT_REFRESH_SECRET=<32-character-minimum-secret>
95
+ FRONTEND_URL=https://app.example.com
96
+ # Optional Google sign-in
97
+ GOOGLE_CLIENT_ID=<google-web-client-id>
98
+ GOOGLE_CLIENT_SECRET=<google-web-client-secret>
99
+ # Optional for a split frontend/API deployment. Otherwise FRONTEND_URL is used.
100
+ GOOGLE_CALLBACK_URL=https://app.example.com/api/auth/oauth/google/callback
101
+ # Optional GitHub sign-in
102
+ GITHUB_CLIENT_ID=<github-oauth-app-client-id>
103
+ GITHUB_CLIENT_SECRET=<github-oauth-app-client-secret>
104
+ GITHUB_CALLBACK_URL=https://app.example.com/api/auth/oauth/github/callback
105
+ ```
106
+
107
+ > ⚠️ **Security:** Generate secrets with `openssl rand -base64 32`
108
+
109
+ ---
110
+
111
+ ## Configuration Reference
112
+
113
+ ### AuthPluginConfig
114
+
115
+ ```typescript
116
+ auth({
117
+ // Database
118
+ dialect?: 'pg' | 'sqlite' // Default: 'pg' (RETURNING-capable engines only)
119
+ schema?: AuthSchema // Override dialect schema
120
+
121
+ // JWT
122
+ jwt?: {
123
+ accessSecret: string // Required, min 32 chars
124
+ accessExpiresIn?: string // Default: 1h
125
+ refreshSecret: string // Required, min 32 chars
126
+ refreshExpiresIn?: string // Default: 7d
127
+ }
128
+
129
+ // Cookies
130
+ refreshCookieName?: string // Default: 'refreshToken'
131
+
132
+ // Database
133
+ database?: string // Default: 'default'
134
+ blacklistPrefix?: string // Default: 'auth:blacklist:'
135
+
136
+ // Registration
137
+ defaultRole?: string | null // Auto-assign role to new users
138
+ publicRegistration?: boolean // Default: true; mounts POST /auth/register
139
+ bcryptRounds?: number // Default: 10 (valid: 4-31)
140
+
141
+ // Frontend
142
+ frontendUrl?: string // Password reset link base URL
143
+ appName?: string // Security email brand (default: 'Your app')
144
+ accountInviteLogo?: { // Optional CID-backed inline mark
145
+ alt?: string
146
+ contentBase64: string
147
+ contentType: string
148
+ filename: string
149
+ }
150
+
151
+ // Login identifier normalization (see "Identity presets")
152
+ identity?: {
153
+ preset?: 'ma' | 'tn' | IdentityPreset | null // Default: 'ma'
154
+ extend?: IdentityNormalizer[] // Runs before the preset
155
+ }
156
+
157
+ // Credential setup policy overrides (the flow itself is always on)
158
+ credentialSetup?: {
159
+ password?: {
160
+ passwordSchema?: ZodType<string> // Default: 8-72 bytes, a letter and a digit
161
+ ttlMs?: number // Default: 600000 (10 minutes)
162
+ cookieName?: string // Default: 'najm.credential-setup'
163
+ }
164
+ }
165
+
166
+ // Optional external identity providers
167
+ oauth?: {
168
+ google?: true | {
169
+ clientId?: string // Or GOOGLE_CLIENT_ID
170
+ clientSecret?: string // Or GOOGLE_CLIENT_SECRET
171
+ callbackUrl?: string // Or GOOGLE_CALLBACK_URL; otherwise frontendUrl + /api/auth/oauth/google/callback
172
+ frontendCallbackPath?: string // Default: /auth/oauth/callback
173
+ errorRedirectPath?: string // Default: /login
174
+ allowSignup?: boolean // Default: true
175
+ autoLinkVerifiedEmail?: boolean // Default: false
176
+ allowedHostedDomains?: string[] // Validates the Google hd claim
177
+ }
178
+ github?: true | {
179
+ clientId?: string // Or GITHUB_CLIENT_ID
180
+ clientSecret?: string // Or GITHUB_CLIENT_SECRET
181
+ callbackUrl?: string // Or GITHUB_CALLBACK_URL
182
+ frontendCallbackPath?: string // Default: /auth/oauth/callback
183
+ errorRedirectPath?: string // Default: /login
184
+ allowSignup?: boolean // Default: true
185
+ autoLinkVerifiedEmail?: boolean // Default: false
186
+ }
187
+ }
188
+
189
+ // Dependencies (forwarded to plugins)
190
+ validation?: ValidationPluginConfig
191
+ rateLimit?: RateLimitPluginConfig
192
+ })
193
+ ```
194
+
195
+ ---
196
+
197
+ ## Auto-Registered Routes
198
+
199
+ All routes are prefixed with `/auth` and auto-registered by the plugin.
200
+
201
+ ### Authentication Routes
202
+
203
+ | Method | Path | Description | Auth |
204
+ |--------|------|-------------|------|
205
+ | `POST` | `/auth/register` | Register new user (omitted when `publicRegistration: false`) | None |
206
+ | `POST` | `/auth/login` | Login with email/password | None |
207
+ | `POST` | `/auth/refresh` | Refresh access token (cookie) | None (uses refresh cookie) |
208
+ | `POST` | `/auth/session/recover` | Reissue signed session without token rotation | Refresh cookie + recovery header |
209
+ | `POST` | `/auth/logout` | Logout and revoke tokens | ✅ Required |
210
+ | `GET` | `/auth/me` | Get current user profile | ✅ Required |
211
+ | `POST` | `/auth/forgot-password` | Request password reset | None |
212
+ | `POST` | `/auth/reset-password` | Confirm password reset | None |
213
+ | `GET` | `/auth/oauth/google/start` | Start Google sign-in | None |
214
+ | `GET` | `/auth/oauth/google/callback` | Verify Google callback and create Najm session | None |
215
+ | `POST` | `/auth/oauth/google/link` | Link Google to the current user | ✅ Required |
216
+ | `GET` | `/auth/credential-setup/setup` | Read the pending setup session | Setup cookie |
217
+ | `POST` | `/auth/credential-setup/change` | Replace the temporary credential | Setup cookie |
218
+ | `POST` | `/auth/credential-setup/cancel` | Abandon the setup session | Setup cookie |
219
+
220
+ Applications with an approval-owned onboarding flow should set
221
+ `publicRegistration: false`. This removes the unauthenticated route while
222
+ retaining `AuthService.registerUser()`, `provisionUser()`, and other internal
223
+ account-management APIs for trusted application services.
224
+
225
+ ### Identity presets
226
+
227
+ Login lookup, lockout accounting, and rate-limit bucketing all normalize the
228
+ submitted identifier the same way. The pipeline is: email (lowercased) →
229
+ project extensions → the country preset → generic E.164.
230
+
231
+ The resolved pipeline belongs to the specific `auth()` plugin/server instance.
232
+ Multiple isolated Najm servers can therefore use different country presets in
233
+ one process without replacing each other's login or rate-limit behavior.
234
+
235
+ Morocco is the default, so `0612345678`, `212612345678`, and `+212612345678`
236
+ all resolve to `+212612345678` with no configuration.
237
+
238
+ ```typescript
239
+ auth(); // preset: 'ma'
240
+ auth({ identity: { extend: [employeeNumberNormalizer] } });
241
+ auth({ identity: { preset: 'tn' } }); // replaces Morocco
242
+ auth({ identity: { preset: null, extend: [custom] } }); // generic only
243
+ ```
244
+
245
+ Local numbers are country-ambiguous, so presets **replace** each other rather
246
+ than stacking — two presets claiming `06…` would resolve one raw input to two
247
+ different accounts.
248
+
249
+ ### First-login credential setup
250
+
251
+ Provision an account with a temporary credential and Najm refuses it a normal
252
+ session until the holder replaces it:
253
+
254
+ ```typescript
255
+ import { moroccanCinTemporaryCredential } from 'najm-auth/identity/ma';
256
+
257
+ await authService.provisionUser({
258
+ email: guardian.email,
259
+ phone: guardian.phone,
260
+ role: 'family',
261
+ temporaryCredential: moroccanCinTemporaryCredential(guardian.cin),
262
+ requireCredentialSetup: 'password',
263
+ });
264
+ ```
265
+
266
+ `temporaryCredential` also accepts a plain string, compared exactly and
267
+ case-sensitively — enough for a student or registration number:
268
+
269
+ ```typescript
270
+ await authService.provisionUser({
271
+ email: student.schoolEmail,
272
+ role: 'student',
273
+ temporaryCredential: student.registrationNumber,
274
+ requireCredentialSetup: 'password',
275
+ });
276
+ ```
277
+
278
+ Supplying both `password` and `temporaryCredential` is rejected, so an account
279
+ can never hold a permanent password that something also treats as temporary.
280
+ Typed helpers such as `moroccanCinTemporaryCredential()` validate their value,
281
+ and every temporary credential remains limited to bcrypt's 72-byte boundary.
282
+
283
+ Login then answers a discriminated result instead of a token pair:
284
+
285
+ ```typescript
286
+ const result = await auth.client.login({ identifier, password, rememberMe });
287
+
288
+ if (result.nextStep === 'credential_setup') {
289
+ router.push('/change-password'); // no tokens were issued
290
+ } else {
291
+ router.push('/dashboard');
292
+ }
293
+ ```
294
+
295
+ The requirement is enforced at every session-establishment path — password
296
+ login, `AuthSessionService.establish()`, Google OAuth (which redirects with
297
+ `oauthError=oauth_credential_setup_required`), refresh, and signed-session
298
+ recovery — so verified-email OAuth linking cannot skip it. Marking a new
299
+ requirement also revokes the user's current sessions.
300
+
301
+ `withAuthCookiePersistence` recognizes logout and setup boundaries on its own.
302
+ After a successful logout it drops stale auth-cookie issuances and guarantees
303
+ exactly one deletion for each configured auth cookie. It preserves a valid
304
+ upstream deletion (including a custom cookie path), or synthesizes a canonical
305
+ deletion when one is missing. A setup response gets the same auth-cookie
306
+ deletions, clears the remembered preference, and leaves the opaque setup cookie
307
+ alone.
308
+
309
+ ### Google Sign-In
310
+
311
+ Google sign-in uses the server-side OpenID Connect authorization-code flow.
312
+ Najm creates state, nonce, and PKCE values, verifies Google's signed ID token,
313
+ then issues the same Najm JWT, refresh token, and session cookie as password
314
+ login. Google tokens are discarded and are never stored.
315
+
316
+ ```ts
317
+ auth({
318
+ dialect: 'pg',
319
+ frontendUrl: 'https://app.example.com',
320
+ oauth: { google: true },
321
+ })
322
+ ```
323
+
324
+ With `google: true`, credentials come from `GOOGLE_CLIENT_ID` and
325
+ `GOOGLE_CLIENT_SECRET`; the callback defaults to
326
+ `${FRONTEND_URL}/api/auth/oauth/google/callback`. Register that value exactly
327
+ as an authorized redirect URI in Google Cloud. Set `GOOGLE_CALLBACK_URL` or
328
+ `google: { callbackUrl: '...' }` when the API runs on a different origin.
329
+ Production callback URLs must use HTTPS; HTTP is accepted only for localhost.
330
+
331
+ Mount the browser completion route configured by `frontendCallbackPath`:
332
+
333
+ ```tsx
334
+ 'use client';
335
+
336
+ import { OAuthCallback } from 'najm-auth/client/react';
337
+
338
+ export default function OAuthCallbackPage() {
339
+ return <OAuthCallback fallback={<p>Finishing sign-in...</p>} />;
340
+ }
341
+ ```
342
+
343
+ Then use the headless button anywhere below `AuthProvider`:
344
+
345
+ ```tsx
346
+ import { GoogleLoginButton } from 'najm-auth/client/react';
347
+
348
+ <GoogleLoginButton returnTo="/dashboard">
349
+ <button type="button">Continue with Google</button>
350
+ </GoogleLoginButton>
351
+ ```
352
+
353
+ Google accounts are keyed by Google's stable `sub` claim. If an existing Najm
354
+ user has the same email but is not linked, sign-in fails with
355
+ `oauth_account_link_required` by default. After password login, call
356
+ `client.linkOAuthAccount('google')` to prove control of both accounts. Setting
357
+ `autoLinkVerifiedEmail: true` opts into verified-email linking.
358
+
359
+ ### GitHub Sign-In
360
+
361
+ Enable GitHub with `oauth: { github: true }`. Credentials come from
362
+ `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`; the callback defaults to
363
+ `${FRONTEND_URL}/api/auth/oauth/github/callback`. Register that exact callback
364
+ on the GitHub OAuth App, and set `GITHUB_CALLBACK_URL` only for a split-origin
365
+ deployment. GitHub login uses authorization code plus PKCE, requests
366
+ `user:email`, requires a verified primary email, and keys the durable provider
367
+ link by GitHub's numeric user ID.
368
+
369
+ The client exposes `loginWithGitHub()`, `useGitHubLogin()`, and the headless
370
+ `GitHubLoginButton`; the generic `linkOAuthAccount('github')` method links an
371
+ authenticated Najm user.
372
+
373
+ ### Admin Routes (all require `@isAdmin()`)
374
+
375
+ | Method | Path | Description |
376
+ |--------|------|-------------|
377
+ | `GET` | `/users?limit=50&offset=0` | List users (limit 1-100) |
378
+ | `GET` | `/users/:id` | Get user by ID |
379
+ | `POST` | `/users` | Create new user |
380
+ | `PUT` | `/users/:id` | Update user |
381
+ | `DELETE` | `/users/:id` | Delete user |
382
+ | `GET` | `/roles` | List all roles |
383
+ | `GET` | `/roles/:id` | Get role by ID |
384
+ | `POST` | `/roles` | Create new role |
385
+ | `PUT` | `/roles/:id` | Update role |
386
+ | `DELETE` | `/roles/:id` | Delete role |
387
+ | `GET` | `/permissions` | List all permissions |
388
+ | `GET` | `/permissions/:id` | Get permission by ID |
389
+ | `POST` | `/permissions` | Create new permission |
390
+ | `PUT` | `/permissions/:id` | Update permission |
391
+ | `DELETE` | `/permissions/:id` | Delete permission |
392
+ | `POST` | `/permissions/assign/:roleId/:permissionId` | Assign permission to role |
393
+ | `DELETE` | `/permissions/remove/:roleId/:permissionId` | Remove permission from role |
394
+
395
+ ---
396
+
397
+ ## Guards Reference
398
+
399
+ ### Authentication Guard
400
+
401
+ ```typescript
402
+ import { isAuth } from 'najm-auth';
403
+
404
+ @Controller('/api/posts')
405
+ class PostController {
406
+ @Get('/') // Public
407
+ getAll() { }
408
+
409
+ @Post('/')
410
+ @isAuth() // Requires valid JWT
411
+ create(@Body() data: any) { }
412
+ }
413
+ ```
414
+
415
+ ### Role Guards
416
+
417
+ ```typescript
418
+ import { defineRoles } from 'najm-auth';
419
+
420
+ const roles = defineRoles({
421
+ ADMIN: 'admin',
422
+ MODERATOR: 'moderator',
423
+ USER: 'user',
424
+ }, {
425
+ superRoles: ['ADMIN'], // admin also passes moderator/user role guards
426
+ });
427
+
428
+ export const { isAdmin, isModerator, isUser } = roles;
429
+
430
+ @Controller('/admin')
431
+ @isAdmin() // All methods require admin role
432
+ class AdminController {
433
+ @Get('/users')
434
+ getUsers() { }
435
+ }
436
+
437
+ @Controller('/api/posts')
438
+ class PostController {
439
+ @Delete('/:id')
440
+ @isModerator() // Method-level guard
441
+ deletePost() { }
442
+ }
443
+ ```
444
+
445
+ ### Permission Guards
446
+
447
+ ```typescript
448
+ import { Can, canRead, canCreate, canUpdate, canDelete } from 'najm-auth';
449
+
450
+ @Controller('/api/posts')
451
+ class PostController {
452
+ @Get('/')
453
+ @canRead('posts') // Requires 'read:posts' permission
454
+ getAll() { }
455
+
456
+ @Post('/')
457
+ @canCreate('posts') // Requires 'create:posts' permission
458
+ create(@Body() data: any) { }
459
+
460
+ @Put('/:id')
461
+ @canUpdate('posts') // Requires 'update:posts' permission
462
+ update() { }
463
+
464
+ @Delete('/:id')
465
+ @canDelete('posts') // Requires 'delete:posts' permission
466
+ delete() { }
467
+
468
+ @Post('/:id/publish')
469
+ @Can('publish:posts') // Custom permission
470
+ publish() { }
471
+ }
472
+ ```
473
+
474
+ **Permission Wildcards:**
475
+ - `*:*` — All actions on all resources
476
+ - `create:*` — Create action on any resource
477
+ - `*:posts` — Any action on posts
478
+
479
+ ### Combined Guards
480
+
481
+ ```typescript
482
+ @Controller('/admin/reports')
483
+ @isAdmin() // Require admin role
484
+ class ReportController {
485
+ @Get('/financial')
486
+ @Can('view:financial') // AND require financial view permission
487
+ getFinancial() { }
488
+ }
489
+ ```
490
+
491
+ ---
492
+
493
+ ## Ownership System
494
+
495
+ Control row-level access based on ownership (e.g., users see only their own data).
496
+
497
+ ### Declaring Ownership Rules
498
+
499
+ ```typescript
500
+ import { own, join, where } from 'najm-auth';
501
+ import { schema } from '../database/schema';
502
+
503
+ const { products, users } = schema;
504
+ const _users = alias(users, '_u');
505
+
506
+ export const Product = own(products)
507
+ .for('user',
508
+ join(products.userId, _users.id),
509
+ where(_users.id)
510
+ )
511
+ .writeBy(products.userId); // Enforce on create/update
512
+ ```
513
+
514
+ ### Using @Policy and @Owned
515
+
516
+ ```typescript
517
+ import { configureOwnership, Policy, CanList, CanRead, CanCreate, CanUpdate, CanDelete } from 'najm-auth';
518
+
519
+ const config = configureOwnership({
520
+ adminRoles: ['admin'],
521
+ rules: {
522
+ 'user': {
523
+ 'products': Product.getRules()['user']
524
+ }
525
+ }
526
+ });
527
+
528
+ @Policy(Product)
529
+ @Controller('/api/products')
530
+ export class ProductController {
531
+ @Get('/')
532
+ @CanList() // List only owned products
533
+ getAll(@GuardParams() filter: any) { }
534
+
535
+ @Get('/:id')
536
+ @CanRead() // Read only if owner
537
+ getOne() { }
538
+
539
+ @Post('/')
540
+ @CanCreate() // Create (ownership assigned automatically)
541
+ create(@Body() data: any) { }
542
+
543
+ @Put('/:id')
544
+ @CanUpdate() // Update only if owner
545
+ update(@Body() data: any) { }
546
+
547
+ @Delete('/:id')
548
+ @CanDelete() // Delete only if owner
549
+ delete() { }
550
+ }
551
+
552
+ @Repository('default')
553
+ @Owned(Product)
554
+ export class ProductRepository {
555
+ @DB() db!: Database;
556
+
557
+ // Auto-scoped to current user
558
+ async findMany(opts?: { where?: any; limit?: number }) {
559
+ return this.findMany(opts); // Only returns owned products
560
+ }
561
+
562
+ async findOne(opts: { where: any }) {
563
+ return this.findOne(opts); // Returns null if not owned
564
+ }
565
+
566
+ async scopedQuery() {
567
+ return this.scopedQuery(); // Raw scoped query builder
568
+ }
569
+ }
570
+ ```
571
+
572
+ ### Advanced Ownership: Multi-Role Scoping
573
+
574
+ ```typescript
575
+ const Grade = own(grades)
576
+ // Teachers see students' grades
577
+ .for('teacher',
578
+ join(grades.studentId, _s.id),
579
+ join(_s.id, _t.studentId),
580
+ where(_t.userId)
581
+ )
582
+ // Parents see only their child's grades
583
+ .for('parent',
584
+ join(grades.studentId, _s.id),
585
+ join(_s.id, _p.studentId),
586
+ where(_p.userId)
587
+ );
588
+ ```
589
+
590
+ ---
591
+
592
+ ## Database Schema
593
+
594
+ ### Tables
595
+
596
+ ```
597
+ users
598
+ ├── id (string, primary key)
599
+ ├── email (string, unique)
600
+ ├── password (string, hashed)
601
+ ├── emailVerified (boolean, default: false)
602
+ ├── image (string, nullable)
603
+ ├── status (enum: ACTIVE, INACTIVE)
604
+ ├── roleId (string, FK → roles.id)
605
+ ├── lastLogin (timestamp, nullable)
606
+ ├── createdAt (timestamp)
607
+ └── updatedAt (timestamp)
608
+
609
+ roles
610
+ ├── id (string, primary key)
611
+ ├── name (string, unique — `roles_name_unique` in both dialects)
612
+ ├── description (string, nullable)
613
+ ├── createdAt (timestamp)
614
+ └── updatedAt (timestamp)
615
+
616
+ permissions
617
+ ├── id (string, primary key)
618
+ ├── name (string, unique)
619
+ ├── description (string, nullable)
620
+ ├── resource (string)
621
+ ├── action (string)
622
+ ├── createdAt (timestamp)
623
+ └── updatedAt (timestamp)
624
+
625
+ tokens
626
+ ├── id (string, primary key)
627
+ ├── userId (string, FK → users.id, unique)
628
+ ├── token (string, hashed)
629
+ ├── type (enum: REFRESH, RESET)
630
+ ├── status (enum: ACTIVE, REVOKED)
631
+ ├── expiresAt (timestamp)
632
+ ├── createdAt (timestamp)
633
+ └── updatedAt (timestamp)
634
+
635
+ role_permissions
636
+ ├── id (string, primary key)
637
+ ├── roleId (string, FK → roles.id)
638
+ ├── permissionId (string, FK → permissions.id)
639
+ ├── createdAt (timestamp)
640
+ └── updatedAt (timestamp)
641
+
642
+ oauth_accounts
643
+ ├── id (string, primary key)
644
+ ├── userId (string, FK → users.id, cascade delete)
645
+ ├── provider (string; `google` in this release)
646
+ ├── providerAccountId (Google `sub`)
647
+ ├── unique(provider, providerAccountId)
648
+ └── unique(userId, provider)
649
+
650
+ credential_setup_sessions
651
+ ├── id (string, primary key)
652
+ ├── userId (string, FK → users.id, cascade delete)
653
+ ├── purpose (string)
654
+ ├── tokenHash (string, unique — SHA-256 of the browser cookie)
655
+ ├── expiresAt (timestamp)
656
+ ├── consumedAt (timestamp, nullable)
657
+ └── revokedAt (timestamp, nullable)
658
+
659
+ credential_setup_requirements
660
+ ├── userId (string, FK → users.id, cascade delete)
661
+ ├── purpose (string; `password` for the built-in flow)
662
+ ├── temporaryCredentialKind (string, nullable; `exact` or `ma-cin`)
663
+ ├── required (boolean, default: true)
664
+ ├── completedAt (timestamp, nullable)
665
+ └── primary key (userId, purpose)
666
+ ```
667
+
668
+ `credential_setup_requirements` is keyed on `(userId, purpose)` rather than
669
+ `userId` alone, so one user can owe more than one future setup purpose.
670
+
671
+ Existing databases must generate and run a migration after upgrading so the
672
+ new `oauth_accounts`, `credential_setup_sessions`, and
673
+ `credential_setup_requirements` tables exist. Custom `AuthSchema` objects may
674
+ omit `oauthAccounts` while OAuth is disabled, but provider configuration fails
675
+ fast unless the custom schema supplies it. Both credential-setup tables are
676
+ required of a custom schema, because the setup flow is always mounted.
677
+
678
+ ### ID Strategy
679
+
680
+ Uses `nanoid` with short lengths for efficient storage:
681
+ - Users: 8 characters
682
+ - Roles: 5 characters
683
+ - Permissions: 5 characters
684
+ - Tokens: 10 characters
685
+
686
+ To use UUIDs instead, customize the schema:
687
+
688
+ ```typescript
689
+ import { customAlphabet } from 'nanoid';
690
+ import { uuid } from 'uuid';
691
+
692
+ // Use UUID for larger ID space
693
+ const customUsers = sqliteTable('users', {
694
+ id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
695
+ // ...
696
+ });
697
+ ```
698
+
699
+ ---
700
+
701
+ ## Seeding
702
+
703
+ Role names are database-enforced identities. `authSeed()` reconciles the `roles`
704
+ entry **by name**, not by the primary key it proposes, so a role that already
705
+ exists under a legacy or randomly generated ID is reused rather than inserted a
706
+ second time. Downstream resolvers receive the row the database actually holds,
707
+ which keeps `users.role_id` and `role_permissions.role_id` pointing at the live
708
+ ID. Repeat seeding is idempotent and never rewrites a role's primary key —
709
+ `users.role_id` carries no `ON UPDATE CASCADE` contract.
710
+
711
+ > **Adopting this from an earlier version:** the unique index is a persistence
712
+ > invariant, not only a validation change. Consolidate any duplicate role names
713
+ > **before** you apply a migration that adds it, or the migration fails. Consumer
714
+ > migrations are application-owned; this package ships the schema declaration,
715
+ > not your migration.
716
+
717
+
718
+ ### Low-Level Seeding (authSeed)
719
+
720
+ ```typescript
721
+ import { authSeed } from 'najm-auth';
722
+ import { SeedService } from 'najm-database';
723
+
724
+ @Service()
725
+ class SetupService {
726
+ constructor(private seeder: SeedService) {}
727
+
728
+ async seed() {
729
+ const entries = authSeed({
730
+ adminEmail: 'admin@app.com',
731
+ adminPass: 'AdminPass123!',
732
+ roles: [
733
+ { name: 'editor', description: 'Can edit content' },
734
+ { name: 'viewer', description: 'Can view only' },
735
+ ],
736
+ permissions: [
737
+ { name: 'read:posts', resource: 'posts', action: 'read' },
738
+ { name: 'create:posts', resource: 'posts', action: 'create' },
739
+ ],
740
+ additionalUsers: [
741
+ { email: 'user@app.com', password: 'User123!', roleName: 'viewer' },
742
+ ]
743
+ });
744
+
745
+ await this.seeder.run(entries);
746
+ }
747
+ }
748
+ ```
749
+
750
+ ### High-Level Seeding (seedAuthData)
751
+
752
+ ```typescript
753
+ import { seedAuthData } from 'najm-auth';
754
+
755
+ await seedAuthData({
756
+ db,
757
+ adminEmail: process.env.ADMIN_EMAIL!,
758
+ adminPassword: process.env.ADMIN_PASSWORD!,
759
+ roles: [
760
+ { name: 'moderator', description: 'Content moderator' },
761
+ ],
762
+ users: [
763
+ { email: 'mod@app.com', password: 'Mod123!' , roleName: 'moderator' },
764
+ ],
765
+ verbose: true
766
+ });
767
+
768
+ // Note: Return type has empty users[] and roles[] arrays
769
+ // Query the database directly to retrieve inserted records
770
+ ```
771
+
772
+ ---
773
+
774
+ ## Rate Limiting
775
+
776
+ Auth routes have built-in rate limiting to prevent brute force attacks.
777
+ The auth plugin registers `najm-rate` as a dependency, so these decorator-level
778
+ limits are active when `auth()` is registered.
779
+
780
+ | Route | Limit | Window | Key Strategy |
781
+ |-------|-------|--------|--------------|
782
+ | `POST /auth/register` | 5 | 15 minutes | IP |
783
+ | `POST /auth/login` | 8 | 10 minutes | IP + hashed normalized identity |
784
+ | `POST /auth/refresh` | 15 | 15 minutes | Cookie fingerprint |
785
+ | `POST /auth/session/recover` | 120 | 1 minute | Cookie fingerprint |
786
+ | `POST /auth/logout` | 10 | 15 minutes | User ID |
787
+ | `GET /auth/me` | 30 | 1 minute | User ID |
788
+ | `POST /auth/forgot-password` | 3 | 15 minutes | IP |
789
+ | `POST /auth/reset-password` | 5 | 15 minutes | IP |
790
+
791
+ ### Customizing Rate Limits
792
+
793
+ The login route has strict environment overrides. Values are read when the
794
+ server imports `najm-auth`, so restart the process after changing them. Invalid
795
+ values fail startup rather than silently weakening the limiter.
796
+
797
+ ```bash
798
+ NAJM_AUTH_LOGIN_RATE_LIMIT_ENABLED=true
799
+ NAJM_AUTH_LOGIN_RATE_LIMIT=8
800
+ NAJM_AUTH_LOGIN_RATE_WINDOW=10m
801
+ ```
802
+
803
+ `NAJM_AUTH_LOGIN_RATE_LIMIT_ENABLED=false` disables only the login-route
804
+ limiter. Keep it enabled on public production deployments; a shorter window is
805
+ the safer setting for a disposable production-built demo.
806
+
807
+ The generic plugin configuration remains available for global limits and skip
808
+ rules:
809
+
810
+ ```typescript
811
+ auth({
812
+ rateLimit: {
813
+ keyGenerator: 'ip', // or 'user', 'api-key', 'user+ip'
814
+ defaultWindow: '10m',
815
+ skip: (ctx) => ctx.path === '/health' // Skip for certain routes
816
+ }
817
+ })
818
+ ```
819
+
820
+ ---
821
+
822
+ ## Next.js App Router Structure
823
+
824
+ Every App Router application keeps the same four files. Copying more than this
825
+ between apps means logic that belongs in the package has leaked into them.
826
+
827
+ ```text
828
+ src/lib/auth.ts defineAuth() configuration — browser, server, and proxy safe
829
+ src/lib/session.ts one createReactServerAuth() instance for Server Components
830
+ src/proxy.ts exports auth.proxy plus Next's required static matcher
831
+ src/app/api/[...route]/route.ts binds the server through auth.routeHandlers()
832
+ ```
833
+
834
+ ```typescript
835
+ // src/lib/auth.ts
836
+ import { defineAuth } from 'najm-auth/client/server';
837
+
838
+ export const auth = defineAuth({
839
+ apiBaseURL: '/api',
840
+ loginRoute: '/login',
841
+ forbiddenRoute: '/forbidden',
842
+ publicRoutes: ['/', '/login'],
843
+ protectedRoutes: ['/dashboard/:path*', '/admin/:path*'],
844
+ roleRoutes: { '/admin/:path*': ['admin'] },
845
+ proxySessionMode: 'optimistic',
846
+ });
847
+ ```
848
+
849
+ ```typescript
850
+ // src/lib/session.ts
851
+ import 'server-only';
852
+
853
+ import { createReactServerAuth } from 'najm-auth/client/server/react';
854
+
855
+ import { auth } from './auth';
856
+
857
+ export const serverAuth = createReactServerAuth(auth);
858
+ ```
859
+
860
+ ```typescript
861
+ // src/proxy.ts
862
+ import { auth } from './lib/auth';
863
+
864
+ export default auth.proxy;
865
+ export const config = {
866
+ matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
867
+ };
868
+ ```
869
+
870
+ Next.js 16 requires the exported Proxy `config` to be a statically analyzable
871
+ object literal. Turbopack rejects `export const config = auth.config`, so the
872
+ matcher is the one integration value that cannot be composed at runtime.
873
+
874
+ When a Proxy generates request-scoped headers for the downstream render, pass
875
+ only the overrides as the optional second argument. Najm merges them over the
876
+ incoming request and preserves them when session recovery replaces the cookie.
877
+ The application still owns any matching response header:
878
+
879
+ ```typescript
880
+ export default async function proxy(request: Request) {
881
+ const nonce = btoa(globalThis.crypto.randomUUID());
882
+ const policy = `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`;
883
+ const response = await auth.proxy(request, {
884
+ requestHeaders: {
885
+ 'content-security-policy': policy,
886
+ 'x-nonce': nonce,
887
+ },
888
+ });
889
+ response.headers.set('Content-Security-Policy', policy);
890
+ return response;
891
+ }
892
+ ```
893
+
894
+ Authentication reads the original request. `requestHeaders` controls only what
895
+ the successful Next.js render receives. Attempts to override `cookie` or
896
+ `authorization` fail closed; only Najm's validated recovery path may replace
897
+ the cookie after it authorizes the recovered session.
898
+
899
+ Speculative/prefetch requests (`next-router-prefetch`, `purpose: prefetch`,
900
+ `sec-purpose` containing `prefetch`, `next-router-state-tree` metadata-only)
901
+ receive the same treatment as direct navigation: a valid signed snapshot or
902
+ non-rotating `/session/recover` validation is still required. Refresh-cookie
903
+ presence is never authorization, so do not bypass `auth.proxy` when a refresh
904
+ cookie is present on a prefetch. Recovery never rotates refresh tokens.
905
+
906
+ ```typescript
907
+ // src/app/api/[...route]/route.ts
908
+ import { handle } from 'najm-core';
909
+ import server from '@app/server';
910
+
911
+ import { auth } from '../../../lib/auth';
912
+
913
+ const handlers = auth.routeHandlers(handle(server));
914
+ export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = handlers;
915
+ ```
916
+
917
+ `auth.routeHandlers()` applies the remember-me lifecycle to login, refresh,
918
+ credential setup, and logout for every supported Next.js verb. It automatically
919
+ uses the refresh and signed-session cookie names from `defineAuth()`; an app only
920
+ passes an option when it intentionally customizes behavior, such as
921
+ `{ rememberCookieName: 'school.remember' }`.
922
+
923
+ ### Why `session.ts` exists
924
+
925
+ A Next.js page is not one function. The root layout, each nested layout, and the
926
+ page render separately, and each one that asks for the session pays for its own
927
+ cookie verification and possibly its own recovery round trip. React's `cache()`
928
+ collapses those into one — but only for callers that go through the *same*
929
+ memoized function, which means the application has to own one module that
930
+ creates it. `session.ts` is that module and nothing else; strictness, redirect
931
+ targets, role fallback, and error classification all stay in the package.
932
+
933
+ ```tsx
934
+ // Root layout, nested layout, and page: one resolution between them.
935
+ const session = await serverAuth.getSession(); // null when anonymous
936
+ const session = await serverAuth.requireSession(); // redirects to loginRoute
937
+ const session = await serverAuth.requireRole(['admin', 'operator']);
938
+ ```
939
+
940
+ - `requireSession()` redirects to `loginRoute` when the visitor is missing,
941
+ invalid, or revoked. An unreachable recovery endpoint or an unset session
942
+ secret is an operational fault, not an anonymous visitor: those stay visible
943
+ errors instead of becoming a login redirect that hides the outage.
944
+ - `requireRole()` redirects to `forbiddenRoute`, never to login — the visitor is
945
+ already authenticated, so signing in again cannot change the answer.
946
+ - `session.roles` is authoritative when present, with `user.role` as the
947
+ single-role fallback.
948
+
949
+ ### Scope and limits
950
+
951
+ - **React Server Components only.** Route handlers, server actions, proxy/Edge
952
+ code, and scripts keep using `auth.getSession()`, `auth.requireSession()`, and
953
+ `auth.requireRole()`. Outside a render there is no request cache for `cache()`
954
+ to write to, so the adapter would resolve the session again on every call.
955
+ - **Call the factory once, at module scope.** Calling it inside a layout, page,
956
+ or component builds a fresh memoized resolver per call and shares nothing.
957
+ - **The snapshot is stable for one render.** Code that mutates authentication
958
+ must redirect or refresh into a new render to observe the result.
959
+ - **Requests never share.** The cache is React's per-request cache — no module
960
+ map, no global, no Redis, no `unstable_cache`, no `"use cache"`.
961
+ - **Requires React 18.3 or newer** (the first version exporting `cache()`); the
962
+ factory throws a named error on older versions. The subpath is opt-in, so
963
+ non-React consumers of `najm-auth` are unaffected. Importing it from a Client
964
+ Component or the Edge runtime fails at build time.
965
+
966
+ ### `auth.ts` and `session.ts` cannot be merged
967
+
968
+ Two files looks like one too many until you try it. Both directions fail, for
969
+ the same reason in mirror image:
970
+
971
+ | Module | Must be reachable from | Must never be reachable from |
972
+ |---|---|---|
973
+ | the `defineAuth()` module | browser, Edge, server | — |
974
+ | the `createReactServerAuth()` module | server only | browser, Edge |
975
+
976
+ `auth.client` and `auth.api` are what Client Components call, and
977
+ `auth.proxy` is what the Edge proxy calls, so the `defineAuth()` module is
978
+ always in the browser and Edge graphs. The adapter must never be. Putting both
979
+ in one file puts the adapter everywhere `auth` already is, and the `browser`
980
+ export condition — which exists precisely to catch this — resolves to a module
981
+ that throws:
982
+
983
+ ```text
984
+ The export createReactServerAuth was not found in module
985
+ …/najm-auth/dist/client/server/reactClientGuard.js [app-client]
986
+
987
+ Import traces:
988
+ Middleware: ./src/lib/auth.ts → ./src/proxy.ts
989
+ Client Component Browser: ./src/lib/auth.ts → … → ./src/app/dashboard/page.tsx
990
+ Client Component SSR: ./src/lib/auth.ts → … → ./src/app/dashboard/page.tsx
991
+ Server Component: ./src/lib/auth.ts → ./src/lib/session.ts → layout.tsx
992
+ ```
993
+
994
+ Renaming the files changes nothing; there simply have to be two. This is a
995
+ property of the runtime boundary, not of the package.
996
+
997
+ ### Protected trees must opt out of prerendering
998
+
999
+ `requireSession()` reads a per-request cookie. A route Next.js tries to
1000
+ prerender has no request, so the read fails and the guard reports a
1001
+ configuration error — correct behavior, wrong context. Mark the protected
1002
+ segment dynamic:
1003
+
1004
+ ```tsx
1005
+ // src/app/(dashboard)/layout.tsx
1006
+ export const dynamic = 'force-dynamic';
1007
+
1008
+ export default async function DashboardLayout({ children }) {
1009
+ await serverAuth.requireSession();
1010
+ return <Shell>{children}</Shell>;
1011
+ }
1012
+ ```
1013
+
1014
+ `getSession()` needs no such opt-out — it returns `null` rather than throwing,
1015
+ so a prerendered public page renders anonymous. Do not "fix" a prerender failure
1016
+ by wrapping a strict guard in `.catch(() => null)`; that turns a real outage
1017
+ into a silently anonymous page.
1018
+
1019
+ ### What the app owns, what the package owns
1020
+
1021
+ | App, via `defineAuth()` | Package |
1022
+ |---|---|
1023
+ | `loginRoute`, `forbiddenRoute`, route matchers, `roleRoutes` | when to redirect where |
1024
+ | cookie names, `apiBaseURL`, `authPrefix`, recovery URL | request memoization |
1025
+ | `refreshThreshold`, `tabSync`, `proxySessionMode` | strict vs optional semantics |
1026
+ | — | `session.roles` / `user.role` fallback |
1027
+ | — | error classification |
1028
+
1029
+ If a new app has to copy anything beyond the four files above, that logic
1030
+ belongs in the package instead.
1031
+
1032
+ `proxySessionMode: 'optimistic'` is the default and locally verifies the signed
1033
+ snapshot, matching Next.js guidance that Proxy is an optimistic routing boundary.
1034
+ Use `'authoritative'` only when every protected navigation must also validate
1035
+ refresh-session state. The older `verifyAlways` option and `auth.middleware`
1036
+ property remain as deprecated compatibility aliases.
1037
+
1038
+ ### What a new app must prove
1039
+
1040
+ At its real Next.js production boundary, not with mocks:
1041
+
1042
+ - two concurrent renders never observe each other's session;
1043
+ - root layout, nested layout, and page resolve once per render — measurable by
1044
+ counting recovery round trips;
1045
+ - anonymous navigation to a protected route redirects to `loginRoute`;
1046
+ - an authenticated role mismatch reaches `forbiddenRoute` without a login loop;
1047
+ - an unset session secret or an unreachable recovery endpoint stays a visible
1048
+ failure rather than a login redirect;
1049
+ - the Edge/proxy bundle builds without pulling in React.
1050
+
1051
+ ---
1052
+
1053
+ ## TypeScript Types
1054
+
1055
+ ```typescript
1056
+ import type {
1057
+ AuthUser, // { id, email, name?, role?, permissions? }
1058
+ TokenPair, // { accessToken, refreshToken, expiresAt? }
1059
+ JwtPayload, // { userId, jti, exp?, iat? }
1060
+ AuthConfig, // Full resolved config
1061
+ AuthPluginConfig, // User-facing config
1062
+ } from 'najm-auth';
1063
+ ```
1064
+
1065
+ ---
1066
+
1067
+ ## Error Handling
1068
+
1069
+ All errors are i18n-based. Error messages are automatically localized.
1070
+
1071
+ ### Common Error Codes
1072
+
1073
+ | HTTP | Scenario |
1074
+ |------|----------|
1075
+ | 400 | Invalid input (bad email format, weak password) |
1076
+ | 401 | Missing or invalid authentication (bad token, no header) |
1077
+ | 403 | Forbidden (lacks required role/permission) |
1078
+ | 409 | Conflict (email already registered) |
1079
+ | 429 | Rate limited (too many requests) |
1080
+ | 500 | Server error (email send failure, DB error) |
1081
+
1082
+ ### Examples
1083
+
1084
+ ```typescript
1085
+ // Invalid credentials
1086
+ throw new HttpError(401, 'Invalid email or password');
1087
+
1088
+ // User already exists
1089
+ throw new HttpError(409, 'Email already registered');
1090
+
1091
+ // Insufficient permissions
1092
+ throw new HttpError(403, 'Insufficient permissions for this action');
1093
+ ```
1094
+
1095
+ ---
1096
+
1097
+ ## Security Considerations
1098
+
1099
+ ### Security Defaults
1100
+
1101
+ - JWT access and refresh secrets are required and must pass minimum strength
1102
+ checks.
1103
+ - Refresh tokens rotate by session family and suspected family compromise does
1104
+ not revoke unrelated user sessions.
1105
+ - Password reset and password change revoke existing user sessions.
1106
+ - Login uses a dummy password hash for missing users to reduce timing leaks.
1107
+ - Forgot-password responses avoid email enumeration.
1108
+ - Auth routes register `najm-rate` and ship route-level brute-force limits.
1109
+ - Session cookies are signed, short-lived, and bound to their refresh-token
1110
+ family; server auth resolution checks both the session version and positive
1111
+ family liveness.
1112
+ - Expired signed sessions recover through authoritative, non-rotating refresh
1113
+ validation; middleware verifies the reissued HMAC before using its claims.
1114
+ - Server-side recovery sends only the configured refresh cookie and accepts
1115
+ relative or exact same-origin endpoints. URL credentials and any
1116
+ scheme/hostname/port change are rejected before the network request.
1117
+ - Self-hosted apps may explicitly use a loopback-only `internalRecoveryURL`
1118
+ when their public reverse-proxy origin is not reachable from the app process.
1119
+ - `onRecoveryFailure` exposes structured, secret-free recovery diagnostics
1120
+ without logging anything by default.
1121
+ - `proxySessionMode: 'authoritative'` forces that check on every protected
1122
+ request; the default `'optimistic'` mode bounds cached role/status staleness
1123
+ to `session.maxAge`. The deprecated `verifyAlways` flag maps to the same
1124
+ behavior for existing applications.
1125
+
1126
+ ### Next.js 16 Reverse-Proxy Recovery
1127
+
1128
+ When a self-hosted Next.js proxy cannot safely call its own public
1129
+ reverse-proxy origin while handling that same request, configure the exact
1130
+ loopback recovery endpoint:
1131
+
1132
+ ```env
1133
+ NAJM_AUTH_INTERNAL_URL=http://127.0.0.1:3000/api/auth/session/recover
1134
+ ```
1135
+
1136
+ `defineAuth()` reads this environment variable automatically. An explicit
1137
+ `internalRecoveryURL` option takes precedence. The internal URL must use HTTP
1138
+ or HTTPS, contain no URL credentials, and resolve to `localhost`, `127.0.0.1`,
1139
+ or `::1`; Najm never guesses a loopback endpoint. Relative and exact
1140
+ same-origin `recoveryURL` values remain supported.
1141
+
1142
+ The recovery request forwards only the configured refresh cookie, requires
1143
+ `X-Najm-Session-Recovery: 1`, never rotates the refresh token, HMAC-verifies
1144
+ the returned session cookie, and fails closed. `onRecoveryFailure` receives
1145
+ only a structured reason and bounded, sanitized fetch-error metadata; callback
1146
+ errors cannot change the authentication result.
1147
+
1148
+ ### Password Reset Tokens
1149
+
1150
+ Reset and invite links are signed JWTs whose `jti` is stored in the configured
1151
+ cache with the same expiry. `consumeSetPasswordToken()` atomically compares and
1152
+ deletes that value while preserving whether the token was a reset or invite;
1153
+ the backward-compatible `verifyResetToken()` returns only the user id. Exactly
1154
+ one concurrent caller can consume a link, and a stale link cannot delete the
1155
+ value for a newer one.
1156
+
1157
+ `AuthService.resetPassword()` validates the replacement password before
1158
+ consumption. Once consumed, a token stays consumed even if the later user
1159
+ mutation fails; restoring it would make the link replayable, so the user must
1160
+ request a new one.
1161
+
1162
+ Accepting an account invitation also marks the destination email verified and
1163
+ activates the account when its status is `pending`. An ordinary password reset
1164
+ changes neither verification nor lifecycle status, and an explicitly inactive
1165
+ invited account remains inactive.
1166
+
1167
+ Set `appName` on `auth()` to brand the invitation subject and email card. The
1168
+ provisioned role is presented as the account type, so a sponsor invitation can
1169
+ say “Activate your sponsor account” without application-owned HTML. The shared
1170
+ template uses inline critical styles for Gmail and keeps the raw token URL out
1171
+ of visible fallback copy.
1172
+
1173
+ For a branded mark that works in email clients without a public asset URL, set
1174
+ `accountInviteLogo` to base64 content plus its MIME type and filename. Najm
1175
+ attaches it inline and points the shared template at a stable CID; when omitted,
1176
+ the template renders `appName` as text.
1177
+
1178
+ The built-in memory and Redis drivers implement the required atomic primitive.
1179
+ A custom cache driver may omit `compareAndDelete()` for compatibility with
1180
+ unrelated cache usage, but reset and invite consumption then fails closed. Do
1181
+ not emulate this operation with separate `get()` and `del()` calls.
1182
+
1183
+ ### Purpose-Bound Credential Setup
1184
+
1185
+ Use `CredentialSetupService` when valid credentials should open only a
1186
+ short-lived setup flow, not a complete application session. The default auth
1187
+ schema includes the durable `credential_setup_sessions` table for PostgreSQL
1188
+ and SQLite; generate and apply a consumer migration after upgrading.
1189
+
1190
+ ```typescript
1191
+ import { AuthService, CredentialSetupService } from 'najm-auth';
1192
+
1193
+ const options = {
1194
+ purpose: 'password-setup',
1195
+ cookieName: 'my-app.password-setup',
1196
+ ttlMs: 10 * 60 * 1000,
1197
+ };
1198
+
1199
+ // Verify the password without minting access/refresh tokens.
1200
+ const user = await authService.verifyCredentials({ identifier, password });
1201
+
1202
+ // Or narrowly accept only an unverified pending account with one exact role.
1203
+ const pendingSponsor = await authService.verifyPendingCredentials(
1204
+ { identifier, password },
1205
+ 'sponsor',
1206
+ );
1207
+
1208
+ if (await appRequiresPasswordSetup(user.id)) {
1209
+ // Revokes normal sessions and writes only an HttpOnly, SameSite=Strict,
1210
+ // browser-session cookie. The database stores only its SHA-256 hash.
1211
+ return credentialSetup.begin(user.id, options);
1212
+ }
1213
+
1214
+ return authService.establishSession(user);
1215
+
1216
+ // Complete an app-owned mutation in the same transaction as one-time
1217
+ // consumption. If the callback fails, token consumption rolls back.
1218
+ await credentialSetup.consume(options, async ({ userId }) => {
1219
+ await replaceApplicationCredential(userId, newCredential);
1220
+ });
1221
+ ```
1222
+
1223
+ Setup tokens are bound to a server-owned purpose, expire automatically, are
1224
+ replaced when the same user starts that purpose again, and can be cancelled or
1225
+ consumed exactly once. `require()` validates the current setup cookie without
1226
+ consuming it; `cancel()` revokes it and clears the cookie.
1227
+
1228
+ ### Session Management
1229
+
1230
+ - 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
1231
+ - Revocation changes the refresh row to a durable `revoked` tombstone until its original expiry. Active-session reads and rotations require `status = active`, so losing Redis cannot revive a logged-out database session; expired tombstones are removed by normal session cleanup
1232
+ - A stale refresh token presented after the 120-second rotation grace window revokes only that session's family as reuse protection
1233
+ - The signed session cookie is accepted on the fast path only while Redis positively identifies its family as live and owned by the same user; an unknown cache state falls back to authoritative refresh-row recovery
1234
+ - Use `@RateLimit` on logout for DDoS protection
1235
+
1236
+ ### Token Blacklist
1237
+
1238
+ - Built-in cache-based blacklist for immediate revocation
1239
+ - Supports Redis via `cache()` plugin configuration
1240
+ - Default: in-memory store (development/single-process only; entries are lost on restart)
1241
+ - Use Redis in production when immediate revocation must survive restarts or propagate across instances
1242
+ - Session-version revocation keys are cache-backed and TTL-bound to active access tokens
1243
+
1244
+ ### Timing Attack Prevention
1245
+
1246
+ - Dummy hash used for missing users in login
1247
+ - Constant-time password comparison
1248
+ - Same response for forgot-password (prevents email enumeration)
1249
+
1250
+ ---
1251
+
1252
+ ## Testing
1253
+
1254
+ ```bash
1255
+ bun run test # Run all tests
1256
+ bun run test:auth # Run auth tests only
1257
+ bun run --cwd packages/najm-auth test:real-infra # Opt-in PostgreSQL + Redis races
1258
+ bun packages/najm-auth/integration/mailpit-forgot-password/run.ts # Loopback Redis + Mailpit HTTP acceptance
1259
+ ```
1260
+
1261
+ The real-infrastructure suite runs only with `NAJM_AUTH_REAL_INFRA=1`. Supply
1262
+ loopback-only `NAJM_AUTH_REAL_POSTGRES_URL` and `NAJM_AUTH_REAL_REDIS_URL` (or
1263
+ the conventional `DATABASE_URL` and `REDIS_URL`). It creates and drops its own
1264
+ randomly named PostgreSQL database and cleans only its unique Redis key prefix;
1265
+ remote endpoints fail before either service is touched.
1266
+
1267
+ The Mailpit acceptance runner requires Redis on `127.0.0.1:6399`, Mailpit SMTP
1268
+ on `127.0.0.1:1025`, and the Mailpit API on `127.0.0.1:8025` by default. It
1269
+ boots the real auth plugin over HTTP with an ephemeral SQLite fixture, proves
1270
+ ignored fields and spoofed forwarding headers cannot buy more reset emails,
1271
+ and removes only its run-specific messages and Redis keys. The three endpoints
1272
+ can be changed with the `NAJM_AUTH_MAILPIT_*` variables, but non-loopback
1273
+ values fail before the fixture is created.
1274
+
1275
+ Test files include:
1276
+ - `schema.test.ts` — Schema exports validation
1277
+ - `auth.test.ts` — Authentication flow
1278
+ - `user.test.ts` — User CRUD
1279
+ - `role.test.ts` — Role management
1280
+ - `permission.test.ts` — Permission guards
1281
+ - `guards.test.ts` — Guard composability
1282
+ - `ownership.test.ts` — Row-level scoping
1283
+ - `integration.test.ts` — Multi-role scenarios
1284
+
1285
+ ---
1286
+
1287
+ ## Production Checklist
1288
+
1289
+ - ✅ Use strong JWT secrets (32+ chars, generated with `openssl rand -base64 32`)
1290
+ - ✅ Set `FRONTEND_URL` environment variable
1291
+ - ✅ Enable HTTPS in production
1292
+ - ✅ Store secrets in environment variables (never in code)
1293
+ - ✅ Use Redis for token blacklist/session-version revocation in production and distributed systems
1294
+ - ✅ Trust forwarded IP headers only behind a known proxy; otherwise provide a custom rate-limit key generator
1295
+ - ✅ Login/register rate keys hash normalized email or international-phone identifiers; passwords and request bodies never appear in cache keys
1296
+ - ✅ Enable rate limiting on all auth routes
1297
+ - ✅ Log authentication events for audit trails
1298
+ - ✅ Test ownership scoping rules with multi-user scenarios
1299
+ - ✅ Run full test suite before deploying
1300
+
1301
+ ---
1302
+
1303
+ ## Migration Guide
1304
+
1305
+ ### From v1.0 to v1.1
1306
+
1307
+ - `FRONTEND_URL` now part of `AuthPluginConfig` (falls back to env var)
1308
+ - New: Rate limiting on `/auth/logout` and `/auth/me`
1309
+ - New: `configureOwnership()` for advanced scoping
1310
+ - New: `@Policy` and `@Owned` decorators
1311
+
1312
+ ---
1313
+
1314
+ ## Support & Contributing
1315
+
1316
+ For issues, feature requests, or contributions, please refer to the main Najm repository: https://github.com/najm/najm-api