@spfn/auth 0.2.0-beta.9 → 0.2.0-beta.90
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 +984 -1743
- package/dist/authenticate-CnccboAg.d.ts +1383 -0
- package/dist/client-proof.d.ts +677 -0
- package/dist/client-proof.js +1814 -0
- package/dist/client-proof.js.map +1 -0
- package/dist/config.d.ts +487 -39
- package/dist/config.js +243 -29
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +208 -3
- package/dist/errors.js +140 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +391 -109
- package/dist/index.js +186 -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 +2495 -1089
- package/dist/server.js +6212 -1499
- package/dist/server.js.map +1 -1
- package/dist/session-CFK4BT25.d.ts +53 -0
- package/dist/types-CD95yudz.d.ts +98 -0
- package/migrations/20251125021229_premium_famine/snapshot.json +2641 -0
- package/migrations/20260225130050_smooth_the_fury/migration.sql +3 -0
- package/migrations/20260225130050_smooth_the_fury/snapshot.json +2686 -0
- package/migrations/20260308141417_deep_iceman/migration.sql +11 -0
- package/migrations/20260308141417_deep_iceman/snapshot.json +2686 -0
- package/migrations/20260308151309_perfect_deathbird/migration.sql +3 -0
- package/migrations/20260308151309_perfect_deathbird/snapshot.json +2731 -0
- package/migrations/20260308201135_concerned_rawhide_kid/migration.sql +5 -0
- package/migrations/20260308201135_concerned_rawhide_kid/snapshot.json +2786 -0
- package/migrations/20260629103209_lethal_lifeguard/migration.sql +32 -0
- package/migrations/20260629103209_lethal_lifeguard/snapshot.json +2786 -0
- package/migrations/20260709073531_easy_hardball/migration.sql +24 -0
- package/migrations/20260709073531_easy_hardball/snapshot.json +3119 -0
- package/migrations/20260714081434_glossy_major_mapleleaf/migration.sql +1 -0
- package/migrations/20260714081434_glossy_major_mapleleaf/snapshot.json +3112 -0
- package/migrations/20260804105939_amazing_bushwacker/migration.sql +3 -0
- package/migrations/20260804105939_amazing_bushwacker/snapshot.json +3112 -0
- package/migrations/20260804110033_fat_piledriver/migration.sql +2 -0
- package/migrations/20260804110033_fat_piledriver/snapshot.json +3138 -0
- package/package.json +60 -46
- package/dist/dto-CRlgoCP5.d.ts +0 -645
- package/migrations/meta/0000_snapshot.json +0 -1632
- package/migrations/meta/_journal.json +0 -13
- /package/migrations/{0000_premium_famine.sql → 20251125021229_premium_famine/migration.sql} +0 -0
package/README.md
CHANGED
|
@@ -1,65 +1,62 @@
|
|
|
1
|
-
# @spfn/auth
|
|
1
|
+
# @spfn/auth
|
|
2
2
|
|
|
3
|
-
**
|
|
4
|
-
**Status:** Alpha - Internal Development
|
|
3
|
+
> **Two applications' worth of auth, in one package**
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
Nothing ships until people can sign in. `@spfn/auth` clears that gate twice over — once
|
|
6
|
+
for the people who use your product, and once for the people who operate it.
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
- **For your users** — registration, password and OTP login, social sign-in, sessions,
|
|
9
|
+
registered devices, and account deletion with a recovery window.
|
|
10
|
+
- **For your operators** — admin accounts seeded from the environment, roles and
|
|
11
|
+
permissions enforced on every route, invitations, and role administration your
|
|
12
|
+
superadmins can change at runtime.
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
The second half is what usually becomes a second application: an admin dashboard with its
|
|
15
|
+
own auth, its own screens, and its own maintenance, growing for as long as the product
|
|
16
|
+
does. Attach [`@spfn/mcp`](../mcp/README.md) instead and those operations become tools an
|
|
17
|
+
AI agent runs, gated by the same roles — see
|
|
18
|
+
[Can I operate the app without building an admin dashboard?](#can-i-operate-the-app-without-building-an-admin-dashboard).
|
|
12
19
|
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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)
|
|
20
|
+
Underneath: asymmetric client-signed JWTs (ES256/RS256), OTP verification, OAuth 2.0
|
|
21
|
+
through a pluggable provider registry (Google, GitHub, Kakao and Naver built in), session
|
|
22
|
+
cookies for Next.js, and runtime RBAC. Routes mount under `/_auth/*` and are reached
|
|
23
|
+
through a typed `authApi` client. Requires `@spfn/core`; Next.js is an optional peer
|
|
24
|
+
(`^16.2.11`).
|
|
28
25
|
|
|
29
|
-
|
|
26
|
+
## Install
|
|
30
27
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
28
|
+
```bash
|
|
29
|
+
pnpm add @spfn/auth drizzle-orm@1.0.0-rc.4
|
|
30
|
+
```
|
|
41
31
|
|
|
42
|
-
|
|
32
|
+
## Import paths
|
|
43
33
|
|
|
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
|
|
34
|
+
Entry points (from `package.json` `exports`). Picking the wrong one breaks the build —
|
|
35
|
+
`/server`, `/client-proof` and `/nextjs/*` pull in Node code and must never reach the browser bundle.
|
|
49
36
|
|
|
50
|
-
|
|
37
|
+
```typescript
|
|
38
|
+
import { authApi, authRouteMap } from '@spfn/auth'; // isomorphic: client + route map + types/constants
|
|
39
|
+
import { authRouter, authenticate } from '@spfn/auth/server'; // SERVER ONLY: router, services, repos, middleware, helpers
|
|
40
|
+
import { /* hooks/components */ } from '@spfn/auth/client'; // browser only (currently empty — WIP)
|
|
41
|
+
import { env, envSchema } from '@spfn/auth/config'; // validated env proxy + schema
|
|
42
|
+
import { InvalidCredentialsError } from '@spfn/auth/errors'; // error classes + authErrorRegistry
|
|
43
|
+
import '@spfn/auth/nextjs/api'; // SERVER: auto-registers RPC interceptors (side-effect)
|
|
44
|
+
import { RequireAuth, getSession } from '@spfn/auth/nextjs/server'; // SERVER: RSC guards, session helpers, OAuth handler
|
|
45
|
+
import { OAuthCallback } from '@spfn/auth/nextjs/client'; // 'use client' OAuth callback component
|
|
46
|
+
import { createClientProofDevHandler } from '@spfn/auth/client-proof'; // SERVER: mobile clientProofV1 profile (see below)
|
|
47
|
+
```
|
|
51
48
|
|
|
52
|
-
|
|
49
|
+
> Database entities (`users`, `userPublicKeys`, …) and all services/repositories are exported
|
|
50
|
+
> from `@spfn/auth/server`, **not** from the root `@spfn/auth`.
|
|
53
51
|
|
|
54
|
-
|
|
52
|
+
## How do I add auth to an SPFN app?
|
|
55
53
|
|
|
56
|
-
|
|
57
|
-
pnpm add @spfn/auth
|
|
58
|
-
```
|
|
54
|
+
Four edits in the consuming app. All four are required for the flow to work end to end.
|
|
59
55
|
|
|
60
|
-
###
|
|
56
|
+
### 1. Lifecycle — `server.config.ts`
|
|
61
57
|
|
|
62
|
-
|
|
58
|
+
`createAuthLifecycle()` validates env before DB connect, then seeds admin accounts and
|
|
59
|
+
initializes RBAC after the DB is ready. Pass custom roles/permissions here (see RBAC below).
|
|
63
60
|
|
|
64
61
|
```typescript
|
|
65
62
|
import { defineServerConfig } from '@spfn/core/server';
|
|
@@ -68,1895 +65,1139 @@ import { appRouter } from './router';
|
|
|
68
65
|
|
|
69
66
|
export default defineServerConfig()
|
|
70
67
|
.port(8790)
|
|
71
|
-
.host('0.0.0.0')
|
|
72
68
|
.routes(appRouter)
|
|
73
|
-
.lifecycle(createAuthLifecycle())
|
|
69
|
+
.lifecycle(createAuthLifecycle())
|
|
74
70
|
.build();
|
|
75
71
|
```
|
|
76
72
|
|
|
77
|
-
|
|
73
|
+
### 2. Router + global middleware — `router.ts`
|
|
74
|
+
|
|
75
|
+
`authRouter` (the package's `mainAuthRouter`) is merged via `.packages()`; `authenticate` is
|
|
76
|
+
applied globally via `.use()`. Public routes opt out per-route with `.skip(['auth'])`.
|
|
78
77
|
|
|
79
78
|
```typescript
|
|
80
79
|
import { defineRouter } from '@spfn/core/route';
|
|
81
|
-
import { authRouter } from '@spfn/auth/server';
|
|
80
|
+
import { authRouter, authenticate } from '@spfn/auth/server';
|
|
81
|
+
import { getHealth } from './routes/health';
|
|
82
82
|
|
|
83
83
|
export const appRouter = defineRouter({
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
getHealth,
|
|
85
|
+
// ...your routes
|
|
86
|
+
})
|
|
87
|
+
.packages([authRouter]) // mounts /_auth/* and exposes routes on authApi
|
|
88
|
+
.use([authenticate]); // global auth middleware
|
|
86
89
|
|
|
87
|
-
|
|
88
|
-
});
|
|
90
|
+
export type AppRouter = typeof appRouter;
|
|
89
91
|
```
|
|
90
92
|
|
|
91
|
-
### 3.
|
|
93
|
+
### 3. Next.js interceptor — RPC proxy route
|
|
92
94
|
|
|
93
|
-
|
|
95
|
+
The interceptor handles session cookies, JWT signing, and key management automatically.
|
|
96
|
+
Import it for its side-effect (it self-registers); it must run before the proxy is created.
|
|
94
97
|
|
|
95
98
|
```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
|
-
```
|
|
99
|
+
// app/api/rpc/[routeName]/route.ts
|
|
100
|
+
import '@spfn/auth/nextjs/api'; // side-effect: registers auth interceptors
|
|
101
|
+
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
102
|
+
import { authRouteMap } from '@spfn/auth';
|
|
103
|
+
import { routeMap } from '@/generated/route-map';
|
|
108
104
|
|
|
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
|
|
105
|
+
export const { GET, POST } = createRpcProxy({ routeMap: { ...routeMap, ...authRouteMap } });
|
|
135
106
|
```
|
|
136
107
|
|
|
137
|
-
###
|
|
108
|
+
### 4. Run migrations
|
|
138
109
|
|
|
139
110
|
```bash
|
|
140
|
-
#
|
|
141
|
-
pnpm spfn db generate
|
|
142
|
-
|
|
143
|
-
# Run migrations
|
|
111
|
+
pnpm spfn db generate # only if entities changed
|
|
144
112
|
pnpm spfn db migrate
|
|
145
113
|
```
|
|
146
114
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
## Architecture
|
|
150
|
-
|
|
151
|
-
### High-Level Overview
|
|
152
|
-
|
|
153
|
-
```
|
|
154
|
-
┌─────────────────────────────────────────────────────────────┐
|
|
155
|
-
│ @spfn/auth Package │
|
|
156
|
-
├─────────────────────────────────────────────────────────────┤
|
|
157
|
-
│ │
|
|
158
|
-
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
|
|
159
|
-
│ │ Server │ │ Next.js │ │ Client │ │
|
|
160
|
-
│ │ (server.ts) │ │ (nextjs/*) │ │ (client.ts) │ │
|
|
161
|
-
│ └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ │
|
|
162
|
-
│ │ │ │ │
|
|
163
|
-
│ ┌───────▼───────────────────▼───────────────────▼───────┐ │
|
|
164
|
-
│ │ Common Types & Entities │ │
|
|
165
|
-
│ │ (index.ts) │ │
|
|
166
|
-
│ └────────────────────────────────────────────────────────┘ │
|
|
167
|
-
│ │
|
|
168
|
-
└─────────────────────────────────────────────────────────────┘
|
|
169
|
-
```
|
|
170
|
-
|
|
171
|
-
### Module Separation
|
|
172
|
-
|
|
173
|
-
The package is split into three distinct entry points to ensure proper code separation:
|
|
174
|
-
|
|
175
|
-
1. **Common Module** (`@spfn/auth`)
|
|
176
|
-
- Database entities (users, roles, permissions)
|
|
177
|
-
- TypeScript types and interfaces
|
|
178
|
-
- RBAC type definitions
|
|
179
|
-
- Can be imported anywhere (server/client)
|
|
180
|
-
|
|
181
|
-
2. **Server Module** (`@spfn/auth/server`)
|
|
182
|
-
- Server-only code (marked with Node.js APIs)
|
|
183
|
-
- Routes, services, repositories
|
|
184
|
-
- Middleware, helpers (JWT, password)
|
|
185
|
-
- RBAC initialization
|
|
186
|
-
- **Never** import in client-side code
|
|
187
|
-
|
|
188
|
-
3. **Client Module** (`@spfn/auth/client`)
|
|
189
|
-
- Client-only code (React hooks, components)
|
|
190
|
-
- Currently in development (placeholders only)
|
|
191
|
-
- **Never** import in server-side code
|
|
192
|
-
|
|
193
|
-
4. **Next.js Adapter** (`@spfn/auth/nextjs/*`)
|
|
194
|
-
- Next.js-specific integrations
|
|
195
|
-
- `@spfn/auth/nextjs/api` - Interceptors for API routes
|
|
196
|
-
- `@spfn/auth/nextjs/server` - Server Components guards & session helpers
|
|
197
|
-
|
|
198
|
-
### Asymmetric JWT Flow
|
|
199
|
-
|
|
200
|
-
```
|
|
201
|
-
┌──────────┐ ┌──────────┐
|
|
202
|
-
│ Client │ │ Server │
|
|
203
|
-
└────┬─────┘ └────┬─────┘
|
|
204
|
-
│ │
|
|
205
|
-
│ 1. Generate ES256 keypair │
|
|
206
|
-
│ (privateKey stored locally) │
|
|
207
|
-
│ │
|
|
208
|
-
│ 2. POST /_auth/register │
|
|
209
|
-
│ { email, password, publicKey, keyId } │
|
|
210
|
-
├──────────────────────────────────────────────>│
|
|
211
|
-
│ │
|
|
212
|
-
│ 3. Store publicKey │
|
|
213
|
-
│ (user_public_keys)
|
|
214
|
-
│ │
|
|
215
|
-
│ 4. Sign JWT with privateKey │
|
|
216
|
-
│ payload: { userId, keyId } │
|
|
217
|
-
│ │
|
|
218
|
-
│ 5. Request with Authorization header │
|
|
219
|
-
│ Authorization: Bearer <jwt> │
|
|
220
|
-
├──────────────────────────────────────────────>│
|
|
221
|
-
│ │
|
|
222
|
-
│ 6. Decode JWT → keyId │
|
|
223
|
-
│ Fetch publicKey │
|
|
224
|
-
│ Verify signature │
|
|
225
|
-
│ │
|
|
226
|
-
│ 7. Success │
|
|
227
|
-
│<──────────────────────────────────────────────┤
|
|
228
|
-
│ │
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
**Key Points:**
|
|
232
|
-
- Server **never** knows the private key
|
|
233
|
-
- Each client has a unique keypair
|
|
234
|
-
- JWT verification uses stored public key
|
|
235
|
-
- No shared secrets (unlike HMAC-based JWT)
|
|
236
|
-
|
|
237
|
-
---
|
|
238
|
-
|
|
239
|
-
## Package Structure
|
|
240
|
-
|
|
241
|
-
```
|
|
242
|
-
packages/auth/
|
|
243
|
-
├── dist/ # Compiled output (tsup)
|
|
244
|
-
│ ├── index.js # Common exports
|
|
245
|
-
│ ├── index.d.ts
|
|
246
|
-
│ ├── server.js # Server exports
|
|
247
|
-
│ ├── server.d.ts
|
|
248
|
-
│ ├── client.js # Client exports (minimal)
|
|
249
|
-
│ ├── client.d.ts
|
|
250
|
-
│ ├── config/ # Configuration module
|
|
251
|
-
│ ├── errors/ # Error classes
|
|
252
|
-
│ ├── nextjs/ # Next.js adapter
|
|
253
|
-
│ └── server/ # Server implementation
|
|
254
|
-
│
|
|
255
|
-
├── migrations/ # Drizzle database migrations
|
|
256
|
-
│ └── *.sql
|
|
257
|
-
│
|
|
258
|
-
├── src/
|
|
259
|
-
│ ├── index.ts # Common entry point
|
|
260
|
-
│ ├── server.ts # Server entry point
|
|
261
|
-
│ ├── client.ts # Client entry point
|
|
262
|
-
│ │
|
|
263
|
-
│ ├── config/ # Configuration system
|
|
264
|
-
│ │ ├── index.ts
|
|
265
|
-
│ │ ├── schema.ts # Env var schema
|
|
266
|
-
│ │ └── types.ts
|
|
267
|
-
│ │
|
|
268
|
-
│ ├── errors/ # Error definitions
|
|
269
|
-
│ │ ├── index.ts
|
|
270
|
-
│ │ └── auth-errors.ts
|
|
271
|
-
│ │
|
|
272
|
-
│ ├── lib/ # Shared code
|
|
273
|
-
│ │ └── contracts/ # Typebox schemas
|
|
274
|
-
│ │
|
|
275
|
-
│ ├── server/ # Server-side implementation
|
|
276
|
-
│ │ ├── entities/ # Drizzle ORM entities
|
|
277
|
-
│ │ ├── services/ # Business logic layer
|
|
278
|
-
│ │ ├── repositories/ # Database access layer
|
|
279
|
-
│ │ ├── routes/ # HTTP route handlers
|
|
280
|
-
│ │ ├── middleware/ # Auth middleware
|
|
281
|
-
│ │ ├── helpers/ # JWT, password, context
|
|
282
|
-
│ │ ├── rbac/ # RBAC types and builtins
|
|
283
|
-
│ │ ├── lib/ # Server utilities
|
|
284
|
-
│ │ ├── lifecycle.ts # SPFN lifecycle hooks
|
|
285
|
-
│ │ ├── setup.ts # Initialization
|
|
286
|
-
│ │ ├── logger.ts # Logging
|
|
287
|
-
│ │ └── types.ts # Server types
|
|
288
|
-
│ │
|
|
289
|
-
│ ├── nextjs/ # Next.js adapter
|
|
290
|
-
│ │ ├── api.ts # Interceptor exports
|
|
291
|
-
│ │ ├── server.ts # Server Components guards
|
|
292
|
-
│ │ ├── session-helpers.ts# Session management
|
|
293
|
-
│ │ ├── interceptors/ # Request interceptors
|
|
294
|
-
│ │ └── guards/ # Auth guards
|
|
295
|
-
│ │
|
|
296
|
-
│ └── client/ # Client-side (WIP)
|
|
297
|
-
│ ├── hooks/ # React hooks (TODO)
|
|
298
|
-
│ ├── store/ # Zustand store (TODO)
|
|
299
|
-
│ └── components/ # UI components (TODO)
|
|
300
|
-
│
|
|
301
|
-
├── package.json # Package configuration + SPFN plugin config
|
|
302
|
-
├── tsup.config.ts # Build configuration
|
|
303
|
-
├── drizzle.config.ts # Database migration config
|
|
304
|
-
└── README.md # This file
|
|
305
|
-
```
|
|
306
|
-
|
|
307
|
-
### Layer Responsibilities
|
|
308
|
-
|
|
309
|
-
#### 1. **Routes Layer** (`src/server/routes/`)
|
|
310
|
-
- Thin HTTP handlers
|
|
311
|
-
- Request validation (Typebox)
|
|
312
|
-
- Delegates to services
|
|
313
|
-
- Returns responses
|
|
314
|
-
|
|
315
|
-
#### 2. **Services Layer** (`src/server/services/`)
|
|
316
|
-
- Business logic
|
|
317
|
-
- Transaction management
|
|
318
|
-
- Reusable functions
|
|
319
|
-
- Can be used outside of routes
|
|
320
|
-
|
|
321
|
-
#### 3. **Repositories Layer** (`src/server/repositories/`)
|
|
322
|
-
- Database access only
|
|
323
|
-
- CRUD operations
|
|
324
|
-
- No business logic
|
|
325
|
-
- Drizzle ORM queries
|
|
326
|
-
|
|
327
|
-
#### 4. **Helpers Layer** (`src/server/helpers/`)
|
|
328
|
-
- Utility functions (JWT, password hashing)
|
|
329
|
-
- Context accessors (getAuth, getUser)
|
|
330
|
-
- Stateless operations
|
|
331
|
-
|
|
332
|
-
---
|
|
333
|
-
|
|
334
|
-
## Module Exports
|
|
335
|
-
|
|
336
|
-
### Common Module (`@spfn/auth`)
|
|
115
|
+
The API client needs no auth-specific config. `authApi` is also available standalone:
|
|
337
116
|
|
|
338
|
-
**Entities:**
|
|
339
117
|
```typescript
|
|
340
|
-
import {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
118
|
+
import { authApi } from '@spfn/auth';
|
|
119
|
+
const session = await authApi.getAuthSession.call({}); // → GET /_auth/session
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Which environment variables do I need?
|
|
123
|
+
|
|
124
|
+
Set across **two files** by audience. Server-only secrets go in `.env.server`; values the
|
|
125
|
+
Next.js runtime needs (session cookie crypto) go in `.env.local`. Names only below — supply
|
|
126
|
+
real secret values out of band, never commit them.
|
|
127
|
+
|
|
128
|
+
| Var | File | Required | Notes |
|
|
129
|
+
|-----|------|----------|-------|
|
|
130
|
+
| `DATABASE_URL` | both | yes | Postgres connection |
|
|
131
|
+
| `SPFN_AUTH_VERIFICATION_TOKEN_SECRET` | `.env.server` | yes | OTP / verification token signing |
|
|
132
|
+
| `SPFN_AUTH_SESSION_SECRET` | `.env.local` | yes | ≥32 chars, AES-256 session cookie encryption (validated: entropy/unique-char checks) |
|
|
133
|
+
| `SPFN_AUTH_TOKEN_ENCRYPTION_KEYS` | `.env.server` | web OAuth | OAuth token keyring: comma-separated `<keyId>:<base64-32-byte-key>` entries; first key is active |
|
|
134
|
+
| `SPFN_API_URL` | `.env.local` | — | default `http://localhost:8790` |
|
|
135
|
+
| `SPFN_AUTH_SESSION_TTL` | both | — | default `7d` (e.g. `7d`, `12h`, `45m`) |
|
|
136
|
+
| `SPFN_AUTH_JWT_SECRET` / `SPFN_AUTH_JWT_EXPIRES_IN` | `.env.server` | — | legacy server-signed JWT mode only |
|
|
137
|
+
| `SPFN_AUTH_BCRYPT_SALT_ROUNDS` | `.env.server` | — | default `12` (native bcrypt, off the event loop) |
|
|
138
|
+
| `SPFN_AUTH_COOKIE_SECURE` | both | — | override Secure flag (defaults to `NODE_ENV==='production'`) |
|
|
139
|
+
| `SPFN_AUTH_ADMIN_*` | `.env.server` | — | admin seeding (see below) |
|
|
140
|
+
| `SPFN_AUTH_GOOGLE_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | enables Google OAuth when both set |
|
|
141
|
+
| `SPFN_AUTH_GOOGLE_SCOPES` | `.env.server` | — | comma-separated; default `email,profile` |
|
|
142
|
+
| `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) |
|
|
143
|
+
| `SPFN_AUTH_KAKAO_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | REST API key enables Kakao Login; secret is included when configured |
|
|
144
|
+
| `SPFN_AUTH_KAKAO_ADMIN_KEY` | `.env.server` | — | app admin key; required to verify the Kakao User Unlinked webhook |
|
|
145
|
+
| `SPFN_AUTH_KAKAO_SCOPES` / `_REDIRECT_URI` | `.env.server` | — | default scope `account_email`; callback `/_auth/oauth/kakao/callback` |
|
|
146
|
+
| `SPFN_AUTH_NAVER_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | both values enable Naver Login |
|
|
147
|
+
| `SPFN_AUTH_NAVER_REDIRECT_URI` | `.env.server` | — | default `{NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL}/_auth/oauth/naver/callback` |
|
|
148
|
+
| `SPFN_AUTH_GITHUB_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | both values enable GitHub OAuth |
|
|
149
|
+
| `SPFN_AUTH_GITHUB_SCOPES` / `_REDIRECT_URI` | `.env.server` | — | default scopes `read:user,user:email`; callback `/_auth/oauth/github/callback` |
|
|
150
|
+
| `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 |
|
|
151
|
+
| `SPFN_AUTH_APPLE_CLIENT_IDS` | `.env.server` | — | comma-separated Apple client IDs (bundle ID / Services ID); enables Apple native sign-in |
|
|
152
|
+
| `SPFN_AUTH_KAKAO_NATIVE_CLIENT_IDS` | `.env.server` | — | comma-separated Kakao app keys accepted as native id_token audience (native app key); `SPFN_AUTH_KAKAO_CLIENT_ID` is also accepted, so either one enables Kakao native sign-in |
|
|
153
|
+
| `SPFN_AUTH_NAVER_NATIVE_CLIENT_IDS` | `.env.server` | — | comma-separated Naver client IDs accepted as native id_token audience. `SPFN_AUTH_NAVER_CLIENT_ID` is also accepted, so this is only needed for a separate app application |
|
|
154
|
+
| `SPFN_AUTH_OAUTH_SUCCESS_URL` | `.env.server` | — | default `/auth/callback` |
|
|
155
|
+
| `SPFN_AUTH_OAUTH_ERROR_URL` | `.env.server` | — | default `/auth/error?error={error}` |
|
|
156
|
+
| `SPFN_AUTH_RESERVED_USERNAMES` / `_USERNAME_MIN_LENGTH` / `_USERNAME_MAX_LENGTH` | `.env.server` | — | username rules |
|
|
157
|
+
| `NEXT_PUBLIC_SPFN_API_URL` / `NEXT_PUBLIC_SPFN_APP_URL` | `.env.local` | — | browser-facing URLs for OAuth redirects |
|
|
158
|
+
|
|
159
|
+
Read validated values via `import { env } from '@spfn/auth/config'` (a proxy validated at
|
|
160
|
+
startup). `envSchema` carries descriptions/defaults.
|
|
161
|
+
|
|
162
|
+
### Admin seeding
|
|
163
|
+
|
|
164
|
+
`createAuthLifecycle()` creates admin accounts on startup from env, in priority order. Seeded
|
|
165
|
+
accounts are auto email-verified, `status: 'active'`, `passwordChangeRequired: true`.
|
|
166
|
+
|
|
167
|
+
- **JSON (recommended):** `SPFN_AUTH_ADMIN_ACCOUNTS` — array of `{email, password, role?, phone?, passwordChangeRequired?}`. `role` defaults to `user` (`user` | `admin` | `superadmin`).
|
|
168
|
+
- **CSV:** `SPFN_AUTH_ADMIN_EMAILS` + `SPFN_AUTH_ADMIN_PASSWORDS` + `SPFN_AUTH_ADMIN_ROLES`.
|
|
169
|
+
- **Single (legacy):** `SPFN_AUTH_ADMIN_EMAIL` + `SPFN_AUTH_ADMIN_PASSWORD` → always `superadmin`.
|
|
170
|
+
|
|
171
|
+
## Routes
|
|
172
|
+
|
|
173
|
+
All routes mount at `/_auth/*` and are reached through `authApi.<name>.call({ body })`. Public
|
|
174
|
+
routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-signed-jwt>`.
|
|
175
|
+
|
|
176
|
+
| `authApi` method | HTTP | Auth | Purpose |
|
|
177
|
+
|------------------|------|------|---------|
|
|
178
|
+
| `sendVerificationCode` | POST `/_auth/codes` | public | send 6-digit OTP |
|
|
179
|
+
| `verifyCode` | POST `/_auth/codes/verify` | public | verify OTP → verification token |
|
|
180
|
+
| `register` | POST `/_auth/register` | public | create user + register public key |
|
|
181
|
+
| `login` | POST `/_auth/login` | public | password login + new session key |
|
|
182
|
+
| `logout` | POST `/_auth/logout` | yes | revoke current key |
|
|
183
|
+
| `rotateKey` | POST `/_auth/keys/rotate` | yes | rotate public key before 90-day expiry |
|
|
184
|
+
| `listKeys` | POST `/_auth/keys/list` | yes | the caller's registered devices — see [Registered devices](#registered-devices-key-management) |
|
|
185
|
+
| `revokeKey` | POST `/_auth/keys/revoke` | yes | sign one device out |
|
|
186
|
+
| `revokeAllKeys` | POST `/_auth/keys/revoke-all` | yes | sign every device out (spares the caller by default) |
|
|
187
|
+
| `changePassword` | PUT `/_auth/password` | yes | change password |
|
|
188
|
+
| `getAuthSession` | GET `/_auth/session` | yes | current session/user |
|
|
189
|
+
| `issueOneTimeToken` | POST | yes | short-lived token (e.g. SSE handshake) |
|
|
190
|
+
| `checkUsername` / `updateUsername` / `updateLocale` | — | mixed | username availability/update, locale |
|
|
191
|
+
| `getUserProfile` / `updateUserProfile` | — | yes | profile read/update |
|
|
192
|
+
| `createInvitation` / `acceptInvitation` / `listInvitations` / `cancelInvitation` / `resendInvitation` / `deleteInvitation` / `getInvitation` | — | mixed | invitation flow |
|
|
193
|
+
| `requestAccountDeletion` | POST `/_auth/deletion/request` | yes | request account deletion (re-auth gated) — see [Account Deletion & Recovery](#account-deletion--recovery) |
|
|
194
|
+
| `cancelAccountDeletion` | POST `/_auth/deletion/cancel` | public | cancel a pending deletion (credential-based recovery) |
|
|
195
|
+
| `listRoles` / `createAdminRole` / `updateAdminRole` / `deleteAdminRole` / `updateUserRole` | — | superadmin | admin RBAC management |
|
|
196
|
+
| OAuth routes | — | — | see OAuth section |
|
|
197
|
+
|
|
198
|
+
There is deliberately **no account-existence endpoint**. `POST /_auth/exists` was removed
|
|
199
|
+
because it answered "does this account exist" directly, which is user enumeration; the
|
|
200
|
+
login path is timing-equalized for the same reason. Do not reintroduce one without
|
|
201
|
+
revisiting that decision.
|
|
202
|
+
|
|
203
|
+
Auth uses **asymmetric, client-signed JWTs**: the client generates an ES256/RS256 keypair,
|
|
204
|
+
sends the public key on register/login, signs request JWTs locally, and the server verifies
|
|
205
|
+
with the stored public key (`keyId` carried in the JWT). The server never holds a private key.
|
|
206
|
+
Keys expire after 90 days — rotate with `rotateKey`.
|
|
207
|
+
|
|
208
|
+
### Registered devices (key management)
|
|
209
|
+
|
|
210
|
+
Keys are per-device, so a login never revokes the previous key and they accumulate on purpose.
|
|
211
|
+
`listKeys` / `revokeKey` / `revokeAllKeys` are what let the account owner see what accumulated and
|
|
212
|
+
cut off anything they no longer recognise.
|
|
353
213
|
|
|
354
|
-
**Types:**
|
|
355
214
|
```typescript
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
VerificationCode,
|
|
360
|
-
Role,
|
|
361
|
-
Permission,
|
|
362
|
-
// ... etc
|
|
363
|
-
} from '@spfn/auth';
|
|
364
|
-
```
|
|
215
|
+
const { keys } = await authApi.listKeys.call({ body: {} });
|
|
216
|
+
// → [{ keyId, deviceName?, platform?, algorithm, fingerprintPrefix, createdAtMillis,
|
|
217
|
+
// lastUsedAtMillis?, expiresAtMillis?, isExpired, isActive, revokedAtMillis? }]
|
|
365
218
|
|
|
366
|
-
|
|
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';
|
|
219
|
+
await authApi.listKeys.call({ body: { includeRevoked: true } }); // also what was cut off
|
|
381
220
|
```
|
|
382
221
|
|
|
383
|
-
|
|
222
|
+
Every moment is epoch milliseconds, not an ISO string — one representation across the whole
|
|
223
|
+
surface, so a generated Swift or Kotlin client reads an integer instead of choosing a date
|
|
224
|
+
formatter. This changed in mobile contract 0.5.0; an app still reading `createdAt` moves to
|
|
225
|
+
`createdAtMillis`.
|
|
384
226
|
|
|
385
|
-
|
|
227
|
+
`algorithm` is the `KeyAlgorithm` enum from contract 0.6.0 rather than a bare string — the routes
|
|
228
|
+
have always constrained it to those values, and the contract had been understating the server. The
|
|
229
|
+
declared values are the ones the server accepts and sends **now**: one can be added, and one can be
|
|
230
|
+
withdrawn for a weakness found later, so a generated client should be built to meet a value it does
|
|
231
|
+
not recognise rather than assume the set is closed.
|
|
386
232
|
|
|
387
|
-
**Router:**
|
|
388
233
|
```typescript
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
234
|
+
await authApi.revokeKey.call({ body: { keyId } }); // → { keyId, selfRevoked }
|
|
235
|
+
await authApi.revokeAllKeys.call({ body: {} }); // other devices only
|
|
236
|
+
await authApi.revokeAllKeys.call({ body: { includeCurrent: true } }); // everything
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
> **All three are POST with their arguments in the body, deliberately.** The mobile auth
|
|
240
|
+
> profile (clientProofV1) signs the request body, and `canonical-json` fixes exactly how those
|
|
241
|
+
> bytes are written. A `GET` has no body to sign, and a value in the path has no such rule —
|
|
242
|
+
> client and server could disagree on the signed string over percent-encoding, a trailing
|
|
243
|
+
> slash, or a proxy rewrite alone, and the request would be refused with nothing in the logs
|
|
244
|
+
> naming the cause. Every operation in the contract is shaped this way.
|
|
245
|
+
|
|
246
|
+
- **The public key never leaves the server**, and the fingerprint is truncated to 8 characters.
|
|
247
|
+
The list exists to recognise a device and point at it; the full fingerprint is what a native
|
|
248
|
+
sign-in sends as its nonce, not a label.
|
|
249
|
+
- **`isExpired` is computed, not stored.** Nothing flips `isActive` when the TTL runs out —
|
|
250
|
+
`authenticate` refuses the key at request time. A list that showed such a key as simply active
|
|
251
|
+
would report something the server does not act on.
|
|
252
|
+
- **Revoking your own key is allowed.** It is this device's sign-out, which `logout` already does.
|
|
253
|
+
`selfRevoked` in the response tells the two cases apart.
|
|
254
|
+
- **`revokeAllKeys` spares the calling device unless you ask otherwise**, so the common case is
|
|
255
|
+
"sign out my other devices". `includeCurrent: true` is the full sign-out — until now reachable
|
|
256
|
+
only as a side effect of changing a password, which nobody does for that reason.
|
|
257
|
+
- **A key id you do not own answers 404** (`KeyNotFoundError`). Every lookup is scoped by user, so
|
|
258
|
+
the answer is only ever "not yours" and reveals nothing about other accounts.
|
|
259
|
+
- **Revocation takes effect immediately.** `authenticate` reads the key from the database on every
|
|
260
|
+
request with no cache in front of it.
|
|
261
|
+
- **`includeRevoked: true` shows what was already cut off**, with `revokedAt`. The default is only
|
|
262
|
+
keys that can still sign.
|
|
263
|
+
|
|
264
|
+
Every path that registers a key (`register`, `login`, `rotateKey`, native OAuth) accepts optional
|
|
265
|
+
`deviceName` (≤64 chars) and `platform` (`ios` / `android` / `web` / `desktop`). Both are display
|
|
266
|
+
only — nothing is authorized by them — and both are absent on keys registered before they existed.
|
|
267
|
+
Rotation carries the replaced key's label over unless the client sends a new one.
|
|
268
|
+
|
|
269
|
+
All three are in the mobile contract (0.4.1) as `auth.keys.list` / `auth.keys.revoke` /
|
|
270
|
+
`auth.keys.revokeAll`, so a generated mobile client reaches them the same way it reaches key
|
|
271
|
+
rotation.
|
|
272
|
+
|
|
273
|
+
A `keyId` is **single-use for its lifetime**: it is unique across all users and is never reissued
|
|
274
|
+
once revoked. A client that logs out, rotates, or is revoked must generate a **fresh keypair and
|
|
275
|
+
`keyId`** for its next sign-in — resending the old one is refused with
|
|
276
|
+
`KeyIdAlreadyRegisteredError` (409), on every path that registers a key. Re-registering a key that
|
|
277
|
+
is still active is the one
|
|
278
|
+
exception: it stays a no-op success, so repeated logins from the same device keep working, and an
|
|
279
|
+
expired-but-active key has its expiry extended by the sign-in that proved the identity again.
|
|
280
|
+
|
|
281
|
+
### Writing protected routes (route DSL)
|
|
282
|
+
|
|
283
|
+
This is the current SPFN route DSL — `route.<method>().input().use().skip().handler()` registered
|
|
284
|
+
via `defineRouter`. Access auth state through the context helpers, not by reading raw context.
|
|
396
285
|
|
|
397
|
-
**Services:**
|
|
398
286
|
```typescript
|
|
399
|
-
import {
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
```
|
|
287
|
+
import { route } from '@spfn/core/route';
|
|
288
|
+
import { authenticate, requirePermissions, optionalAuth } from '@spfn/auth/server';
|
|
289
|
+
import { getAuth, getOptionalAuth } from '@spfn/auth/server';
|
|
477
290
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
verificationCodesRepository,
|
|
486
|
-
invitationsRepository,
|
|
487
|
-
rolePermissionsRepository,
|
|
488
|
-
userPermissionsRepository,
|
|
489
|
-
userProfilesRepository,
|
|
490
|
-
} from '@spfn/auth/server';
|
|
491
|
-
```
|
|
291
|
+
// Protected (global `authenticate` already applies; helpers read the context)
|
|
292
|
+
export const getMe = route.get('/me')
|
|
293
|
+
.handler(async (c) =>
|
|
294
|
+
{
|
|
295
|
+
const { user, userId, role, locale } = getAuth(c);
|
|
296
|
+
return { id: userId, email: user.email, role };
|
|
297
|
+
});
|
|
492
298
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
requirePermissions,
|
|
498
|
-
requireRole,
|
|
499
|
-
} from '@spfn/auth/server';
|
|
299
|
+
// Permission-gated (all required); use requireAnyPermission for OR, requireRole for roles
|
|
300
|
+
export const deleteUser = route.delete('/users/:id')
|
|
301
|
+
.use([authenticate, requirePermissions('user:delete')])
|
|
302
|
+
.handler(async (c) => { /* ... */ });
|
|
500
303
|
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
);
|
|
304
|
+
// Public + optional user context. optionalAuth auto-skips global 'auth' — no .skip needed
|
|
305
|
+
export const getProducts = route.get('/products')
|
|
306
|
+
.use([optionalAuth])
|
|
307
|
+
.handler(async (c) =>
|
|
308
|
+
{
|
|
309
|
+
const auth = getOptionalAuth(c); // AuthContext | undefined
|
|
310
|
+
return auth ? personalized(auth.userId) : publicList();
|
|
311
|
+
});
|
|
509
312
|
```
|
|
510
313
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
```
|
|
314
|
+
Context helpers from `@spfn/auth/server`: `getAuth`, `getOptionalAuth`, `getUser`, `getUserId`,
|
|
315
|
+
`getRole`, `getLocale`, `getKeyId`. Middleware: `authenticate`, `optionalAuth`,
|
|
316
|
+
`requirePermissions`, `requireAnyPermission`, `requireRole`, `roleGuard`, `oneTimeTokenAuth`.
|
|
532
317
|
|
|
533
|
-
|
|
534
|
-
```typescript
|
|
535
|
-
import { createAuthLifecycle } from '@spfn/auth/server';
|
|
318
|
+
## OAuth
|
|
536
319
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
320
|
+
OAuth uses a **pluggable provider registry** — not hardcoded branches. The built-in `google`,
|
|
321
|
+
`github`, `kakao`, and `naver` web providers self-register on module load; `apple` provides native
|
|
322
|
+
`id_token` sign-in. External packages add providers at runtime with `registerOAuthProvider()`.
|
|
323
|
+
Google, GitHub, and Naver each require their client ID and secret; Kakao requires its REST API
|
|
324
|
+
key (and sends its optional client secret when configured).
|
|
542
325
|
|
|
543
|
-
|
|
326
|
+
Client flow: call `authApi.getGoogleOAuthUrl.call({ body: { returnUrl } })`, redirect the browser
|
|
327
|
+
to the returned `authUrl`, and render `OAuthCallback` on your success page. The Next.js interceptor
|
|
328
|
+
manages the keypair → pending-session-cookie → full-session handoff transparently.
|
|
544
329
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
// Currently empty exports
|
|
549
|
-
import {} from '@spfn/auth/client';
|
|
330
|
+
```tsx
|
|
331
|
+
// app/auth/callback/page.tsx
|
|
332
|
+
export { OAuthCallback as default } from '@spfn/auth/nextjs/client';
|
|
550
333
|
```
|
|
551
334
|
|
|
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
335
|
```typescript
|
|
562
|
-
import {
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
336
|
+
import { authApi } from '@spfn/auth';
|
|
337
|
+
const { authUrl } = await authApi.getGoogleOAuthUrl.call({
|
|
338
|
+
body: {
|
|
339
|
+
returnUrl: '/dashboard',
|
|
340
|
+
metadata: { birthDate: '2000-01-01', termsAgreed: true },
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
window.location.href = authUrl;
|
|
570
344
|
```
|
|
571
345
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
### Errors Module (`@spfn/auth/errors`)
|
|
346
|
+
GitHub, Kakao, and Naver use the provider-generic URL route:
|
|
575
347
|
|
|
576
348
|
```typescript
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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';
|
|
349
|
+
const { authUrl } = await authApi.getProviderOAuthUrl.call({
|
|
350
|
+
params: { provider: 'github' }, // or 'kakao', 'naver'
|
|
351
|
+
body: {
|
|
352
|
+
returnUrl: '/dashboard',
|
|
353
|
+
metadata: { birthDate: '2000-01-01', termsAgreed: true },
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
window.location.href = authUrl;
|
|
599
357
|
```
|
|
600
358
|
|
|
601
|
-
|
|
359
|
+
Both convenience URL APIs seal `metadata` into the encrypted OAuth state. On a new social
|
|
360
|
+
signup, the callback passes it to `beforeRegister` and `authRegisterEvent`; existing-account
|
|
361
|
+
logins do not run the registration hook.
|
|
602
362
|
|
|
603
|
-
|
|
363
|
+
Built-in OAuth routes: `POST /_auth/oauth/google/url`, `GET /_auth/oauth/google` (redirect),
|
|
364
|
+
`GET /_auth/oauth/google/callback`, `POST /_auth/oauth/finalize`, `GET /_auth/oauth/providers`,
|
|
365
|
+
plus the provider-generic `POST /_auth/oauth/start`. `getGoogleAccessToken(userId)` returns a
|
|
366
|
+
valid Google access token (auto-refreshing via stored refresh token when near expiry; throws if
|
|
367
|
+
no Google account is linked or no refresh token is available).
|
|
604
368
|
|
|
605
|
-
|
|
369
|
+
Kakao's `is_email_valid` and `is_email_verified` claims are both required before its email can
|
|
370
|
+
link an existing SPFN account. GitHub uses the primary email from `/user/emails` (needs the
|
|
371
|
+
`user:email` scope) and treats it as verified only when GitHub marks it verified; without that
|
|
372
|
+
scope it falls back to the public profile email, unverified. Naver's profile email is either the
|
|
373
|
+
Naver account email or a contact email that passed Naver's own verification, so a present email
|
|
374
|
+
is treated as verified — it is stored on the user row and may link an existing account by email,
|
|
375
|
+
the same trust level as Kakao. Accounts created before this policy (user row with `email` null)
|
|
376
|
+
are backfilled on their next login: if the provider reports a verified email and no other account
|
|
377
|
+
owns it, `email` and `emailVerifiedAt` are filled in (best-effort; a conflict skips the backfill
|
|
378
|
+
and the login continues).
|
|
606
379
|
|
|
607
|
-
|
|
608
|
-
import {
|
|
609
|
-
authInterceptors,
|
|
610
|
-
loginRegisterInterceptor,
|
|
611
|
-
generalAuthInterceptor,
|
|
612
|
-
keyRotationInterceptor,
|
|
613
|
-
} from '@spfn/auth/nextjs/api';
|
|
380
|
+
### Provider-initiated unlink notifications (`unlink-notify`)
|
|
614
381
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
#### `@spfn/auth/nextjs/server`
|
|
382
|
+
Kakao and Naver notify the service when a user disconnects the app **from the provider's side**
|
|
383
|
+
(account deletion, "연결된 서비스 관리" 해제 등). Without handling this, the service keeps the
|
|
384
|
+
OAuth link and stored tokens for a user who already revoked consent — a privacy-compliance gap
|
|
385
|
+
(Kakao shows a permanent console warning until the webhook is registered).
|
|
620
386
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
RequireRole,
|
|
626
|
-
RequirePermission,
|
|
627
|
-
|
|
628
|
-
// Auth Utils
|
|
629
|
-
getUserRole,
|
|
630
|
-
getUserPermissions,
|
|
631
|
-
hasAnyRole,
|
|
632
|
-
hasAnyPermission,
|
|
633
|
-
|
|
634
|
-
// Session Helpers
|
|
635
|
-
saveSession,
|
|
636
|
-
getSession,
|
|
637
|
-
clearSession,
|
|
638
|
-
|
|
639
|
-
// Types
|
|
640
|
-
type SessionData,
|
|
641
|
-
type PublicSession,
|
|
642
|
-
type SaveSessionOptions,
|
|
643
|
-
} from '@spfn/auth/nextjs/server';
|
|
644
|
-
```
|
|
387
|
+
`GET|POST /_auth/oauth/:provider/unlink-notify` is a public endpoint that verifies the
|
|
388
|
+
provider's signature, deletes the `user_social_accounts` row (destroying the stored
|
|
389
|
+
access/refresh tokens with it), and emits `auth.oauth.unlinked`. Requests that fail
|
|
390
|
+
verification are rejected by status code and touch nothing.
|
|
645
391
|
|
|
646
|
-
|
|
647
|
-
```typescript
|
|
648
|
-
// Save session (Server Actions / Route Handlers)
|
|
649
|
-
await saveSession({
|
|
650
|
-
userId: '123',
|
|
651
|
-
privateKey: '...',
|
|
652
|
-
keyId: 'uuid',
|
|
653
|
-
algorithm: 'ES256',
|
|
654
|
-
});
|
|
392
|
+
Register in the provider console:
|
|
655
393
|
|
|
656
|
-
|
|
657
|
-
|
|
394
|
+
| Provider | Console setting | URL to register | Verification | Success response |
|
|
395
|
+
|----------|-----------------|-----------------|--------------|------------------|
|
|
396
|
+
| Kakao | [앱] > [웹훅] > 연결 해제 웹훅 | `https://<host>/_auth/oauth/kakao/unlink-notify` | `Authorization: KakaoAK <admin key>` vs `SPFN_AUTH_KAKAO_ADMIN_KEY` | 200 within 3s |
|
|
397
|
+
| Naver | API 설정 > 연결끊기 Callback URL | `https://<host>/_auth/oauth/naver/unlink-notify` | HMAC-SHA256 signature + AES-128-CBC `encryptUniqueId` (key = `md5(client_secret)[0..16]`) | 204 No Content |
|
|
658
398
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
```
|
|
399
|
+
The framework only severs the link. What happens next (keep the account, start account
|
|
400
|
+
deletion, …) is app policy — subscribe to the event:
|
|
662
401
|
|
|
663
|
-
**Guard Usage:**
|
|
664
402
|
```typescript
|
|
665
|
-
|
|
666
|
-
import { RequireAuth } from '@spfn/auth/nextjs/server';
|
|
403
|
+
import { oauthUnlinkedEvent } from '@spfn/auth/server';
|
|
667
404
|
|
|
668
|
-
|
|
405
|
+
oauthUnlinkedEvent.subscribe(async ({ userId, provider, providerUserId, reason }) =>
|
|
669
406
|
{
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
<div>Protected content</div>
|
|
673
|
-
</RequireAuth>
|
|
674
|
-
);
|
|
675
|
-
}
|
|
407
|
+
// e.g. delete the account when the social link was its only credential
|
|
408
|
+
});
|
|
676
409
|
```
|
|
677
410
|
|
|
678
|
-
|
|
411
|
+
Custom providers opt in by implementing `verifyUnlinkNotification()` (and optionally
|
|
412
|
+
`unlinkNotifyAckStatus`) — providers without it answer 404 on this route.
|
|
679
413
|
|
|
680
|
-
|
|
414
|
+
### OAuth callback origin (web app host + rewrite)
|
|
681
415
|
|
|
682
|
-
|
|
416
|
+
The callback's CSRF check is a double-submit: the Next.js interceptor sets an `oauth_csrf`
|
|
417
|
+
cookie on the **web app host**, and the callback compares it against the nonce sealed in the
|
|
418
|
+
state. Host-only cookies never reach a different host, so **the provider callback must return
|
|
419
|
+
to the web app origin** — redirect URIs default to
|
|
420
|
+
`{NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}/_auth/oauth/<provider>/callback`.
|
|
683
421
|
|
|
684
|
-
The
|
|
422
|
+
The app forwards `/_auth/*` to the API with a standard rewrite (**required** — without it the
|
|
423
|
+
callback 404s on the web host, including in local dev):
|
|
685
424
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
425
|
+
```javascript
|
|
426
|
+
// next.config.js
|
|
427
|
+
const nextConfig = {
|
|
428
|
+
async rewrites()
|
|
429
|
+
{
|
|
430
|
+
return [
|
|
431
|
+
{
|
|
432
|
+
source: '/_auth/:path*',
|
|
433
|
+
destination: `${process.env.SPFN_API_URL}/_auth/:path*`,
|
|
434
|
+
},
|
|
435
|
+
];
|
|
436
|
+
},
|
|
437
|
+
};
|
|
697
438
|
```
|
|
698
439
|
|
|
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
|
-
```
|
|
440
|
+
Register each **web app host** callback URL in its provider console, for example
|
|
441
|
+
`https://app.example.com/_auth/oauth/kakao/callback` and
|
|
442
|
+
`https://app.example.com/_auth/oauth/naver/callback`.
|
|
712
443
|
|
|
713
|
-
|
|
444
|
+
The cookie name also carries a `_${PORT}` suffix from the process that set it (the Next.js
|
|
445
|
+
process), which differs from the API process in a split deployment — the callback therefore
|
|
446
|
+
matches every `spfn_oauth_csrf*` cookie candidate against the state nonce, so no PORT
|
|
447
|
+
coordination is needed.
|
|
714
448
|
|
|
715
|
-
|
|
449
|
+
One caveat: the direct `POST /_auth/oauth/start` flow (no Next.js interceptor) sets its CSRF
|
|
450
|
+
cookie on the **API host**. If you use that flow in a split deployment, set
|
|
451
|
+
the corresponding provider redirect URI explicitly to the API host callback instead.
|
|
716
452
|
|
|
717
|
-
|
|
453
|
+
### Native social sign-in (mobile / web id_token)
|
|
718
454
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
455
|
+
For native apps — and for Apple on Android/web, which has no native SDK — the client obtains an
|
|
456
|
+
`id_token` from the platform SDK and posts it to **`POST /_auth/oauth/:provider/native`**. No
|
|
457
|
+
authorization code, no client secret: the server verifies the id_token against the provider's
|
|
458
|
+
JWKS (signature, issuer, audience, expiry, nonce), links/creates the user, and **registers the
|
|
459
|
+
client's public key**. It returns `{ userId, keyId, isNewUser }` — *not* a token. The client mints
|
|
460
|
+
its own Bearer client token by signing with the on-device private key (the same client-signs /
|
|
461
|
+
server-verifies model as the rest of auth).
|
|
722
462
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
```
|
|
463
|
+
Enable per provider by declaring the accepted audiences: `SPFN_AUTH_GOOGLE_NATIVE_CLIENT_IDS` for
|
|
464
|
+
Google (the web `SPFN_AUTH_GOOGLE_CLIENT_ID` is also accepted), `SPFN_AUTH_APPLE_CLIENT_IDS` for
|
|
465
|
+
Apple, and `SPFN_AUTH_KAKAO_NATIVE_CLIENT_IDS` for Kakao (the REST API key in
|
|
466
|
+
`SPFN_AUTH_KAKAO_CLIENT_ID` is also accepted). Apple is native-only here — its web OAuth
|
|
467
|
+
(code-exchange) methods throw.
|
|
729
468
|
|
|
730
|
-
**Custom SMS Provider:**
|
|
731
469
|
```typescript
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
registerSMSProvider({
|
|
736
|
-
name: 'twilio',
|
|
737
|
-
sendSMS: async ({ phone, message }) => {
|
|
738
|
-
// Your Twilio implementation
|
|
739
|
-
return { success: true, messageId: '...' };
|
|
740
|
-
},
|
|
470
|
+
await authApi.oauthNative.call({
|
|
471
|
+
params: { provider: 'apple' }, // or 'google', 'kakao'
|
|
472
|
+
body: { idToken, nonce, publicKey, keyId, fingerprint, algorithm: 'ES256', profile: { name } },
|
|
741
473
|
});
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
474
|
+
// → { userId, keyId, isNewUser }; client then signs its own ES256 Bearer token with keyId
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Every refusal names itself. The response body carries `error.code` — the server's error class
|
|
478
|
+
name — alongside the usual `__type`, so a client that has no TypeScript error registry can still
|
|
479
|
+
tell the eleven ways this call fails apart:
|
|
480
|
+
|
|
481
|
+
| `error.code` | HTTP | What the client does |
|
|
482
|
+
| --- | --- | --- |
|
|
483
|
+
| `ValidationError` | 400 | fix the request body |
|
|
484
|
+
| `NativeSignInUnsupportedError` | 400 | hide that provider's native button — server configuration |
|
|
485
|
+
| `NonceKeyBindingError` | 400 | send `nonce === fingerprint` |
|
|
486
|
+
| `InvalidKeyFingerprintError` | 400 | send the SHA-256 of the submitted key |
|
|
487
|
+
| `UnverifiedEmailLinkError` | 400 | send the user to verify that address |
|
|
488
|
+
| `InvalidSocialTokenError` | 401 | obtain a fresh id_token |
|
|
489
|
+
| `AccountDisabledError` | 403 | show the account status |
|
|
490
|
+
| `AccountPendingDeletionError` | 403 | offer restore |
|
|
491
|
+
| `KeyIdAlreadyRegisteredError` | 409 | generate a new keyId and retry |
|
|
492
|
+
| `TooManyRequestsError` | 429 | **the only retry-the-same-request code** |
|
|
493
|
+
| `Error` | 500 | generic failure |
|
|
494
|
+
|
|
495
|
+
The `nonce` is the **raw** nonce the client used; Apple hashes it (SHA-256) into the token, so send
|
|
496
|
+
the raw value for any provider. `profile.name` captures the name Apple returns only on first
|
|
497
|
+
sign-in. Trade-off: skipping code exchange means no Apple refresh token / server-side revoke —
|
|
498
|
+
revoke SPFN access by revoking the registered key instead.
|
|
499
|
+
|
|
500
|
+
> **The nonce must be the `fingerprint` of the key being registered.** Since contract 0.4.0 the
|
|
501
|
+
> server refuses the call when `nonce !== fingerprint`, or when that fingerprint is not the
|
|
502
|
+
> SHA-256 of the submitted `publicKey`'s DER bytes. So the client does not mint a random nonce —
|
|
503
|
+
> it asks the provider for a token bound to the key it is about to enroll:
|
|
504
|
+
>
|
|
505
|
+
> ```typescript
|
|
506
|
+
> const fingerprint = sha256Hex(derBytesOf(publicKey)); // lowercase hex, 64 chars
|
|
507
|
+
> const nonce = fingerprint; // what the provider echoes back
|
|
508
|
+
> // Apple only: put sha256Hex(nonce) in the authorization request — Apple hashes what it receives
|
|
509
|
+
> ```
|
|
510
|
+
>
|
|
511
|
+
> Why: an `id_token` is a bearer credential. It is not bound to the channel it came over, so
|
|
512
|
+
> verifying it alone means whoever holds one valid token can enroll **their own** key on **someone
|
|
513
|
+
> else's** account — by extracting the app key from a real app binary, from a rooted device, or
|
|
514
|
+
> from a leaked log. The web OAuth flow is not exposed this way: there the public key travels
|
|
515
|
+
> inside encrypted `state` whose nonce must match the browser's CSRF cookie. Deriving the nonce
|
|
516
|
+
> from the key gives the native path the same binding, because a stolen token carries the victim's
|
|
517
|
+
> fingerprint and cannot be re-paired with an attacker's key. Re-submitting the victim's own key
|
|
518
|
+
> stays possible and is worthless — the attacker has no matching private key.
|
|
519
|
+
>
|
|
520
|
+
> Naver's trailing-`A` problem (below) is satisfied for free: a SHA-256 hex digest is lowercase.
|
|
521
|
+
|
|
522
|
+
> **Generate the nonce as lowercase hex, not base64.** Naver drops a trailing `A` from a base64url
|
|
523
|
+
> nonce before putting it in the id_token. A 16-byte base64url value ends in one of `A Q g w` —
|
|
524
|
+
> its last character carries only 2 bits of data plus 4 bits of padding — so a base64 nonce fails
|
|
525
|
+
> verification for roughly one sign-in in four, intermittently and with nothing in the logs
|
|
526
|
+
> pointing at the cause.
|
|
527
|
+
>
|
|
528
|
+
> The trigger is the character `A`, not the encoding as such. **Uppercase hex ends in `A` once in
|
|
529
|
+
> sixteen and breaks the same way**; lowercase hex (`0-9a-f`) has no `A` in its alphabet, so it
|
|
530
|
+
> cannot hit the case at all. Nonce comparison is exact by design (`jwks-verify.ts`) — accepting a
|
|
531
|
+
> truncated value would also accept any other nonce sharing those first characters — so the fix
|
|
532
|
+
> belongs on the client. Confirmed on Naver; not yet measured on the other providers, and
|
|
533
|
+
> lowercase hex is safe for all of them.
|
|
534
|
+
|
|
535
|
+
#### The optional `accessToken`
|
|
536
|
+
|
|
537
|
+
`accessToken` is the provider access token from the same sign-in. It is **optional and
|
|
538
|
+
provider-specific** — the server never requires it, and a client that omits it still signs in.
|
|
539
|
+
|
|
540
|
+
Send it only when a provider's id_token cannot establish the user's **email**, which is identity
|
|
541
|
+
data: `createOrLinkUser` matches an existing account by verified email. Display-side profile
|
|
542
|
+
(name, avatar) is deliberately *not* a reason to send it — that belongs to the app, not to auth.
|
|
543
|
+
|
|
544
|
+
| Provider | Send `accessToken`? | Why |
|
|
545
|
+
|---|---|---|
|
|
546
|
+
| Google | No | id_token carries `email` + `email_verified` |
|
|
547
|
+
| Apple | No | same, and Apple relay addresses are already the authoritative value |
|
|
548
|
+
| Kakao | **Optional, recommended** | id_token carries `email` but no `email_verified`; without it the address is stored unverified |
|
|
549
|
+
| Naver | **Optional, recommended** | id_token carries no profile claim at all; userinfo returns the address, which carries no verification flag (see below) |
|
|
550
|
+
|
|
551
|
+
Whatever the provider, the server trusts a lookup made with this token only after the identity it
|
|
552
|
+
returns matches the id_token's `sub`. A mismatch, or a failed lookup, is treated as if the token
|
|
553
|
+
had not been sent.
|
|
554
|
+
|
|
555
|
+
**Kakao.** Enable OpenID Connect in the Kakao developer console and request the `openid` scope, or
|
|
556
|
+
the SDK returns no `idToken`. One Kakao app issues several keys (native app key, REST API key), and
|
|
557
|
+
the `aud` claim is whichever key obtained the token — so list the native app key and let the REST
|
|
558
|
+
API key be accepted alongside it. The `sub` (회원번호) is per-app, not per-key, so web and app
|
|
559
|
+
sign-ins resolve to the same user.
|
|
560
|
+
|
|
561
|
+
Kakao's id_token carries `email` but no `email_verified`, so the identity comes back **unverified**
|
|
562
|
+
and the account is created with a null email. To match the web flow's strength, send the
|
|
563
|
+
`accessToken` the SDK returned in the same sign-in as an optional body field: the server then reads
|
|
564
|
+
`is_email_valid` / `is_email_verified` from `/v2/user/me`. That token is client-supplied, so the
|
|
565
|
+
lookup is trusted only when its 회원번호 equals the id_token's `sub`; a mismatch or a failed lookup
|
|
566
|
+
leaves the email unverified and the sign-in still succeeds.
|
|
747
567
|
|
|
748
|
-
### Built-in Templates
|
|
749
|
-
|
|
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
568
|
```typescript
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
code: '123456',
|
|
763
|
-
purpose: 'registration',
|
|
764
|
-
expiresInMinutes: 5,
|
|
765
|
-
appName: 'MyApp',
|
|
569
|
+
await authApi.oauthNative.call({
|
|
570
|
+
params: { provider: 'kakao' },
|
|
571
|
+
body: { idToken, nonce, accessToken, publicKey, keyId, fingerprint, algorithm: 'ES256' },
|
|
766
572
|
});
|
|
767
|
-
|
|
768
|
-
await sendEmail({ to: 'user@example.com', subject, text, html });
|
|
769
573
|
```
|
|
770
574
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
575
|
+
**Naver.** Naver runs two login surfaces. The web redirect flow uses `/oauth2.0/*`, which is plain
|
|
576
|
+
OAuth2 and issues no id_token; native verification uses the OIDC surface at `/oauth2/*`. The
|
|
577
|
+
`SPFN_AUTH_NAVER_CLIENT_ID` you already have is accepted as the audience — one Naver application
|
|
578
|
+
has a single client ID covering its web and app environments — so
|
|
579
|
+
`SPFN_AUTH_NAVER_NATIVE_CLIENT_IDS` is only needed when the app registers a separate application.
|
|
580
|
+
|
|
581
|
+
Naver's native SDK cannot produce an id_token: it is pinned to `/oauth2.0/*` and its authorize
|
|
582
|
+
request has no `scope` parameter at all. The app therefore obtains the id_token through a browser
|
|
583
|
+
flow (`ASWebAuthenticationSession` / Custom Tab) against `/oauth2/authorize?scope=openid` with PKCE
|
|
584
|
+
— `token_endpoint_auth_methods_supported` includes `none`, so no client secret is needed. The
|
|
585
|
+
server contract is the same whichever way the token was obtained.
|
|
586
|
+
|
|
587
|
+
The id_token carries `iss`, `aud`, `azp`, `sub`, `nonce`, `jti`, `iat`, `exp` — no email, no name,
|
|
588
|
+
no picture, even when the application marks email as required. Send `accessToken` to fill it: the
|
|
589
|
+
server reads `/v1/nid/me`, whose `id` is the same pairwise value as the id_token's `sub`, and
|
|
590
|
+
treats a returned address as verified (the same rule the web flow uses). `sub` being pairwise helps
|
|
591
|
+
here — a token from another application resolves to a different `sub` and is rejected by the match.
|
|
592
|
+
|
|
593
|
+
That verified verdict rests on one fact and it is worth stating plainly, because `createOrLinkUser`
|
|
594
|
+
links a social identity to an existing account on a verified address alone. The `/v1/nid/me`
|
|
595
|
+
response carries **no** verification flag — unlike Kakao, which reports `is_email_valid` and
|
|
596
|
+
`is_email_verified` and is checked against both. What Naver guarantees instead is at change time:
|
|
597
|
+
moving the contact email requires a code sent to the new address, so the returned value is an
|
|
598
|
+
address the user has proven they control. It is **not** a stable identifier: the user can change it,
|
|
599
|
+
one address can be shared by up to six Naver IDs, and it may be absent entirely. `providerUserId` is
|
|
600
|
+
the only key that identifies the account.
|
|
776
601
|
|
|
777
602
|
```typescript
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
}),
|
|
603
|
+
await authApi.oauthNative.call({
|
|
604
|
+
params: { provider: 'naver' },
|
|
605
|
+
body: { idToken, nonce, accessToken, publicKey, keyId, fingerprint, algorithm: 'ES256' },
|
|
806
606
|
});
|
|
807
607
|
```
|
|
808
608
|
|
|
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
|
-
---
|
|
609
|
+
Without `accessToken` a Naver sign-in has no email at all, so every user is created fresh and never
|
|
610
|
+
links to an existing account.
|
|
819
611
|
|
|
820
|
-
|
|
612
|
+
### Custom providers
|
|
821
613
|
|
|
822
|
-
|
|
614
|
+
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.
|
|
823
615
|
|
|
824
|
-
All routes are automatically registered at `/_auth/*` via SPFN plugin system.
|
|
825
|
-
|
|
826
|
-
#### `POST /_auth/exists`
|
|
827
|
-
|
|
828
|
-
Check if account exists.
|
|
829
|
-
|
|
830
|
-
**Request:**
|
|
831
616
|
```typescript
|
|
832
|
-
{
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
617
|
+
import {
|
|
618
|
+
registerOAuthProvider, getOAuthProvider, getRegisteredProviders,
|
|
619
|
+
oauthCallbackService,
|
|
620
|
+
type OAuthProvider, type NormalizedIdentity, type OAuthTokens,
|
|
621
|
+
} from '@spfn/auth/server';
|
|
837
622
|
|
|
838
|
-
|
|
839
|
-
```typescript
|
|
840
|
-
{
|
|
841
|
-
exists: boolean;
|
|
842
|
-
identifier: string;
|
|
843
|
-
identifierType: 'email' | 'phone';
|
|
844
|
-
}
|
|
623
|
+
registerOAuthProvider(myProvider); // same id re-registers (override)
|
|
845
624
|
```
|
|
846
625
|
|
|
847
|
-
|
|
626
|
+
### OAuth token encryption and key rotation
|
|
848
627
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
**Request:**
|
|
854
|
-
```typescript
|
|
855
|
-
{
|
|
856
|
-
target: string; // Email or phone
|
|
857
|
-
targetType: 'email' | 'phone';
|
|
858
|
-
purpose: 'registration' | 'login' | 'password_reset';
|
|
859
|
-
}
|
|
860
|
-
```
|
|
628
|
+
Web OAuth access and refresh tokens are encrypted at rest with AES-256-GCM. Token encryption is
|
|
629
|
+
separate from session-cookie encryption: `SPFN_AUTH_TOKEN_ENCRYPTION_KEYS` is backend-only and
|
|
630
|
+
must never be exposed to the Next.js process. Generate a key with `openssl rand -base64 32` and
|
|
631
|
+
assign it a non-secret key ID:
|
|
861
632
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
{
|
|
865
|
-
success: boolean;
|
|
866
|
-
expiresAt: string; // ISO 8601
|
|
867
|
-
}
|
|
633
|
+
```dotenv
|
|
634
|
+
SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v2:<base64-32-byte-key>
|
|
868
635
|
```
|
|
869
636
|
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
#### `POST /_auth/codes/verify`
|
|
637
|
+
For zero-downtime rotation, prepend the new key and retain old keys for decryption:
|
|
873
638
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
**Request:**
|
|
877
|
-
```typescript
|
|
878
|
-
{
|
|
879
|
-
target: string;
|
|
880
|
-
targetType: 'email' | 'phone';
|
|
881
|
-
code: string; // 6 digits
|
|
882
|
-
purpose: 'registration' | 'login' | 'password_reset';
|
|
883
|
-
}
|
|
639
|
+
```dotenv
|
|
640
|
+
SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v3:<new-key>,v2:<old-key>
|
|
884
641
|
```
|
|
885
642
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
```
|
|
643
|
+
New writes use the first key. Reads using an older key, the legacy session-secret-derived `enc:v1`
|
|
644
|
+
format, or historical plaintext are automatically re-encrypted with the active key. Keep every old
|
|
645
|
+
key available until all rows have been read or explicitly migrated; removing a referenced key makes
|
|
646
|
+
those tokens undecryptable. Ciphertext is bound to `provider`, `providerUserId`, and token type
|
|
647
|
+
(`access` or `refresh`) with authenticated data, preventing ciphertext from being moved to another
|
|
648
|
+
account or field.
|
|
893
649
|
|
|
894
|
-
|
|
650
|
+
Deployments that need a KMS or per-account envelope encryption can call
|
|
651
|
+
`configureOAuthTokenCipher()` from `@spfn/auth/server` before the server starts. The custom cipher
|
|
652
|
+
receives the same account/token context and owns its key rotation policy.
|
|
895
653
|
|
|
896
|
-
|
|
654
|
+
**Integration contract for custom providers:**
|
|
897
655
|
|
|
898
|
-
|
|
656
|
+
- The built-in provider-generic callback route handles any registered provider. A custom callback is
|
|
657
|
+
only needed when the provider does not follow the standard `code` / `state` response contract.
|
|
658
|
+
- If a custom callback calls `oauthCallbackService()` directly, wrap the route in `Transactional()`
|
|
659
|
+
(`import { Transactional } from '@spfn/core/db'`).
|
|
660
|
+
- The provider `id` must be in `SOCIAL_PROVIDERS` (`enumText`, plain text — adding a value needs **no**
|
|
661
|
+
DB migration).
|
|
662
|
+
- `auth.login` / `auth.register` events now carry any `SOCIAL_PROVIDERS` value in `provider` —
|
|
663
|
+
update any `switch(provider)` in subscribers.
|
|
899
664
|
|
|
900
|
-
|
|
901
|
-
```typescript
|
|
902
|
-
{
|
|
903
|
-
email?: string;
|
|
904
|
-
phone?: string;
|
|
905
|
-
verificationToken: string; // From /codes/verify
|
|
906
|
-
password: string; // Min 8 chars
|
|
907
|
-
publicKey: string; // Base64 DER (SPKI)
|
|
908
|
-
keyId: string; // UUID v4
|
|
909
|
-
fingerprint: string; // SHA-256 hex (64 chars)
|
|
910
|
-
algorithm: 'ES256' | 'RS256';
|
|
911
|
-
keySize?: number;
|
|
912
|
-
}
|
|
913
|
-
```
|
|
665
|
+
## How do I read the session in a Next.js page?
|
|
914
666
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
userId: string;
|
|
919
|
-
email?: string;
|
|
920
|
-
phone?: string;
|
|
921
|
-
}
|
|
922
|
-
```
|
|
923
|
-
|
|
924
|
-
---
|
|
925
|
-
|
|
926
|
-
#### `POST /_auth/login`
|
|
927
|
-
|
|
928
|
-
User login.
|
|
667
|
+
Sessions are HttpOnly cookies encrypted with `SPFN_AUTH_SESSION_SECRET` (JWE), holding the
|
|
668
|
+
client private key + `keyId` (`SessionData`: `{ userId, privateKey, keyId, algorithm }`). The
|
|
669
|
+
interceptor reads them to sign outbound RPC JWTs. From `@spfn/auth/nextjs/server`:
|
|
929
670
|
|
|
930
|
-
**Request:**
|
|
931
671
|
```typescript
|
|
932
|
-
{
|
|
933
|
-
email?: string;
|
|
934
|
-
phone?: string;
|
|
935
|
-
password: string;
|
|
936
|
-
publicKey: string; // New key for session
|
|
937
|
-
keyId: string;
|
|
938
|
-
fingerprint: string;
|
|
939
|
-
oldKeyId?: string; // Revoke previous key
|
|
940
|
-
algorithm: 'ES256' | 'RS256';
|
|
941
|
-
keySize?: number;
|
|
942
|
-
}
|
|
943
|
-
```
|
|
672
|
+
import { saveSession, getSession, clearSession } from '@spfn/auth/nextjs/server';
|
|
944
673
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
userId: string;
|
|
949
|
-
email?: string;
|
|
950
|
-
phone?: string;
|
|
951
|
-
passwordChangeRequired: boolean;
|
|
952
|
-
}
|
|
674
|
+
await saveSession({ userId: '123', privateKey: '...', keyId: 'uuid', algorithm: 'ES256' });
|
|
675
|
+
const session = await getSession(); // read-only, safe in Server Components
|
|
676
|
+
await clearSession();
|
|
953
677
|
```
|
|
954
678
|
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
### Authenticated Routes (Require JWT)
|
|
958
|
-
|
|
959
|
-
**Authentication:**
|
|
960
|
-
- Header: `Authorization: Bearer <jwt>`
|
|
961
|
-
- JWT payload must contain: `{ userId, keyId }`
|
|
962
|
-
- Server extracts `keyId` from JWT, fetches public key, verifies signature
|
|
963
|
-
|
|
964
|
-
---
|
|
679
|
+
RSC guards (redirect when unmet) — `RequireAuth`, `RequireRole`, `RequirePermission`:
|
|
965
680
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
Logout and revoke current key.
|
|
969
|
-
|
|
970
|
-
**Request:**
|
|
971
|
-
```typescript
|
|
972
|
-
{} // Empty body
|
|
973
|
-
```
|
|
681
|
+
```tsx
|
|
682
|
+
import { RequireAuth, RequireRole } from '@spfn/auth/nextjs/server';
|
|
974
683
|
|
|
975
|
-
|
|
976
|
-
```typescript
|
|
684
|
+
export default async function AdminPage()
|
|
977
685
|
{
|
|
978
|
-
|
|
686
|
+
return (
|
|
687
|
+
<RequireAuth redirectTo="/login">
|
|
688
|
+
<RequireRole roles={['admin', 'superadmin']} redirectTo="/forbidden">
|
|
689
|
+
<Dashboard />
|
|
690
|
+
</RequireRole>
|
|
691
|
+
</RequireAuth>
|
|
692
|
+
);
|
|
979
693
|
}
|
|
980
694
|
```
|
|
981
695
|
|
|
982
|
-
|
|
696
|
+
Also exported: `getAuthSessionData`, `getUserRole`, `getUserPermissions`, `hasAnyRole`,
|
|
697
|
+
`hasAnyPermission`, the OAuth pending-session helpers, and `createOAuthCallbackHandler`.
|
|
983
698
|
|
|
984
|
-
|
|
699
|
+
## How do I define roles and permissions?
|
|
985
700
|
|
|
986
|
-
|
|
701
|
+
Built-in roles: `superadmin` (priority 100), `admin` (80), `user` (10). Built-in permissions:
|
|
702
|
+
`auth:self:manage`, `user:read|write|delete|invite`, `rbac:role:manage`, `rbac:permission:manage`.
|
|
703
|
+
Custom roles/permissions are declared on the lifecycle (preferred — runs on startup) or via
|
|
704
|
+
`initializeAuth(options)`.
|
|
987
705
|
|
|
988
|
-
**Request:**
|
|
989
706
|
```typescript
|
|
990
|
-
{
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
keySize?: number;
|
|
996
|
-
}
|
|
707
|
+
createAuthLifecycle({
|
|
708
|
+
roles: [{ name: 'editor', displayName: 'Editor', priority: 30 }],
|
|
709
|
+
permissions: [{ name: 'post:publish', displayName: 'Publish Posts', category: 'content' }],
|
|
710
|
+
rolePermissions: { editor: ['post:publish'] },
|
|
711
|
+
});
|
|
997
712
|
```
|
|
998
713
|
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
keyId: string;
|
|
1004
|
-
}
|
|
1005
|
-
```
|
|
714
|
+
Programmatic checks (server): `hasPermission`, `hasAnyPermission`, `hasAllPermissions`, `hasRole`,
|
|
715
|
+
`hasAnyRole`, `getUserRole`, `getUserPermissions`. Runtime role admin: `createRole`, `updateRole`,
|
|
716
|
+
`deleteRole`, `setRolePermissions`, `addPermissionToRole`, `removePermissionFromRole`,
|
|
717
|
+
`getAllRoles`, `getRoleByName`, `getRolePermissions`.
|
|
1006
718
|
|
|
1007
|
-
|
|
719
|
+
## Can I operate the app without building an admin dashboard?
|
|
1008
720
|
|
|
1009
|
-
|
|
721
|
+
Yes, and that is the point of the operator half of this package. The day after you deploy,
|
|
722
|
+
someone has to refund an order, look up a user, publish a change, retry a failed job. The
|
|
723
|
+
usual answer is to build screens for each of those. `@spfn/auth` already knows who your
|
|
724
|
+
operators are and which of them may do what; [`@spfn/mcp`](../mcp/README.md) turns those
|
|
725
|
+
operations into tools an AI agent can run, so the screens never get built.
|
|
1010
726
|
|
|
1011
|
-
|
|
727
|
+
The connection is app code, deliberately. `@spfn/mcp` does not read this package's RBAC on
|
|
728
|
+
its own — it asks you for a `validateToken` and a `listTools`, and those are where auth's
|
|
729
|
+
answers go:
|
|
1012
730
|
|
|
1013
|
-
**Request:**
|
|
1014
731
|
```typescript
|
|
1015
|
-
{
|
|
1016
|
-
|
|
1017
|
-
newPassword: string; // Min 8 chars
|
|
1018
|
-
}
|
|
1019
|
-
```
|
|
1020
|
-
|
|
1021
|
-
**Response:**
|
|
1022
|
-
```typescript
|
|
1023
|
-
{
|
|
1024
|
-
success: boolean;
|
|
1025
|
-
}
|
|
1026
|
-
```
|
|
1027
|
-
|
|
1028
|
-
---
|
|
1029
|
-
|
|
1030
|
-
## Database Schema
|
|
1031
|
-
|
|
1032
|
-
### Core Tables
|
|
1033
|
-
|
|
1034
|
-
#### `users`
|
|
1035
|
-
|
|
1036
|
-
Main user identity table.
|
|
1037
|
-
|
|
1038
|
-
```sql
|
|
1039
|
-
CREATE TABLE users (
|
|
1040
|
-
id BIGSERIAL PRIMARY KEY,
|
|
1041
|
-
email TEXT UNIQUE,
|
|
1042
|
-
phone TEXT UNIQUE,
|
|
1043
|
-
password_hash TEXT NOT NULL,
|
|
1044
|
-
password_change_required BOOLEAN DEFAULT false,
|
|
1045
|
-
role_id BIGINT REFERENCES roles(id) NOT NULL,
|
|
1046
|
-
status TEXT NOT NULL CHECK (status IN ('active', 'inactive', 'suspended')),
|
|
1047
|
-
email_verified_at TIMESTAMP,
|
|
1048
|
-
phone_verified_at TIMESTAMP,
|
|
1049
|
-
last_login_at TIMESTAMP,
|
|
1050
|
-
created_at TIMESTAMP DEFAULT NOW(),
|
|
1051
|
-
updated_at TIMESTAMP DEFAULT NOW(),
|
|
1052
|
-
|
|
1053
|
-
CONSTRAINT users_identifier_check CHECK (
|
|
1054
|
-
(email IS NOT NULL) OR (phone IS NOT NULL)
|
|
1055
|
-
)
|
|
1056
|
-
);
|
|
1057
|
-
```
|
|
1058
|
-
|
|
1059
|
-
**Key Points:**
|
|
1060
|
-
- At least one of `email` OR `phone` required
|
|
1061
|
-
- `passwordHash` is bcrypt ($2b$10$..., 60 chars)
|
|
1062
|
-
- `roleId` references roles table (NOT NULL)
|
|
1063
|
-
|
|
1064
|
-
---
|
|
1065
|
-
|
|
1066
|
-
#### `user_public_keys`
|
|
1067
|
-
|
|
1068
|
-
Stores client public keys for JWT verification.
|
|
1069
|
-
|
|
1070
|
-
```sql
|
|
1071
|
-
CREATE TABLE user_public_keys (
|
|
1072
|
-
id BIGSERIAL PRIMARY KEY,
|
|
1073
|
-
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
|
|
1074
|
-
key_id TEXT UNIQUE NOT NULL,
|
|
1075
|
-
public_key TEXT NOT NULL,
|
|
1076
|
-
algorithm TEXT NOT NULL CHECK (algorithm IN ('ES256', 'RS256')),
|
|
1077
|
-
fingerprint TEXT NOT NULL,
|
|
1078
|
-
is_active BOOLEAN DEFAULT true,
|
|
1079
|
-
created_at TIMESTAMP DEFAULT NOW(),
|
|
1080
|
-
last_used_at TIMESTAMP,
|
|
1081
|
-
expires_at TIMESTAMP NOT NULL,
|
|
1082
|
-
revoked_at TIMESTAMP,
|
|
1083
|
-
revoked_reason TEXT
|
|
1084
|
-
);
|
|
1085
|
-
|
|
1086
|
-
CREATE INDEX idx_user_public_keys_user_id ON user_public_keys(user_id);
|
|
1087
|
-
CREATE INDEX idx_user_public_keys_key_id ON user_public_keys(key_id);
|
|
1088
|
-
CREATE INDEX idx_user_public_keys_is_active ON user_public_keys(is_active);
|
|
1089
|
-
```
|
|
1090
|
-
|
|
1091
|
-
**Key Points:**
|
|
1092
|
-
- `keyId` is client-generated UUID v4
|
|
1093
|
-
- `fingerprint` is SHA-256(publicKey) for verification
|
|
1094
|
-
- `expiresAt` defaults to 90 days from creation
|
|
1095
|
-
- `isActive` determines if key can be used
|
|
1096
|
-
|
|
1097
|
-
---
|
|
1098
|
-
|
|
1099
|
-
#### `verification_codes`
|
|
1100
|
-
|
|
1101
|
-
OTP codes for email/SMS verification.
|
|
1102
|
-
|
|
1103
|
-
```sql
|
|
1104
|
-
CREATE TABLE verification_codes (
|
|
1105
|
-
id BIGSERIAL PRIMARY KEY,
|
|
1106
|
-
target TEXT NOT NULL,
|
|
1107
|
-
target_type TEXT NOT NULL CHECK (target_type IN ('email', 'phone')),
|
|
1108
|
-
code TEXT NOT NULL,
|
|
1109
|
-
purpose TEXT NOT NULL CHECK (purpose IN ('registration', 'login', 'password_reset')),
|
|
1110
|
-
expires_at TIMESTAMP NOT NULL,
|
|
1111
|
-
used_at TIMESTAMP,
|
|
1112
|
-
created_at TIMESTAMP DEFAULT NOW()
|
|
1113
|
-
);
|
|
1114
|
-
|
|
1115
|
-
CREATE INDEX idx_verification_codes_target ON verification_codes(target);
|
|
1116
|
-
```
|
|
1117
|
-
|
|
1118
|
-
**Key Points:**
|
|
1119
|
-
- 6-digit numeric code
|
|
1120
|
-
- Expires in 5-10 minutes (configurable)
|
|
1121
|
-
- Single-use (marked via `usedAt`)
|
|
1122
|
-
|
|
1123
|
-
---
|
|
1124
|
-
|
|
1125
|
-
### RBAC Tables
|
|
1126
|
-
|
|
1127
|
-
#### `roles`
|
|
1128
|
-
|
|
1129
|
-
```sql
|
|
1130
|
-
CREATE TABLE roles (
|
|
1131
|
-
id BIGSERIAL PRIMARY KEY,
|
|
1132
|
-
name TEXT UNIQUE NOT NULL,
|
|
1133
|
-
display_name TEXT NOT NULL,
|
|
1134
|
-
description TEXT,
|
|
1135
|
-
is_builtin BOOLEAN DEFAULT false,
|
|
1136
|
-
is_system BOOLEAN DEFAULT false,
|
|
1137
|
-
is_active BOOLEAN DEFAULT true,
|
|
1138
|
-
priority INTEGER NOT NULL,
|
|
1139
|
-
created_at TIMESTAMP DEFAULT NOW(),
|
|
1140
|
-
updated_at TIMESTAMP DEFAULT NOW()
|
|
1141
|
-
);
|
|
1142
|
-
```
|
|
1143
|
-
|
|
1144
|
-
**Built-in Roles:**
|
|
1145
|
-
- `user` (priority 10) - Default role
|
|
1146
|
-
- `admin` (priority 80)
|
|
1147
|
-
- `superadmin` (priority 100)
|
|
1148
|
-
|
|
1149
|
-
---
|
|
1150
|
-
|
|
1151
|
-
#### `permissions`
|
|
1152
|
-
|
|
1153
|
-
```sql
|
|
1154
|
-
CREATE TABLE permissions (
|
|
1155
|
-
id BIGSERIAL PRIMARY KEY,
|
|
1156
|
-
name TEXT UNIQUE NOT NULL,
|
|
1157
|
-
display_name TEXT NOT NULL,
|
|
1158
|
-
description TEXT,
|
|
1159
|
-
category TEXT,
|
|
1160
|
-
is_builtin BOOLEAN DEFAULT false,
|
|
1161
|
-
is_system BOOLEAN DEFAULT false,
|
|
1162
|
-
is_active BOOLEAN DEFAULT true,
|
|
1163
|
-
created_at TIMESTAMP DEFAULT NOW(),
|
|
1164
|
-
updated_at TIMESTAMP DEFAULT NOW()
|
|
1165
|
-
);
|
|
1166
|
-
```
|
|
1167
|
-
|
|
1168
|
-
**Built-in Permissions:**
|
|
1169
|
-
- `auth:self:manage`
|
|
1170
|
-
- `user:read`, `user:write`, `user:delete`
|
|
1171
|
-
- `rbac:role:manage`, `rbac:permission:manage`
|
|
1172
|
-
|
|
1173
|
-
---
|
|
1174
|
-
|
|
1175
|
-
#### `role_permissions`
|
|
1176
|
-
|
|
1177
|
-
Many-to-many mapping between roles and permissions.
|
|
1178
|
-
|
|
1179
|
-
```sql
|
|
1180
|
-
CREATE TABLE role_permissions (
|
|
1181
|
-
id BIGSERIAL PRIMARY KEY,
|
|
1182
|
-
role_id BIGINT REFERENCES roles(id) ON DELETE CASCADE,
|
|
1183
|
-
permission_id BIGINT REFERENCES permissions(id) ON DELETE CASCADE,
|
|
1184
|
-
created_at TIMESTAMP DEFAULT NOW(),
|
|
1185
|
-
updated_at TIMESTAMP DEFAULT NOW(),
|
|
1186
|
-
|
|
1187
|
-
UNIQUE(role_id, permission_id)
|
|
1188
|
-
);
|
|
1189
|
-
```
|
|
1190
|
-
|
|
1191
|
-
---
|
|
1192
|
-
|
|
1193
|
-
#### `user_permissions`
|
|
1194
|
-
|
|
1195
|
-
User-specific permission overrides.
|
|
1196
|
-
|
|
1197
|
-
```sql
|
|
1198
|
-
CREATE TABLE user_permissions (
|
|
1199
|
-
id BIGSERIAL PRIMARY KEY,
|
|
1200
|
-
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
|
|
1201
|
-
permission_id BIGINT REFERENCES permissions(id) ON DELETE CASCADE,
|
|
1202
|
-
granted BOOLEAN NOT NULL,
|
|
1203
|
-
reason TEXT,
|
|
1204
|
-
expires_at TIMESTAMP,
|
|
1205
|
-
created_at TIMESTAMP DEFAULT NOW(),
|
|
1206
|
-
updated_at TIMESTAMP DEFAULT NOW(),
|
|
1207
|
-
|
|
1208
|
-
UNIQUE(user_id, permission_id)
|
|
1209
|
-
);
|
|
1210
|
-
```
|
|
1211
|
-
|
|
1212
|
-
**Use Cases:**
|
|
1213
|
-
- `granted: true` - Grant permission temporarily
|
|
1214
|
-
- `granted: false` - Revoke permission (even if role has it)
|
|
1215
|
-
- `expiresAt` - Temporary access with expiration
|
|
732
|
+
import { createMcpRoute } from '@spfn/mcp/server';
|
|
733
|
+
import { hasPermission, getUserRole } from '@spfn/auth/server';
|
|
1216
734
|
|
|
1217
|
-
|
|
735
|
+
// one required permission per tool — the same permission names your routes check
|
|
736
|
+
const allTools = [
|
|
737
|
+
{ name: 'orders.refund', permission: 'order:refund', /* … */ },
|
|
738
|
+
{ name: 'content.publish', permission: 'post:publish', /* … */ },
|
|
739
|
+
];
|
|
1218
740
|
|
|
1219
|
-
|
|
741
|
+
export const mcpRouter = createMcpRoute({
|
|
742
|
+
appUrl: 'https://app.example.com',
|
|
743
|
+
serverInfo: { name: 'example-app', version: '1.0.0' },
|
|
1220
744
|
|
|
1221
|
-
|
|
745
|
+
validateToken: async (token, resource) => verifyAccessToken(token, resource),
|
|
1222
746
|
|
|
1223
|
-
|
|
747
|
+
resolveContext: async (auth) => ({
|
|
748
|
+
userId: auth.userId,
|
|
749
|
+
role: await getUserRole(auth.userId),
|
|
750
|
+
}),
|
|
1224
751
|
|
|
1225
|
-
|
|
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: [
|
|
752
|
+
listTools: async (ctx) =>
|
|
1307
753
|
{
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
754
|
+
const allowed = await Promise.all(
|
|
755
|
+
allTools.map(t => hasPermission(ctx.userId, t.permission)),
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
return allTools.filter((_, i) => allowed[i]);
|
|
1311
759
|
},
|
|
1312
|
-
],
|
|
1313
|
-
rolePermissions: {
|
|
1314
|
-
'content-creator': ['post:create'],
|
|
1315
|
-
},
|
|
1316
760
|
});
|
|
1317
761
|
```
|
|
1318
762
|
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
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
|
|
763
|
+
Two rules keep this safe. **Expose operations, not tables** — `orders.refund` carries an
|
|
764
|
+
authorization rule; a generic `db.query` carries none. And **check the permission inside
|
|
765
|
+
the handler too**, not only in `listTools`: hiding a tool from the list is discovery
|
|
766
|
+
control, not authorization.
|
|
1336
767
|
|
|
1337
|
-
|
|
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
|
-
```
|
|
768
|
+
## Events
|
|
1367
769
|
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
### Programmatic Checks
|
|
770
|
+
`@spfn/auth` emits decoupled events (via `@spfn/core/event`). Subscribe for welcome emails,
|
|
771
|
+
analytics, onboarding, etc. Client-supplied `metadata` on register/OAuth flows is forwarded verbatim.
|
|
1371
772
|
|
|
1372
773
|
```typescript
|
|
1373
|
-
import {
|
|
1374
|
-
|
|
1375
|
-
const canPublish = await hasPermission(userId, 'post:publish');
|
|
1376
|
-
const isAdmin = await hasRole(userId, 'admin');
|
|
1377
|
-
const permissions = await getUserPermissions(userId);
|
|
774
|
+
import { authLoginEvent, authRegisterEvent, invitationCreatedEvent, invitationAcceptedEvent } from '@spfn/auth/server';
|
|
1378
775
|
|
|
1379
|
-
|
|
776
|
+
authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
|
|
1380
777
|
{
|
|
1381
|
-
|
|
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],
|
|
778
|
+
if (email) await sendWelcome(email);
|
|
1398
779
|
});
|
|
1399
|
-
|
|
1400
|
-
// Add permission
|
|
1401
|
-
await addPermissionToRole(role.id, 5n);
|
|
1402
|
-
|
|
1403
|
-
// Delete (system roles protected)
|
|
1404
|
-
await deleteRole(role.id);
|
|
1405
780
|
```
|
|
1406
781
|
|
|
1407
|
-
|
|
782
|
+
Payload types: `AuthLoginPayload`, `AuthRegisterPayload`, `InvitationCreatedPayload`,
|
|
783
|
+
`InvitationAcceptedPayload`, `AuthDeletionRequestedPayload`, `AuthDeletionCancelledPayload`,
|
|
784
|
+
`AuthDeletionCompletedPayload`, `OAuthUnlinkedPayload` (`auth.oauth.unlinked` — provider-side
|
|
785
|
+
disconnect, see the OAuth unlink-notify section). These events also bind to `@spfn/core/job`
|
|
786
|
+
jobs via `.on(event)`.
|
|
1408
787
|
|
|
1409
|
-
##
|
|
788
|
+
## Registration gate (`beforeRegister`)
|
|
1410
789
|
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
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
|
|
790
|
+
Events fire *after* the user exists — they cannot reject a registration. For server-enforced
|
|
791
|
+
signup policy (age gate, invite-only domains, block lists) inject a validator with
|
|
792
|
+
`configureAuth`; it runs **before the user row is created** on every registration channel:
|
|
793
|
+
`credentials` (email/phone register), `oauth` (new-user social signup, web + native), and
|
|
794
|
+
`invitation` (acceptance). Throwing rejects the registration; `RegistrationRejectedError` (403)
|
|
795
|
+
is the recommended error. The hook receives the same `metadata` the app supplied to
|
|
796
|
+
`register` / OAuth start / the invitation — never credentials.
|
|
1435
797
|
|
|
1436
798
|
```typescript
|
|
1437
|
-
|
|
1438
|
-
import {
|
|
799
|
+
import { configureAuth } from '@spfn/auth/server';
|
|
800
|
+
import { RegistrationRejectedError } from '@spfn/auth/errors';
|
|
1439
801
|
|
|
1440
|
-
|
|
1441
|
-
{
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
}
|
|
802
|
+
configureAuth({
|
|
803
|
+
beforeRegister: async ({ channel, provider, email, phone, metadata }) =>
|
|
804
|
+
{
|
|
805
|
+
if (!isOldEnough(metadata?.birthDate))
|
|
806
|
+
{
|
|
807
|
+
throw new RegistrationRejectedError({ message: 'Age requirement not met' });
|
|
808
|
+
}
|
|
809
|
+
},
|
|
810
|
+
});
|
|
1450
811
|
```
|
|
1451
812
|
|
|
1452
|
-
|
|
813
|
+
Notes:
|
|
814
|
+
- Runs after built-in checks (verification token, duplicate account) — existing error
|
|
815
|
+
precedence is unchanged, and the hook cannot be probed without a valid verification token.
|
|
816
|
+
- Not called when an OAuth login links a social account to an existing user, nor for admin
|
|
817
|
+
seeding in `initializeAuth()`.
|
|
818
|
+
- OAuth signups have no client-typed fields unless you pass `metadata` at OAuth start — decide
|
|
819
|
+
per channel (reject, or allow and collect during onboarding).
|
|
820
|
+
- On the `oauth` channel `email` is the provider-reported address and may be **unverified**
|
|
821
|
+
(the created account then stores `email` as `null`). The context carries
|
|
822
|
+
`emailVerified` — an email-based allow/block policy must check it before trusting `email`.
|
|
823
|
+
- The hook runs **inside the registration DB transaction** on every channel — keep it fast.
|
|
824
|
+
A slow call (e.g. an external policy API) holds a pooled DB connection open per signup.
|
|
825
|
+
- On the **web** OAuth flow a rejection surfaces as the standard OAuth error redirect
|
|
826
|
+
(302 to the app's OAuth error URL, message only) — not a 403 JSON response. The native
|
|
827
|
+
OAuth flow, credentials, and invitation channels return the error status (403) directly.
|
|
828
|
+
|
|
829
|
+
## One-Time Token
|
|
830
|
+
|
|
831
|
+
For short-lived authenticated handshakes (e.g. SSE) where a `Bearer` header is awkward: issue
|
|
832
|
+
with `authApi.issueOneTimeToken`, protect the consuming route with the `oneTimeTokenAuth`
|
|
833
|
+
middleware. Call `initOneTimeTokenManager({ ttl, store })` during setup for a custom TTL/store.
|
|
834
|
+
|
|
835
|
+
## Mobile clientProofV1 (`@spfn/auth/client-proof`)
|
|
836
|
+
|
|
837
|
+
Server side of the spfn-mobile native SDK auth profile (issue #46; asymmetric revision in
|
|
838
|
+
contract 0.2.0). Implements the pinned mobile contract exactly: SPFN-CANON-JSON-1 canonical
|
|
839
|
+
JSON (custom parser/encoder — int64 via BigInt, duplicate-key rejection, UTF-8 byte key
|
|
840
|
+
order), SPFN-PROOF-INPUT-1 proof assembly with ECDSA P-256 + SHA-256 signature verification
|
|
841
|
+
(wire form: raw `r‖s`, 64 bytes, base16-lower; DER is rejected, low-S is not required — the
|
|
842
|
+
nonce + replay window own uniqueness), the contract admission order (revoked → session →
|
|
843
|
+
expired → replayed → signature; a nonce is spent only on admission), in-memory session
|
|
844
|
+
issuance/expiry, and
|
|
845
|
+
the fixed-string contract error envelope (`PROOF_INVALID` · `PROOF_REPLAYED` · `PROOF_EXPIRED` ·
|
|
846
|
+
`SESSION_REVOKED` · `PROFILE_REJECTED` · `CONTRACT_UNSUPPORTED` — SDKs classify by code, never
|
|
847
|
+
HTTP status).
|
|
848
|
+
|
|
849
|
+
- Wire headers (D23, ratified): `x-spfn-auth-profile`, `x-spfn-client-id`, `x-spfn-key-id`,
|
|
850
|
+
`x-spfn-nonce`, `x-spfn-issued-at`, `x-spfn-proof`, `x-spfn-session`.
|
|
851
|
+
- A request body must be **byte-canonical** — a body that parses but re-encodes differently is
|
|
852
|
+
refused even when its proof verifies (the proof binds the received bytes).
|
|
853
|
+
- `createClientProofDevHandler(...)` — framework-free `fetch(Request) → Response` dev surface
|
|
854
|
+
with the three contract operations and the `/control` test hooks the spfn-mobile integration
|
|
855
|
+
suites drive (`examples/04-mobile-contract-dev` is the runnable wiring).
|
|
856
|
+
- `createClientProofGuard(state)` — Hono middleware for mounting `requiresSession` operations
|
|
857
|
+
on an SPFN server; tags admitted requests `clientType: 'mobile'` (the attestation slot
|
|
858
|
+
proxy-guard reserved). hono is a type-only import here.
|
|
859
|
+
- Replay ledger is module-local, NOT core's `NonceStore` — `checkAndSet` records on check,
|
|
860
|
+
which would spend a nonce on a refused request; the contract requires spending only on
|
|
861
|
+
admission.
|
|
862
|
+
- Conformance: spfn-mobile fixtures are vendored under
|
|
863
|
+
`src/server/client-proof/__tests__/fixtures/` (digest-pinned to upstream `MANIFEST.json`,
|
|
864
|
+
dev bundle sha256 `07fd8268…a433e45`) and run in the unit suite.
|
|
865
|
+
- Dev/test scope: public keys (SPKI DER base64, keyed by `x-spfn-key-id`) are registered at
|
|
866
|
+
construction or through the `/control/register-key` hook; the private half never reaches
|
|
867
|
+
the server. No persistence — a production enrollment/rotation story is phase 2.
|
|
868
|
+
|
|
869
|
+
### The contract version on the wire (contract 0.6.0)
|
|
870
|
+
|
|
871
|
+
A client compiled and shipped separately from the server cannot be fixed by redeploying. Until
|
|
872
|
+
0.6.0 a mismatch between what that client was generated against and what the server serves
|
|
873
|
+
surfaced as an undecodable body: the app looked broken and nothing said why.
|
|
874
|
+
|
|
875
|
+
Both ends now say what they are.
|
|
876
|
+
|
|
877
|
+
| Header | Direction | Sent by |
|
|
878
|
+
|--------|-----------|---------|
|
|
879
|
+
| `x-spfn-client-kind` | request | every client — `web`, `ios` or `android` |
|
|
880
|
+
| `x-spfn-client-version` | request | the client's own release: a store version, or a bundle build |
|
|
881
|
+
| `x-spfn-client-contract-version` | request | `ios` and `android` only |
|
|
882
|
+
| `x-spfn-server-contract-version` | response | the server, on every response including a refusal |
|
|
883
|
+
| `x-spfn-supported-contract-range` | response | the server, likewise |
|
|
1453
884
|
|
|
1454
|
-
### Interceptors (API Routes)
|
|
1455
|
-
|
|
1456
|
-
**Setup:**
|
|
1457
885
|
```typescript
|
|
1458
|
-
|
|
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
|
|
886
|
+
import { createClientVersionMiddleware } from '@spfn/auth/client-proof';
|
|
1478
887
|
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
# Generate migrations
|
|
1484
|
-
pnpm db:generate
|
|
1485
|
-
|
|
1486
|
-
# Run migrations (via @spfn/core)
|
|
1487
|
-
cd ../../
|
|
1488
|
-
pnpm spfn db migrate
|
|
888
|
+
// Mount before authentication: enrollment and login carry no proof, and they are
|
|
889
|
+
// where a stale client arrives first.
|
|
890
|
+
app.use('*', createClientVersionMiddleware());
|
|
1489
891
|
```
|
|
1490
892
|
|
|
1491
|
-
|
|
893
|
+
- **`web` states no contract version**, because a browser bundle is deployed with the server that
|
|
894
|
+
serves it and has no second version to reconcile. It is exempt by construction, not by leniency.
|
|
895
|
+
- **An `ios` or `android` client that states no contract version, or one outside the range, is
|
|
896
|
+
refused** `CONTRACT_UNSUPPORTED` (409) with the usual envelope.
|
|
897
|
+
- **A request naming no kind passes** — a curl, a health probe, a server-to-server call is not a
|
|
898
|
+
deployed client this rule is about.
|
|
899
|
+
- **None of it enters the proof input.** These are diagnostic; `PROOF_INPUT_FIELDS` is unchanged.
|
|
900
|
+
- **The server states facts and stops there.** Comparing the announced range against its own version
|
|
901
|
+
and deciding a user should see an update prompt is the client's judgment, made in the client. The
|
|
902
|
+
server has no way to make an app update and does not pretend to.
|
|
1492
903
|
|
|
1493
|
-
|
|
904
|
+
Response header names are deliberately distinct from the request ones: a proxy that echoes a request
|
|
905
|
+
header into the response would otherwise make the client's own version look like the server's.
|
|
1494
906
|
|
|
1495
|
-
|
|
1496
|
-
# All tests
|
|
1497
|
-
pnpm test
|
|
1498
|
-
|
|
1499
|
-
# With coverage
|
|
1500
|
-
pnpm test:coverage
|
|
907
|
+
### Usage — dev surface (mobile integration target)
|
|
1501
908
|
|
|
1502
|
-
|
|
1503
|
-
|
|
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
|
|
909
|
+
The fastest path: run the packaged dev handler, which already serves the three contract
|
|
910
|
+
operations and `/control`. `examples/04-mobile-contract-dev` is exactly this, runnable.
|
|
1533
911
|
|
|
1534
912
|
```typescript
|
|
1535
|
-
import {
|
|
1536
|
-
import {
|
|
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
|
-
});
|
|
913
|
+
import { serve } from '@hono/node-server';
|
|
914
|
+
import { createClientProofDevHandler } from '@spfn/auth/client-proof';
|
|
1555
915
|
|
|
1556
|
-
|
|
1557
|
-
|
|
916
|
+
const handler = createClientProofDevHandler({
|
|
917
|
+
// keyId → registered public key (SPKI DER base64); the private key stays on the client
|
|
918
|
+
publicKeys: { 'key-dev-0001': process.env.SPFN_CLIENT_PROOF_PUBLIC_KEY! },
|
|
919
|
+
sessionTtlMillis: 600_000,
|
|
1558
920
|
});
|
|
921
|
+
serve({ fetch: handler.fetch, port: 8791, hostname: '127.0.0.1' });
|
|
922
|
+
// handler.controlToken — pass to the test harness for /control routes
|
|
923
|
+
// handler.state — revokeKey() / expireSessions() / stats() from code
|
|
1559
924
|
```
|
|
1560
925
|
|
|
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
|
-
```
|
|
926
|
+
### Usage — mounting on your own Hono/SPFN server
|
|
1577
927
|
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
DATABASE_URL=postgresql://spfn:spfn_dev_password@localhost:5433/spfn_auth_test
|
|
1581
|
-
```
|
|
928
|
+
Protect `requiresSession` operations with the guard, and assemble the handshake route from
|
|
929
|
+
the exported primitives (`admitClientProofRequest` + `state.openSession`):
|
|
1582
930
|
|
|
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
931
|
```typescript
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
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
|
-
---
|
|
932
|
+
import { Hono } from 'hono';
|
|
933
|
+
import {
|
|
934
|
+
ClientProofState, createClientProofGuard, admitClientProofRequest,
|
|
935
|
+
decodeHandshakeRequest, encodeHandshakeResponse, encodeCanonicalJson,
|
|
936
|
+
ClientProofRefusal, newHexId,
|
|
937
|
+
} from '@spfn/auth/client-proof';
|
|
1662
938
|
|
|
1663
|
-
|
|
939
|
+
const state = new ClientProofState({ publicKeys: { 'key-dev-0001': process.env.SPFN_CLIENT_PROOF_PUBLIC_KEY! } });
|
|
940
|
+
const app = new Hono();
|
|
1664
941
|
|
|
1665
|
-
|
|
1666
|
-
```json
|
|
942
|
+
app.post('/v1/auth/client-proof/handshake', async (c) =>
|
|
1667
943
|
{
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
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
|
|
944
|
+
const body = new Uint8Array(await c.req.arrayBuffer());
|
|
945
|
+
const admission = admitClientProofRequest({
|
|
946
|
+
state, headers: c.req.raw.headers, method: 'POST',
|
|
947
|
+
path: '/v1/auth/client-proof/handshake', requiresSession: false, body,
|
|
948
|
+
});
|
|
949
|
+
if (!admission.admitted)
|
|
1707
950
|
{
|
|
1708
|
-
|
|
951
|
+
return c.newResponse(admission.refusal.envelopeBytes(newHexId()).slice().buffer,
|
|
952
|
+
admission.refusal.httpStatus as 401, { 'content-type': 'application/json' });
|
|
1709
953
|
}
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
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
|
-
---
|
|
954
|
+
const request = decodeHandshakeRequest(admission.value);
|
|
955
|
+
const opened = state.openSession(request.clientId, request.keyId);
|
|
956
|
+
return c.newResponse(
|
|
957
|
+
encodeCanonicalJson(encodeHandshakeResponse(opened.sessionId, BigInt(opened.expiresAtMillis))).slice().buffer,
|
|
958
|
+
200, { 'content-type': 'application/json' });
|
|
959
|
+
});
|
|
1740
960
|
|
|
1741
|
-
|
|
961
|
+
// Any route behind the guard sees clientType='mobile' and c.get('clientProof')
|
|
962
|
+
app.post('/v1/echo', createClientProofGuard(state), (c) => { /* handler */ });
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
Responses and errors MUST be canonical bytes with the contract envelope — build them with
|
|
966
|
+
`encodeCanonicalJson`/`ClientProofRefusal`, never `c.json()` (key order and int64 differ).
|
|
967
|
+
|
|
968
|
+
## Account Deletion & Recovery
|
|
969
|
+
|
|
970
|
+
Grace-period deletion with in-window recovery, an admin/GDPR-response entry point for immediate
|
|
971
|
+
purge, and a pluggable app-data cleanup hook. Not covered by this feature: re-signup email
|
|
972
|
+
blind-index/hashing (a purged account's email becomes reusable immediately — see the project's
|
|
973
|
+
PII protection track for blind-index re-signup prevention), backup beyond-use handling, DSR
|
|
974
|
+
intake/response workflows, and webhook fan-out — those are app/ops concerns.
|
|
975
|
+
|
|
976
|
+
```
|
|
977
|
+
active ──request (re-auth)──> pending_deletion ──grace period elapses (cron)──> deleted (anonymize) | row removed (hard-delete)
|
|
978
|
+
^ │
|
|
979
|
+
└───────────cancel (re-auth)───────┘ immediate = grace period of 0, same pipeline
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
- **Request** — `POST /_auth/deletion/request` (authenticated). Step-up re-auth: password
|
|
983
|
+
holders confirm with `password`; OAuth-only/passwordless accounts confirm with a
|
|
984
|
+
`verificationToken` from `/_auth/codes` + `/_auth/codes/verify` (`purpose: 'account_deletion'`).
|
|
985
|
+
On success: status → `pending_deletion`, every active session key is revoked, a
|
|
986
|
+
`account_deletion_requests` audit row is created, `auth.deletion.requested` fires, and (if
|
|
987
|
+
the user has an email and `sendNotifications` is on) a notice is sent with the scheduled purge
|
|
988
|
+
date.
|
|
989
|
+
- **Login is blocked while pending** — password login, OAuth login, and the `authenticate`
|
|
990
|
+
middleware all reject a `pending_deletion` account with `AccountPendingDeletionError` (403,
|
|
991
|
+
`details.purgeScheduledAt`) instead of the generic `AccountDisabledError`, so the client can
|
|
992
|
+
show a recovery prompt.
|
|
993
|
+
- **Cancel (recovery)** — `POST /_auth/deletion/cancel` (public — sessions were revoked at
|
|
994
|
+
request time, so there's no Bearer token to authenticate with). Credential-based: email/phone
|
|
995
|
+
plus `password` or a fresh `verificationToken`. On success, status → `active`; the user still
|
|
996
|
+
needs to log in separately afterward.
|
|
997
|
+
- **Purge job** — sweeps `account_deletion_requests` for rows past their grace period and
|
|
998
|
+
destroys the account. Register it explicitly (see below); it is **not** wired up by
|
|
999
|
+
`createAuthLifecycle()` automatically.
|
|
1000
|
+
- **Admin / GDPR-response entry points** — `requestAccountDeletionService(userId, { requestedBy: 'admin', immediate })`
|
|
1001
|
+
and `purgeUserService(userId)` are exported for app-side admin routes / DSR handling; the app
|
|
1002
|
+
owns the route and its authorization.
|
|
1742
1003
|
|
|
1743
|
-
**Enable logging:**
|
|
1744
1004
|
```typescript
|
|
1745
|
-
import {
|
|
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.
|
|
1005
|
+
import { defineServerConfig } from '@spfn/core/server';
|
|
1006
|
+
import { createAuthLifecycle, authJobRouter } from '@spfn/auth/server';
|
|
1905
1007
|
|
|
1906
|
-
|
|
1008
|
+
export default defineServerConfig()
|
|
1009
|
+
.lifecycle(createAuthLifecycle({
|
|
1010
|
+
deletion: {
|
|
1011
|
+
gracePeriodDays: 30, // default; 0 = immediate
|
|
1012
|
+
purgeStrategy: 'anonymize', // default; or 'hard-delete'
|
|
1013
|
+
allowSelfImmediate: false, // default; self-service immediate: true
|
|
1014
|
+
sendNotifications: true, // default
|
|
1015
|
+
onBeforePurge: async (user) =>
|
|
1016
|
+
{
|
|
1017
|
+
// throw to skip this user for the current sweep (retried next run)
|
|
1018
|
+
await appDataCleanup(user.id);
|
|
1019
|
+
},
|
|
1020
|
+
},
|
|
1021
|
+
}))
|
|
1022
|
+
.jobs(authJobRouter) // registers the daily (04:00 UTC) purge sweep
|
|
1023
|
+
.routes(appRouter)
|
|
1024
|
+
.build();
|
|
1907
1025
|
```
|
|
1908
1026
|
|
|
1909
|
-
|
|
1027
|
+
**Purge strategies:**
|
|
1028
|
+
|
|
1029
|
+
- `anonymize` (default) — scrubs PII, keeps the row: `email` → `deleted-{publicId}@deleted.invalid`,
|
|
1030
|
+
`phone`/`username`/`passwordHash` → `null`, `status` → `'deleted'`, `deletedAt`/`deletedBy` set
|
|
1031
|
+
(`softDelete()` on `users`). Social accounts and public keys are deleted (frees the provider
|
|
1032
|
+
link and revokes access), the profile's PII columns are cleared, and any leftover verification
|
|
1033
|
+
codes for the original email/phone are removed. The freed email/phone can be re-registered
|
|
1034
|
+
immediately.
|
|
1035
|
+
- `hard-delete` — physically removes the `users` row; child rows (`user_profiles`,
|
|
1036
|
+
`user_public_keys`, `user_social_accounts`, `user_permissions`) cascade-delete via their FK.
|
|
1037
|
+
The `account_deletion_requests` audit row survives either strategy — its `userId` FK is
|
|
1038
|
+
`set null` (not cascade), by design, so "who requested/purged what, when" outlives the user row.
|
|
1039
|
+
|
|
1040
|
+
The final "your account has been deleted" notice is sent **after** the purge transaction commits
|
|
1041
|
+
(never before, and never on a purge that aborted or rolled back — see below), using the address
|
|
1042
|
+
captured before the destructive step ran. This holds for `hard-delete` too: the row is already
|
|
1043
|
+
gone by send time, but the address was captured beforehand, so the notice still goes out.
|
|
1044
|
+
|
|
1045
|
+
**Concurrency.** The purge job re-verifies the user is still `pending_deletion` on the write
|
|
1046
|
+
primary immediately before any destructive DML, inside the same transaction as the DML itself —
|
|
1047
|
+
closing the window between a stale read (the sweep's own batch, or replica lag) and a concurrent
|
|
1048
|
+
`cancel`. The `account_deletion_requests` claim (`markCompleted`) is a conditional `UPDATE ...
|
|
1049
|
+
WHERE status = 'pending'`; if a concurrent cancel already moved the row off `pending`, the claim
|
|
1050
|
+
matches zero rows and the purge aborts with no destructive DML and no overwritten audit row.
|
|
1051
|
+
|
|
1052
|
+
**Cron schedule caveat.** `deletion.purgeCron` (default `0 4 * * *`) is stored for reference, but
|
|
1053
|
+
the static `authJobRouter` export above always runs on the *default* cron — `job(...).cron(...)`
|
|
1054
|
+
is fixed at module-import time, which happens before `createAuthLifecycle()` runs in your
|
|
1055
|
+
`server.config.ts`. For a non-default schedule, build the router yourself, after the
|
|
1056
|
+
`createAuthLifecycle()` call, and register that instead:
|
|
1910
1057
|
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1058
|
+
```typescript
|
|
1059
|
+
import { createAuthDeletionJobRouter } from '@spfn/auth/server';
|
|
1060
|
+
|
|
1061
|
+
// ... after .lifecycle(createAuthLifecycle({ deletion: { purgeCron: '0 3 * * *' } }))
|
|
1062
|
+
.jobs(createAuthDeletionJobRouter({ purgeCron: '0 3 * * *' }))
|
|
1063
|
+
```
|
|
1064
|
+
|
|
1065
|
+
Register **only one** of `authJobRouter` / `createAuthDeletionJobRouter(...)` — both build a job
|
|
1066
|
+
named `auth.deletion.purge`, so registering both (e.g. the static export *and* a custom-cron
|
|
1067
|
+
router) double-registers the same job name against pg-boss instead of overriding it.
|
|
1068
|
+
|
|
1069
|
+
## FAQ
|
|
1070
|
+
|
|
1071
|
+
**How do I add one social provider?**
|
|
1072
|
+
Set its two environment variables. Google, GitHub, Kakao and Naver each turn on when their
|
|
1073
|
+
client ID and secret are both present — there is no separate registration step. Then
|
|
1074
|
+
register the callback URL in that provider's console, and read the next answer before you
|
|
1075
|
+
deploy.
|
|
1076
|
+
|
|
1077
|
+
**Social login worked locally and broke after deploying. Why?**
|
|
1078
|
+
Almost always the callback origin. The CSRF check is a double-submit against a host-only
|
|
1079
|
+
cookie set on your **web app** host, so the provider must return to the web app origin, and
|
|
1080
|
+
the app must forward `/_auth/*` to the API with a Next.js rewrite. Without that rewrite the
|
|
1081
|
+
callback 404s — including in local dev. Details in
|
|
1082
|
+
[OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite).
|
|
1083
|
+
|
|
1084
|
+
**Does the server hold my users' private keys?**
|
|
1085
|
+
No. The client generates an ES256/RS256 keypair, sends only the public key on register or
|
|
1086
|
+
login, and signs each request itself. The server verifies with the stored public key. Keys
|
|
1087
|
+
expire after 90 days; `rotateKey` renews one.
|
|
1088
|
+
|
|
1089
|
+
**Does signing in on a new device sign the old one out?**
|
|
1090
|
+
No, and that is on purpose — keys are per-device and accumulate. `listKeys` shows the
|
|
1091
|
+
account owner what accumulated, `revokeKey` cuts one off, `revokeAllKeys` cuts off
|
|
1092
|
+
everything but the caller.
|
|
1093
|
+
|
|
1094
|
+
**How long does a session last?**
|
|
1095
|
+
`SPFN_AUTH_SESSION_TTL`, seven days by default. It accepts `7d`, `12h`, `45m`.
|
|
1096
|
+
|
|
1097
|
+
**Is account deletion immediate?**
|
|
1098
|
+
No. A request moves the account to `pending_deletion`, revokes every session key, and
|
|
1099
|
+
schedules the purge for 30 days later by default. The user can cancel with their
|
|
1100
|
+
credentials during that window. Two things need your attention: the purge sweep is a job
|
|
1101
|
+
you register explicitly (`.jobs(authJobRouter)`), and a purged account's email becomes
|
|
1102
|
+
reusable immediately. See [Account Deletion & Recovery](#account-deletion--recovery).
|
|
1103
|
+
|
|
1104
|
+
**Can an admin delete a user's account?**
|
|
1105
|
+
Yes, through `requestAccountDeletionService(userId, { requestedBy: 'admin', immediate })`
|
|
1106
|
+
and `purgeUserService(userId)`. The package exports the services; you own the route and its
|
|
1107
|
+
authorization.
|
|
1108
|
+
|
|
1109
|
+
**Where do my admin accounts come from?**
|
|
1110
|
+
The environment, seeded on startup by `createAuthLifecycle()`. Seeded accounts are email
|
|
1111
|
+
verified, active, and required to change their password on first login.
|
|
1112
|
+
|
|
1113
|
+
## Pitfalls & anti-patterns
|
|
1114
|
+
|
|
1115
|
+
- **"relation \"auth.users\" does not exist" — tables come from bundled migrations, not push.**
|
|
1116
|
+
Package schemas are excluded from `spfn db push`'s diff; the `auth.*` tables are created by the
|
|
1117
|
+
migration files shipped in this package. Run `pnpm spfn db migrate` (state check:
|
|
1118
|
+
`pnpm spfn db status`). Installing via plain `pnpm add @spfn/auth` runs no migration — only
|
|
1119
|
+
`spfn add @spfn/auth` auto-applies them.
|
|
1120
|
+
- **Wrong entry point.** `@spfn/auth/server` and `@spfn/auth/nextjs/*` are server-only (Node /
|
|
1121
|
+
`server-only`). Importing them in a client component breaks the build. Entities, services, and
|
|
1122
|
+
repositories are on `/server`, not on root `@spfn/auth`.
|
|
1123
|
+
- **No `app.bind(contract, ...)`.** That contract pattern is removed. Use the route DSL
|
|
1124
|
+
(`route.get().handler()` + `defineRouter`). Any docs/snippets using `app.bind` are stale.
|
|
1125
|
+
- **Custom error classes must be registered.** Add them to an `ErrorRegistry` (mirror
|
|
1126
|
+
`authErrorRegistry` in `src/errors/index.ts`) and pass it to your `createApi({ errorRegistry })`,
|
|
1127
|
+
or the client receives a generic error instead of the typed one.
|
|
1128
|
+
- **Two env files, by audience.** `SPFN_AUTH_SESSION_SECRET` lives in `.env.local` (Next.js needs
|
|
1129
|
+
it for cookie crypto); `SPFN_AUTH_VERIFICATION_TOKEN_SECRET` and
|
|
1130
|
+
`SPFN_AUTH_TOKEN_ENCRYPTION_KEYS` live in `.env.server`. Token encryption keys are backend-only;
|
|
1131
|
+
putting them in `.env.local` unnecessarily gives the Next.js process token-decryption authority.
|
|
1132
|
+
- **`SPFN_AUTH_SESSION_SECRET` is validated.** Minimum 32 chars plus entropy/unique-char checks —
|
|
1133
|
+
a short or low-entropy value fails startup, not just a warning.
|
|
1134
|
+
- **Forgetting the interceptor import.** Without `import '@spfn/auth/nextjs/api'` in the RPC proxy
|
|
1135
|
+
route, the client sends no `Authorization` header and every protected call 401s. The
|
|
1136
|
+
`authenticate` middleware error message points here.
|
|
1137
|
+
- **Custom OAuth callback without `Transactional()`.** A failure mid-callback leaves an orphan
|
|
1138
|
+
user. Always wrap the callback route in `Transactional()` and call `oauthCallbackService`.
|
|
1139
|
+
- **`sideEffects: false` tree-shakes the google provider.** The built-in provider self-registers
|
|
1140
|
+
via a module side-effect; an aggressive bundler config can drop it. Don't mark this package's
|
|
1141
|
+
imports side-effect-free.
|
|
1142
|
+
- **Public routes need an explicit opt-out.** With global `authenticate`, any route without
|
|
1143
|
+
`.skip(['auth'])` (or `optionalAuth`, which auto-skips) requires a valid token.
|
|
1144
|
+
- **`SOCIAL_PROVIDERS` is plain `enumText`.** Adding a provider value needs no DB migration, but
|
|
1145
|
+
every `switch(provider)` over login/register events must handle the new value.
|
|
1146
|
+
- **Email/SMS is not here.** It moved to `@spfn/notification` (`import { sendEmail, sendSMS } from
|
|
1147
|
+
'@spfn/notification/server'`). Wire verification-code / invitation emails through its events.
|
|
1148
|
+
- **`authJobRouter` isn't registered for you.** `createAuthLifecycle()`'s `afterInfrastructure`
|
|
1149
|
+
hook runs *before* `@spfn/core` initializes pg-boss and registers jobs, so the lifecycle has no
|
|
1150
|
+
opportunity to auto-register the account-deletion purge job. Call `.jobs(authJobRouter)`
|
|
1151
|
+
yourself — see [Account Deletion & Recovery](#account-deletion--recovery).
|
|
1152
|
+
- **`USER_STATUSES` gained `pending_deletion` / `deleted`.** Any code with a `switch(user.status)`
|
|
1153
|
+
or an exhaustive status union must handle both — `enumText` is plain `text` with no DB `CHECK`,
|
|
1154
|
+
so nothing enforces this at the database layer.
|
|
1155
|
+
|
|
1156
|
+
## Complete example
|
|
1914
1157
|
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1158
|
+
```typescript
|
|
1159
|
+
// server.config.ts
|
|
1160
|
+
import { defineServerConfig } from '@spfn/core/server';
|
|
1161
|
+
import { createAuthLifecycle } from '@spfn/auth/server';
|
|
1162
|
+
import { appRouter } from './router';
|
|
1918
1163
|
|
|
1919
|
-
|
|
1164
|
+
export default defineServerConfig()
|
|
1165
|
+
.port(8790)
|
|
1166
|
+
.routes(appRouter)
|
|
1167
|
+
.lifecycle(createAuthLifecycle({
|
|
1168
|
+
roles: [{ name: 'editor', displayName: 'Editor', priority: 30 }],
|
|
1169
|
+
permissions: [{ name: 'post:publish', displayName: 'Publish Posts', category: 'content' }],
|
|
1170
|
+
rolePermissions: { editor: ['post:publish'] },
|
|
1171
|
+
}))
|
|
1172
|
+
.build();
|
|
1920
1173
|
|
|
1921
|
-
|
|
1174
|
+
// router.ts
|
|
1175
|
+
import { defineRouter } from '@spfn/core/route';
|
|
1176
|
+
import { authRouter, authenticate } from '@spfn/auth/server';
|
|
1177
|
+
import { getMe } from './routes/me';
|
|
1922
1178
|
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1179
|
+
export const appRouter = defineRouter({ getMe })
|
|
1180
|
+
.packages([authRouter])
|
|
1181
|
+
.use([authenticate]);
|
|
1182
|
+
export type AppRouter = typeof appRouter;
|
|
1926
1183
|
|
|
1927
|
-
|
|
1928
|
-
|
|
1184
|
+
// app/api/rpc/[routeName]/route.ts
|
|
1185
|
+
import '@spfn/auth/nextjs/api';
|
|
1186
|
+
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
1187
|
+
import { authRouteMap } from '@spfn/auth';
|
|
1188
|
+
import { routeMap } from '@/generated/route-map';
|
|
1189
|
+
export const { GET, POST } = createRpcProxy({ routeMap: { ...routeMap, ...authRouteMap } });
|
|
1929
1190
|
|
|
1930
|
-
|
|
1931
|
-
|
|
1191
|
+
// any client component
|
|
1192
|
+
import { authApi } from '@spfn/auth';
|
|
1193
|
+
const session = await authApi.getAuthSession.call({});
|
|
1932
1194
|
```
|
|
1933
1195
|
|
|
1934
|
-
|
|
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
|
-
---
|
|
1196
|
+
## Related
|
|
1959
1197
|
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1198
|
+
- [`@spfn/core`](../core/README.md) — route DSL (`route`, `defineRouter`), `createApi`, env
|
|
1199
|
+
(`@spfn/core/env`), errors (`ErrorRegistry`), db (`Transactional`), events, jobs.
|
|
1200
|
+
- [`@spfn/mcp`](../mcp/README.md) — exposes operations as MCP tools, so the operator half of
|
|
1201
|
+
this package needs no admin dashboard.
|
|
1202
|
+
- `@spfn/notification` — email/SMS/push (verification codes, invitation emails).
|
|
1203
|
+
- Full guide: `docs/guides/authentication.md`.
|