@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.
- package/LICENSE +1 -1
- package/README.md +561 -1827
- package/dist/authenticate-ofdEmk6x.d.ts +1109 -0
- package/dist/config.d.ts +431 -39
- package/dist/config.js +217 -29
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +117 -3
- package/dist/errors.js +83 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +351 -109
- package/dist/index.js +124 -7
- package/dist/index.js.map +1 -1
- package/dist/nextjs/api.js +591 -61
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/client.d.ts +28 -0
- package/dist/nextjs/client.js +80 -0
- package/dist/nextjs/client.js.map +1 -0
- package/dist/nextjs/server.d.ts +92 -3
- package/dist/nextjs/server.js +288 -24
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +2019 -511
- package/dist/server.js +4435 -1157
- package/dist/server.js.map +1 -1
- package/dist/session-CGxgH3C9.d.ts +53 -0
- package/dist/types-1BMx0OX1.d.ts +84 -0
- package/migrations/0001_smooth_the_fury.sql +3 -0
- package/migrations/0002_deep_iceman.sql +11 -0
- package/migrations/0003_perfect_deathbird.sql +3 -0
- package/migrations/0004_concerned_rawhide_kid.sql +5 -0
- package/migrations/0005_lethal_lifeguard.sql +32 -0
- package/migrations/0006_easy_hardball.sql +24 -0
- package/migrations/0007_glossy_major_mapleleaf.sql +1 -0
- package/migrations/meta/0001_snapshot.json +1660 -0
- package/migrations/meta/0002_snapshot.json +1660 -0
- package/migrations/meta/0003_snapshot.json +1689 -0
- package/migrations/meta/0004_snapshot.json +1721 -0
- package/migrations/meta/0005_snapshot.json +1721 -0
- package/migrations/meta/0006_snapshot.json +1921 -0
- package/migrations/meta/0007_snapshot.json +1916 -0
- package/migrations/meta/_journal.json +49 -0
- package/package.json +43 -39
- package/dist/dto-lZmWuObc.d.ts +0 -645
package/README.md
CHANGED
|
@@ -1,65 +1,43 @@
|
|
|
1
|
-
# @spfn/auth
|
|
1
|
+
# @spfn/auth — Authentication, OAuth, and RBAC for SPFN
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
> For user-facing documentation, see [SPFN Documentation](https://spfn.dev/docs).
|
|
8
|
+
## Install
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
+
## Import paths
|
|
43
15
|
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
+
## Setup (4 wiring points)
|
|
55
34
|
|
|
56
|
-
|
|
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
|
-
###
|
|
37
|
+
### 1. Lifecycle — `server.config.ts`
|
|
61
38
|
|
|
62
|
-
|
|
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())
|
|
50
|
+
.lifecycle(createAuthLifecycle())
|
|
74
51
|
.build();
|
|
75
52
|
```
|
|
76
53
|
|
|
77
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
});
|
|
71
|
+
export type AppRouter = typeof appRouter;
|
|
89
72
|
```
|
|
90
73
|
|
|
91
|
-
### 3.
|
|
74
|
+
### 3. Next.js interceptor — RPC proxy route
|
|
92
75
|
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
import
|
|
98
|
-
import {
|
|
99
|
-
import {
|
|
100
|
-
import {
|
|
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
|
-
|
|
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
|
-
###
|
|
89
|
+
### 4. Run migrations
|
|
138
90
|
|
|
139
91
|
```bash
|
|
140
|
-
#
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
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
|
-
|
|
616
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
|
|
649
|
-
await
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
**
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
+
### Native social sign-in (mobile / web id_token)
|
|
718
315
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
341
|
+
### Custom providers
|
|
747
342
|
|
|
748
|
-
|
|
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 {
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
778
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
394
|
+
## Sessions (Next.js)
|
|
873
395
|
|
|
874
|
-
|
|
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
|
-
|
|
887
|
-
|
|
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
|
-
|
|
901
|
-
|
|
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
|
-
|
|
916
|
-
```typescript
|
|
413
|
+
export default async function AdminPage()
|
|
917
414
|
{
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
428
|
+
## RBAC
|
|
929
429
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{}
|
|
973
|
-
```
|
|
454
|
+
import { authLoginEvent, authRegisterEvent, invitationCreatedEvent, invitationAcceptedEvent } from '@spfn/auth/server';
|
|
974
455
|
|
|
975
|
-
|
|
976
|
-
```typescript
|
|
456
|
+
authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
|
|
977
457
|
{
|
|
978
|
-
|
|
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
|
-
|
|
466
|
+
## Registration gate (`beforeRegister`)
|
|
985
467
|
|
|
986
|
-
|
|
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
|
-
|
|
1000
|
-
|
|
1001
|
-
{
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
-
**
|
|
1092
|
-
|
|
1093
|
-
- `
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
-
|
|
1116
|
-
|
|
674
|
+
export const appRouter = defineRouter({ getMe })
|
|
675
|
+
.packages([authRouter])
|
|
676
|
+
.use([authenticate]);
|
|
677
|
+
export type AppRouter = typeof appRouter;
|
|
1117
678
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
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`.
|