najm-auth 4.2.4 → 6.0.0

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