@spfn/auth 0.2.0-beta.8 → 0.2.0-beta.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +561 -1827
  3. package/dist/authenticate-ofdEmk6x.d.ts +1109 -0
  4. package/dist/config.d.ts +431 -39
  5. package/dist/config.js +217 -29
  6. package/dist/config.js.map +1 -1
  7. package/dist/errors.d.ts +117 -3
  8. package/dist/errors.js +83 -1
  9. package/dist/errors.js.map +1 -1
  10. package/dist/index.d.ts +351 -109
  11. package/dist/index.js +124 -7
  12. package/dist/index.js.map +1 -1
  13. package/dist/nextjs/api.js +591 -61
  14. package/dist/nextjs/api.js.map +1 -1
  15. package/dist/nextjs/client.d.ts +28 -0
  16. package/dist/nextjs/client.js +80 -0
  17. package/dist/nextjs/client.js.map +1 -0
  18. package/dist/nextjs/server.d.ts +92 -3
  19. package/dist/nextjs/server.js +288 -24
  20. package/dist/nextjs/server.js.map +1 -1
  21. package/dist/server.d.ts +2019 -511
  22. package/dist/server.js +4435 -1157
  23. package/dist/server.js.map +1 -1
  24. package/dist/session-CGxgH3C9.d.ts +53 -0
  25. package/dist/types-1BMx0OX1.d.ts +84 -0
  26. package/migrations/0001_smooth_the_fury.sql +3 -0
  27. package/migrations/0002_deep_iceman.sql +11 -0
  28. package/migrations/0003_perfect_deathbird.sql +3 -0
  29. package/migrations/0004_concerned_rawhide_kid.sql +5 -0
  30. package/migrations/0005_lethal_lifeguard.sql +32 -0
  31. package/migrations/0006_easy_hardball.sql +24 -0
  32. package/migrations/0007_glossy_major_mapleleaf.sql +1 -0
  33. package/migrations/meta/0001_snapshot.json +1660 -0
  34. package/migrations/meta/0002_snapshot.json +1660 -0
  35. package/migrations/meta/0003_snapshot.json +1689 -0
  36. package/migrations/meta/0004_snapshot.json +1721 -0
  37. package/migrations/meta/0005_snapshot.json +1721 -0
  38. package/migrations/meta/0006_snapshot.json +1921 -0
  39. package/migrations/meta/0007_snapshot.json +1916 -0
  40. package/migrations/meta/_journal.json +49 -0
  41. package/package.json +43 -39
  42. package/dist/dto-lZmWuObc.d.ts +0 -645
package/README.md CHANGED
@@ -1,65 +1,43 @@
1
- # @spfn/auth - Technical Documentation
1
+ # @spfn/auth Authentication, OAuth, and RBAC for SPFN
2
2
 
3
- **Version:** 0.1.0-alpha.88
4
- **Status:** Alpha - Internal Development
3
+ Asymmetric client-signed JWT auth (ES256/RS256), OTP verification, OAuth 2.0 (pluggable
4
+ provider registry; Google, GitHub, Kakao, and Naver built in), session cookies for Next.js, and runtime RBAC.
5
+ Routes are exposed under the `/_auth/*` namespace and reached through a type-safe `authApi`
6
+ client. Requires `@spfn/core`; Next.js is an optional peer (`^15 || ^16`).
5
7
 
6
- > **Note:** This is a technical documentation for developers working on the @spfn/auth package.
7
- > For user-facing documentation, see [SPFN Documentation](https://spfn.dev/docs).
8
+ ## Install
8
9
 
9
- ---
10
-
11
- ## Table of Contents
12
-
13
- - [Overview](#overview)
14
- - [Installation](#installation)
15
- - [Architecture](#architecture)
16
- - [Package Structure](#package-structure)
17
- - [Module Exports](#module-exports)
18
- - [Email & SMS Services](#email--sms-services)
19
- - [Email Templates](#email-templates)
20
- - [Server-Side API](#server-side-api)
21
- - [Database Schema](#database-schema)
22
- - [RBAC System](#rbac-system)
23
- - [Next.js Adapter](#nextjs-adapter)
24
- - [Testing](#testing)
25
- - [Development Workflow](#development-workflow)
26
- - [Known Issues](#known-issues)
27
- - [Roadmap](#roadmap)
28
-
29
- ---
30
-
31
- ## Overview
32
-
33
- `@spfn/auth` is an authentication and authorization package for the SPFN framework, providing:
34
-
35
- - **Asymmetric JWT Authentication** - Client-signed tokens using ES256/RS256
36
- - **User Management** - Email/phone-based identity with bcrypt hashing
37
- - **Multi-Factor Authentication** - OTP verification via email/SMS
38
- - **Session Management** - Public key rotation with 90-day expiry
39
- - **Role-Based Access Control** - Flexible RBAC with runtime role/permission management
40
- - **Next.js Integration** - Session helpers and server-side guards
10
+ ```bash
11
+ pnpm add @spfn/auth
12
+ ```
41
13
 
42
- ### Design Principles
14
+ ## Import paths
43
15
 
44
- 1. **Security First** - Asymmetric cryptography, no shared secrets
45
- 2. **Type Safety** - Full TypeScript support with Typebox validation
46
- 3. **Framework Integration** - Seamless SPFN plugin architecture
47
- 4. **Extensibility** - Service layer for custom authentication flows
48
- 5. **Developer Experience** - Clear separation of concerns, reusable components
16
+ Five entry points (from `package.json` `exports`). Picking the wrong one breaks the build —
17
+ `/server` and `/nextjs/*` pull in Node/`server-only` code and must never reach the browser bundle.
49
18
 
50
- ---
19
+ ```typescript
20
+ import { authApi, authRouteMap } from '@spfn/auth'; // isomorphic: client + route map + types/constants
21
+ import { authRouter, authenticate } from '@spfn/auth/server'; // SERVER ONLY: router, services, repos, middleware, helpers
22
+ import { /* hooks/components */ } from '@spfn/auth/client'; // browser only (currently empty — WIP)
23
+ import { env, envSchema } from '@spfn/auth/config'; // validated env proxy + schema
24
+ import { InvalidCredentialsError } from '@spfn/auth/errors'; // error classes + authErrorRegistry
25
+ import '@spfn/auth/nextjs/api'; // SERVER: auto-registers RPC interceptors (side-effect)
26
+ import { RequireAuth, getSession } from '@spfn/auth/nextjs/server'; // SERVER: RSC guards, session helpers, OAuth handler
27
+ import { OAuthCallback } from '@spfn/auth/nextjs/client'; // 'use client' OAuth callback component
28
+ ```
51
29
 
52
- ## Installation
30
+ > Database entities (`users`, `userPublicKeys`, …) and all services/repositories are exported
31
+ > from `@spfn/auth/server`, **not** from the root `@spfn/auth`.
53
32
 
54
- ### 1. Install Package
33
+ ## Setup (4 wiring points)
55
34
 
56
- ```bash
57
- pnpm add @spfn/auth
58
- ```
35
+ Auth needs four edits in the consuming app. All four are required for the flow to work end to end.
59
36
 
60
- ### 2. Configure Server
37
+ ### 1. Lifecycle — `server.config.ts`
61
38
 
62
- #### Add Lifecycle to `server.config.ts`
39
+ `createAuthLifecycle()` validates env before DB connect, then seeds admin accounts and
40
+ initializes RBAC after the DB is ready. Pass custom roles/permissions here (see RBAC below).
63
41
 
64
42
  ```typescript
65
43
  import { defineServerConfig } from '@spfn/core/server';
@@ -68,1895 +46,651 @@ import { appRouter } from './router';
68
46
 
69
47
  export default defineServerConfig()
70
48
  .port(8790)
71
- .host('0.0.0.0')
72
49
  .routes(appRouter)
73
- .lifecycle(createAuthLifecycle()) // Add auth lifecycle
50
+ .lifecycle(createAuthLifecycle())
74
51
  .build();
75
52
  ```
76
53
 
77
- #### Register Router in `router.ts`
54
+ ### 2. Router + global middleware — `router.ts`
55
+
56
+ `authRouter` (the package's `mainAuthRouter`) is merged via `.packages()`; `authenticate` is
57
+ applied globally via `.use()`. Public routes opt out per-route with `.skip(['auth'])`.
78
58
 
79
59
  ```typescript
80
60
  import { defineRouter } from '@spfn/core/route';
81
- import { authRouter } from '@spfn/auth/server';
61
+ import { authRouter, authenticate } from '@spfn/auth/server';
62
+ import { getHealth } from './routes/health';
82
63
 
83
64
  export const appRouter = defineRouter({
84
- // Auth routes (fixed namespace)
85
- auth: authRouter,
65
+ getHealth,
66
+ // ...your routes
67
+ })
68
+ .packages([authRouter]) // mounts /_auth/* and exposes routes on authApi
69
+ .use([authenticate]); // global auth middleware
86
70
 
87
- // ... your other routes
88
- });
71
+ export type AppRouter = typeof appRouter;
89
72
  ```
90
73
 
91
- ### 3. Configure Client (Next.js)
74
+ ### 3. Next.js interceptor — RPC proxy route
92
75
 
93
- #### Register Router Metadata and Errors in `api-client.ts`
76
+ The interceptor handles session cookies, JWT signing, and key management automatically.
77
+ Import it for its side-effect (it self-registers); it must run before the proxy is created.
94
78
 
95
79
  ```typescript
96
- import { createApi } from '@spfn/core/nextjs';
97
- import type { AppRouter } from '@/server/router';
98
- import { appMetadata as authAppMetadata } from "@spfn/auth";
99
- import { authErrorRegistry } from "@spfn/auth/errors";
100
- import { appMetadata } from '@/server/router.metadata';
101
- import { errorRegistry } from "@spfn/core/errors";
102
-
103
- export const api = createApi<AppRouter>({
104
- metadata: { ...appMetadata, ...authAppMetadata },
105
- errorRegistry: errorRegistry.concat(authErrorRegistry),
106
- });
107
- ```
80
+ // app/api/rpc/[routeName]/route.ts
81
+ import '@spfn/auth/nextjs/api'; // side-effect: registers auth interceptors
82
+ import { createRpcProxy } from '@spfn/core/nextjs/server';
83
+ import { authRouteMap } from '@spfn/auth';
84
+ import { routeMap } from '@/generated/route-map';
108
85
 
109
- ### 4. Environment Variables
110
-
111
- ```bash
112
- # Required
113
- SPFN_AUTH_JWT_SECRET=your-secret-key
114
- SPFN_AUTH_VERIFICATION_TOKEN_SECRET=your-verification-secret
115
- DATABASE_URL=postgresql://...
116
-
117
- # Next.js (required)
118
- SPFN_AUTH_SESSION_SECRET=your-32-char-secret
119
-
120
- # Optional
121
- SPFN_AUTH_JWT_EXPIRES_IN=7d
122
- SPFN_AUTH_BCRYPT_SALT_ROUNDS=10
123
- SPFN_AUTH_SESSION_TTL=7d
124
-
125
- # AWS SES (Email)
126
- SPFN_AUTH_AWS_REGION=ap-northeast-2
127
- SPFN_AUTH_AWS_SES_ACCESS_KEY_ID=AKIA...
128
- SPFN_AUTH_AWS_SES_SECRET_ACCESS_KEY=...
129
- SPFN_AUTH_AWS_SES_FROM_EMAIL=noreply@yourdomain.com
130
-
131
- # AWS SNS (SMS)
132
- SPFN_AUTH_AWS_SNS_ACCESS_KEY_ID=AKIA...
133
- SPFN_AUTH_AWS_SNS_SECRET_ACCESS_KEY=...
134
- SPFN_AUTH_AWS_SNS_SENDER_ID=MyApp
86
+ export const { GET, POST } = createRpcProxy({ routeMap: { ...routeMap, ...authRouteMap } });
135
87
  ```
136
88
 
137
- ### 5. Run Migrations
89
+ ### 4. Run migrations
138
90
 
139
91
  ```bash
140
- # Generate migrations (if needed)
141
- pnpm spfn db generate
142
-
143
- # Run migrations
92
+ pnpm spfn db generate # only if entities changed
144
93
  pnpm spfn db migrate
145
94
  ```
146
95
 
147
- ---
148
-
149
- ## Architecture
150
-
151
- ### High-Level Overview
152
-
153
- ```
154
- ┌─────────────────────────────────────────────────────────────┐
155
- │ @spfn/auth Package │
156
- ├─────────────────────────────────────────────────────────────┤
157
- │ │
158
- │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
159
- │ │ Server │ │ Next.js │ │ Client │ │
160
- │ │ (server.ts) │ │ (nextjs/*) │ │ (client.ts) │ │
161
- │ └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ │
162
- │ │ │ │ │
163
- │ ┌───────▼───────────────────▼───────────────────▼───────┐ │
164
- │ │ Common Types & Entities │ │
165
- │ │ (index.ts) │ │
166
- │ └────────────────────────────────────────────────────────┘ │
167
- │ │
168
- └─────────────────────────────────────────────────────────────┘
169
- ```
170
-
171
- ### Module Separation
172
-
173
- The package is split into three distinct entry points to ensure proper code separation:
174
-
175
- 1. **Common Module** (`@spfn/auth`)
176
- - Database entities (users, roles, permissions)
177
- - TypeScript types and interfaces
178
- - RBAC type definitions
179
- - Can be imported anywhere (server/client)
180
-
181
- 2. **Server Module** (`@spfn/auth/server`)
182
- - Server-only code (marked with Node.js APIs)
183
- - Routes, services, repositories
184
- - Middleware, helpers (JWT, password)
185
- - RBAC initialization
186
- - **Never** import in client-side code
187
-
188
- 3. **Client Module** (`@spfn/auth/client`)
189
- - Client-only code (React hooks, components)
190
- - Currently in development (placeholders only)
191
- - **Never** import in server-side code
192
-
193
- 4. **Next.js Adapter** (`@spfn/auth/nextjs/*`)
194
- - Next.js-specific integrations
195
- - `@spfn/auth/nextjs/api` - Interceptors for API routes
196
- - `@spfn/auth/nextjs/server` - Server Components guards & session helpers
197
-
198
- ### Asymmetric JWT Flow
199
-
200
- ```
201
- ┌──────────┐ ┌──────────┐
202
- │ Client │ │ Server │
203
- └────┬─────┘ └────┬─────┘
204
- │ │
205
- 1. Generate ES256 keypair │
206
- │ (privateKey stored locally) │
207
- │ │
208
- 2. POST /_auth/register │
209
- │ { email, password, publicKey, keyId } │
210
- ├──────────────────────────────────────────────>│
211
- │ │
212
- │ 3. Store publicKey
213
- │ (user_public_keys)
214
- │ │
215
- 4. Sign JWT with privateKey │
216
- │ payload: { userId, keyId } │
217
- │ │
218
- 5. Request with Authorization header │
219
- │ Authorization: Bearer <jwt> │
220
- ├──────────────────────────────────────────────>│
221
- │ │
222
- │ 6. Decode JWT keyId │
223
- │ Fetch publicKey │
224
- │ Verify signature │
225
- │ │
226
- │ 7. Success
227
- │<──────────────────────────────────────────────┤
228
- │ │
229
- ```
230
-
231
- **Key Points:**
232
- - Server **never** knows the private key
233
- - Each client has a unique keypair
234
- - JWT verification uses stored public key
235
- - No shared secrets (unlike HMAC-based JWT)
236
-
237
- ---
238
-
239
- ## Package Structure
240
-
241
- ```
242
- packages/auth/
243
- ├── dist/ # Compiled output (tsup)
244
- │ ├── index.js # Common exports
245
- │ ├── index.d.ts
246
- │ ├── server.js # Server exports
247
- │ ├── server.d.ts
248
- │ ├── client.js # Client exports (minimal)
249
- │ ├── client.d.ts
250
- │ ├── config/ # Configuration module
251
- │ ├── errors/ # Error classes
252
- │ ├── nextjs/ # Next.js adapter
253
- │ └── server/ # Server implementation
254
-
255
- ├── migrations/ # Drizzle database migrations
256
- │ └── *.sql
257
-
258
- ├── src/
259
- │ ├── index.ts # Common entry point
260
- │ ├── server.ts # Server entry point
261
- │ ├── client.ts # Client entry point
262
- │ │
263
- │ ├── config/ # Configuration system
264
- │ │ ├── index.ts
265
- │ │ ├── schema.ts # Env var schema
266
- │ │ └── types.ts
267
- │ │
268
- │ ├── errors/ # Error definitions
269
- │ │ ├── index.ts
270
- │ │ └── auth-errors.ts
271
- │ │
272
- │ ├── lib/ # Shared code
273
- │ │ └── contracts/ # Typebox schemas
274
- │ │
275
- │ ├── server/ # Server-side implementation
276
- │ │ ├── entities/ # Drizzle ORM entities
277
- │ │ ├── services/ # Business logic layer
278
- │ │ ├── repositories/ # Database access layer
279
- │ │ ├── routes/ # HTTP route handlers
280
- │ │ ├── middleware/ # Auth middleware
281
- │ │ ├── helpers/ # JWT, password, context
282
- │ │ ├── rbac/ # RBAC types and builtins
283
- │ │ ├── lib/ # Server utilities
284
- │ │ ├── lifecycle.ts # SPFN lifecycle hooks
285
- │ │ ├── setup.ts # Initialization
286
- │ │ ├── logger.ts # Logging
287
- │ │ └── types.ts # Server types
288
- │ │
289
- │ ├── nextjs/ # Next.js adapter
290
- │ │ ├── api.ts # Interceptor exports
291
- │ │ ├── server.ts # Server Components guards
292
- │ │ ├── session-helpers.ts# Session management
293
- │ │ ├── interceptors/ # Request interceptors
294
- │ │ └── guards/ # Auth guards
295
- │ │
296
- │ └── client/ # Client-side (WIP)
297
- │ ├── hooks/ # React hooks (TODO)
298
- │ ├── store/ # Zustand store (TODO)
299
- │ └── components/ # UI components (TODO)
300
-
301
- ├── package.json # Package configuration + SPFN plugin config
302
- ├── tsup.config.ts # Build configuration
303
- ├── drizzle.config.ts # Database migration config
304
- └── README.md # This file
305
- ```
306
-
307
- ### Layer Responsibilities
308
-
309
- #### 1. **Routes Layer** (`src/server/routes/`)
310
- - Thin HTTP handlers
311
- - Request validation (Typebox)
312
- - Delegates to services
313
- - Returns responses
314
-
315
- #### 2. **Services Layer** (`src/server/services/`)
316
- - Business logic
317
- - Transaction management
318
- - Reusable functions
319
- - Can be used outside of routes
320
-
321
- #### 3. **Repositories Layer** (`src/server/repositories/`)
322
- - Database access only
323
- - CRUD operations
324
- - No business logic
325
- - Drizzle ORM queries
326
-
327
- #### 4. **Helpers Layer** (`src/server/helpers/`)
328
- - Utility functions (JWT, password hashing)
329
- - Context accessors (getAuth, getUser)
330
- - Stateless operations
331
-
332
- ---
333
-
334
- ## Module Exports
335
-
336
- ### Common Module (`@spfn/auth`)
337
-
338
- **Entities:**
339
- ```typescript
340
- import {
341
- users,
342
- userPublicKeys,
343
- verificationCodes,
344
- roles,
345
- permissions,
346
- rolePermissions,
347
- userPermissions,
348
- userInvitations,
349
- userSocialAccounts,
350
- userProfiles
351
- } from '@spfn/auth';
352
- ```
353
-
354
- **Types:**
355
- ```typescript
356
- import type {
357
- User,
358
- UserPublicKey,
359
- VerificationCode,
360
- Role,
361
- Permission,
362
- // ... etc
363
- } from '@spfn/auth';
364
- ```
365
-
366
- **RBAC:**
367
- ```typescript
368
- import {
369
- BUILTIN_ROLES,
370
- BUILTIN_PERMISSIONS,
371
- BUILTIN_ROLE_PERMISSIONS
372
- } from '@spfn/auth';
373
-
374
- import type {
375
- RoleConfig,
376
- PermissionConfig,
377
- InitializeAuthOptions,
378
- BuiltinRoleName,
379
- BuiltinPermissionName
380
- } from '@spfn/auth';
381
- ```
382
-
383
- ---
384
-
385
- ### Server Module (`@spfn/auth/server`)
386
-
387
- **Router:**
388
- ```typescript
389
- import { authRouter } from '@spfn/auth/server';
390
-
391
- // Explicit registration in your app router
392
- export const appRouter = defineRouter({
393
- auth: authRouter, // Mounts at /_auth/*
394
- });
395
- ```
396
-
397
- **Services:**
398
- ```typescript
399
- import {
400
- // Auth
401
- checkAccountExistsService,
402
- registerService,
403
- loginService,
404
- logoutService,
405
- changePasswordService,
406
-
407
- // Verification
408
- sendVerificationCodeService,
409
- verifyCodeService,
410
-
411
- // Key Management
412
- registerPublicKeyService,
413
- rotateKeyService,
414
- revokeKeyService,
415
-
416
- // User
417
- getUserByIdService,
418
- getUserByEmailService,
419
- getUserByPhoneService,
420
- updateUserService,
421
- updateLastLoginService,
422
-
423
- // RBAC
424
- initializeAuth,
425
-
426
- // Permission
427
- getUserPermissions,
428
- hasPermission,
429
- hasAnyPermission,
430
- hasAllPermissions,
431
- hasRole,
432
- hasAnyRole,
433
-
434
- // Role
435
- createRole,
436
- updateRole,
437
- deleteRole,
438
- addPermissionToRole,
439
- removePermissionFromRole,
440
- setRolePermissions,
441
- getAllRoles,
442
- getRoleByName,
443
- getRolePermissions,
444
-
445
- // Invitation
446
- createInvitation,
447
- getInvitationByToken,
448
- getInvitationWithDetails,
449
- validateInvitation,
450
- acceptInvitation,
451
- listInvitations,
452
- cancelInvitation,
453
- deleteInvitation,
454
- expireOldInvitations,
455
- resendInvitation,
456
-
457
- // Session
458
- getAuthSessionService,
459
- getUserProfileService,
460
-
461
- // Email
462
- sendEmail,
463
- registerEmailProvider,
464
-
465
- // SMS
466
- sendSMS,
467
- registerSMSProvider,
468
-
469
- // Email Templates
470
- registerEmailTemplates,
471
- getVerificationCodeTemplate,
472
- getWelcomeTemplate,
473
- getPasswordResetTemplate,
474
- getInvitationTemplate,
475
- } from '@spfn/auth/server';
476
- ```
477
-
478
- **Repositories:**
479
- ```typescript
480
- import {
481
- usersRepository,
482
- keysRepository,
483
- rolesRepository,
484
- permissionsRepository,
485
- verificationCodesRepository,
486
- invitationsRepository,
487
- rolePermissionsRepository,
488
- userPermissionsRepository,
489
- userProfilesRepository,
490
- } from '@spfn/auth/server';
491
- ```
492
-
493
- **Middleware:**
494
- ```typescript
495
- import {
496
- authenticate,
497
- requirePermissions,
498
- requireRole,
499
- } from '@spfn/auth/server';
500
-
501
- // Usage
502
- app.bind(
503
- myContract,
504
- [authenticate, requirePermissions('user:delete')],
505
- async (c) => {
506
- // Handler
507
- }
508
- );
509
- ```
510
-
511
- **Helpers:**
512
- ```typescript
513
- import {
514
- // Context
515
- getAuth,
516
- getUser,
517
- getUserId,
518
- getKeyId,
519
-
520
- // JWT
521
- generateToken, // Legacy server-signed (deprecated)
522
- verifyToken, // Legacy server-signed (deprecated)
523
- verifyClientToken, // Client-signed asymmetric JWT
524
- decodeToken, // Decode without verification (debugging)
525
- verifyKeyFingerprint,
526
-
527
- // Password
528
- hashPassword,
529
- verifyPassword,
530
- } from '@spfn/auth/server';
531
- ```
532
-
533
- **Lifecycle:**
534
- ```typescript
535
- import { createAuthLifecycle } from '@spfn/auth/server';
536
-
537
- // SPFN plugin lifecycle hooks
538
- const lifecycle = createAuthLifecycle();
539
- ```
540
-
541
- ---
542
-
543
- ### Client Module (`@spfn/auth/client`)
544
-
545
- > **Status:** Work in Progress - Placeholders only
546
-
547
- ```typescript
548
- // Currently empty exports
549
- import {} from '@spfn/auth/client';
550
- ```
551
-
552
- **Planned:**
553
- - React hooks (useAuth, useSession)
554
- - Zustand store
555
- - UI components (LoginForm, etc.)
556
-
557
- ---
558
-
559
- ### Configuration Module (`@spfn/auth/config`)
560
-
561
- ```typescript
562
- import { env, envSchema } from '@spfn/auth/config';
563
-
564
- // Access environment variables (validated at startup)
565
- console.log(env.SPFN_AUTH_JWT_SECRET);
566
- console.log(env.SPFN_AUTH_JWT_EXPIRES_IN);
567
- console.log(env.SPFN_AUTH_BCRYPT_SALT_ROUNDS);
568
-
569
- // envSchema can be used for custom validation
570
- ```
571
-
572
- ---
96
+ The API client needs no auth-specific config. `authApi` is also available standalone:
97
+
98
+ ```typescript
99
+ import { authApi } from '@spfn/auth';
100
+ const session = await authApi.getAuthSession.call({}); // → GET /_auth/session
101
+ ```
102
+
103
+ ## Environment variables
104
+
105
+ Set across **two files** by audience. Server-only secrets go in `.env.server`; values the
106
+ Next.js runtime needs (session cookie crypto) go in `.env.local`. Names only below — supply
107
+ real secret values out of band, never commit them.
108
+
109
+ | Var | File | Required | Notes |
110
+ |-----|------|----------|-------|
111
+ | `DATABASE_URL` | both | yes | Postgres connection |
112
+ | `SPFN_AUTH_VERIFICATION_TOKEN_SECRET` | `.env.server` | yes | OTP / verification token signing |
113
+ | `SPFN_AUTH_SESSION_SECRET` | `.env.local` | yes | ≥32 chars, AES-256 session cookie encryption (validated: entropy/unique-char checks) |
114
+ | `SPFN_AUTH_TOKEN_ENCRYPTION_KEYS` | `.env.server` | web OAuth | OAuth token keyring: comma-separated `<keyId>:<base64-32-byte-key>` entries; first key is active |
115
+ | `SPFN_API_URL` | `.env.local` | — | default `http://localhost:8790` |
116
+ | `SPFN_AUTH_SESSION_TTL` | both | — | default `7d` (e.g. `7d`, `12h`, `45m`) |
117
+ | `SPFN_AUTH_JWT_SECRET` / `SPFN_AUTH_JWT_EXPIRES_IN` | `.env.server` | — | legacy server-signed JWT mode only |
118
+ | `SPFN_AUTH_BCRYPT_SALT_ROUNDS` | `.env.server` | — | default `12` (native bcrypt, off the event loop) |
119
+ | `SPFN_AUTH_COOKIE_SECURE` | both | — | override Secure flag (defaults to `NODE_ENV==='production'`) |
120
+ | `SPFN_AUTH_ADMIN_*` | `.env.server` | — | admin seeding (see below) |
121
+ | `SPFN_AUTH_GOOGLE_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | enables Google OAuth when both set |
122
+ | `SPFN_AUTH_GOOGLE_SCOPES` | `.env.server` | | comma-separated; default `email,profile` |
123
+ | `SPFN_AUTH_GOOGLE_REDIRECT_URI` | `.env.server` | — | default `{NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL}/_auth/oauth/google/callback` — see [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite) |
124
+ | `SPFN_AUTH_KAKAO_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | | REST API key enables Kakao Login; secret is included when configured |
125
+ | `SPFN_AUTH_KAKAO_SCOPES` / `_REDIRECT_URI` | `.env.server` | — | default scope `account_email`; callback `/_auth/oauth/kakao/callback` |
126
+ | `SPFN_AUTH_NAVER_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | both values enable Naver Login |
127
+ | `SPFN_AUTH_NAVER_REDIRECT_URI` | `.env.server` | — | default `{NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL}/_auth/oauth/naver/callback` |
128
+ | `SPFN_AUTH_GITHUB_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | both values enable GitHub OAuth |
129
+ | `SPFN_AUTH_GITHUB_SCOPES` / `_REDIRECT_URI` | `.env.server` | — | default scopes `read:user,user:email`; callback `/_auth/oauth/github/callback` |
130
+ | `SPFN_AUTH_GOOGLE_NATIVE_CLIENT_IDS` | `.env.server` | | comma-separated client IDs accepted as native id_token audience (iOS/Android/web); enables Google native sign-in |
131
+ | `SPFN_AUTH_APPLE_CLIENT_IDS` | `.env.server` | — | comma-separated Apple client IDs (bundle ID / Services ID); enables Apple native sign-in |
132
+ | `SPFN_AUTH_OAUTH_SUCCESS_URL` | `.env.server` | — | default `/auth/callback` |
133
+ | `SPFN_AUTH_OAUTH_ERROR_URL` | `.env.server` | — | default `/auth/error?error={error}` |
134
+ | `SPFN_AUTH_RESERVED_USERNAMES` / `_USERNAME_MIN_LENGTH` / `_USERNAME_MAX_LENGTH` | `.env.server` | — | username rules |
135
+ | `NEXT_PUBLIC_SPFN_API_URL` / `NEXT_PUBLIC_SPFN_APP_URL` | `.env.local` | — | browser-facing URLs for OAuth redirects |
136
+
137
+ Read validated values via `import { env } from '@spfn/auth/config'` (a proxy validated at
138
+ startup). `envSchema` carries descriptions/defaults.
139
+
140
+ ### Admin seeding
141
+
142
+ `createAuthLifecycle()` creates admin accounts on startup from env, in priority order. Seeded
143
+ accounts are auto email-verified, `status: 'active'`, `passwordChangeRequired: true`.
144
+
145
+ - **JSON (recommended):** `SPFN_AUTH_ADMIN_ACCOUNTS` array of `{email, password, role?, phone?, passwordChangeRequired?}`. `role` defaults to `user` (`user` | `admin` | `superadmin`).
146
+ - **CSV:** `SPFN_AUTH_ADMIN_EMAILS` + `SPFN_AUTH_ADMIN_PASSWORDS` + `SPFN_AUTH_ADMIN_ROLES`.
147
+ - **Single (legacy):** `SPFN_AUTH_ADMIN_EMAIL` + `SPFN_AUTH_ADMIN_PASSWORD` → always `superadmin`.
148
+
149
+ ## Routes
150
+
151
+ All routes mount at `/_auth/*` and are reached through `authApi.<name>.call({ body })`. Public
152
+ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-signed-jwt>`.
153
+
154
+ | `authApi` method | HTTP | Auth | Purpose |
155
+ |------------------|------|------|---------|
156
+ | `checkAccountExists` | POST `/_auth/exists` | public | email/phone existence check |
157
+ | `sendVerificationCode` | POST `/_auth/codes` | public | send 6-digit OTP |
158
+ | `verifyCode` | POST `/_auth/codes/verify` | public | verify OTP → verification token |
159
+ | `register` | POST `/_auth/register` | public | create user + register public key |
160
+ | `login` | POST `/_auth/login` | public | password login + new session key |
161
+ | `logout` | POST `/_auth/logout` | yes | revoke current key |
162
+ | `rotateKey` | POST `/_auth/keys/rotate` | yes | rotate public key before 90-day expiry |
163
+ | `changePassword` | PUT `/_auth/password` | yes | change password |
164
+ | `getAuthSession` | GET `/_auth/session` | yes | current session/user |
165
+ | `issueOneTimeToken` | POST | yes | short-lived token (e.g. SSE handshake) |
166
+ | `checkUsername` / `updateUsername` / `updateLocale` | — | mixed | username availability/update, locale |
167
+ | `getUserProfile` / `updateUserProfile` | — | yes | profile read/update |
168
+ | `createInvitation` / `acceptInvitation` / `listInvitations` / `cancelInvitation` / `resendInvitation` / `deleteInvitation` / `getInvitation` | — | mixed | invitation flow |
169
+ | `requestAccountDeletion` | POST `/_auth/deletion/request` | yes | request account deletion (re-auth gated) — see [Account Deletion & Recovery](#account-deletion--recovery) |
170
+ | `cancelAccountDeletion` | POST `/_auth/deletion/cancel` | public | cancel a pending deletion (credential-based recovery) |
171
+ | `listRoles` / `createAdminRole` / `updateAdminRole` / `deleteAdminRole` / `updateUserRole` | — | superadmin | admin RBAC management |
172
+ | OAuth routes | — | — | see OAuth section |
173
+
174
+ Auth uses **asymmetric, client-signed JWTs**: the client generates an ES256/RS256 keypair,
175
+ sends the public key on register/login, signs request JWTs locally, and the server verifies
176
+ with the stored public key (`keyId` carried in the JWT). The server never holds a private key.
177
+ Keys expire after 90 days — rotate with `rotateKey`.
178
+
179
+ ### Writing protected routes (route DSL)
180
+
181
+ This is the current SPFN route DSL — `route.<method>().input().use().skip().handler()` registered
182
+ via `defineRouter`. Access auth state through the context helpers, not by reading raw context.
183
+
184
+ ```typescript
185
+ import { route } from '@spfn/core/route';
186
+ import { authenticate, requirePermissions, optionalAuth } from '@spfn/auth/server';
187
+ import { getAuth, getOptionalAuth } from '@spfn/auth/server';
188
+
189
+ // Protected (global `authenticate` already applies; helpers read the context)
190
+ export const getMe = route.get('/me')
191
+ .handler(async (c) =>
192
+ {
193
+ const { user, userId, role, locale } = getAuth(c);
194
+ return { id: userId, email: user.email, role };
195
+ });
573
196
 
574
- ### Errors Module (`@spfn/auth/errors`)
197
+ // Permission-gated (all required); use requireAnyPermission for OR, requireRole for roles
198
+ export const deleteUser = route.delete('/users/:id')
199
+ .use([authenticate, requirePermissions('user:delete')])
200
+ .handler(async (c) => { /* ... */ });
575
201
 
576
- ```typescript
577
- import {
578
- // Auth namespace (contains all error classes)
579
- AuthError,
580
-
581
- // Individual error classes
582
- InvalidCredentialsError,
583
- InvalidTokenError,
584
- TokenExpiredError,
585
- KeyExpiredError,
586
- AccountDisabledError,
587
- AccountAlreadyExistsError,
588
- InvalidVerificationCodeError,
589
- InvalidVerificationTokenError,
590
- InvalidKeyFingerprintError,
591
- VerificationTokenPurposeMismatchError,
592
- VerificationTokenTargetMismatchError,
593
- InsufficientPermissionsError,
594
- InsufficientRoleError,
595
-
596
- // Error registry for client-side error handling
597
- authErrorRegistry,
598
- } from '@spfn/auth/errors';
202
+ // Public + optional user context. optionalAuth auto-skips global 'auth' — no .skip needed
203
+ export const getProducts = route.get('/products')
204
+ .use([optionalAuth])
205
+ .handler(async (c) =>
206
+ {
207
+ const auth = getOptionalAuth(c); // AuthContext | undefined
208
+ return auth ? personalized(auth.userId) : publicList();
209
+ });
599
210
  ```
600
211
 
601
- ---
212
+ Context helpers from `@spfn/auth/server`: `getAuth`, `getOptionalAuth`, `getUser`, `getUserId`,
213
+ `getRole`, `getLocale`, `getKeyId`. Middleware: `authenticate`, `optionalAuth`,
214
+ `requirePermissions`, `requireAnyPermission`, `requireRole`, `roleGuard`, `oneTimeTokenAuth`.
602
215
 
603
- ### Next.js Adapter (`@spfn/auth/nextjs/*`)
604
-
605
- #### `@spfn/auth/nextjs/api`
606
-
607
- ```typescript
608
- import {
609
- authInterceptors,
610
- loginRegisterInterceptor,
611
- generalAuthInterceptor,
612
- keyRotationInterceptor,
613
- } from '@spfn/auth/nextjs/api';
216
+ ## OAuth
614
217
 
615
- // Auto-registers interceptors on import
616
- import '@spfn/auth/nextjs/api';
617
- ```
218
+ OAuth uses a **pluggable provider registry** — not hardcoded branches. The built-in `google`,
219
+ `github`, `kakao`, and `naver` web providers self-register on module load; `apple` provides native
220
+ `id_token` sign-in. External packages add providers at runtime with `registerOAuthProvider()`.
221
+ Google, GitHub, and Naver each require their client ID and secret; Kakao requires its REST API
222
+ key (and sends its optional client secret when configured).
618
223
 
619
- #### `@spfn/auth/nextjs/server`
224
+ Client flow: call `authApi.getGoogleOAuthUrl.call({ body: { returnUrl } })`, redirect the browser
225
+ to the returned `authUrl`, and render `OAuthCallback` on your success page. The Next.js interceptor
226
+ manages the keypair → pending-session-cookie → full-session handoff transparently.
620
227
 
621
- ```typescript
622
- import {
623
- // Guards (Server Components)
624
- RequireAuth,
625
- RequireRole,
626
- RequirePermission,
627
-
628
- // Auth Utils
629
- getUserRole,
630
- getUserPermissions,
631
- hasAnyRole,
632
- hasAnyPermission,
633
-
634
- // Session Helpers
635
- saveSession,
636
- getSession,
637
- clearSession,
638
-
639
- // Types
640
- type SessionData,
641
- type PublicSession,
642
- type SaveSessionOptions,
643
- } from '@spfn/auth/nextjs/server';
228
+ ```tsx
229
+ // app/auth/callback/page.tsx
230
+ export { OAuthCallback as default } from '@spfn/auth/nextjs/client';
644
231
  ```
645
232
 
646
- **Session Helpers Usage:**
647
233
  ```typescript
648
- // Save session (Server Actions / Route Handlers)
649
- await saveSession({
650
- userId: '123',
651
- privateKey: '...',
652
- keyId: 'uuid',
653
- algorithm: 'ES256',
234
+ import { authApi } from '@spfn/auth';
235
+ const { authUrl } = await authApi.getGoogleOAuthUrl.call({
236
+ body: {
237
+ returnUrl: '/dashboard',
238
+ metadata: { birthDate: '2000-01-01', termsAgreed: true },
239
+ },
654
240
  });
655
-
656
- // Get session (read-only, safe in Server Components)
657
- const session = await getSession();
658
-
659
- // Clear session
660
- await clearSession();
241
+ window.location.href = authUrl;
661
242
  ```
662
243
 
663
- **Guard Usage:**
664
- ```typescript
665
- // app/dashboard/page.tsx
666
- import { RequireAuth } from '@spfn/auth/nextjs/server';
667
-
668
- export default async function DashboardPage()
669
- {
670
- return (
671
- <RequireAuth redirectTo="/login">
672
- <div>Protected content</div>
673
- </RequireAuth>
674
- );
675
- }
676
- ```
677
-
678
- ---
679
-
680
- ## Email & SMS Services
681
-
682
- ### Email Service
244
+ GitHub, Kakao, and Naver use the provider-generic URL route:
683
245
 
684
- The email service uses AWS SES by default, with fallback to console logging in development.
685
-
686
- **Send Email:**
687
246
  ```typescript
688
- import { sendEmail } from '@spfn/auth/server';
689
-
690
- await sendEmail({
691
- to: 'user@example.com',
692
- subject: 'Welcome!',
693
- text: 'Plain text content',
694
- html: '<h1>HTML content</h1>',
695
- purpose: 'welcome', // for logging
247
+ const { authUrl } = await authApi.getProviderOAuthUrl.call({
248
+ params: { provider: 'github' }, // or 'kakao', 'naver'
249
+ body: {
250
+ returnUrl: '/dashboard',
251
+ metadata: { birthDate: '2000-01-01', termsAgreed: true },
252
+ },
696
253
  });
254
+ window.location.href = authUrl;
255
+ ```
256
+
257
+ Both convenience URL APIs seal `metadata` into the encrypted OAuth state. On a new social
258
+ signup, the callback passes it to `beforeRegister` and `authRegisterEvent`; existing-account
259
+ logins do not run the registration hook.
260
+
261
+ Built-in OAuth routes: `POST /_auth/oauth/google/url`, `GET /_auth/oauth/google` (redirect),
262
+ `GET /_auth/oauth/google/callback`, `POST /_auth/oauth/finalize`, `GET /_auth/oauth/providers`,
263
+ plus the provider-generic `POST /_auth/oauth/start`. `getGoogleAccessToken(userId)` returns a
264
+ valid Google access token (auto-refreshing via stored refresh token when near expiry; throws if
265
+ no Google account is linked or no refresh token is available).
266
+
267
+ Kakao's `is_email_valid` and `is_email_verified` claims are both required before its email can
268
+ link an existing SPFN account. GitHub uses the primary email from `/user/emails` (needs the
269
+ `user:email` scope) and treats it as verified only when GitHub marks it verified; without that
270
+ scope it falls back to the public profile email, unverified. Naver provides an email address but no verified-email claim, so
271
+ Naver login never links an existing account by email; new Naver users can verify and add their
272
+ email through the application's normal onboarding flow. Until then, the OAuth account is identified
273
+ by its provider and provider user ID, so its user row may have both email and phone unset.
274
+
275
+ ### OAuth callback origin (web app host + rewrite)
276
+
277
+ The callback's CSRF check is a double-submit: the Next.js interceptor sets an `oauth_csrf`
278
+ cookie on the **web app host**, and the callback compares it against the nonce sealed in the
279
+ state. Host-only cookies never reach a different host, so **the provider callback must return
280
+ to the web app origin** — redirect URIs default to
281
+ `{NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}/_auth/oauth/<provider>/callback`.
282
+
283
+ The app forwards `/_auth/*` to the API with a standard rewrite (**required** — without it the
284
+ callback 404s on the web host, including in local dev):
285
+
286
+ ```javascript
287
+ // next.config.js
288
+ const nextConfig = {
289
+ async rewrites()
290
+ {
291
+ return [
292
+ {
293
+ source: '/_auth/:path*',
294
+ destination: `${process.env.SPFN_API_URL}/_auth/:path*`,
295
+ },
296
+ ];
297
+ },
298
+ };
697
299
  ```
698
300
 
699
- **Custom Email Provider:**
700
- ```typescript
701
- import { registerEmailProvider } from '@spfn/auth/server';
702
-
703
- // Register SendGrid provider
704
- registerEmailProvider({
705
- name: 'sendgrid',
706
- sendEmail: async ({ to, subject, text, html }) => {
707
- // Your SendGrid implementation
708
- return { success: true, messageId: '...' };
709
- },
710
- });
711
- ```
301
+ Register each **web app host** callback URL in its provider console, for example
302
+ `https://app.example.com/_auth/oauth/kakao/callback` and
303
+ `https://app.example.com/_auth/oauth/naver/callback`.
712
304
 
713
- ---
305
+ The cookie name also carries a `_${PORT}` suffix from the process that set it (the Next.js
306
+ process), which differs from the API process in a split deployment — the callback therefore
307
+ matches every `spfn_oauth_csrf*` cookie candidate against the state nonce, so no PORT
308
+ coordination is needed.
714
309
 
715
- ### SMS Service
310
+ One caveat: the direct `POST /_auth/oauth/start` flow (no Next.js interceptor) sets its CSRF
311
+ cookie on the **API host**. If you use that flow in a split deployment, set
312
+ the corresponding provider redirect URI explicitly to the API host callback instead.
716
313
 
717
- The SMS service uses AWS SNS by default.
314
+ ### Native social sign-in (mobile / web id_token)
718
315
 
719
- **Send SMS:**
720
- ```typescript
721
- import { sendSMS } from '@spfn/auth/server';
316
+ For native apps — and for Apple on Android/web, which has no native SDK — the client obtains an
317
+ `id_token` from the platform SDK and posts it to **`POST /_auth/oauth/:provider/native`**. No
318
+ authorization code, no client secret: the server verifies the id_token against the provider's
319
+ JWKS (signature, issuer, audience, expiry, nonce), links/creates the user, and **registers the
320
+ client's public key**. It returns `{ userId, keyId, isNewUser }` — *not* a token. The client mints
321
+ its own Bearer client token by signing with the on-device private key (the same client-signs /
322
+ server-verifies model as the rest of auth).
722
323
 
723
- await sendSMS({
724
- phone: '+821012345678', // E.164 format
725
- message: 'Your code is: 123456',
726
- purpose: 'verification',
727
- });
728
- ```
324
+ Enable per provider by declaring the accepted audiences: `SPFN_AUTH_GOOGLE_NATIVE_CLIENT_IDS` for
325
+ Google (the web `SPFN_AUTH_GOOGLE_CLIENT_ID` is also accepted) and `SPFN_AUTH_APPLE_CLIENT_IDS` for
326
+ Apple. Apple is native-only here — its web OAuth (code-exchange) methods throw.
729
327
 
730
- **Custom SMS Provider:**
731
328
  ```typescript
732
- import { registerSMSProvider } from '@spfn/auth/server';
733
-
734
- // Register Twilio provider
735
- registerSMSProvider({
736
- name: 'twilio',
737
- sendSMS: async ({ phone, message }) => {
738
- // Your Twilio implementation
739
- return { success: true, messageId: '...' };
740
- },
329
+ await authApi.oauthNative.call({
330
+ params: { provider: 'apple' }, // or 'google'
331
+ body: { idToken, nonce, publicKey, keyId, fingerprint, algorithm: 'ES256', profile: { name } },
741
332
  });
333
+ // → { userId, keyId, isNewUser }; client then signs its own ES256 Bearer token with keyId
742
334
  ```
743
335
 
744
- ---
336
+ The `nonce` is the **raw** nonce the client used; Apple hashes it (SHA-256) into the token, so send
337
+ the raw value for either provider. `profile.name` captures the name Apple returns only on first
338
+ sign-in. Trade-off: skipping code exchange means no Apple refresh token / server-side revoke —
339
+ revoke SPFN access by revoking the registered key instead.
745
340
 
746
- ## Email Templates
341
+ ### Custom providers
747
342
 
748
- ### Built-in Templates
343
+ Implement `OAuthProvider` and register it. `SOCIAL_PROVIDERS` is `['google','apple','github','kakao','naver','superself']`. Implement the optional `verifyNativeIdToken(idToken, { nonce })` to support native id_token sign-in.
749
344
 
750
- | Template | Function | Purpose |
751
- |----------|----------|---------|
752
- | `verificationCode` | `getVerificationCodeTemplate` | Verification codes (registration, login, password reset) |
753
- | `welcome` | `getWelcomeTemplate` | Welcome email after registration |
754
- | `passwordReset` | `getPasswordResetTemplate` | Password reset link |
755
- | `invitation` | `getInvitationTemplate` | User invitation |
756
-
757
- **Usage:**
758
345
  ```typescript
759
- import { getVerificationCodeTemplate, sendEmail } from '@spfn/auth/server';
760
-
761
- const { subject, text, html } = getVerificationCodeTemplate({
762
- code: '123456',
763
- purpose: 'registration',
764
- expiresInMinutes: 5,
765
- appName: 'MyApp',
766
- });
346
+ import {
347
+ registerOAuthProvider, getOAuthProvider, getRegisteredProviders,
348
+ oauthCallbackService,
349
+ type OAuthProvider, type NormalizedIdentity, type OAuthTokens,
350
+ } from '@spfn/auth/server';
767
351
 
768
- await sendEmail({ to: 'user@example.com', subject, text, html });
352
+ registerOAuthProvider(myProvider); // same id re-registers (override)
769
353
  ```
770
354
 
771
- ---
772
-
773
- ### Custom Templates
355
+ ### OAuth token encryption and key rotation
774
356
 
775
- Register custom templates to override defaults with your brand design:
357
+ Web OAuth access and refresh tokens are encrypted at rest with AES-256-GCM. Token encryption is
358
+ separate from session-cookie encryption: `SPFN_AUTH_TOKEN_ENCRYPTION_KEYS` is backend-only and
359
+ must never be exposed to the Next.js process. Generate a key with `openssl rand -base64 32` and
360
+ assign it a non-secret key ID:
776
361
 
777
- ```typescript
778
- import { registerEmailTemplates } from '@spfn/auth/server';
779
-
780
- // Register at app initialization (e.g., server.config.ts)
781
- registerEmailTemplates({
782
- // Override verification code template
783
- verificationCode: ({ code, purpose, expiresInMinutes, appName }) => ({
784
- subject: `[${appName}] Your verification code`,
785
- text: `Your code: ${code}\nExpires in ${expiresInMinutes} minutes.`,
786
- html: `
787
- <div style="font-family: Arial, sans-serif;">
788
- <img src="https://myapp.com/logo.png" alt="Logo" />
789
- <h1>Verification Code</h1>
790
- <div style="font-size: 32px; font-weight: bold;">${code}</div>
791
- <p>This code expires in ${expiresInMinutes} minutes.</p>
792
- </div>
793
- `,
794
- }),
795
-
796
- // Override invitation template
797
- invitation: ({ inviteLink, inviterName, roleName, appName }) => ({
798
- subject: `${inviterName} invited you to ${appName}`,
799
- text: `Accept invitation: ${inviteLink}`,
800
- html: `
801
- <h1>You're Invited!</h1>
802
- <p>${inviterName} invited you to join ${appName} as ${roleName}.</p>
803
- <a href="${inviteLink}">Accept Invitation</a>
804
- `,
805
- }),
806
- });
362
+ ```dotenv
363
+ SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v2:<base64-32-byte-key>
807
364
  ```
808
365
 
809
- **Template Parameters:**
810
-
811
- | Template | Parameters |
812
- |----------|------------|
813
- | `verificationCode` | `code`, `purpose`, `expiresInMinutes?`, `appName?` |
814
- | `welcome` | `email`, `appName?` |
815
- | `passwordReset` | `resetLink`, `expiresInMinutes?`, `appName?` |
816
- | `invitation` | `inviteLink`, `inviterName?`, `roleName?`, `appName?` |
817
-
818
- ---
819
-
820
- ## Server-Side API
821
-
822
- ### Public Routes (No Authentication)
823
-
824
- All routes are automatically registered at `/_auth/*` via SPFN plugin system.
825
-
826
- #### `POST /_auth/exists`
366
+ For zero-downtime rotation, prepend the new key and retain old keys for decryption:
827
367
 
828
- Check if account exists.
829
-
830
- **Request:**
831
- ```typescript
832
- {
833
- email?: string;
834
- phone?: string; // E.164 format
835
- }
836
- ```
837
-
838
- **Response:**
839
- ```typescript
840
- {
841
- exists: boolean;
842
- identifier: string;
843
- identifierType: 'email' | 'phone';
844
- }
368
+ ```dotenv
369
+ SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v3:<new-key>,v2:<old-key>
845
370
  ```
846
371
 
847
- ---
848
-
849
- #### `POST /_auth/codes`
372
+ New writes use the first key. Reads using an older key, the legacy session-secret-derived `enc:v1`
373
+ format, or historical plaintext are automatically re-encrypted with the active key. Keep every old
374
+ key available until all rows have been read or explicitly migrated; removing a referenced key makes
375
+ those tokens undecryptable. Ciphertext is bound to `provider`, `providerUserId`, and token type
376
+ (`access` or `refresh`) with authenticated data, preventing ciphertext from being moved to another
377
+ account or field.
850
378
 
851
- Send verification code.
852
-
853
- **Request:**
854
- ```typescript
855
- {
856
- target: string; // Email or phone
857
- targetType: 'email' | 'phone';
858
- purpose: 'registration' | 'login' | 'password_reset';
859
- }
860
- ```
379
+ Deployments that need a KMS or per-account envelope encryption can call
380
+ `configureOAuthTokenCipher()` from `@spfn/auth/server` before the server starts. The custom cipher
381
+ receives the same account/token context and owns its key rotation policy.
861
382
 
862
- **Response:**
863
- ```typescript
864
- {
865
- success: boolean;
866
- expiresAt: string; // ISO 8601
867
- }
868
- ```
383
+ **Integration contract for custom providers:**
869
384
 
870
- ---
385
+ - The built-in provider-generic callback route handles any registered provider. A custom callback is
386
+ only needed when the provider does not follow the standard `code` / `state` response contract.
387
+ - If a custom callback calls `oauthCallbackService()` directly, wrap the route in `Transactional()`
388
+ (`import { Transactional } from '@spfn/core/db'`).
389
+ - The provider `id` must be in `SOCIAL_PROVIDERS` (`enumText`, plain text — adding a value needs **no**
390
+ DB migration).
391
+ - `auth.login` / `auth.register` events now carry any `SOCIAL_PROVIDERS` value in `provider` —
392
+ update any `switch(provider)` in subscribers.
871
393
 
872
- #### `POST /_auth/codes/verify`
394
+ ## Sessions (Next.js)
873
395
 
874
- Verify OTP code.
396
+ Sessions are HttpOnly cookies encrypted with `SPFN_AUTH_SESSION_SECRET` (JWE), holding the
397
+ client private key + `keyId` (`SessionData`: `{ userId, privateKey, keyId, algorithm }`). The
398
+ interceptor reads them to sign outbound RPC JWTs. From `@spfn/auth/nextjs/server`:
875
399
 
876
- **Request:**
877
400
  ```typescript
878
- {
879
- target: string;
880
- targetType: 'email' | 'phone';
881
- code: string; // 6 digits
882
- purpose: 'registration' | 'login' | 'password_reset';
883
- }
884
- ```
401
+ import { saveSession, getSession, clearSession } from '@spfn/auth/nextjs/server';
885
402
 
886
- **Response:**
887
- ```typescript
888
- {
889
- valid: boolean;
890
- verificationToken?: string; // 15min JWT for registration
891
- }
403
+ await saveSession({ userId: '123', privateKey: '...', keyId: 'uuid', algorithm: 'ES256' });
404
+ const session = await getSession(); // read-only, safe in Server Components
405
+ await clearSession();
892
406
  ```
893
407
 
894
- ---
895
-
896
- #### `POST /_auth/register`
897
-
898
- Register new user.
408
+ RSC guards (redirect when unmet) — `RequireAuth`, `RequireRole`, `RequirePermission`:
899
409
 
900
- **Request:**
901
- ```typescript
902
- {
903
- email?: string;
904
- phone?: string;
905
- verificationToken: string; // From /codes/verify
906
- password: string; // Min 8 chars
907
- publicKey: string; // Base64 DER (SPKI)
908
- keyId: string; // UUID v4
909
- fingerprint: string; // SHA-256 hex (64 chars)
910
- algorithm: 'ES256' | 'RS256';
911
- keySize?: number;
912
- }
913
- ```
410
+ ```tsx
411
+ import { RequireAuth, RequireRole } from '@spfn/auth/nextjs/server';
914
412
 
915
- **Response:**
916
- ```typescript
413
+ export default async function AdminPage()
917
414
  {
918
- userId: string;
919
- email?: string;
920
- phone?: string;
415
+ return (
416
+ <RequireAuth redirectTo="/login">
417
+ <RequireRole roles={['admin', 'superadmin']} redirectTo="/forbidden">
418
+ <Dashboard />
419
+ </RequireRole>
420
+ </RequireAuth>
421
+ );
921
422
  }
922
423
  ```
923
424
 
924
- ---
925
-
926
- #### `POST /_auth/login`
425
+ Also exported: `getAuthSessionData`, `getUserRole`, `getUserPermissions`, `hasAnyRole`,
426
+ `hasAnyPermission`, the OAuth pending-session helpers, and `createOAuthCallbackHandler`.
927
427
 
928
- User login.
428
+ ## RBAC
929
429
 
930
- **Request:**
931
- ```typescript
932
- {
933
- email?: string;
934
- phone?: string;
935
- password: string;
936
- publicKey: string; // New key for session
937
- keyId: string;
938
- fingerprint: string;
939
- oldKeyId?: string; // Revoke previous key
940
- algorithm: 'ES256' | 'RS256';
941
- keySize?: number;
942
- }
943
- ```
430
+ Built-in roles: `superadmin` (priority 100), `admin` (80), `user` (10). Built-in permissions:
431
+ `auth:self:manage`, `user:read|write|delete|invite`, `rbac:role:manage`, `rbac:permission:manage`.
432
+ Custom roles/permissions are declared on the lifecycle (preferred — runs on startup) or via
433
+ `initializeAuth(options)`.
944
434
 
945
- **Response:**
946
435
  ```typescript
947
- {
948
- userId: string;
949
- email?: string;
950
- phone?: string;
951
- passwordChangeRequired: boolean;
952
- }
436
+ createAuthLifecycle({
437
+ roles: [{ name: 'editor', displayName: 'Editor', priority: 30 }],
438
+ permissions: [{ name: 'post:publish', displayName: 'Publish Posts', category: 'content' }],
439
+ rolePermissions: { editor: ['post:publish'] },
440
+ });
953
441
  ```
954
442
 
955
- ---
956
-
957
- ### Authenticated Routes (Require JWT)
443
+ Programmatic checks (server): `hasPermission`, `hasAnyPermission`, `hasAllPermissions`, `hasRole`,
444
+ `hasAnyRole`, `getUserRole`, `getUserPermissions`. Runtime role admin: `createRole`, `updateRole`,
445
+ `deleteRole`, `setRolePermissions`, `addPermissionToRole`, `removePermissionFromRole`,
446
+ `getAllRoles`, `getRoleByName`, `getRolePermissions`.
958
447
 
959
- **Authentication:**
960
- - Header: `Authorization: Bearer <jwt>`
961
- - JWT payload must contain: `{ userId, keyId }`
962
- - Server extracts `keyId` from JWT, fetches public key, verifies signature
448
+ ## Events
963
449
 
964
- ---
450
+ `@spfn/auth` emits decoupled events (via `@spfn/core/event`). Subscribe for welcome emails,
451
+ analytics, onboarding, etc. Client-supplied `metadata` on register/OAuth flows is forwarded verbatim.
965
452
 
966
- #### `POST /_auth/logout`
967
-
968
- Logout and revoke current key.
969
-
970
- **Request:**
971
453
  ```typescript
972
- {} // Empty body
973
- ```
454
+ import { authLoginEvent, authRegisterEvent, invitationCreatedEvent, invitationAcceptedEvent } from '@spfn/auth/server';
974
455
 
975
- **Response:**
976
- ```typescript
456
+ authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
977
457
  {
978
- success: boolean;
979
- }
458
+ if (email) await sendWelcome(email);
459
+ });
980
460
  ```
981
461
 
982
- ---
462
+ Payload types: `AuthLoginPayload`, `AuthRegisterPayload`, `InvitationCreatedPayload`,
463
+ `InvitationAcceptedPayload`, `AuthDeletionRequestedPayload`, `AuthDeletionCancelledPayload`,
464
+ `AuthDeletionCompletedPayload`. These events also bind to `@spfn/core/job` jobs via `.on(event)`.
983
465
 
984
- #### `POST /_auth/keys/rotate`
466
+ ## Registration gate (`beforeRegister`)
985
467
 
986
- Rotate public key before expiry (90 days).
468
+ Events fire *after* the user exists — they cannot reject a registration. For server-enforced
469
+ signup policy (age gate, invite-only domains, block lists) inject a validator with
470
+ `configureAuth`; it runs **before the user row is created** on every registration channel:
471
+ `credentials` (email/phone register), `oauth` (new-user social signup, web + native), and
472
+ `invitation` (acceptance). Throwing rejects the registration; `RegistrationRejectedError` (403)
473
+ is the recommended error. The hook receives the same `metadata` the app supplied to
474
+ `register` / OAuth start / the invitation — never credentials.
987
475
 
988
- **Request:**
989
476
  ```typescript
990
- {
991
- publicKey: string; // New public key
992
- keyId: string; // New UUID
993
- fingerprint: string;
994
- algorithm: 'ES256' | 'RS256';
995
- keySize?: number;
996
- }
997
- ```
477
+ import { configureAuth, RegistrationRejectedError } from '@spfn/auth/server';
998
478
 
999
- **Response:**
1000
- ```typescript
1001
- {
1002
- success: boolean;
1003
- keyId: string;
1004
- }
479
+ configureAuth({
480
+ beforeRegister: async ({ channel, provider, email, phone, metadata }) =>
481
+ {
482
+ if (!isOldEnough(metadata?.birthDate))
483
+ {
484
+ throw new RegistrationRejectedError({ message: 'Age requirement not met' });
485
+ }
486
+ },
487
+ });
1005
488
  ```
1006
489
 
1007
- ---
1008
-
1009
- #### `PUT /_auth/password`
1010
-
1011
- Change password.
1012
-
1013
- **Request:**
1014
- ```typescript
1015
- {
1016
- currentPassword: string;
1017
- newPassword: string; // Min 8 chars
1018
- }
1019
- ```
490
+ Notes:
491
+ - Runs after built-in checks (verification token, duplicate account) — existing error
492
+ precedence is unchanged, and the hook cannot be probed without a valid verification token.
493
+ - Not called when an OAuth login links a social account to an existing user, nor for admin
494
+ seeding in `initializeAuth()`.
495
+ - OAuth signups have no client-typed fields unless you pass `metadata` at OAuth start — decide
496
+ per channel (reject, or allow and collect during onboarding).
497
+ - On the `oauth` channel `email` is the provider-reported address and may be **unverified**
498
+ (the created account then stores `email` as `null`). The context carries
499
+ `emailVerified` — an email-based allow/block policy must check it before trusting `email`.
500
+ - The hook runs **inside the registration DB transaction** on every channel — keep it fast.
501
+ A slow call (e.g. an external policy API) holds a pooled DB connection open per signup.
502
+ - On the **web** OAuth flow a rejection surfaces as the standard OAuth error redirect
503
+ (302 to the app's OAuth error URL, message only) — not a 403 JSON response. The native
504
+ OAuth flow, credentials, and invitation channels return the error status (403) directly.
505
+
506
+ ## One-Time Token
507
+
508
+ For short-lived authenticated handshakes (e.g. SSE) where a `Bearer` header is awkward: issue
509
+ with `authApi.issueOneTimeToken`, protect the consuming route with the `oneTimeTokenAuth`
510
+ middleware. Call `initOneTimeTokenManager({ ttl, store })` during setup for a custom TTL/store.
511
+
512
+ ## Account Deletion & Recovery
513
+
514
+ Grace-period deletion with in-window recovery, an admin/GDPR-response entry point for immediate
515
+ purge, and a pluggable app-data cleanup hook. Not covered by this feature: re-signup email
516
+ blind-index/hashing (a purged account's email becomes reusable immediately — see the project's
517
+ PII protection track for blind-index re-signup prevention), backup beyond-use handling, DSR
518
+ intake/response workflows, and webhook fan-out — those are app/ops concerns.
519
+
520
+ ```
521
+ active ──request (re-auth)──> pending_deletion ──grace period elapses (cron)──> deleted (anonymize) | row removed (hard-delete)
522
+ ^ │
523
+ └───────────cancel (re-auth)───────┘ immediate = grace period of 0, same pipeline
524
+ ```
525
+
526
+ - **Request** — `POST /_auth/deletion/request` (authenticated). Step-up re-auth: password
527
+ holders confirm with `password`; OAuth-only/passwordless accounts confirm with a
528
+ `verificationToken` from `/_auth/codes` + `/_auth/codes/verify` (`purpose: 'account_deletion'`).
529
+ On success: status → `pending_deletion`, every active session key is revoked, a
530
+ `account_deletion_requests` audit row is created, `auth.deletion.requested` fires, and (if
531
+ the user has an email and `sendNotifications` is on) a notice is sent with the scheduled purge
532
+ date.
533
+ - **Login is blocked while pending** — password login, OAuth login, and the `authenticate`
534
+ middleware all reject a `pending_deletion` account with `AccountPendingDeletionError` (403,
535
+ `details.purgeScheduledAt`) instead of the generic `AccountDisabledError`, so the client can
536
+ show a recovery prompt.
537
+ - **Cancel (recovery)** — `POST /_auth/deletion/cancel` (public — sessions were revoked at
538
+ request time, so there's no Bearer token to authenticate with). Credential-based: email/phone
539
+ plus `password` or a fresh `verificationToken`. On success, status → `active`; the user still
540
+ needs to log in separately afterward.
541
+ - **Purge job** — sweeps `account_deletion_requests` for rows past their grace period and
542
+ destroys the account. Register it explicitly (see below); it is **not** wired up by
543
+ `createAuthLifecycle()` automatically.
544
+ - **Admin / GDPR-response entry points** — `requestAccountDeletionService(userId, { requestedBy: 'admin', immediate })`
545
+ and `purgeUserService(userId)` are exported for app-side admin routes / DSR handling; the app
546
+ owns the route and its authorization.
1020
547
 
1021
- **Response:**
1022
548
  ```typescript
1023
- {
1024
- success: boolean;
1025
- }
1026
- ```
1027
-
1028
- ---
1029
-
1030
- ## Database Schema
1031
-
1032
- ### Core Tables
1033
-
1034
- #### `users`
1035
-
1036
- Main user identity table.
1037
-
1038
- ```sql
1039
- CREATE TABLE users (
1040
- id BIGSERIAL PRIMARY KEY,
1041
- email TEXT UNIQUE,
1042
- phone TEXT UNIQUE,
1043
- password_hash TEXT NOT NULL,
1044
- password_change_required BOOLEAN DEFAULT false,
1045
- role_id BIGINT REFERENCES roles(id) NOT NULL,
1046
- status TEXT NOT NULL CHECK (status IN ('active', 'inactive', 'suspended')),
1047
- email_verified_at TIMESTAMP,
1048
- phone_verified_at TIMESTAMP,
1049
- last_login_at TIMESTAMP,
1050
- created_at TIMESTAMP DEFAULT NOW(),
1051
- updated_at TIMESTAMP DEFAULT NOW(),
1052
-
1053
- CONSTRAINT users_identifier_check CHECK (
1054
- (email IS NOT NULL) OR (phone IS NOT NULL)
1055
- )
1056
- );
1057
- ```
549
+ import { defineServerConfig } from '@spfn/core/server';
550
+ import { createAuthLifecycle, authJobRouter } from '@spfn/auth/server';
1058
551
 
1059
- **Key Points:**
1060
- - At least one of `email` OR `phone` required
1061
- - `passwordHash` is bcrypt ($2b$10$..., 60 chars)
1062
- - `roleId` references roles table (NOT NULL)
1063
-
1064
- ---
1065
-
1066
- #### `user_public_keys`
1067
-
1068
- Stores client public keys for JWT verification.
1069
-
1070
- ```sql
1071
- CREATE TABLE user_public_keys (
1072
- id BIGSERIAL PRIMARY KEY,
1073
- user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
1074
- key_id TEXT UNIQUE NOT NULL,
1075
- public_key TEXT NOT NULL,
1076
- algorithm TEXT NOT NULL CHECK (algorithm IN ('ES256', 'RS256')),
1077
- fingerprint TEXT NOT NULL,
1078
- is_active BOOLEAN DEFAULT true,
1079
- created_at TIMESTAMP DEFAULT NOW(),
1080
- last_used_at TIMESTAMP,
1081
- expires_at TIMESTAMP NOT NULL,
1082
- revoked_at TIMESTAMP,
1083
- revoked_reason TEXT
1084
- );
1085
-
1086
- CREATE INDEX idx_user_public_keys_user_id ON user_public_keys(user_id);
1087
- CREATE INDEX idx_user_public_keys_key_id ON user_public_keys(key_id);
1088
- CREATE INDEX idx_user_public_keys_is_active ON user_public_keys(is_active);
552
+ export default defineServerConfig()
553
+ .lifecycle(createAuthLifecycle({
554
+ deletion: {
555
+ gracePeriodDays: 30, // default; 0 = immediate
556
+ purgeStrategy: 'anonymize', // default; or 'hard-delete'
557
+ allowSelfImmediate: false, // default; self-service immediate: true
558
+ sendNotifications: true, // default
559
+ onBeforePurge: async (user) =>
560
+ {
561
+ // throw to skip this user for the current sweep (retried next run)
562
+ await appDataCleanup(user.id);
563
+ },
564
+ },
565
+ }))
566
+ .jobs(authJobRouter) // registers the daily (04:00 UTC) purge sweep
567
+ .routes(appRouter)
568
+ .build();
1089
569
  ```
1090
570
 
1091
- **Key Points:**
1092
- - `keyId` is client-generated UUID v4
1093
- - `fingerprint` is SHA-256(publicKey) for verification
1094
- - `expiresAt` defaults to 90 days from creation
1095
- - `isActive` determines if key can be used
1096
-
1097
- ---
1098
-
1099
- #### `verification_codes`
571
+ **Purge strategies:**
572
+
573
+ - `anonymize` (default) scrubs PII, keeps the row: `email` → `deleted-{publicId}@deleted.invalid`,
574
+ `phone`/`username`/`passwordHash` `null`, `status` `'deleted'`, `deletedAt`/`deletedBy` set
575
+ (`softDelete()` on `users`). Social accounts and public keys are deleted (frees the provider
576
+ link and revokes access), the profile's PII columns are cleared, and any leftover verification
577
+ codes for the original email/phone are removed. The freed email/phone can be re-registered
578
+ immediately.
579
+ - `hard-delete` — physically removes the `users` row; child rows (`user_profiles`,
580
+ `user_public_keys`, `user_social_accounts`, `user_permissions`) cascade-delete via their FK.
581
+ The `account_deletion_requests` audit row survives either strategy — its `userId` FK is
582
+ `set null` (not cascade), by design, so "who requested/purged what, when" outlives the user row.
583
+
584
+ The final "your account has been deleted" notice is sent **after** the purge transaction commits
585
+ (never before, and never on a purge that aborted or rolled back — see below), using the address
586
+ captured before the destructive step ran. This holds for `hard-delete` too: the row is already
587
+ gone by send time, but the address was captured beforehand, so the notice still goes out.
588
+
589
+ **Concurrency.** The purge job re-verifies the user is still `pending_deletion` on the write
590
+ primary immediately before any destructive DML, inside the same transaction as the DML itself —
591
+ closing the window between a stale read (the sweep's own batch, or replica lag) and a concurrent
592
+ `cancel`. The `account_deletion_requests` claim (`markCompleted`) is a conditional `UPDATE ...
593
+ WHERE status = 'pending'`; if a concurrent cancel already moved the row off `pending`, the claim
594
+ matches zero rows and the purge aborts with no destructive DML and no overwritten audit row.
595
+
596
+ **Cron schedule caveat.** `deletion.purgeCron` (default `0 4 * * *`) is stored for reference, but
597
+ the static `authJobRouter` export above always runs on the *default* cron — `job(...).cron(...)`
598
+ is fixed at module-import time, which happens before `createAuthLifecycle()` runs in your
599
+ `server.config.ts`. For a non-default schedule, build the router yourself, after the
600
+ `createAuthLifecycle()` call, and register that instead:
601
+
602
+ ```typescript
603
+ import { createAuthDeletionJobRouter } from '@spfn/auth/server';
604
+
605
+ // ... after .lifecycle(createAuthLifecycle({ deletion: { purgeCron: '0 3 * * *' } }))
606
+ .jobs(createAuthDeletionJobRouter({ purgeCron: '0 3 * * *' }))
607
+ ```
608
+
609
+ Register **only one** of `authJobRouter` / `createAuthDeletionJobRouter(...)` — both build a job
610
+ named `auth.deletion.purge`, so registering both (e.g. the static export *and* a custom-cron
611
+ router) double-registers the same job name against pg-boss instead of overriding it.
612
+
613
+ ## Pitfalls & anti-patterns
614
+
615
+ - **Wrong entry point.** `@spfn/auth/server` and `@spfn/auth/nextjs/*` are server-only (Node /
616
+ `server-only`). Importing them in a client component breaks the build. Entities, services, and
617
+ repositories are on `/server`, not on root `@spfn/auth`.
618
+ - **No `app.bind(contract, ...)`.** That contract pattern is removed. Use the route DSL
619
+ (`route.get().handler()` + `defineRouter`). Any docs/snippets using `app.bind` are stale.
620
+ - **Custom error classes must be registered.** Add them to an `ErrorRegistry` (mirror
621
+ `authErrorRegistry` in `src/errors/index.ts`) and pass it to your `createApi({ errorRegistry })`,
622
+ or the client receives a generic error instead of the typed one.
623
+ - **Two env files, by audience.** `SPFN_AUTH_SESSION_SECRET` lives in `.env.local` (Next.js needs
624
+ it for cookie crypto); `SPFN_AUTH_VERIFICATION_TOKEN_SECRET` and
625
+ `SPFN_AUTH_TOKEN_ENCRYPTION_KEYS` live in `.env.server`. Token encryption keys are backend-only;
626
+ putting them in `.env.local` unnecessarily gives the Next.js process token-decryption authority.
627
+ - **`SPFN_AUTH_SESSION_SECRET` is validated.** Minimum 32 chars plus entropy/unique-char checks —
628
+ a short or low-entropy value fails startup, not just a warning.
629
+ - **Forgetting the interceptor import.** Without `import '@spfn/auth/nextjs/api'` in the RPC proxy
630
+ route, the client sends no `Authorization` header and every protected call 401s. The
631
+ `authenticate` middleware error message points here.
632
+ - **Custom OAuth callback without `Transactional()`.** A failure mid-callback leaves an orphan
633
+ user. Always wrap the callback route in `Transactional()` and call `oauthCallbackService`.
634
+ - **`sideEffects: false` tree-shakes the google provider.** The built-in provider self-registers
635
+ via a module side-effect; an aggressive bundler config can drop it. Don't mark this package's
636
+ imports side-effect-free.
637
+ - **Public routes need an explicit opt-out.** With global `authenticate`, any route without
638
+ `.skip(['auth'])` (or `optionalAuth`, which auto-skips) requires a valid token.
639
+ - **`SOCIAL_PROVIDERS` is plain `enumText`.** Adding a provider value needs no DB migration, but
640
+ every `switch(provider)` over login/register events must handle the new value.
641
+ - **Email/SMS is not here.** It moved to `@spfn/notification` (`import { sendEmail, sendSMS } from
642
+ '@spfn/notification/server'`). Wire verification-code / invitation emails through its events.
643
+ - **`authJobRouter` isn't registered for you.** `createAuthLifecycle()`'s `afterInfrastructure`
644
+ hook runs *before* `@spfn/core` initializes pg-boss and registers jobs, so the lifecycle has no
645
+ opportunity to auto-register the account-deletion purge job. Call `.jobs(authJobRouter)`
646
+ yourself — see [Account Deletion & Recovery](#account-deletion--recovery).
647
+ - **`USER_STATUSES` gained `pending_deletion` / `deleted`.** Any code with a `switch(user.status)`
648
+ or an exhaustive status union must handle both — `enumText` is plain `text` with no DB `CHECK`,
649
+ so nothing enforces this at the database layer.
650
+
651
+ ## Complete example
652
+
653
+ ```typescript
654
+ // server.config.ts
655
+ import { defineServerConfig } from '@spfn/core/server';
656
+ import { createAuthLifecycle } from '@spfn/auth/server';
657
+ import { appRouter } from './router';
1100
658
 
1101
- OTP codes for email/SMS verification.
659
+ export default defineServerConfig()
660
+ .port(8790)
661
+ .routes(appRouter)
662
+ .lifecycle(createAuthLifecycle({
663
+ roles: [{ name: 'editor', displayName: 'Editor', priority: 30 }],
664
+ permissions: [{ name: 'post:publish', displayName: 'Publish Posts', category: 'content' }],
665
+ rolePermissions: { editor: ['post:publish'] },
666
+ }))
667
+ .build();
1102
668
 
1103
- ```sql
1104
- CREATE TABLE verification_codes (
1105
- id BIGSERIAL PRIMARY KEY,
1106
- target TEXT NOT NULL,
1107
- target_type TEXT NOT NULL CHECK (target_type IN ('email', 'phone')),
1108
- code TEXT NOT NULL,
1109
- purpose TEXT NOT NULL CHECK (purpose IN ('registration', 'login', 'password_reset')),
1110
- expires_at TIMESTAMP NOT NULL,
1111
- used_at TIMESTAMP,
1112
- created_at TIMESTAMP DEFAULT NOW()
1113
- );
669
+ // router.ts
670
+ import { defineRouter } from '@spfn/core/route';
671
+ import { authRouter, authenticate } from '@spfn/auth/server';
672
+ import { getMe } from './routes/me';
1114
673
 
1115
- CREATE INDEX idx_verification_codes_target ON verification_codes(target);
1116
- ```
674
+ export const appRouter = defineRouter({ getMe })
675
+ .packages([authRouter])
676
+ .use([authenticate]);
677
+ export type AppRouter = typeof appRouter;
1117
678
 
1118
- **Key Points:**
1119
- - 6-digit numeric code
1120
- - Expires in 5-10 minutes (configurable)
1121
- - Single-use (marked via `usedAt`)
1122
-
1123
- ---
1124
-
1125
- ### RBAC Tables
1126
-
1127
- #### `roles`
1128
-
1129
- ```sql
1130
- CREATE TABLE roles (
1131
- id BIGSERIAL PRIMARY KEY,
1132
- name TEXT UNIQUE NOT NULL,
1133
- display_name TEXT NOT NULL,
1134
- description TEXT,
1135
- is_builtin BOOLEAN DEFAULT false,
1136
- is_system BOOLEAN DEFAULT false,
1137
- is_active BOOLEAN DEFAULT true,
1138
- priority INTEGER NOT NULL,
1139
- created_at TIMESTAMP DEFAULT NOW(),
1140
- updated_at TIMESTAMP DEFAULT NOW()
1141
- );
1142
- ```
679
+ // app/api/rpc/[routeName]/route.ts
680
+ import '@spfn/auth/nextjs/api';
681
+ import { createRpcProxy } from '@spfn/core/nextjs/server';
682
+ import { authRouteMap } from '@spfn/auth';
683
+ import { routeMap } from '@/generated/route-map';
684
+ export const { GET, POST } = createRpcProxy({ routeMap: { ...routeMap, ...authRouteMap } });
1143
685
 
1144
- **Built-in Roles:**
1145
- - `user` (priority 10) - Default role
1146
- - `admin` (priority 80)
1147
- - `superadmin` (priority 100)
1148
-
1149
- ---
1150
-
1151
- #### `permissions`
1152
-
1153
- ```sql
1154
- CREATE TABLE permissions (
1155
- id BIGSERIAL PRIMARY KEY,
1156
- name TEXT UNIQUE NOT NULL,
1157
- display_name TEXT NOT NULL,
1158
- description TEXT,
1159
- category TEXT,
1160
- is_builtin BOOLEAN DEFAULT false,
1161
- is_system BOOLEAN DEFAULT false,
1162
- is_active BOOLEAN DEFAULT true,
1163
- created_at TIMESTAMP DEFAULT NOW(),
1164
- updated_at TIMESTAMP DEFAULT NOW()
1165
- );
686
+ // any client component
687
+ import { authApi } from '@spfn/auth';
688
+ const session = await authApi.getAuthSession.call({});
1166
689
  ```
1167
690
 
1168
- **Built-in Permissions:**
1169
- - `auth:self:manage`
1170
- - `user:read`, `user:write`, `user:delete`
1171
- - `rbac:role:manage`, `rbac:permission:manage`
1172
-
1173
- ---
1174
-
1175
- #### `role_permissions`
1176
-
1177
- Many-to-many mapping between roles and permissions.
1178
-
1179
- ```sql
1180
- CREATE TABLE role_permissions (
1181
- id BIGSERIAL PRIMARY KEY,
1182
- role_id BIGINT REFERENCES roles(id) ON DELETE CASCADE,
1183
- permission_id BIGINT REFERENCES permissions(id) ON DELETE CASCADE,
1184
- created_at TIMESTAMP DEFAULT NOW(),
1185
- updated_at TIMESTAMP DEFAULT NOW(),
1186
-
1187
- UNIQUE(role_id, permission_id)
1188
- );
1189
- ```
1190
-
1191
- ---
1192
-
1193
- #### `user_permissions`
1194
-
1195
- User-specific permission overrides.
1196
-
1197
- ```sql
1198
- CREATE TABLE user_permissions (
1199
- id BIGSERIAL PRIMARY KEY,
1200
- user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
1201
- permission_id BIGINT REFERENCES permissions(id) ON DELETE CASCADE,
1202
- granted BOOLEAN NOT NULL,
1203
- reason TEXT,
1204
- expires_at TIMESTAMP,
1205
- created_at TIMESTAMP DEFAULT NOW(),
1206
- updated_at TIMESTAMP DEFAULT NOW(),
1207
-
1208
- UNIQUE(user_id, permission_id)
1209
- );
1210
- ```
1211
-
1212
- **Use Cases:**
1213
- - `granted: true` - Grant permission temporarily
1214
- - `granted: false` - Revoke permission (even if role has it)
1215
- - `expiresAt` - Temporary access with expiration
1216
-
1217
- ---
1218
-
1219
- ### Supporting Tables
1220
-
1221
- #### `invitations`
1222
-
1223
- User invitation system.
1224
-
1225
- ```sql
1226
- CREATE TABLE invitations (
1227
- id BIGSERIAL PRIMARY KEY,
1228
- email TEXT NOT NULL,
1229
- token TEXT UNIQUE NOT NULL,
1230
- role_id BIGINT REFERENCES roles(id),
1231
- invited_by BIGINT REFERENCES users(id),
1232
- status TEXT CHECK (status IN ('pending', 'accepted', 'cancelled', 'expired')),
1233
- expires_at TIMESTAMP NOT NULL,
1234
- accepted_at TIMESTAMP,
1235
- created_at TIMESTAMP DEFAULT NOW()
1236
- );
1237
- ```
1238
-
1239
- ---
1240
-
1241
- #### `user_profiles`
1242
-
1243
- Extended user profile information.
1244
-
1245
- ```sql
1246
- CREATE TABLE user_profiles (
1247
- id BIGSERIAL PRIMARY KEY,
1248
- user_id BIGINT REFERENCES users(id) ON DELETE CASCADE UNIQUE,
1249
- first_name TEXT,
1250
- last_name TEXT,
1251
- display_name TEXT,
1252
- avatar_url TEXT,
1253
- bio TEXT,
1254
- created_at TIMESTAMP DEFAULT NOW(),
1255
- updated_at TIMESTAMP DEFAULT NOW()
1256
- );
1257
- ```
1258
-
1259
- ---
1260
-
1261
- #### `user_social_accounts`
1262
-
1263
- OAuth provider accounts (future feature).
1264
-
1265
- ```sql
1266
- CREATE TABLE user_social_accounts (
1267
- id BIGSERIAL PRIMARY KEY,
1268
- user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
1269
- provider TEXT NOT NULL,
1270
- provider_id TEXT NOT NULL,
1271
- access_token TEXT,
1272
- refresh_token TEXT,
1273
- expires_at TIMESTAMP,
1274
- created_at TIMESTAMP DEFAULT NOW(),
1275
-
1276
- UNIQUE(provider, provider_id)
1277
- );
1278
- ```
1279
-
1280
- ---
1281
-
1282
- ## RBAC System
1283
-
1284
- ### Initialization
1285
-
1286
- ```typescript
1287
- import { initializeAuth } from '@spfn/auth/server';
1288
-
1289
- // Minimal setup (built-in roles only)
1290
- await initializeAuth();
1291
-
1292
- // With presets
1293
- await initializeAuth({
1294
- usePresets: true, // Adds moderator, editor, viewer roles
1295
- });
1296
-
1297
- // Custom roles and permissions
1298
- await initializeAuth({
1299
- roles: [
1300
- {
1301
- name: 'content-creator',
1302
- displayName: 'Content Creator',
1303
- priority: 20,
1304
- },
1305
- ],
1306
- permissions: [
1307
- {
1308
- name: 'post:create',
1309
- displayName: 'Create Posts',
1310
- category: 'content',
1311
- },
1312
- ],
1313
- rolePermissions: {
1314
- 'content-creator': ['post:create'],
1315
- },
1316
- });
1317
- ```
1318
-
1319
- ---
1320
-
1321
- ### Built-in System
1322
-
1323
- **Roles:**
1324
- - `superadmin` (priority 100) - Full access
1325
- - `admin` (priority 80) - User management
1326
- - `user` (priority 10) - Self management
1327
-
1328
- **Permissions:**
1329
- - `auth:self:manage` - Change password, rotate keys
1330
- - `user:read`, `user:write`, `user:delete`
1331
- - `rbac:role:manage`, `rbac:permission:manage`
1332
-
1333
- ---
1334
-
1335
- ### Middleware Usage
1336
-
1337
- ```typescript
1338
- import { authenticate, requirePermissions, requireRole } from '@spfn/auth/server';
1339
-
1340
- // Single permission
1341
- app.bind(
1342
- deleteUserContract,
1343
- [authenticate, requirePermissions('user:delete')],
1344
- async (c) => {
1345
- // Only users with user:delete permission
1346
- }
1347
- );
1348
-
1349
- // Multiple permissions (all required)
1350
- app.bind(
1351
- publishPostContract,
1352
- [authenticate, requirePermissions('post:write', 'post:publish')],
1353
- async (c) => {
1354
- // Needs both permissions
1355
- }
1356
- );
1357
-
1358
- // Role-based
1359
- app.bind(
1360
- adminDashboardContract,
1361
- [authenticate, requireRole('admin', 'superadmin')],
1362
- async (c) => {
1363
- // Admin or superadmin only
1364
- }
1365
- );
1366
- ```
1367
-
1368
- ---
1369
-
1370
- ### Programmatic Checks
1371
-
1372
- ```typescript
1373
- import { hasPermission, hasRole, getUserPermissions } from '@spfn/auth/server';
1374
-
1375
- const canPublish = await hasPermission(userId, 'post:publish');
1376
- const isAdmin = await hasRole(userId, 'admin');
1377
- const permissions = await getUserPermissions(userId);
1378
-
1379
- if (canPublish)
1380
- {
1381
- // Allow publish
1382
- }
1383
- ```
1384
-
1385
- ---
1386
-
1387
- ### Runtime Role Management
1388
-
1389
- ```typescript
1390
- import { createRole, addPermissionToRole } from '@spfn/auth/server';
1391
-
1392
- // Create role
1393
- const role = await createRole({
1394
- name: 'moderator',
1395
- displayName: 'Moderator',
1396
- priority: 40,
1397
- permissionIds: [1n, 2n],
1398
- });
1399
-
1400
- // Add permission
1401
- await addPermissionToRole(role.id, 5n);
1402
-
1403
- // Delete (system roles protected)
1404
- await deleteRole(role.id);
1405
- ```
1406
-
1407
- ---
1408
-
1409
- ## Next.js Adapter
1410
-
1411
- ### Session Management
1412
-
1413
- The Next.js adapter provides encrypted HttpOnly cookie-based sessions.
1414
-
1415
- **Configuration:**
1416
- ```bash
1417
- # .env
1418
- SPFN_AUTH_SESSION_SECRET=your-32-char-secret
1419
- SPFN_AUTH_SESSION_TTL=7d # Optional, default 7d
1420
- ```
1421
-
1422
- **Session Data:**
1423
- ```typescript
1424
- interface SessionData {
1425
- userId: string;
1426
- privateKey: string; // Encrypted in cookie
1427
- keyId: string;
1428
- algorithm: 'ES256' | 'RS256';
1429
- }
1430
- ```
1431
-
1432
- ---
1433
-
1434
- ### Server Component Guards
1435
-
1436
- ```typescript
1437
- // app/admin/page.tsx
1438
- import { RequireAuth, RequireRole } from '@spfn/auth/nextjs/server';
1439
-
1440
- export default async function AdminPage()
1441
- {
1442
- return (
1443
- <RequireAuth redirectTo="/login">
1444
- <RequireRole roles={['admin', 'superadmin']} redirectTo="/forbidden">
1445
- <div>Admin Dashboard</div>
1446
- </RequireRole>
1447
- </RequireAuth>
1448
- );
1449
- }
1450
- ```
1451
-
1452
- ---
1453
-
1454
- ### Interceptors (API Routes)
1455
-
1456
- **Setup:**
1457
- ```typescript
1458
- // Simply import to auto-register
1459
- import '@spfn/auth/nextjs/api';
1460
- ```
1461
-
1462
- **How It Works:**
1463
- 1. Reads `session` HttpOnly cookie
1464
- 2. Unseals session data
1465
- 3. Generates JWT signed with `privateKey`
1466
- 4. Injects `Authorization: Bearer <jwt>` header
1467
-
1468
- **Target Routes:**
1469
- - `/_auth/login`, `/_auth/register` - Login/register interceptor
1470
- - `/_auth/keys/rotate` - Key rotation interceptor
1471
- - All other authenticated routes - General auth interceptor
1472
-
1473
- ---
1474
-
1475
- ## Testing
1476
-
1477
- ### Setup Test Environment
1478
-
1479
- ```bash
1480
- # Start test database
1481
- pnpm docker:test:up
1482
-
1483
- # Generate migrations
1484
- pnpm db:generate
1485
-
1486
- # Run migrations (via @spfn/core)
1487
- cd ../../
1488
- pnpm spfn db migrate
1489
- ```
1490
-
1491
- ---
1492
-
1493
- ### Run Tests
1494
-
1495
- ```bash
1496
- # All tests
1497
- pnpm test
1498
-
1499
- # With coverage
1500
- pnpm test:coverage
1501
-
1502
- # Route tests only
1503
- pnpm test:routes
1504
-
1505
- # Watch mode
1506
- pnpm test --watch
1507
- ```
1508
-
1509
- ---
1510
-
1511
- ### Test Structure
1512
-
1513
- ```
1514
- src/
1515
- ├── __tests__/
1516
- │ └── setup.ts # Global test setup
1517
- └── server/
1518
- ├── routes/
1519
- │ └── auth/
1520
- │ └── __tests__/
1521
- │ ├── login.test.ts
1522
- │ ├── register.test.ts
1523
- │ └── ...
1524
- └── services/
1525
- └── __tests__/
1526
- ├── auth.service.test.ts
1527
- └── ...
1528
- ```
1529
-
1530
- ---
1531
-
1532
- ### Writing Tests
1533
-
1534
- ```typescript
1535
- import { describe, it, expect, beforeEach } from 'vitest';
1536
- import { loginService } from '@/server/services';
1537
-
1538
- describe('loginService', () =>
1539
- {
1540
- beforeEach(async () =>
1541
- {
1542
- // Setup test data
1543
- });
1544
-
1545
- it('should login with valid credentials', async () =>
1546
- {
1547
- const result = await loginService({
1548
- email: 'test@example.com',
1549
- password: 'password123',
1550
- publicKey: '...',
1551
- keyId: '...',
1552
- fingerprint: '...',
1553
- algorithm: 'ES256',
1554
- });
1555
-
1556
- expect(result.userId).toBeDefined();
1557
- });
1558
- });
1559
- ```
1560
-
1561
- ---
1562
-
1563
- ### Test Database
1564
-
1565
- **docker-compose.test.yml:**
1566
- ```yaml
1567
- services:
1568
- postgres-test:
1569
- image: postgres:16-alpine
1570
- environment:
1571
- POSTGRES_DB: spfn_auth_test
1572
- POSTGRES_USER: spfn
1573
- POSTGRES_PASSWORD: spfn_dev_password
1574
- ports:
1575
- - "5433:5432"
1576
- ```
1577
-
1578
- **Test env variables:**
1579
- ```bash
1580
- DATABASE_URL=postgresql://spfn:spfn_dev_password@localhost:5433/spfn_auth_test
1581
- ```
1582
-
1583
- ---
1584
-
1585
- ## Development Workflow
1586
-
1587
- ### Initial Setup
1588
-
1589
- ```bash
1590
- # Install dependencies
1591
- pnpm install
1592
-
1593
- # Generate migrations
1594
- pnpm db:generate
1595
-
1596
- # Build package
1597
- pnpm build
1598
- ```
1599
-
1600
- ---
1601
-
1602
- ### Development
1603
-
1604
- ```bash
1605
- # Watch mode (auto-rebuild on changes)
1606
- pnpm dev
1607
-
1608
- # Type checking
1609
- pnpm type-check
1610
-
1611
- # Run tests
1612
- pnpm test
1613
- ```
1614
-
1615
- ---
1616
-
1617
- ### Build Process
1618
-
1619
- The package uses `tsup` for building:
1620
-
1621
- **tsup.config.ts:**
1622
- ```typescript
1623
- export default defineConfig({
1624
- entry: {
1625
- index: 'src/index.ts',
1626
- server: 'src/server.ts',
1627
- client: 'src/client.ts',
1628
- // ... more entry points
1629
- },
1630
- format: ['esm'],
1631
- dts: true,
1632
- clean: true,
1633
- sourcemap: true,
1634
- });
1635
- ```
1636
-
1637
- **Build outputs:**
1638
- - `dist/index.js` + `dist/index.d.ts`
1639
- - `dist/server.js` + `dist/server.d.ts`
1640
- - `dist/client.js` + `dist/client.d.ts`
1641
- - `dist/config/`, `dist/errors/`, `dist/nextjs/`
1642
-
1643
- ---
1644
-
1645
- ### Database Migrations
1646
-
1647
- ```bash
1648
- # Generate new migration (after entity changes)
1649
- pnpm db:generate
1650
-
1651
- # Apply migrations (via SPFN CLI)
1652
- cd ../../
1653
- pnpm spfn db migrate
1654
-
1655
- # View database
1656
- pnpm spfn db studio
1657
- ```
1658
-
1659
- **Migration files:** `migrations/*.sql`
1660
-
1661
- ---
1662
-
1663
- ### SPFN Plugin Integration
1664
-
1665
- **package.json:**
1666
- ```json
1667
- {
1668
- "spfn": {
1669
- "schemas": ["./dist/server/entities/*.js"],
1670
- "routes": {
1671
- "basePath": "/_auth",
1672
- "dir": "./dist/server/routes"
1673
- },
1674
- "migrations": {
1675
- "dir": "./migrations"
1676
- }
1677
- }
1678
- }
1679
- ```
1680
-
1681
- **How it works:**
1682
- 1. SPFN CLI discovers packages with `spfn` field
1683
- 2. Auto-loads database schemas
1684
- 3. Auto-registers routes at `basePath`
1685
- 4. Includes migrations in `db migrate` command
1686
-
1687
- ---
1688
-
1689
- ### Code Style
1690
-
1691
- Follow the project's code style (see `/Users/launchscreen/PROJECTS/SPFN/workspaces/.claude/rules.md`):
1692
-
1693
- - **Brace placement:** Next line (Allman-style)
1694
- - **Indentation:** 4 spaces
1695
- - **Semicolons:** Always
1696
- - **Type assertions:** Use `as`, not `<>`
1697
-
1698
- **Example:**
1699
- ```typescript
1700
- export async function myFunction(): Promise<void>
1701
- {
1702
- if (condition)
1703
- {
1704
- await operation();
1705
- }
1706
- else
1707
- {
1708
- handleError();
1709
- }
1710
- }
1711
- ```
1712
-
1713
- ---
1714
-
1715
- ### Environment Variables
1716
-
1717
- **Server-side:**
1718
- ```bash
1719
- # Required
1720
- SPFN_AUTH_JWT_SECRET=your-secret-key
1721
- DATABASE_URL=postgresql://...
1722
-
1723
- # Optional
1724
- SPFN_AUTH_JWT_EXPIRES_IN=7d
1725
- SPFN_AUTH_BCRYPT_SALT_ROUNDS=10
1726
- SPFN_AUTH_VERIFICATION_TOKEN_SECRET=separate-secret
1727
- ```
1728
-
1729
- **Next.js adapter:**
1730
- ```bash
1731
- # Required
1732
- SPFN_AUTH_SESSION_SECRET=your-32-char-secret
1733
-
1734
- # Optional
1735
- SPFN_AUTH_SESSION_TTL=7d
1736
- SPFN_API_URL=http://localhost:8790
1737
- ```
1738
-
1739
- ---
1740
-
1741
- ### Debugging
1742
-
1743
- **Enable logging:**
1744
- ```typescript
1745
- import { serverLogger } from '@/server/logger';
1746
-
1747
- serverLogger.info('Debug message', { context });
1748
- serverLogger.error('Error occurred', error);
1749
- ```
1750
-
1751
- **Inspect database:**
1752
- ```bash
1753
- pnpm spfn db studio
1754
- ```
1755
-
1756
- **Check migrations:**
1757
- ```bash
1758
- ls migrations/
1759
- ```
1760
-
1761
- ---
1762
-
1763
- ## Known Issues
1764
-
1765
- ### 1. Client Crypto Functions Missing
1766
-
1767
- **Issue:** README documents `generateKeyPair` and `generateClientToken` in `@spfn/auth/client`, but they only exist in `@spfn/auth/server`.
1768
-
1769
- **Workaround:** Use server-side crypto functions or implement client-side crypto separately.
1770
-
1771
- **Status:** Needs design decision - keep server-only or implement browser-compatible version.
1772
-
1773
- ---
1774
-
1775
- ### 2. Next.js Proxy Route Not Implemented
1776
-
1777
- **Issue:** Documentation mentions `@spfn/auth/nextjs/proxy` for client-side API proxying, but it doesn't exist.
1778
-
1779
- **Status:** Feature planned but not implemented. Current alternative: use server-side `createAuthInterceptor`.
1780
-
1781
- ---
1782
-
1783
- ### 3. `lib/api` Client Functions Removed
1784
-
1785
- **Issue:** Old `src/lib/api/` directory was deleted during refactoring.
1786
-
1787
- **Status:** Intentional removal. Use services or HTTP routes directly.
1788
-
1789
- ---
1790
-
1791
- ### 4. Test Coverage Below Target
1792
-
1793
- **Current:** ~83%
1794
- **Target:** 90%+
1795
-
1796
- **Areas needing tests:**
1797
- - Invitation service edge cases
1798
- - RBAC permission checks
1799
- - Key rotation scenarios
1800
- - Session expiry handling
1801
-
1802
- ---
1803
-
1804
- ## Roadmap
1805
-
1806
- ### Short-term (Alpha → Beta)
1807
-
1808
- - [ ] **Client-side crypto** - Browser-compatible key generation
1809
- - [ ] **Next.js proxy route** - Implement or remove from docs
1810
- - [x] **High-level authApi** - Simplified Next.js auth functions (implemented in `@spfn/auth`)
1811
- - [ ] **Test coverage** - Reach 90%+ coverage
1812
- - [x] **Documentation** - Sync docs with actual code
1813
-
1814
- ---
1815
-
1816
- ### Mid-term (Beta → v1.0)
1817
-
1818
- - [ ] **React hooks** - useAuth, useSession, usePermissions
1819
- - [ ] **UI components** - LoginForm, RegisterForm, AuthProvider
1820
- - [ ] **OAuth integration** - Google, GitHub, etc.
1821
- - [ ] **2FA support** - TOTP/authenticator apps
1822
- - [ ] **Password reset flow** - Complete email-based reset
1823
- - [ ] **Email change flow** - Verification for email updates
1824
- - [ ] **Phone change flow** - SMS verification for phone updates
1825
-
1826
- ---
1827
-
1828
- ### Long-term (Post v1.0)
1829
-
1830
- - [ ] **Admin UI** - User/role/permission management dashboard
1831
- - [ ] **Audit logging** - Track auth events
1832
- - [ ] **Rate limiting** - Built-in protection against brute force
1833
- - [ ] **Multi-tenancy** - Organization/workspace support
1834
- - [ ] **SSO integration** - SAML, OIDC
1835
- - [ ] **Biometric auth** - WebAuthn/FIDO2 support
1836
-
1837
- ---
1838
-
1839
- ## Contributing
1840
-
1841
- ### Before Contributing
1842
-
1843
- 1. Read this documentation thoroughly
1844
- 2. Check existing issues/PRs
1845
- 3. Understand the architecture
1846
- 4. Follow code style guidelines
1847
-
1848
- ---
1849
-
1850
- ### Pull Request Process
1851
-
1852
- 1. **Create feature branch**
1853
- ```bash
1854
- git checkout -b feature/my-feature
1855
- ```
1856
-
1857
- 2. **Make changes**
1858
- - Follow code style
1859
- - Add tests
1860
- - Update docs if needed
1861
-
1862
- 3. **Run checks**
1863
- ```bash
1864
- pnpm type-check
1865
- pnpm test
1866
- pnpm build
1867
- ```
1868
-
1869
- 4. **Commit with conventional commits**
1870
- ```bash
1871
- git commit -m "feat(auth): add password strength validation"
1872
- ```
1873
-
1874
- 5. **Push and create PR**
1875
- ```bash
1876
- git push origin feature/my-feature
1877
- ```
1878
-
1879
- ---
1880
-
1881
- ### Commit Message Format
1882
-
1883
- ```
1884
- <type>(<scope>): <subject>
1885
-
1886
- <body>
1887
-
1888
- <footer>
1889
- ```
1890
-
1891
- **Types:**
1892
- - `feat` - New feature
1893
- - `fix` - Bug fix
1894
- - `refactor` - Code refactoring
1895
- - `test` - Test changes
1896
- - `docs` - Documentation
1897
- - `chore` - Maintenance
1898
-
1899
- **Example:**
1900
- ```
1901
- feat(rbac): add permission inheritance
1902
-
1903
- Implement hierarchical permission inheritance where child roles
1904
- automatically inherit parent role permissions.
1905
-
1906
- Closes #123
1907
- ```
1908
-
1909
- ---
1910
-
1911
- ## Release Process
1912
-
1913
- ### Version Naming
1914
-
1915
- - `0.1.0-alpha.x` - Alpha releases (current)
1916
- - `0.1.0-beta.x` - Beta releases
1917
- - `1.0.0` - Stable release
1918
-
1919
- ---
1920
-
1921
- ### Publishing
1922
-
1923
- ```bash
1924
- # Alpha release
1925
- pnpm run publish:alpha
1926
-
1927
- # Beta release
1928
- pnpm run publish:beta
1929
-
1930
- # Production release
1931
- pnpm run publish:latest
1932
- ```
1933
-
1934
- **Pre-publish checklist:**
1935
- - [ ] All tests pass
1936
- - [ ] Type checking passes
1937
- - [ ] Build succeeds
1938
- - [ ] CHANGELOG updated
1939
- - [ ] Version bumped
1940
- - [ ] Docs updated
1941
-
1942
- ---
1943
-
1944
- ## Support
1945
-
1946
- ### Internal Team
1947
-
1948
- - **Issues:** GitHub Issues
1949
- - **Discussions:** GitHub Discussions
1950
- - **Slack:** #spfn-auth channel
1951
-
1952
- ---
1953
-
1954
- ## License
1955
-
1956
- MIT License - See LICENSE file for details.
1957
-
1958
- ---
691
+ ## Related
1959
692
 
1960
- **Last Updated:** 2025-12-07
1961
- **Document Version:** 2.2.0 (Technical Documentation)
1962
- **Package Version:** 0.1.0-alpha.88
693
+ - `@spfn/core` — route DSL (`route`, `defineRouter`), `createApi`, env (`@spfn/core/env`),
694
+ errors (`ErrorRegistry`), db (`Transactional`), events, jobs.
695
+ - `@spfn/notification` — email/SMS/push (verification codes, invitation emails).
696
+ - Full guide: `docs/guides/authentication.md`.