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

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 +549 -1819
  3. package/dist/authenticate-ofdEmk6x.d.ts +1109 -0
  4. package/dist/config.d.ts +359 -41
  5. package/dist/config.js +186 -30
  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 +2015 -513
  22. package/dist/server.js +4198 -1086
  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, 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,647 @@ 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
- ---
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_GOOGLE_NATIVE_CLIENT_IDS` | `.env.server` | — | comma-separated client IDs accepted as native id_token audience (iOS/Android/web); enables Google native sign-in |
129
+ | `SPFN_AUTH_APPLE_CLIENT_IDS` | `.env.server` | — | comma-separated Apple client IDs (bundle ID / Services ID); enables Apple native sign-in |
130
+ | `SPFN_AUTH_OAUTH_SUCCESS_URL` | `.env.server` | | default `/auth/callback` |
131
+ | `SPFN_AUTH_OAUTH_ERROR_URL` | `.env.server` | | default `/auth/error?error={error}` |
132
+ | `SPFN_AUTH_RESERVED_USERNAMES` / `_USERNAME_MIN_LENGTH` / `_USERNAME_MAX_LENGTH` | `.env.server` | — | username rules |
133
+ | `NEXT_PUBLIC_SPFN_API_URL` / `NEXT_PUBLIC_SPFN_APP_URL` | `.env.local` | — | browser-facing URLs for OAuth redirects |
134
+
135
+ Read validated values via `import { env } from '@spfn/auth/config'` (a proxy validated at
136
+ startup). `envSchema` carries descriptions/defaults.
137
+
138
+ ### Admin seeding
139
+
140
+ `createAuthLifecycle()` creates admin accounts on startup from env, in priority order. Seeded
141
+ accounts are auto email-verified, `status: 'active'`, `passwordChangeRequired: true`.
142
+
143
+ - **JSON (recommended):** `SPFN_AUTH_ADMIN_ACCOUNTS` — array of `{email, password, role?, phone?, passwordChangeRequired?}`. `role` defaults to `user` (`user` | `admin` | `superadmin`).
144
+ - **CSV:** `SPFN_AUTH_ADMIN_EMAILS` + `SPFN_AUTH_ADMIN_PASSWORDS` + `SPFN_AUTH_ADMIN_ROLES`.
145
+ - **Single (legacy):** `SPFN_AUTH_ADMIN_EMAIL` + `SPFN_AUTH_ADMIN_PASSWORD` always `superadmin`.
146
+
147
+ ## Routes
148
+
149
+ All routes mount at `/_auth/*` and are reached through `authApi.<name>.call({ body })`. Public
150
+ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-signed-jwt>`.
151
+
152
+ | `authApi` method | HTTP | Auth | Purpose |
153
+ |------------------|------|------|---------|
154
+ | `checkAccountExists` | POST `/_auth/exists` | public | email/phone existence check |
155
+ | `sendVerificationCode` | POST `/_auth/codes` | public | send 6-digit OTP |
156
+ | `verifyCode` | POST `/_auth/codes/verify` | public | verify OTP → verification token |
157
+ | `register` | POST `/_auth/register` | public | create user + register public key |
158
+ | `login` | POST `/_auth/login` | public | password login + new session key |
159
+ | `logout` | POST `/_auth/logout` | yes | revoke current key |
160
+ | `rotateKey` | POST `/_auth/keys/rotate` | yes | rotate public key before 90-day expiry |
161
+ | `changePassword` | PUT `/_auth/password` | yes | change password |
162
+ | `getAuthSession` | GET `/_auth/session` | yes | current session/user |
163
+ | `issueOneTimeToken` | POST | yes | short-lived token (e.g. SSE handshake) |
164
+ | `checkUsername` / `updateUsername` / `updateLocale` | — | mixed | username availability/update, locale |
165
+ | `getUserProfile` / `updateUserProfile` | — | yes | profile read/update |
166
+ | `createInvitation` / `acceptInvitation` / `listInvitations` / `cancelInvitation` / `resendInvitation` / `deleteInvitation` / `getInvitation` | — | mixed | invitation flow |
167
+ | `requestAccountDeletion` | POST `/_auth/deletion/request` | yes | request account deletion (re-auth gated) — see [Account Deletion & Recovery](#account-deletion--recovery) |
168
+ | `cancelAccountDeletion` | POST `/_auth/deletion/cancel` | public | cancel a pending deletion (credential-based recovery) |
169
+ | `listRoles` / `createAdminRole` / `updateAdminRole` / `deleteAdminRole` / `updateUserRole` | — | superadmin | admin RBAC management |
170
+ | OAuth routes | — | — | see OAuth section |
171
+
172
+ Auth uses **asymmetric, client-signed JWTs**: the client generates an ES256/RS256 keypair,
173
+ sends the public key on register/login, signs request JWTs locally, and the server verifies
174
+ with the stored public key (`keyId` carried in the JWT). The server never holds a private key.
175
+ Keys expire after 90 days — rotate with `rotateKey`.
176
+
177
+ ### Writing protected routes (route DSL)
178
+
179
+ This is the current SPFN route DSL — `route.<method>().input().use().skip().handler()` registered
180
+ via `defineRouter`. Access auth state through the context helpers, not by reading raw context.
181
+
182
+ ```typescript
183
+ import { route } from '@spfn/core/route';
184
+ import { authenticate, requirePermissions, optionalAuth } from '@spfn/auth/server';
185
+ import { getAuth, getOptionalAuth } from '@spfn/auth/server';
186
+
187
+ // Protected (global `authenticate` already applies; helpers read the context)
188
+ export const getMe = route.get('/me')
189
+ .handler(async (c) =>
190
+ {
191
+ const { user, userId, role, locale } = getAuth(c);
192
+ return { id: userId, email: user.email, role };
193
+ });
238
194
 
239
- ## Package Structure
195
+ // Permission-gated (all required); use requireAnyPermission for OR, requireRole for roles
196
+ export const deleteUser = route.delete('/users/:id')
197
+ .use([authenticate, requirePermissions('user:delete')])
198
+ .handler(async (c) => { /* ... */ });
240
199
 
200
+ // Public + optional user context. optionalAuth auto-skips global 'auth' — no .skip needed
201
+ export const getProducts = route.get('/products')
202
+ .use([optionalAuth])
203
+ .handler(async (c) =>
204
+ {
205
+ const auth = getOptionalAuth(c); // AuthContext | undefined
206
+ return auth ? personalized(auth.userId) : publicList();
207
+ });
241
208
  ```
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
209
 
332
- ---
210
+ Context helpers from `@spfn/auth/server`: `getAuth`, `getOptionalAuth`, `getUser`, `getUserId`,
211
+ `getRole`, `getLocale`, `getKeyId`. Middleware: `authenticate`, `optionalAuth`,
212
+ `requirePermissions`, `requireAnyPermission`, `requireRole`, `roleGuard`, `oneTimeTokenAuth`.
333
213
 
334
- ## Module Exports
214
+ ## OAuth
335
215
 
336
- ### Common Module (`@spfn/auth`)
216
+ OAuth uses a **pluggable provider registry** — not hardcoded branches. The built-in `google`,
217
+ `kakao`, and `naver` web providers self-register on module load; `apple` provides native
218
+ `id_token` sign-in. External packages add providers at runtime with `registerOAuthProvider()`.
219
+ Google requires its client ID and secret, Kakao requires its REST API key (and sends its optional
220
+ client secret when configured), and Naver requires its client ID and secret.
337
221
 
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
- ```
222
+ Client flow: call `authApi.getGoogleOAuthUrl.call({ body: { returnUrl } })`, redirect the browser
223
+ to the returned `authUrl`, and render `OAuthCallback` on your success page. The Next.js interceptor
224
+ manages the keypair → pending-session-cookie → full-session handoff transparently.
365
225
 
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';
226
+ ```tsx
227
+ // app/auth/callback/page.tsx
228
+ export { OAuthCallback as default } from '@spfn/auth/nextjs/client';
381
229
  ```
382
230
 
383
- ---
384
-
385
- ### Server Module (`@spfn/auth/server`)
386
-
387
- **Router:**
388
231
  ```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/*
232
+ import { authApi } from '@spfn/auth';
233
+ const { authUrl } = await authApi.getGoogleOAuthUrl.call({
234
+ body: {
235
+ returnUrl: '/dashboard',
236
+ metadata: { birthDate: '2000-01-01', termsAgreed: true },
237
+ },
394
238
  });
239
+ window.location.href = authUrl;
395
240
  ```
396
241
 
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
- ---
573
-
574
- ### Errors Module (`@spfn/auth/errors`)
575
-
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';
599
- ```
600
-
601
- ---
602
-
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';
614
-
615
- // Auto-registers interceptors on import
616
- import '@spfn/auth/nextjs/api';
617
- ```
618
-
619
- #### `@spfn/auth/nextjs/server`
620
-
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';
644
- ```
242
+ Kakao and Naver use the provider-generic URL route:
645
243
 
646
- **Session Helpers Usage:**
647
244
  ```typescript
648
- // Save session (Server Actions / Route Handlers)
649
- await saveSession({
650
- userId: '123',
651
- privateKey: '...',
652
- keyId: 'uuid',
653
- algorithm: 'ES256',
245
+ const { authUrl } = await authApi.getProviderOAuthUrl.call({
246
+ params: { provider: 'kakao' }, // or 'naver'
247
+ body: {
248
+ returnUrl: '/dashboard',
249
+ metadata: { birthDate: '2000-01-01', termsAgreed: true },
250
+ },
654
251
  });
655
-
656
- // Get session (read-only, safe in Server Components)
657
- const session = await getSession();
658
-
659
- // Clear session
660
- await clearSession();
252
+ window.location.href = authUrl;
661
253
  ```
662
254
 
663
- **Guard Usage:**
664
- ```typescript
665
- // app/dashboard/page.tsx
666
- import { RequireAuth } from '@spfn/auth/nextjs/server';
255
+ Both convenience URL APIs seal `metadata` into the encrypted OAuth state. On a new social
256
+ signup, the callback passes it to `beforeRegister` and `authRegisterEvent`; existing-account
257
+ logins do not run the registration hook.
667
258
 
668
- export default async function DashboardPage()
669
- {
670
- return (
671
- <RequireAuth redirectTo="/login">
672
- <div>Protected content</div>
673
- </RequireAuth>
674
- );
675
- }
676
- ```
259
+ Built-in OAuth routes: `POST /_auth/oauth/google/url`, `GET /_auth/oauth/google` (redirect),
260
+ `GET /_auth/oauth/google/callback`, `POST /_auth/oauth/finalize`, `GET /_auth/oauth/providers`,
261
+ plus the provider-generic `POST /_auth/oauth/start`. `getGoogleAccessToken(userId)` returns a
262
+ valid Google access token (auto-refreshing via stored refresh token when near expiry; throws if
263
+ no Google account is linked or no refresh token is available).
677
264
 
678
- ---
265
+ Kakao's `is_email_valid` and `is_email_verified` claims are both required before its email can
266
+ link an existing SPFN account. Naver provides an email address but no verified-email claim, so
267
+ Naver login never links an existing account by email; new Naver users can verify and add their
268
+ email through the application's normal onboarding flow. Until then, the OAuth account is identified
269
+ by its provider and provider user ID, so its user row may have both email and phone unset.
679
270
 
680
- ## Email & SMS Services
271
+ ### OAuth callback origin (web app host + rewrite)
681
272
 
682
- ### Email Service
273
+ The callback's CSRF check is a double-submit: the Next.js interceptor sets an `oauth_csrf`
274
+ cookie on the **web app host**, and the callback compares it against the nonce sealed in the
275
+ state. Host-only cookies never reach a different host, so **the provider callback must return
276
+ to the web app origin** — redirect URIs default to
277
+ `{NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}/_auth/oauth/<provider>/callback`.
683
278
 
684
- The email service uses AWS SES by default, with fallback to console logging in development.
279
+ The app forwards `/_auth/*` to the API with a standard rewrite (**required** without it the
280
+ callback 404s on the web host, including in local dev):
685
281
 
686
- **Send Email:**
687
- ```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
696
- });
282
+ ```javascript
283
+ // next.config.js
284
+ const nextConfig = {
285
+ async rewrites()
286
+ {
287
+ return [
288
+ {
289
+ source: '/_auth/:path*',
290
+ destination: `${process.env.SPFN_API_URL}/_auth/:path*`,
291
+ },
292
+ ];
293
+ },
294
+ };
697
295
  ```
698
296
 
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
- ```
297
+ Register each **web app host** callback URL in its provider console, for example
298
+ `https://app.example.com/_auth/oauth/kakao/callback` and
299
+ `https://app.example.com/_auth/oauth/naver/callback`.
712
300
 
713
- ---
301
+ The cookie name also carries a `_${PORT}` suffix from the process that set it (the Next.js
302
+ process), which differs from the API process in a split deployment — the callback therefore
303
+ matches every `spfn_oauth_csrf*` cookie candidate against the state nonce, so no PORT
304
+ coordination is needed.
714
305
 
715
- ### SMS Service
306
+ One caveat: the direct `POST /_auth/oauth/start` flow (no Next.js interceptor) sets its CSRF
307
+ cookie on the **API host**. If you use that flow in a split deployment, set
308
+ the corresponding provider redirect URI explicitly to the API host callback instead.
716
309
 
717
- The SMS service uses AWS SNS by default.
310
+ ### Native social sign-in (mobile / web id_token)
718
311
 
719
- **Send SMS:**
720
- ```typescript
721
- import { sendSMS } from '@spfn/auth/server';
312
+ For native apps — and for Apple on Android/web, which has no native SDK — the client obtains an
313
+ `id_token` from the platform SDK and posts it to **`POST /_auth/oauth/:provider/native`**. No
314
+ authorization code, no client secret: the server verifies the id_token against the provider's
315
+ JWKS (signature, issuer, audience, expiry, nonce), links/creates the user, and **registers the
316
+ client's public key**. It returns `{ userId, keyId, isNewUser }` — *not* a token. The client mints
317
+ its own Bearer client token by signing with the on-device private key (the same client-signs /
318
+ server-verifies model as the rest of auth).
722
319
 
723
- await sendSMS({
724
- phone: '+821012345678', // E.164 format
725
- message: 'Your code is: 123456',
726
- purpose: 'verification',
727
- });
728
- ```
320
+ Enable per provider by declaring the accepted audiences: `SPFN_AUTH_GOOGLE_NATIVE_CLIENT_IDS` for
321
+ Google (the web `SPFN_AUTH_GOOGLE_CLIENT_ID` is also accepted) and `SPFN_AUTH_APPLE_CLIENT_IDS` for
322
+ Apple. Apple is native-only here — its web OAuth (code-exchange) methods throw.
729
323
 
730
- **Custom SMS Provider:**
731
324
  ```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
- },
325
+ await authApi.oauthNative.call({
326
+ params: { provider: 'apple' }, // or 'google'
327
+ body: { idToken, nonce, publicKey, keyId, fingerprint, algorithm: 'ES256', profile: { name } },
741
328
  });
329
+ // → { userId, keyId, isNewUser }; client then signs its own ES256 Bearer token with keyId
742
330
  ```
743
331
 
744
- ---
332
+ The `nonce` is the **raw** nonce the client used; Apple hashes it (SHA-256) into the token, so send
333
+ the raw value for either provider. `profile.name` captures the name Apple returns only on first
334
+ sign-in. Trade-off: skipping code exchange means no Apple refresh token / server-side revoke —
335
+ revoke SPFN access by revoking the registered key instead.
745
336
 
746
- ## Email Templates
337
+ ### Custom providers
747
338
 
748
- ### Built-in Templates
339
+ 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
340
 
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
341
  ```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
- });
342
+ import {
343
+ registerOAuthProvider, getOAuthProvider, getRegisteredProviders,
344
+ oauthCallbackService,
345
+ type OAuthProvider, type NormalizedIdentity, type OAuthTokens,
346
+ } from '@spfn/auth/server';
767
347
 
768
- await sendEmail({ to: 'user@example.com', subject, text, html });
348
+ registerOAuthProvider(myProvider); // same id re-registers (override)
769
349
  ```
770
350
 
771
- ---
772
-
773
- ### Custom Templates
351
+ ### OAuth token encryption and key rotation
774
352
 
775
- Register custom templates to override defaults with your brand design:
353
+ Web OAuth access and refresh tokens are encrypted at rest with AES-256-GCM. Token encryption is
354
+ separate from session-cookie encryption: `SPFN_AUTH_TOKEN_ENCRYPTION_KEYS` is backend-only and
355
+ must never be exposed to the Next.js process. Generate a key with `openssl rand -base64 32` and
356
+ assign it a non-secret key ID:
776
357
 
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
- });
358
+ ```dotenv
359
+ SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v2:<base64-32-byte-key>
807
360
  ```
808
361
 
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`
362
+ For zero-downtime rotation, prepend the new key and retain old keys for decryption:
827
363
 
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
- }
364
+ ```dotenv
365
+ SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v3:<new-key>,v2:<old-key>
845
366
  ```
846
367
 
847
- ---
848
-
849
- #### `POST /_auth/codes`
368
+ New writes use the first key. Reads using an older key, the legacy session-secret-derived `enc:v1`
369
+ format, or historical plaintext are automatically re-encrypted with the active key. Keep every old
370
+ key available until all rows have been read or explicitly migrated; removing a referenced key makes
371
+ those tokens undecryptable. Ciphertext is bound to `provider`, `providerUserId`, and token type
372
+ (`access` or `refresh`) with authenticated data, preventing ciphertext from being moved to another
373
+ account or field.
850
374
 
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
- ```
375
+ Deployments that need a KMS or per-account envelope encryption can call
376
+ `configureOAuthTokenCipher()` from `@spfn/auth/server` before the server starts. The custom cipher
377
+ receives the same account/token context and owns its key rotation policy.
861
378
 
862
- **Response:**
863
- ```typescript
864
- {
865
- success: boolean;
866
- expiresAt: string; // ISO 8601
867
- }
868
- ```
379
+ **Integration contract for custom providers:**
869
380
 
870
- ---
381
+ - The built-in provider-generic callback route handles any registered provider. A custom callback is
382
+ only needed when the provider does not follow the standard `code` / `state` response contract.
383
+ - If a custom callback calls `oauthCallbackService()` directly, wrap the route in `Transactional()`
384
+ (`import { Transactional } from '@spfn/core/db'`).
385
+ - The provider `id` must be in `SOCIAL_PROVIDERS` (`enumText`, plain text — adding a value needs **no**
386
+ DB migration).
387
+ - `auth.login` / `auth.register` events now carry any `SOCIAL_PROVIDERS` value in `provider` —
388
+ update any `switch(provider)` in subscribers.
871
389
 
872
- #### `POST /_auth/codes/verify`
390
+ ## Sessions (Next.js)
873
391
 
874
- Verify OTP code.
392
+ Sessions are HttpOnly cookies encrypted with `SPFN_AUTH_SESSION_SECRET` (JWE), holding the
393
+ client private key + `keyId` (`SessionData`: `{ userId, privateKey, keyId, algorithm }`). The
394
+ interceptor reads them to sign outbound RPC JWTs. From `@spfn/auth/nextjs/server`:
875
395
 
876
- **Request:**
877
396
  ```typescript
878
- {
879
- target: string;
880
- targetType: 'email' | 'phone';
881
- code: string; // 6 digits
882
- purpose: 'registration' | 'login' | 'password_reset';
883
- }
884
- ```
397
+ import { saveSession, getSession, clearSession } from '@spfn/auth/nextjs/server';
885
398
 
886
- **Response:**
887
- ```typescript
888
- {
889
- valid: boolean;
890
- verificationToken?: string; // 15min JWT for registration
891
- }
399
+ await saveSession({ userId: '123', privateKey: '...', keyId: 'uuid', algorithm: 'ES256' });
400
+ const session = await getSession(); // read-only, safe in Server Components
401
+ await clearSession();
892
402
  ```
893
403
 
894
- ---
895
-
896
- #### `POST /_auth/register`
897
-
898
- Register new user.
404
+ RSC guards (redirect when unmet) — `RequireAuth`, `RequireRole`, `RequirePermission`:
899
405
 
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
- ```
406
+ ```tsx
407
+ import { RequireAuth, RequireRole } from '@spfn/auth/nextjs/server';
914
408
 
915
- **Response:**
916
- ```typescript
409
+ export default async function AdminPage()
917
410
  {
918
- userId: string;
919
- email?: string;
920
- phone?: string;
411
+ return (
412
+ <RequireAuth redirectTo="/login">
413
+ <RequireRole roles={['admin', 'superadmin']} redirectTo="/forbidden">
414
+ <Dashboard />
415
+ </RequireRole>
416
+ </RequireAuth>
417
+ );
921
418
  }
922
419
  ```
923
420
 
924
- ---
925
-
926
- #### `POST /_auth/login`
421
+ Also exported: `getAuthSessionData`, `getUserRole`, `getUserPermissions`, `hasAnyRole`,
422
+ `hasAnyPermission`, the OAuth pending-session helpers, and `createOAuthCallbackHandler`.
927
423
 
928
- User login.
424
+ ## RBAC
929
425
 
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
- ```
426
+ Built-in roles: `superadmin` (priority 100), `admin` (80), `user` (10). Built-in permissions:
427
+ `auth:self:manage`, `user:read|write|delete|invite`, `rbac:role:manage`, `rbac:permission:manage`.
428
+ Custom roles/permissions are declared on the lifecycle (preferred — runs on startup) or via
429
+ `initializeAuth(options)`.
944
430
 
945
- **Response:**
946
431
  ```typescript
947
- {
948
- userId: string;
949
- email?: string;
950
- phone?: string;
951
- passwordChangeRequired: boolean;
952
- }
432
+ createAuthLifecycle({
433
+ roles: [{ name: 'editor', displayName: 'Editor', priority: 30 }],
434
+ permissions: [{ name: 'post:publish', displayName: 'Publish Posts', category: 'content' }],
435
+ rolePermissions: { editor: ['post:publish'] },
436
+ });
953
437
  ```
954
438
 
955
- ---
956
-
957
- ### Authenticated Routes (Require JWT)
439
+ Programmatic checks (server): `hasPermission`, `hasAnyPermission`, `hasAllPermissions`, `hasRole`,
440
+ `hasAnyRole`, `getUserRole`, `getUserPermissions`. Runtime role admin: `createRole`, `updateRole`,
441
+ `deleteRole`, `setRolePermissions`, `addPermissionToRole`, `removePermissionFromRole`,
442
+ `getAllRoles`, `getRoleByName`, `getRolePermissions`.
958
443
 
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
444
+ ## Events
963
445
 
964
- ---
446
+ `@spfn/auth` emits decoupled events (via `@spfn/core/event`). Subscribe for welcome emails,
447
+ analytics, onboarding, etc. Client-supplied `metadata` on register/OAuth flows is forwarded verbatim.
965
448
 
966
- #### `POST /_auth/logout`
967
-
968
- Logout and revoke current key.
969
-
970
- **Request:**
971
449
  ```typescript
972
- {} // Empty body
973
- ```
450
+ import { authLoginEvent, authRegisterEvent, invitationCreatedEvent, invitationAcceptedEvent } from '@spfn/auth/server';
974
451
 
975
- **Response:**
976
- ```typescript
452
+ authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
977
453
  {
978
- success: boolean;
979
- }
454
+ if (email) await sendWelcome(email);
455
+ });
980
456
  ```
981
457
 
982
- ---
458
+ Payload types: `AuthLoginPayload`, `AuthRegisterPayload`, `InvitationCreatedPayload`,
459
+ `InvitationAcceptedPayload`, `AuthDeletionRequestedPayload`, `AuthDeletionCancelledPayload`,
460
+ `AuthDeletionCompletedPayload`. These events also bind to `@spfn/core/job` jobs via `.on(event)`.
983
461
 
984
- #### `POST /_auth/keys/rotate`
462
+ ## Registration gate (`beforeRegister`)
985
463
 
986
- Rotate public key before expiry (90 days).
464
+ Events fire *after* the user exists — they cannot reject a registration. For server-enforced
465
+ signup policy (age gate, invite-only domains, block lists) inject a validator with
466
+ `configureAuth`; it runs **before the user row is created** on every registration channel:
467
+ `credentials` (email/phone register), `oauth` (new-user social signup, web + native), and
468
+ `invitation` (acceptance). Throwing rejects the registration; `RegistrationRejectedError` (403)
469
+ is the recommended error. The hook receives the same `metadata` the app supplied to
470
+ `register` / OAuth start / the invitation — never credentials.
987
471
 
988
- **Request:**
989
472
  ```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
- ```
473
+ import { configureAuth, RegistrationRejectedError } from '@spfn/auth/server';
998
474
 
999
- **Response:**
1000
- ```typescript
1001
- {
1002
- success: boolean;
1003
- keyId: string;
1004
- }
475
+ configureAuth({
476
+ beforeRegister: async ({ channel, provider, email, phone, metadata }) =>
477
+ {
478
+ if (!isOldEnough(metadata?.birthDate))
479
+ {
480
+ throw new RegistrationRejectedError({ message: 'Age requirement not met' });
481
+ }
482
+ },
483
+ });
1005
484
  ```
1006
485
 
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
- ```
486
+ Notes:
487
+ - Runs after built-in checks (verification token, duplicate account) — existing error
488
+ precedence is unchanged, and the hook cannot be probed without a valid verification token.
489
+ - Not called when an OAuth login links a social account to an existing user, nor for admin
490
+ seeding in `initializeAuth()`.
491
+ - OAuth signups have no client-typed fields unless you pass `metadata` at OAuth start — decide
492
+ per channel (reject, or allow and collect during onboarding).
493
+ - On the `oauth` channel `email` is the provider-reported address and may be **unverified**
494
+ (the created account then stores `email` as `null`). The context carries
495
+ `emailVerified` — an email-based allow/block policy must check it before trusting `email`.
496
+ - The hook runs **inside the registration DB transaction** on every channel — keep it fast.
497
+ A slow call (e.g. an external policy API) holds a pooled DB connection open per signup.
498
+ - On the **web** OAuth flow a rejection surfaces as the standard OAuth error redirect
499
+ (302 to the app's OAuth error URL, message only) — not a 403 JSON response. The native
500
+ OAuth flow, credentials, and invitation channels return the error status (403) directly.
501
+
502
+ ## One-Time Token
503
+
504
+ For short-lived authenticated handshakes (e.g. SSE) where a `Bearer` header is awkward: issue
505
+ with `authApi.issueOneTimeToken`, protect the consuming route with the `oneTimeTokenAuth`
506
+ middleware. Call `initOneTimeTokenManager({ ttl, store })` during setup for a custom TTL/store.
507
+
508
+ ## Account Deletion & Recovery
509
+
510
+ Grace-period deletion with in-window recovery, an admin/GDPR-response entry point for immediate
511
+ purge, and a pluggable app-data cleanup hook. Not covered by this feature: re-signup email
512
+ blind-index/hashing (a purged account's email becomes reusable immediately — see the project's
513
+ PII protection track for blind-index re-signup prevention), backup beyond-use handling, DSR
514
+ intake/response workflows, and webhook fan-out — those are app/ops concerns.
515
+
516
+ ```
517
+ active ──request (re-auth)──> pending_deletion ──grace period elapses (cron)──> deleted (anonymize) | row removed (hard-delete)
518
+ ^ │
519
+ └───────────cancel (re-auth)───────┘ immediate = grace period of 0, same pipeline
520
+ ```
521
+
522
+ - **Request** — `POST /_auth/deletion/request` (authenticated). Step-up re-auth: password
523
+ holders confirm with `password`; OAuth-only/passwordless accounts confirm with a
524
+ `verificationToken` from `/_auth/codes` + `/_auth/codes/verify` (`purpose: 'account_deletion'`).
525
+ On success: status → `pending_deletion`, every active session key is revoked, a
526
+ `account_deletion_requests` audit row is created, `auth.deletion.requested` fires, and (if
527
+ the user has an email and `sendNotifications` is on) a notice is sent with the scheduled purge
528
+ date.
529
+ - **Login is blocked while pending** — password login, OAuth login, and the `authenticate`
530
+ middleware all reject a `pending_deletion` account with `AccountPendingDeletionError` (403,
531
+ `details.purgeScheduledAt`) instead of the generic `AccountDisabledError`, so the client can
532
+ show a recovery prompt.
533
+ - **Cancel (recovery)** — `POST /_auth/deletion/cancel` (public — sessions were revoked at
534
+ request time, so there's no Bearer token to authenticate with). Credential-based: email/phone
535
+ plus `password` or a fresh `verificationToken`. On success, status → `active`; the user still
536
+ needs to log in separately afterward.
537
+ - **Purge job** — sweeps `account_deletion_requests` for rows past their grace period and
538
+ destroys the account. Register it explicitly (see below); it is **not** wired up by
539
+ `createAuthLifecycle()` automatically.
540
+ - **Admin / GDPR-response entry points** — `requestAccountDeletionService(userId, { requestedBy: 'admin', immediate })`
541
+ and `purgeUserService(userId)` are exported for app-side admin routes / DSR handling; the app
542
+ owns the route and its authorization.
1020
543
 
1021
- **Response:**
1022
544
  ```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
- ```
1058
-
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);
1089
- ```
1090
-
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`
1100
-
1101
- OTP codes for email/SMS verification.
1102
-
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
- );
1114
-
1115
- CREATE INDEX idx_verification_codes_target ON verification_codes(target);
1116
- ```
1117
-
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
- ```
545
+ import { defineServerConfig } from '@spfn/core/server';
546
+ import { createAuthLifecycle, authJobRouter } from '@spfn/auth/server';
1143
547
 
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
- );
548
+ export default defineServerConfig()
549
+ .lifecycle(createAuthLifecycle({
550
+ deletion: {
551
+ gracePeriodDays: 30, // default; 0 = immediate
552
+ purgeStrategy: 'anonymize', // default; or 'hard-delete'
553
+ allowSelfImmediate: false, // default; self-service immediate: true
554
+ sendNotifications: true, // default
555
+ onBeforePurge: async (user) =>
556
+ {
557
+ // throw to skip this user for the current sweep (retried next run)
558
+ await appDataCleanup(user.id);
559
+ },
560
+ },
561
+ }))
562
+ .jobs(authJobRouter) // registers the daily (04:00 UTC) purge sweep
563
+ .routes(appRouter)
564
+ .build();
1166
565
  ```
1167
566
 
1168
- **Built-in Permissions:**
1169
- - `auth:self:manage`
1170
- - `user:read`, `user:write`, `user:delete`
1171
- - `rbac:role:manage`, `rbac:permission:manage`
567
+ **Purge strategies:**
568
+
569
+ - `anonymize` (default) — scrubs PII, keeps the row: `email` `deleted-{publicId}@deleted.invalid`,
570
+ `phone`/`username`/`passwordHash` `null`, `status` → `'deleted'`, `deletedAt`/`deletedBy` set
571
+ (`softDelete()` on `users`). Social accounts and public keys are deleted (frees the provider
572
+ link and revokes access), the profile's PII columns are cleared, and any leftover verification
573
+ codes for the original email/phone are removed. The freed email/phone can be re-registered
574
+ immediately.
575
+ - `hard-delete` — physically removes the `users` row; child rows (`user_profiles`,
576
+ `user_public_keys`, `user_social_accounts`, `user_permissions`) cascade-delete via their FK.
577
+ The `account_deletion_requests` audit row survives either strategy — its `userId` FK is
578
+ `set null` (not cascade), by design, so "who requested/purged what, when" outlives the user row.
579
+
580
+ The final "your account has been deleted" notice is sent **after** the purge transaction commits
581
+ (never before, and never on a purge that aborted or rolled back — see below), using the address
582
+ captured before the destructive step ran. This holds for `hard-delete` too: the row is already
583
+ gone by send time, but the address was captured beforehand, so the notice still goes out.
584
+
585
+ **Concurrency.** The purge job re-verifies the user is still `pending_deletion` on the write
586
+ primary immediately before any destructive DML, inside the same transaction as the DML itself —
587
+ closing the window between a stale read (the sweep's own batch, or replica lag) and a concurrent
588
+ `cancel`. The `account_deletion_requests` claim (`markCompleted`) is a conditional `UPDATE ...
589
+ WHERE status = 'pending'`; if a concurrent cancel already moved the row off `pending`, the claim
590
+ matches zero rows and the purge aborts with no destructive DML and no overwritten audit row.
591
+
592
+ **Cron schedule caveat.** `deletion.purgeCron` (default `0 4 * * *`) is stored for reference, but
593
+ the static `authJobRouter` export above always runs on the *default* cron — `job(...).cron(...)`
594
+ is fixed at module-import time, which happens before `createAuthLifecycle()` runs in your
595
+ `server.config.ts`. For a non-default schedule, build the router yourself, after the
596
+ `createAuthLifecycle()` call, and register that instead:
597
+
598
+ ```typescript
599
+ import { createAuthDeletionJobRouter } from '@spfn/auth/server';
600
+
601
+ // ... after .lifecycle(createAuthLifecycle({ deletion: { purgeCron: '0 3 * * *' } }))
602
+ .jobs(createAuthDeletionJobRouter({ purgeCron: '0 3 * * *' }))
603
+ ```
604
+
605
+ Register **only one** of `authJobRouter` / `createAuthDeletionJobRouter(...)` — both build a job
606
+ named `auth.deletion.purge`, so registering both (e.g. the static export *and* a custom-cron
607
+ router) double-registers the same job name against pg-boss instead of overriding it.
608
+
609
+ ## Pitfalls & anti-patterns
610
+
611
+ - **Wrong entry point.** `@spfn/auth/server` and `@spfn/auth/nextjs/*` are server-only (Node /
612
+ `server-only`). Importing them in a client component breaks the build. Entities, services, and
613
+ repositories are on `/server`, not on root `@spfn/auth`.
614
+ - **No `app.bind(contract, ...)`.** That contract pattern is removed. Use the route DSL
615
+ (`route.get().handler()` + `defineRouter`). Any docs/snippets using `app.bind` are stale.
616
+ - **Custom error classes must be registered.** Add them to an `ErrorRegistry` (mirror
617
+ `authErrorRegistry` in `src/errors/index.ts`) and pass it to your `createApi({ errorRegistry })`,
618
+ or the client receives a generic error instead of the typed one.
619
+ - **Two env files, by audience.** `SPFN_AUTH_SESSION_SECRET` lives in `.env.local` (Next.js needs
620
+ it for cookie crypto); `SPFN_AUTH_VERIFICATION_TOKEN_SECRET` and
621
+ `SPFN_AUTH_TOKEN_ENCRYPTION_KEYS` live in `.env.server`. Token encryption keys are backend-only;
622
+ putting them in `.env.local` unnecessarily gives the Next.js process token-decryption authority.
623
+ - **`SPFN_AUTH_SESSION_SECRET` is validated.** Minimum 32 chars plus entropy/unique-char checks —
624
+ a short or low-entropy value fails startup, not just a warning.
625
+ - **Forgetting the interceptor import.** Without `import '@spfn/auth/nextjs/api'` in the RPC proxy
626
+ route, the client sends no `Authorization` header and every protected call 401s. The
627
+ `authenticate` middleware error message points here.
628
+ - **Custom OAuth callback without `Transactional()`.** A failure mid-callback leaves an orphan
629
+ user. Always wrap the callback route in `Transactional()` and call `oauthCallbackService`.
630
+ - **`sideEffects: false` tree-shakes the google provider.** The built-in provider self-registers
631
+ via a module side-effect; an aggressive bundler config can drop it. Don't mark this package's
632
+ imports side-effect-free.
633
+ - **Public routes need an explicit opt-out.** With global `authenticate`, any route without
634
+ `.skip(['auth'])` (or `optionalAuth`, which auto-skips) requires a valid token.
635
+ - **`SOCIAL_PROVIDERS` is plain `enumText`.** Adding a provider value needs no DB migration, but
636
+ every `switch(provider)` over login/register events must handle the new value.
637
+ - **Email/SMS is not here.** It moved to `@spfn/notification` (`import { sendEmail, sendSMS } from
638
+ '@spfn/notification/server'`). Wire verification-code / invitation emails through its events.
639
+ - **`authJobRouter` isn't registered for you.** `createAuthLifecycle()`'s `afterInfrastructure`
640
+ hook runs *before* `@spfn/core` initializes pg-boss and registers jobs, so the lifecycle has no
641
+ opportunity to auto-register the account-deletion purge job. Call `.jobs(authJobRouter)`
642
+ yourself — see [Account Deletion & Recovery](#account-deletion--recovery).
643
+ - **`USER_STATUSES` gained `pending_deletion` / `deleted`.** Any code with a `switch(user.status)`
644
+ or an exhaustive status union must handle both — `enumText` is plain `text` with no DB `CHECK`,
645
+ so nothing enforces this at the database layer.
646
+
647
+ ## Complete example
648
+
649
+ ```typescript
650
+ // server.config.ts
651
+ import { defineServerConfig } from '@spfn/core/server';
652
+ import { createAuthLifecycle } from '@spfn/auth/server';
653
+ import { appRouter } from './router';
1172
654
 
1173
- ---
655
+ export default defineServerConfig()
656
+ .port(8790)
657
+ .routes(appRouter)
658
+ .lifecycle(createAuthLifecycle({
659
+ roles: [{ name: 'editor', displayName: 'Editor', priority: 30 }],
660
+ permissions: [{ name: 'post:publish', displayName: 'Publish Posts', category: 'content' }],
661
+ rolePermissions: { editor: ['post:publish'] },
662
+ }))
663
+ .build();
1174
664
 
1175
- #### `role_permissions`
665
+ // router.ts
666
+ import { defineRouter } from '@spfn/core/route';
667
+ import { authRouter, authenticate } from '@spfn/auth/server';
668
+ import { getMe } from './routes/me';
1176
669
 
1177
- Many-to-many mapping between roles and permissions.
670
+ export const appRouter = defineRouter({ getMe })
671
+ .packages([authRouter])
672
+ .use([authenticate]);
673
+ export type AppRouter = typeof appRouter;
1178
674
 
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(),
675
+ // app/api/rpc/[routeName]/route.ts
676
+ import '@spfn/auth/nextjs/api';
677
+ import { createRpcProxy } from '@spfn/core/nextjs/server';
678
+ import { authRouteMap } from '@spfn/auth';
679
+ import { routeMap } from '@/generated/route-map';
680
+ export const { GET, POST } = createRpcProxy({ routeMap: { ...routeMap, ...authRouteMap } });
1186
681
 
1187
- UNIQUE(role_id, permission_id)
1188
- );
682
+ // any client component
683
+ import { authApi } from '@spfn/auth';
684
+ const session = await authApi.getAuthSession.call({});
1189
685
  ```
1190
686
 
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
- ---
687
+ ## Related
1959
688
 
1960
- **Last Updated:** 2025-12-07
1961
- **Document Version:** 2.2.0 (Technical Documentation)
1962
- **Package Version:** 0.1.0-alpha.88
689
+ - `@spfn/core` — route DSL (`route`, `defineRouter`), `createApi`, env (`@spfn/core/env`),
690
+ errors (`ErrorRegistry`), db (`Transactional`), events, jobs.
691
+ - `@spfn/notification` — email/SMS/push (verification codes, invitation emails).
692
+ - Full guide: `docs/guides/authentication.md`.