@spfn/auth 0.2.0-beta.9 → 0.2.0-beta.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +1007 -1739
  3. package/dist/authenticate-DlTGaBT8.d.ts +1403 -0
  4. package/dist/client-proof.d.ts +606 -0
  5. package/dist/client-proof.js +1842 -0
  6. package/dist/client-proof.js.map +1 -0
  7. package/dist/config.d.ts +487 -39
  8. package/dist/config.js +243 -29
  9. package/dist/config.js.map +1 -1
  10. package/dist/errors.d.ts +208 -3
  11. package/dist/errors.js +140 -1
  12. package/dist/errors.js.map +1 -1
  13. package/dist/index.d.ts +392 -110
  14. package/dist/index.js +186 -7
  15. package/dist/index.js.map +1 -1
  16. package/dist/nextjs/api.js +591 -61
  17. package/dist/nextjs/api.js.map +1 -1
  18. package/dist/nextjs/client.d.ts +28 -0
  19. package/dist/nextjs/client.js +80 -0
  20. package/dist/nextjs/client.js.map +1 -0
  21. package/dist/nextjs/server.d.ts +92 -3
  22. package/dist/nextjs/server.js +288 -24
  23. package/dist/nextjs/server.js.map +1 -1
  24. package/dist/server.d.ts +2618 -1092
  25. package/dist/server.js +6553 -1500
  26. package/dist/server.js.map +1 -1
  27. package/dist/session-DTHahDQ9.d.ts +53 -0
  28. package/dist/types-DYyhze28.d.ts +98 -0
  29. package/dist/wire-version-CtzMKvBB.d.ts +134 -0
  30. package/migrations/20251125021229_premium_famine/snapshot.json +2641 -0
  31. package/migrations/20260225130050_smooth_the_fury/migration.sql +3 -0
  32. package/migrations/20260225130050_smooth_the_fury/snapshot.json +2686 -0
  33. package/migrations/20260308141417_deep_iceman/migration.sql +11 -0
  34. package/migrations/20260308141417_deep_iceman/snapshot.json +2686 -0
  35. package/migrations/20260308151309_perfect_deathbird/migration.sql +3 -0
  36. package/migrations/20260308151309_perfect_deathbird/snapshot.json +2731 -0
  37. package/migrations/20260308201135_concerned_rawhide_kid/migration.sql +5 -0
  38. package/migrations/20260308201135_concerned_rawhide_kid/snapshot.json +2786 -0
  39. package/migrations/20260629103209_lethal_lifeguard/migration.sql +32 -0
  40. package/migrations/20260629103209_lethal_lifeguard/snapshot.json +2786 -0
  41. package/migrations/20260709073531_easy_hardball/migration.sql +24 -0
  42. package/migrations/20260709073531_easy_hardball/snapshot.json +3119 -0
  43. package/migrations/20260714081434_glossy_major_mapleleaf/migration.sql +1 -0
  44. package/migrations/20260714081434_glossy_major_mapleleaf/snapshot.json +3112 -0
  45. package/migrations/20260804105939_amazing_bushwacker/migration.sql +3 -0
  46. package/migrations/20260804105939_amazing_bushwacker/snapshot.json +3112 -0
  47. package/migrations/20260804110033_fat_piledriver/migration.sql +2 -0
  48. package/migrations/20260804110033_fat_piledriver/snapshot.json +3138 -0
  49. package/migrations/20260805143152_vengeful_ravenous/migration.sql +4 -0
  50. package/migrations/20260805143152_vengeful_ravenous/snapshot.json +3190 -0
  51. package/package.json +60 -46
  52. package/dist/dto-CRlgoCP5.d.ts +0 -645
  53. package/migrations/meta/0000_snapshot.json +0 -1632
  54. package/migrations/meta/_journal.json +0 -13
  55. /package/migrations/{0000_premium_famine.sql → 20251125021229_premium_famine/migration.sql} +0 -0
package/README.md CHANGED
@@ -1,65 +1,62 @@
1
- # @spfn/auth - Technical Documentation
1
+ # @spfn/auth
2
2
 
3
- **Version:** 0.1.0-alpha.88
4
- **Status:** Alpha - Internal Development
3
+ > **Two applications' worth of auth, in one package**
5
4
 
6
- > **Note:** This is a technical documentation for developers working on the @spfn/auth package.
7
- > For user-facing documentation, see [SPFN Documentation](https://spfn.dev/docs).
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
- ## Table of Contents
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
- - [Overview](#overview)
14
- - [Installation](#installation)
15
- - [Architecture](#architecture)
16
- - [Package Structure](#package-structure)
17
- - [Module Exports](#module-exports)
18
- - [Email & SMS Services](#email--sms-services)
19
- - [Email Templates](#email-templates)
20
- - [Server-Side API](#server-side-api)
21
- - [Database Schema](#database-schema)
22
- - [RBAC System](#rbac-system)
23
- - [Next.js Adapter](#nextjs-adapter)
24
- - [Testing](#testing)
25
- - [Development Workflow](#development-workflow)
26
- - [Known Issues](#known-issues)
27
- - [Roadmap](#roadmap)
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
- ## Overview
32
-
33
- `@spfn/auth` is an authentication and authorization package for the SPFN framework, providing:
34
-
35
- - **Asymmetric JWT Authentication** - Client-signed tokens using ES256/RS256
36
- - **User Management** - Email/phone-based identity with bcrypt hashing
37
- - **Multi-Factor Authentication** - OTP verification via email/SMS
38
- - **Session Management** - Public key rotation with 90-day expiry
39
- - **Role-Based Access Control** - Flexible RBAC with runtime role/permission management
40
- - **Next.js Integration** - Session helpers and server-side guards
28
+ ```bash
29
+ pnpm add @spfn/auth drizzle-orm@1.0.0-rc.4
30
+ ```
41
31
 
42
- ### Design Principles
32
+ ## Import paths
43
33
 
44
- 1. **Security First** - Asymmetric cryptography, no shared secrets
45
- 2. **Type Safety** - Full TypeScript support with Typebox validation
46
- 3. **Framework Integration** - Seamless SPFN plugin architecture
47
- 4. **Extensibility** - Service layer for custom authentication flows
48
- 5. **Developer Experience** - Clear separation of concerns, reusable components
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
- ## Installation
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
- ### 1. Install Package
52
+ ## How do I add auth to an SPFN app?
55
53
 
56
- ```bash
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
- ### 2. Configure Server
56
+ ### 1. Lifecycle — `server.config.ts`
61
57
 
62
- #### Add Lifecycle to `server.config.ts`
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,1166 @@ 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()) // Add auth lifecycle
69
+ .lifecycle(createAuthLifecycle())
74
70
  .build();
75
71
  ```
76
72
 
77
- #### Register Router in `router.ts`
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
- // Auth routes (fixed namespace)
85
- auth: authRouter,
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
- // ... your other routes
88
- });
90
+ export type AppRouter = typeof appRouter;
89
91
  ```
90
92
 
91
- ### 3. Configure Client (Next.js)
93
+ ### 3. Next.js interceptor — RPC proxy route
92
94
 
93
- #### Register Router Metadata and Errors in `api-client.ts`
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
- import { createApi } from '@spfn/core/nextjs';
97
- import type { AppRouter } from '@/server/router';
98
- import { appMetadata as authAppMetadata } from "@spfn/auth";
99
- import { authErrorRegistry } from "@spfn/auth/errors";
100
- import { appMetadata } from '@/server/router.metadata';
101
- import { errorRegistry } from "@spfn/core/errors";
102
-
103
- export const api = createApi<AppRouter>({
104
- metadata: { ...appMetadata, ...authAppMetadata },
105
- errorRegistry: errorRegistry.concat(authErrorRegistry),
106
- });
107
- ```
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
- ### 4. Environment Variables
110
-
111
- ```bash
112
- # Required
113
- SPFN_AUTH_JWT_SECRET=your-secret-key
114
- SPFN_AUTH_VERIFICATION_TOKEN_SECRET=your-verification-secret
115
- DATABASE_URL=postgresql://...
116
-
117
- # Next.js (required)
118
- SPFN_AUTH_SESSION_SECRET=your-32-char-secret
119
-
120
- # Optional
121
- SPFN_AUTH_JWT_EXPIRES_IN=7d
122
- SPFN_AUTH_BCRYPT_SALT_ROUNDS=10
123
- SPFN_AUTH_SESSION_TTL=7d
124
-
125
- # AWS SES (Email)
126
- SPFN_AUTH_AWS_REGION=ap-northeast-2
127
- SPFN_AUTH_AWS_SES_ACCESS_KEY_ID=AKIA...
128
- SPFN_AUTH_AWS_SES_SECRET_ACCESS_KEY=...
129
- SPFN_AUTH_AWS_SES_FROM_EMAIL=noreply@yourdomain.com
130
-
131
- # AWS SNS (SMS)
132
- SPFN_AUTH_AWS_SNS_ACCESS_KEY_ID=AKIA...
133
- SPFN_AUTH_AWS_SNS_SECRET_ACCESS_KEY=...
134
- SPFN_AUTH_AWS_SNS_SENDER_ID=MyApp
105
+ export const { GET, POST } = createRpcProxy({ routeMap: { ...routeMap, ...authRouteMap } });
135
106
  ```
136
107
 
137
- ### 5. Run Migrations
108
+ ### 4. Run migrations
138
109
 
139
110
  ```bash
140
- # Generate migrations (if needed)
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
- ---
115
+ The API client needs no auth-specific config. `authApi` is also available standalone:
148
116
 
149
- ## Architecture
150
-
151
- ### High-Level Overview
152
-
153
- ```
154
- ┌─────────────────────────────────────────────────────────────┐
155
- │ @spfn/auth Package │
156
- ├─────────────────────────────────────────────────────────────┤
157
- │ │
158
- │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
159
- │ │ Server │ │ Next.js │ │ Client │ │
160
- │ │ (server.ts) │ │ (nextjs/*) │ │ (client.ts) │ │
161
- │ └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ │
162
- │ │ │ │ │
163
- │ ┌───────▼───────────────────▼───────────────────▼───────┐ │
164
- │ │ Common Types & Entities │ │
165
- │ │ (index.ts) │ │
166
- │ └────────────────────────────────────────────────────────┘ │
167
- │ │
168
- └─────────────────────────────────────────────────────────────┘
169
- ```
170
-
171
- ### Module Separation
172
-
173
- The package is split into three distinct entry points to ensure proper code separation:
174
-
175
- 1. **Common Module** (`@spfn/auth`)
176
- - Database entities (users, roles, permissions)
177
- - TypeScript types and interfaces
178
- - RBAC type definitions
179
- - Can be imported anywhere (server/client)
180
-
181
- 2. **Server Module** (`@spfn/auth/server`)
182
- - Server-only code (marked with Node.js APIs)
183
- - Routes, services, repositories
184
- - Middleware, helpers (JWT, password)
185
- - RBAC initialization
186
- - **Never** import in client-side code
187
-
188
- 3. **Client Module** (`@spfn/auth/client`)
189
- - Client-only code (React hooks, components)
190
- - Currently in development (placeholders only)
191
- - **Never** import in server-side code
192
-
193
- 4. **Next.js Adapter** (`@spfn/auth/nextjs/*`)
194
- - Next.js-specific integrations
195
- - `@spfn/auth/nextjs/api` - Interceptors for API routes
196
- - `@spfn/auth/nextjs/server` - Server Components guards & session helpers
197
-
198
- ### Asymmetric JWT Flow
199
-
200
- ```
201
- ┌──────────┐ ┌──────────┐
202
- │ Client │ │ Server │
203
- └────┬─────┘ └────┬─────┘
204
- │ │
205
- │ 1. Generate ES256 keypair │
206
- │ (privateKey stored locally) │
207
- │ │
208
- │ 2. POST /_auth/register │
209
- │ { email, password, publicKey, keyId } │
210
- ├──────────────────────────────────────────────>│
211
- │ │
212
- │ 3. Store publicKey │
213
- │ (user_public_keys)
214
- │ │
215
- │ 4. Sign JWT with privateKey │
216
- │ payload: { userId, keyId } │
217
- │ │
218
- │ 5. Request with Authorization header │
219
- │ Authorization: Bearer <jwt> │
220
- ├──────────────────────────────────────────────>│
221
- │ │
222
- │ 6. Decode JWT → keyId │
223
- │ Fetch publicKey │
224
- │ Verify signature │
225
- │ │
226
- │ 7. Success │
227
- │<──────────────────────────────────────────────┤
228
- │ │
229
- ```
230
-
231
- **Key Points:**
232
- - Server **never** knows the private key
233
- - Each client has a unique keypair
234
- - JWT verification uses stored public key
235
- - No shared secrets (unlike HMAC-based JWT)
236
-
237
- ---
238
-
239
- ## Package Structure
240
-
241
- ```
242
- packages/auth/
243
- ├── dist/ # Compiled output (tsup)
244
- │ ├── index.js # Common exports
245
- │ ├── index.d.ts
246
- │ ├── server.js # Server exports
247
- │ ├── server.d.ts
248
- │ ├── client.js # Client exports (minimal)
249
- │ ├── client.d.ts
250
- │ ├── config/ # Configuration module
251
- │ ├── errors/ # Error classes
252
- │ ├── nextjs/ # Next.js adapter
253
- │ └── server/ # Server implementation
254
-
255
- ├── migrations/ # Drizzle database migrations
256
- │ └── *.sql
257
-
258
- ├── src/
259
- │ ├── index.ts # Common entry point
260
- │ ├── server.ts # Server entry point
261
- │ ├── client.ts # Client entry point
262
- │ │
263
- │ ├── config/ # Configuration system
264
- │ │ ├── index.ts
265
- │ │ ├── schema.ts # Env var schema
266
- │ │ └── types.ts
267
- │ │
268
- │ ├── errors/ # Error definitions
269
- │ │ ├── index.ts
270
- │ │ └── auth-errors.ts
271
- │ │
272
- │ ├── lib/ # Shared code
273
- │ │ └── contracts/ # Typebox schemas
274
- │ │
275
- │ ├── server/ # Server-side implementation
276
- │ │ ├── entities/ # Drizzle ORM entities
277
- │ │ ├── services/ # Business logic layer
278
- │ │ ├── repositories/ # Database access layer
279
- │ │ ├── routes/ # HTTP route handlers
280
- │ │ ├── middleware/ # Auth middleware
281
- │ │ ├── helpers/ # JWT, password, context
282
- │ │ ├── rbac/ # RBAC types and builtins
283
- │ │ ├── lib/ # Server utilities
284
- │ │ ├── lifecycle.ts # SPFN lifecycle hooks
285
- │ │ ├── setup.ts # Initialization
286
- │ │ ├── logger.ts # Logging
287
- │ │ └── types.ts # Server types
288
- │ │
289
- │ ├── nextjs/ # Next.js adapter
290
- │ │ ├── api.ts # Interceptor exports
291
- │ │ ├── server.ts # Server Components guards
292
- │ │ ├── session-helpers.ts# Session management
293
- │ │ ├── interceptors/ # Request interceptors
294
- │ │ └── guards/ # Auth guards
295
- │ │
296
- │ └── client/ # Client-side (WIP)
297
- │ ├── hooks/ # React hooks (TODO)
298
- │ ├── store/ # Zustand store (TODO)
299
- │ └── components/ # UI components (TODO)
300
-
301
- ├── package.json # Package configuration + SPFN plugin config
302
- ├── tsup.config.ts # Build configuration
303
- ├── drizzle.config.ts # Database migration config
304
- └── README.md # This file
305
- ```
306
-
307
- ### Layer Responsibilities
308
-
309
- #### 1. **Routes Layer** (`src/server/routes/`)
310
- - Thin HTTP handlers
311
- - Request validation (Typebox)
312
- - Delegates to services
313
- - Returns responses
314
-
315
- #### 2. **Services Layer** (`src/server/services/`)
316
- - Business logic
317
- - Transaction management
318
- - Reusable functions
319
- - Can be used outside of routes
320
-
321
- #### 3. **Repositories Layer** (`src/server/repositories/`)
322
- - Database access only
323
- - CRUD operations
324
- - No business logic
325
- - Drizzle ORM queries
326
-
327
- #### 4. **Helpers Layer** (`src/server/helpers/`)
328
- - Utility functions (JWT, password hashing)
329
- - Context accessors (getAuth, getUser)
330
- - Stateless operations
331
-
332
- ---
333
-
334
- ## Module Exports
335
-
336
- ### Common Module (`@spfn/auth`)
337
-
338
- **Entities:**
339
117
  ```typescript
340
- import {
341
- users,
342
- userPublicKeys,
343
- verificationCodes,
344
- roles,
345
- permissions,
346
- rolePermissions,
347
- userPermissions,
348
- userInvitations,
349
- userSocialAccounts,
350
- userProfiles
351
- } from '@spfn/auth';
352
- ```
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
- import type {
357
- User,
358
- UserPublicKey,
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
- **RBAC:**
367
- ```typescript
368
- import {
369
- BUILTIN_ROLES,
370
- BUILTIN_PERMISSIONS,
371
- BUILTIN_ROLE_PERMISSIONS
372
- } from '@spfn/auth';
373
-
374
- import type {
375
- RoleConfig,
376
- PermissionConfig,
377
- InitializeAuthOptions,
378
- BuiltinRoleName,
379
- BuiltinPermissionName
380
- } from '@spfn/auth';
219
+ await authApi.listKeys.call({ body: { includeRevoked: true } }); // also what was cut off
381
220
  ```
382
221
 
383
- ---
384
-
385
- ### Server Module (`@spfn/auth/server`)
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`.
386
226
 
387
- **Router:**
388
- ```typescript
389
- import { authRouter } from '@spfn/auth/server';
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.
390
232
 
391
- // Explicit registration in your app router
392
- export const appRouter = defineRouter({
393
- auth: authRouter, // Mounts at /_auth/*
394
- });
395
- ```
396
-
397
- **Services:**
398
233
  ```typescript
399
- import {
400
- // Auth
401
- checkAccountExistsService,
402
- registerService,
403
- loginService,
404
- logoutService,
405
- changePasswordService,
406
-
407
- // Verification
408
- sendVerificationCodeService,
409
- verifyCodeService,
410
-
411
- // Key Management
412
- registerPublicKeyService,
413
- rotateKeyService,
414
- revokeKeyService,
415
-
416
- // User
417
- getUserByIdService,
418
- getUserByEmailService,
419
- getUserByPhoneService,
420
- updateUserService,
421
- updateLastLoginService,
422
-
423
- // RBAC
424
- initializeAuth,
425
-
426
- // Permission
427
- getUserPermissions,
428
- hasPermission,
429
- hasAnyPermission,
430
- hasAllPermissions,
431
- hasRole,
432
- hasAnyRole,
433
-
434
- // Role
435
- createRole,
436
- updateRole,
437
- deleteRole,
438
- addPermissionToRole,
439
- removePermissionFromRole,
440
- setRolePermissions,
441
- getAllRoles,
442
- getRoleByName,
443
- getRolePermissions,
444
-
445
- // Invitation
446
- createInvitation,
447
- getInvitationByToken,
448
- getInvitationWithDetails,
449
- validateInvitation,
450
- acceptInvitation,
451
- listInvitations,
452
- cancelInvitation,
453
- deleteInvitation,
454
- expireOldInvitations,
455
- resendInvitation,
456
-
457
- // Session
458
- getAuthSessionService,
459
- getUserProfileService,
460
-
461
- // Email
462
- sendEmail,
463
- registerEmailProvider,
464
-
465
- // SMS
466
- sendSMS,
467
- registerSMSProvider,
468
-
469
- // Email Templates
470
- registerEmailTemplates,
471
- getVerificationCodeTemplate,
472
- getWelcomeTemplate,
473
- getPasswordResetTemplate,
474
- getInvitationTemplate,
475
- } from '@spfn/auth/server';
476
- ```
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.
477
285
 
478
- **Repositories:**
479
286
  ```typescript
480
- import {
481
- usersRepository,
482
- keysRepository,
483
- rolesRepository,
484
- permissionsRepository,
485
- verificationCodesRepository,
486
- invitationsRepository,
487
- rolePermissionsRepository,
488
- userPermissionsRepository,
489
- userProfilesRepository,
490
- } from '@spfn/auth/server';
491
- ```
287
+ import { route } from '@spfn/core/route';
288
+ import { authenticate, requirePermissions, optionalAuth } from '@spfn/auth/server';
289
+ import { getAuth, getOptionalAuth } from '@spfn/auth/server';
492
290
 
493
- **Middleware:**
494
- ```typescript
495
- import {
496
- authenticate,
497
- requirePermissions,
498
- requireRole,
499
- } from '@spfn/auth/server';
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
+ });
500
298
 
501
- // Usage
502
- app.bind(
503
- myContract,
504
- [authenticate, requirePermissions('user:delete')],
505
- async (c) => {
506
- // Handler
507
- }
508
- );
509
- ```
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) => { /* ... */ });
510
303
 
511
- **Helpers:**
512
- ```typescript
513
- import {
514
- // Context
515
- getAuth,
516
- getUser,
517
- getUserId,
518
- getKeyId,
519
-
520
- // JWT
521
- generateToken, // Legacy server-signed (deprecated)
522
- verifyToken, // Legacy server-signed (deprecated)
523
- verifyClientToken, // Client-signed asymmetric JWT
524
- decodeToken, // Decode without verification (debugging)
525
- verifyKeyFingerprint,
526
-
527
- // Password
528
- hashPassword,
529
- verifyPassword,
530
- } from '@spfn/auth/server';
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
+ });
531
312
  ```
532
313
 
533
- **Lifecycle:**
534
- ```typescript
535
- import { createAuthLifecycle } from '@spfn/auth/server';
536
-
537
- // SPFN plugin lifecycle hooks
538
- const lifecycle = createAuthLifecycle();
539
- ```
314
+ Context helpers from `@spfn/auth/server`: `getAuth`, `getOptionalAuth`, `getUser`, `getUserId`,
315
+ `getRole`, `getLocale`, `getKeyId`. Middleware: `authenticate`, `optionalAuth`,
316
+ `requirePermissions`, `requireAnyPermission`, `requireRole`, `roleGuard`, `oneTimeTokenAuth`.
540
317
 
541
- ---
318
+ ## OAuth
542
319
 
543
- ### Client Module (`@spfn/auth/client`)
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).
544
325
 
545
- > **Status:** Work in Progress - Placeholders only
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.
546
329
 
547
- ```typescript
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 { env, envSchema } from '@spfn/auth/config';
563
-
564
- // Access environment variables (validated at startup)
565
- console.log(env.SPFN_AUTH_JWT_SECRET);
566
- console.log(env.SPFN_AUTH_JWT_EXPIRES_IN);
567
- console.log(env.SPFN_AUTH_BCRYPT_SALT_ROUNDS);
568
-
569
- // envSchema can be used for custom validation
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
- import {
578
- // Auth namespace (contains all error classes)
579
- AuthError,
580
-
581
- // Individual error classes
582
- InvalidCredentialsError,
583
- InvalidTokenError,
584
- TokenExpiredError,
585
- KeyExpiredError,
586
- AccountDisabledError,
587
- AccountAlreadyExistsError,
588
- InvalidVerificationCodeError,
589
- InvalidVerificationTokenError,
590
- InvalidKeyFingerprintError,
591
- VerificationTokenPurposeMismatchError,
592
- VerificationTokenTargetMismatchError,
593
- InsufficientPermissionsError,
594
- InsufficientRoleError,
595
-
596
- // Error registry for client-side error handling
597
- authErrorRegistry,
598
- } from '@spfn/auth/errors';
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
- ### Next.js Adapter (`@spfn/auth/nextjs/*`)
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
- #### `@spfn/auth/nextjs/api`
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
- ```typescript
608
- import {
609
- authInterceptors,
610
- loginRegisterInterceptor,
611
- generalAuthInterceptor,
612
- keyRotationInterceptor,
613
- } from '@spfn/auth/nextjs/api';
614
-
615
- // Auto-registers interceptors on import
616
- import '@spfn/auth/nextjs/api';
617
- ```
380
+ ### Provider-initiated unlink notifications (`unlink-notify`)
618
381
 
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
- ```typescript
622
- import {
623
- // Guards (Server Components)
624
- RequireAuth,
625
- RequireRole,
626
- RequirePermission,
627
-
628
- // Auth Utils
629
- getUserRole,
630
- getUserPermissions,
631
- hasAnyRole,
632
- hasAnyPermission,
633
-
634
- // Session Helpers
635
- saveSession,
636
- getSession,
637
- clearSession,
638
-
639
- // Types
640
- type SessionData,
641
- type PublicSession,
642
- type SaveSessionOptions,
643
- } from '@spfn/auth/nextjs/server';
644
- ```
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
- **Session Helpers Usage:**
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
- // Get session (read-only, safe in Server Components)
657
- const session = await getSession();
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
- // Clear session
660
- await clearSession();
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
- // app/dashboard/page.tsx
666
- import { RequireAuth } from '@spfn/auth/nextjs/server';
403
+ import { oauthUnlinkedEvent } from '@spfn/auth/server';
667
404
 
668
- export default async function DashboardPage()
405
+ oauthUnlinkedEvent.subscribe(async ({ userId, provider, providerUserId, reason }) =>
669
406
  {
670
- return (
671
- <RequireAuth redirectTo="/login">
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
- ## Email & SMS Services
414
+ ### OAuth callback origin (web app host + rewrite)
681
415
 
682
- ### Email Service
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 email service uses AWS SES by default, with fallback to console logging in development.
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
- **Send Email:**
687
- ```typescript
688
- import { sendEmail } from '@spfn/auth/server';
689
-
690
- await sendEmail({
691
- to: 'user@example.com',
692
- subject: 'Welcome!',
693
- text: 'Plain text content',
694
- html: '<h1>HTML content</h1>',
695
- purpose: 'welcome', // for logging
696
- });
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
- **Custom Email Provider:**
700
- ```typescript
701
- import { registerEmailProvider } from '@spfn/auth/server';
702
-
703
- // Register SendGrid provider
704
- registerEmailProvider({
705
- name: 'sendgrid',
706
- sendEmail: async ({ to, subject, text, html }) => {
707
- // Your SendGrid implementation
708
- return { success: true, messageId: '...' };
709
- },
710
- });
711
- ```
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
- ### SMS Service
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
- The SMS service uses AWS SNS by default.
453
+ ### Native social sign-in (mobile / web id_token)
718
454
 
719
- **Send SMS:**
720
- ```typescript
721
- import { sendSMS } from '@spfn/auth/server';
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
- await sendSMS({
724
- phone: '+821012345678', // E.164 format
725
- message: 'Your code is: 123456',
726
- purpose: 'verification',
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
- import { registerSMSProvider } from '@spfn/auth/server';
733
-
734
- // Register Twilio provider
735
- registerSMSProvider({
736
- name: 'twilio',
737
- sendSMS: async ({ phone, message }) => {
738
- // Your Twilio implementation
739
- return { success: true, messageId: '...' };
740
- },
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
- ---
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.
745
567
 
746
- ## Email Templates
747
-
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
- import { getVerificationCodeTemplate, sendEmail } from '@spfn/auth/server';
760
-
761
- const { subject, text, html } = getVerificationCodeTemplate({
762
- code: '123456',
763
- purpose: 'registration',
764
- expiresInMinutes: 5,
765
- appName: 'MyApp',
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
- ### Custom Templates
774
-
775
- Register custom templates to override defaults with your brand design:
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
- import { registerEmailTemplates } from '@spfn/auth/server';
779
-
780
- // Register at app initialization (e.g., server.config.ts)
781
- registerEmailTemplates({
782
- // Override verification code template
783
- verificationCode: ({ code, purpose, expiresInMinutes, appName }) => ({
784
- subject: `[${appName}] Your verification code`,
785
- text: `Your code: ${code}\nExpires in ${expiresInMinutes} minutes.`,
786
- html: `
787
- <div style="font-family: Arial, sans-serif;">
788
- <img src="https://myapp.com/logo.png" alt="Logo" />
789
- <h1>Verification Code</h1>
790
- <div style="font-size: 32px; font-weight: bold;">${code}</div>
791
- <p>This code expires in ${expiresInMinutes} minutes.</p>
792
- </div>
793
- `,
794
- }),
795
-
796
- // Override invitation template
797
- invitation: ({ inviteLink, inviterName, roleName, appName }) => ({
798
- subject: `${inviterName} invited you to ${appName}`,
799
- text: `Accept invitation: ${inviteLink}`,
800
- html: `
801
- <h1>You're Invited!</h1>
802
- <p>${inviterName} invited you to join ${appName} as ${roleName}.</p>
803
- <a href="${inviteLink}">Accept Invitation</a>
804
- `,
805
- }),
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
- **Template Parameters:**
810
-
811
- | Template | Parameters |
812
- |----------|------------|
813
- | `verificationCode` | `code`, `purpose`, `expiresInMinutes?`, `appName?` |
814
- | `welcome` | `email`, `appName?` |
815
- | `passwordReset` | `resetLink`, `expiresInMinutes?`, `appName?` |
816
- | `invitation` | `inviteLink`, `inviterName?`, `roleName?`, `appName?` |
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.
817
611
 
818
- ---
612
+ ### Custom providers
819
613
 
820
- ## Server-Side API
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.
821
615
 
822
- ### Public Routes (No Authentication)
823
-
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
- email?: string;
834
- phone?: string; // E.164 format
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
- **Response:**
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
- ---
848
-
849
- #### `POST /_auth/codes`
850
-
851
- Send verification code.
626
+ ### OAuth token encryption and key rotation
852
627
 
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
- **Response:**
863
- ```typescript
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
- ---
637
+ For zero-downtime rotation, prepend the new key and retain old keys for decryption:
871
638
 
872
- #### `POST /_auth/codes/verify`
873
-
874
- Verify OTP code.
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
- }
884
- ```
885
-
886
- **Response:**
887
- ```typescript
888
- {
889
- valid: boolean;
890
- verificationToken?: string; // 15min JWT for registration
891
- }
639
+ ```dotenv
640
+ SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v3:<new-key>,v2:<old-key>
892
641
  ```
893
642
 
894
- ---
895
-
896
- #### `POST /_auth/register`
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.
897
649
 
898
- Register new user.
899
-
900
- **Request:**
901
- ```typescript
902
- {
903
- email?: string;
904
- phone?: string;
905
- verificationToken: string; // From /codes/verify
906
- password: string; // Min 8 chars
907
- publicKey: string; // Base64 DER (SPKI)
908
- keyId: string; // UUID v4
909
- fingerprint: string; // SHA-256 hex (64 chars)
910
- algorithm: 'ES256' | 'RS256';
911
- keySize?: number;
912
- }
913
- ```
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.
914
653
 
915
- **Response:**
916
- ```typescript
917
- {
918
- userId: string;
919
- email?: string;
920
- phone?: string;
921
- }
922
- ```
654
+ **Integration contract for custom providers:**
923
655
 
924
- ---
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.
925
664
 
926
- #### `POST /_auth/login`
665
+ ## How do I read the session in a Next.js page?
927
666
 
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
- **Response:**
946
- ```typescript
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)
679
+ RSC guards (redirect when unmet) — `RequireAuth`, `RequireRole`, `RequirePermission`:
958
680
 
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
- ---
965
-
966
- #### `POST /_auth/logout`
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
- **Response:**
976
- ```typescript
684
+ export default async function AdminPage()
977
685
  {
978
- success: boolean;
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
- #### `POST /_auth/keys/rotate`
699
+ ## How do I define roles and permissions?
985
700
 
986
- Rotate public key before expiry (90 days).
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
- publicKey: string; // New public key
992
- keyId: string; // New UUID
993
- fingerprint: string;
994
- algorithm: 'ES256' | 'RS256';
995
- keySize?: number;
996
- }
997
- ```
998
-
999
- **Response:**
1000
- ```typescript
1001
- {
1002
- success: boolean;
1003
- keyId: string;
1004
- }
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
+ });
1005
712
  ```
1006
713
 
1007
- ---
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`.
1008
718
 
1009
- #### `PUT /_auth/password`
719
+ ## Can I operate the app without building an admin dashboard?
1010
720
 
1011
- Change password.
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.
1012
726
 
1013
- **Request:**
1014
- ```typescript
1015
- {
1016
- currentPassword: string;
1017
- newPassword: string; // Min 8 chars
1018
- }
1019
- ```
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:
1020
730
 
1021
- **Response:**
1022
731
  ```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
1216
-
1217
- ---
1218
-
1219
- ### Supporting Tables
1220
-
1221
- #### `invitations`
1222
-
1223
- User invitation system.
1224
-
1225
- ```sql
1226
- CREATE TABLE invitations (
1227
- id BIGSERIAL PRIMARY KEY,
1228
- email TEXT NOT NULL,
1229
- token TEXT UNIQUE NOT NULL,
1230
- role_id BIGINT REFERENCES roles(id),
1231
- invited_by BIGINT REFERENCES users(id),
1232
- status TEXT CHECK (status IN ('pending', 'accepted', 'cancelled', 'expired')),
1233
- expires_at TIMESTAMP NOT NULL,
1234
- accepted_at TIMESTAMP,
1235
- created_at TIMESTAMP DEFAULT NOW()
1236
- );
1237
- ```
732
+ import { createMcpRoute } from '@spfn/mcp/server';
733
+ import { hasPermission, getUserRole } from '@spfn/auth/server';
1238
734
 
1239
- ---
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
+ ];
1240
740
 
1241
- #### `user_profiles`
741
+ export const mcpRouter = createMcpRoute({
742
+ appUrl: 'https://app.example.com',
743
+ serverInfo: { name: 'example-app', version: '1.0.0' },
1242
744
 
1243
- Extended user profile information.
745
+ validateToken: async (token, resource) => verifyAccessToken(token, resource),
1244
746
 
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
- ```
747
+ resolveContext: async (auth) => ({
748
+ userId: auth.userId,
749
+ role: await getUserRole(auth.userId),
750
+ }),
1279
751
 
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
- name: 'post:create',
1309
- displayName: 'Create Posts',
1310
- category: 'content',
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
- ### Built-in System
1322
-
1323
- **Roles:**
1324
- - `superadmin` (priority 100) - Full access
1325
- - `admin` (priority 80) - User management
1326
- - `user` (priority 10) - Self management
1327
-
1328
- **Permissions:**
1329
- - `auth:self:manage` - Change password, rotate keys
1330
- - `user:read`, `user:write`, `user:delete`
1331
- - `rbac:role:manage`, `rbac:permission:manage`
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.
1332
767
 
1333
- ---
768
+ ## Events
1334
769
 
1335
- ### Middleware Usage
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.
1336
772
 
1337
773
  ```typescript
1338
- import { authenticate, requirePermissions, requireRole } from '@spfn/auth/server';
1339
-
1340
- // Single permission
1341
- app.bind(
1342
- deleteUserContract,
1343
- [authenticate, requirePermissions('user:delete')],
1344
- async (c) => {
1345
- // Only users with user:delete permission
1346
- }
1347
- );
1348
-
1349
- // Multiple permissions (all required)
1350
- app.bind(
1351
- publishPostContract,
1352
- [authenticate, requirePermissions('post:write', 'post:publish')],
1353
- async (c) => {
1354
- // Needs both permissions
1355
- }
1356
- );
1357
-
1358
- // Role-based
1359
- app.bind(
1360
- adminDashboardContract,
1361
- [authenticate, requireRole('admin', 'superadmin')],
1362
- async (c) => {
1363
- // Admin or superadmin only
1364
- }
1365
- );
1366
- ```
1367
-
1368
- ---
774
+ import { authLoginEvent, authRegisterEvent, invitationCreatedEvent, invitationAcceptedEvent } from '@spfn/auth/server';
1369
775
 
1370
- ### Programmatic Checks
1371
-
1372
- ```typescript
1373
- import { hasPermission, hasRole, getUserPermissions } from '@spfn/auth/server';
1374
-
1375
- const canPublish = await hasPermission(userId, 'post:publish');
1376
- const isAdmin = await hasRole(userId, 'admin');
1377
- const permissions = await getUserPermissions(userId);
1378
-
1379
- if (canPublish)
776
+ authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
1380
777
  {
1381
- // Allow publish
1382
- }
1383
- ```
1384
-
1385
- ---
1386
-
1387
- ### Runtime Role Management
1388
-
1389
- ```typescript
1390
- import { createRole, addPermissionToRole } from '@spfn/auth/server';
1391
-
1392
- // Create role
1393
- const role = await createRole({
1394
- name: 'moderator',
1395
- displayName: 'Moderator',
1396
- priority: 40,
1397
- permissionIds: [1n, 2n],
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
- ## Next.js Adapter
788
+ ## Registration gate (`beforeRegister`)
1410
789
 
1411
- ### Session Management
1412
-
1413
- The Next.js adapter provides encrypted HttpOnly cookie-based sessions.
1414
-
1415
- **Configuration:**
1416
- ```bash
1417
- # .env
1418
- SPFN_AUTH_SESSION_SECRET=your-32-char-secret
1419
- SPFN_AUTH_SESSION_TTL=7d # Optional, default 7d
1420
- ```
1421
-
1422
- **Session Data:**
1423
- ```typescript
1424
- interface SessionData {
1425
- userId: string;
1426
- privateKey: string; // Encrypted in cookie
1427
- keyId: string;
1428
- algorithm: 'ES256' | 'RS256';
1429
- }
1430
- ```
1431
-
1432
- ---
1433
-
1434
- ### Server Component Guards
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
- // app/admin/page.tsx
1438
- import { RequireAuth, RequireRole } from '@spfn/auth/nextjs/server';
799
+ import { configureAuth } from '@spfn/auth/server';
800
+ import { RegistrationRejectedError } from '@spfn/auth/errors';
1439
801
 
1440
- export default async function AdminPage()
1441
- {
1442
- return (
1443
- <RequireAuth redirectTo="/login">
1444
- <RequireRole roles={['admin', 'superadmin']} redirectTo="/forbidden">
1445
- <div>Admin Dashboard</div>
1446
- </RequireRole>
1447
- </RequireAuth>
1448
- );
1449
- }
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
- ---
1453
-
1454
- ### Interceptors (API Routes)
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
+ - A refusal is **answered**, never thrown: `authenticate` / `optionalAuth` answer a request that
860
+ named this profile with the canonical envelope (`error.code` is one of the six codes, and the
861
+ body carries nothing else), and the guard and dev handler do the same. Handing the refusal to
862
+ the generic error handler instead would put the carrying error class's name in `error.code`
863
+ (`UnauthorizedError`) — a code no generated SDK can classify (#106). Errors raised **after**
864
+ admission (account status, application errors) are ordinary SPFN errors and keep the REST
865
+ envelope.
866
+ - Replay ledger is module-local, NOT core's `NonceStore` — `checkAndSet` records on check,
867
+ which would spend a nonce on a refused request; the contract requires spending only on
868
+ admission.
869
+ - Conformance: spfn-mobile fixtures are vendored under
870
+ `src/server/client-proof/__tests__/fixtures/` (digest-pinned to upstream `MANIFEST.json`,
871
+ dev bundle sha256 `07fd8268…a433e45`) and run in the unit suite.
872
+ - Dev/test scope: public keys (SPKI DER base64, keyed by `x-spfn-key-id`) are registered at
873
+ construction or through the `/control/register-key` hook; the private half never reaches
874
+ the server. No persistence — a production enrollment/rotation story is phase 2.
875
+
876
+ ### The contract version on the wire (contract 0.6.0)
877
+
878
+ A client compiled and shipped separately from the server cannot be fixed by redeploying. Until
879
+ 0.6.0 a mismatch between what that client was generated against and what the server serves
880
+ surfaced as an undecodable body: the app looked broken and nothing said why.
881
+
882
+ Both ends now say what they are.
883
+
884
+ | Header | Direction | Sent by |
885
+ |--------|-----------|---------|
886
+ | `x-spfn-client-kind` | request | every client — `web`, `ios` or `android` |
887
+ | `x-spfn-client-version` | request | the client's own release: a store version, or a bundle build |
888
+ | `x-spfn-client-contract-version` | request | `ios` and `android` only |
889
+ | `x-spfn-server-contract-version` | response | the server, on every response including a refusal |
890
+ | `x-spfn-supported-contract-range` | response | the server, likewise |
1455
891
 
1456
- **Setup:**
1457
892
  ```typescript
1458
- // Simply import to auto-register
1459
- import '@spfn/auth/nextjs/api';
1460
- ```
1461
-
1462
- **How It Works:**
1463
- 1. Reads `session` HttpOnly cookie
1464
- 2. Unseals session data
1465
- 3. Generates JWT signed with `privateKey`
1466
- 4. Injects `Authorization: Bearer <jwt>` header
1467
-
1468
- **Target Routes:**
1469
- - `/_auth/login`, `/_auth/register` - Login/register interceptor
1470
- - `/_auth/keys/rotate` - Key rotation interceptor
1471
- - All other authenticated routes - General auth interceptor
893
+ import { createClientVersionMiddleware } from '@spfn/auth/client-proof';
1472
894
 
1473
- ---
1474
-
1475
- ## Testing
1476
-
1477
- ### Setup Test Environment
1478
-
1479
- ```bash
1480
- # Start test database
1481
- pnpm docker:test:up
1482
-
1483
- # Generate migrations
1484
- pnpm db:generate
1485
-
1486
- # Run migrations (via @spfn/core)
1487
- cd ../../
1488
- pnpm spfn db migrate
895
+ // Mount before authentication: enrollment and login carry no proof, and they are
896
+ // where a stale client arrives first.
897
+ app.use('*', createClientVersionMiddleware());
1489
898
  ```
1490
899
 
1491
- ---
900
+ - **`web` states no contract version**, because a browser bundle is deployed with the server that
901
+ serves it and has no second version to reconcile. It is exempt by construction, not by leniency.
902
+ - **An `ios` or `android` client that states no contract version, or one outside the range, is
903
+ refused** `CONTRACT_UNSUPPORTED` (409) with the usual envelope.
904
+ - **A request naming no kind passes** — a curl, a health probe, a server-to-server call is not a
905
+ deployed client this rule is about.
906
+ - **None of it enters the proof input.** These are diagnostic; `PROOF_INPUT_FIELDS` is unchanged.
907
+ - **The server states facts and stops there.** Comparing the announced range against its own version
908
+ and deciding a user should see an update prompt is the client's judgment, made in the client. The
909
+ server has no way to make an app update and does not pretend to.
1492
910
 
1493
- ### Run Tests
911
+ Response header names are deliberately distinct from the request ones: a proxy that echoes a request
912
+ header into the response would otherwise make the client's own version look like the server's.
1494
913
 
1495
- ```bash
1496
- # All tests
1497
- pnpm test
1498
-
1499
- # With coverage
1500
- pnpm test:coverage
914
+ ### When each operation became available (contract 0.6.1)
1501
915
 
1502
- # Route tests only
1503
- pnpm test:routes
1504
-
1505
- # Watch mode
1506
- pnpm test --watch
1507
- ```
916
+ Every operation in the exported bundle carries `since` — the contract version it first appeared in.
917
+ `deprecatedIn` and `removedIn` are optional and absent today, because nothing has been deprecated.
1508
918
 
1509
- ---
919
+ | Operation | `since` |
920
+ |-----------|---------|
921
+ | `auth.clientProof.handshake`, `echo.send`, `items.list` | 0.1.0 |
922
+ | `auth.enroll.register`, `auth.enroll.login`, `auth.enroll.oauthNative`, `auth.keys.rotate` | 0.3.0 |
923
+ | `auth.keys.list`, `auth.keys.revoke`, `auth.keys.revokeAll` | 0.4.1 |
1510
924
 
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
- ```
925
+ - **This is history, not policy.** The mobile contract's compatibility policy is `allOrNothing`: one
926
+ contract version passes or refuses the whole surface, so these three fields change no verdict here.
927
+ An app contract generated from SPFN routes decides `perOperation` and reads the same fields as an
928
+ input — the shape is shared so the two never diverge.
929
+ - **A removal is mark, then wait, then remove.** `deprecatedIn` in one version with the operation
930
+ still served, `removedIn` in a later one. Nothing is removed in the version that deprecates it.
931
+ - **A removed operation leaves the operations list**, so no entry carries `removedIn` today. It is
932
+ where the fact gets recorded when the first removal happens.
1529
933
 
1530
- ---
934
+ ### Usage — dev surface (mobile integration target)
1531
935
 
1532
- ### Writing Tests
936
+ The fastest path: run the packaged dev handler, which already serves the three contract
937
+ operations and `/control`. `examples/04-mobile-contract-dev` is exactly this, runnable.
1533
938
 
1534
939
  ```typescript
1535
- import { describe, it, expect, beforeEach } from 'vitest';
1536
- import { loginService } from '@/server/services';
940
+ import { serve } from '@hono/node-server';
941
+ import { createClientProofDevHandler } from '@spfn/auth/client-proof';
1537
942
 
1538
- describe('loginService', () =>
1539
- {
1540
- beforeEach(async () =>
1541
- {
1542
- // Setup test data
1543
- });
1544
-
1545
- it('should login with valid credentials', async () =>
1546
- {
1547
- const result = await loginService({
1548
- email: 'test@example.com',
1549
- password: 'password123',
1550
- publicKey: '...',
1551
- keyId: '...',
1552
- fingerprint: '...',
1553
- algorithm: 'ES256',
1554
- });
1555
-
1556
- expect(result.userId).toBeDefined();
1557
- });
943
+ const handler = createClientProofDevHandler({
944
+ // keyId → registered public key (SPKI DER base64); the private key stays on the client
945
+ publicKeys: { 'key-dev-0001': process.env.SPFN_CLIENT_PROOF_PUBLIC_KEY! },
946
+ sessionTtlMillis: 600_000,
1558
947
  });
948
+ serve({ fetch: handler.fetch, port: 8791, hostname: '127.0.0.1' });
949
+ // handler.controlToken — pass to the test harness for /control routes
950
+ // handler.state — revokeKey() / expireSessions() / stats() from code
1559
951
  ```
1560
952
 
1561
- ---
1562
-
1563
- ### Test Database
1564
-
1565
- **docker-compose.test.yml:**
1566
- ```yaml
1567
- services:
1568
- postgres-test:
1569
- image: postgres:16-alpine
1570
- environment:
1571
- POSTGRES_DB: spfn_auth_test
1572
- POSTGRES_USER: spfn
1573
- POSTGRES_PASSWORD: spfn_dev_password
1574
- ports:
1575
- - "5433:5432"
1576
- ```
1577
-
1578
- **Test env variables:**
1579
- ```bash
1580
- DATABASE_URL=postgresql://spfn:spfn_dev_password@localhost:5433/spfn_auth_test
1581
- ```
953
+ ### Usage — mounting on your own Hono/SPFN server
1582
954
 
1583
- ---
955
+ Protect `requiresSession` operations with the guard, and assemble the handshake route from
956
+ the exported primitives (`admitClientProofRequest` + `state.openSession`):
1584
957
 
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
958
  ```typescript
1623
- export default defineConfig({
1624
- entry: {
1625
- index: 'src/index.ts',
1626
- server: 'src/server.ts',
1627
- client: 'src/client.ts',
1628
- // ... more entry points
1629
- },
1630
- format: ['esm'],
1631
- dts: true,
1632
- clean: true,
1633
- sourcemap: true,
1634
- });
1635
- ```
1636
-
1637
- **Build outputs:**
1638
- - `dist/index.js` + `dist/index.d.ts`
1639
- - `dist/server.js` + `dist/server.d.ts`
1640
- - `dist/client.js` + `dist/client.d.ts`
1641
- - `dist/config/`, `dist/errors/`, `dist/nextjs/`
1642
-
1643
- ---
1644
-
1645
- ### Database Migrations
1646
-
1647
- ```bash
1648
- # Generate new migration (after entity changes)
1649
- pnpm db:generate
1650
-
1651
- # Apply migrations (via SPFN CLI)
1652
- cd ../../
1653
- pnpm spfn db migrate
1654
-
1655
- # View database
1656
- pnpm spfn db studio
1657
- ```
1658
-
1659
- **Migration files:** `migrations/*.sql`
1660
-
1661
- ---
1662
-
1663
- ### SPFN Plugin Integration
1664
-
1665
- **package.json:**
1666
- ```json
1667
- {
1668
- "spfn": {
1669
- "schemas": ["./dist/server/entities/*.js"],
1670
- "routes": {
1671
- "basePath": "/_auth",
1672
- "dir": "./dist/server/routes"
1673
- },
1674
- "migrations": {
1675
- "dir": "./migrations"
1676
- }
1677
- }
1678
- }
1679
- ```
1680
-
1681
- **How it works:**
1682
- 1. SPFN CLI discovers packages with `spfn` field
1683
- 2. Auto-loads database schemas
1684
- 3. Auto-registers routes at `basePath`
1685
- 4. Includes migrations in `db migrate` command
1686
-
1687
- ---
1688
-
1689
- ### Code Style
1690
-
1691
- Follow the project's code style (see `/Users/launchscreen/PROJECTS/SPFN/workspaces/.claude/rules.md`):
959
+ import { Hono } from 'hono';
960
+ import {
961
+ ClientProofState, createClientProofGuard, admitClientProofRequest,
962
+ decodeHandshakeRequest, encodeHandshakeResponse, encodeCanonicalJson,
963
+ ClientProofRefusal, newHexId,
964
+ } from '@spfn/auth/client-proof';
1692
965
 
1693
- - **Brace placement:** Next line (Allman-style)
1694
- - **Indentation:** 4 spaces
1695
- - **Semicolons:** Always
1696
- - **Type assertions:** Use `as`, not `<>`
966
+ const state = new ClientProofState({ publicKeys: { 'key-dev-0001': process.env.SPFN_CLIENT_PROOF_PUBLIC_KEY! } });
967
+ const app = new Hono();
1697
968
 
1698
- **Example:**
1699
- ```typescript
1700
- export async function myFunction(): Promise<void>
969
+ app.post('/v1/auth/client-proof/handshake', async (c) =>
1701
970
  {
1702
- if (condition)
1703
- {
1704
- await operation();
1705
- }
1706
- else
971
+ const body = new Uint8Array(await c.req.arrayBuffer());
972
+ const admission = admitClientProofRequest({
973
+ state, headers: c.req.raw.headers, method: 'POST',
974
+ path: '/v1/auth/client-proof/handshake', requiresSession: false, body,
975
+ });
976
+ if (!admission.admitted)
1707
977
  {
1708
- handleError();
978
+ return c.newResponse(admission.refusal.envelopeBytes(newHexId()).slice().buffer,
979
+ admission.refusal.httpStatus as 401, { 'content-type': 'application/json' });
1709
980
  }
1710
- }
1711
- ```
1712
-
1713
- ---
1714
-
1715
- ### Environment Variables
1716
-
1717
- **Server-side:**
1718
- ```bash
1719
- # Required
1720
- SPFN_AUTH_JWT_SECRET=your-secret-key
1721
- DATABASE_URL=postgresql://...
1722
-
1723
- # Optional
1724
- SPFN_AUTH_JWT_EXPIRES_IN=7d
1725
- SPFN_AUTH_BCRYPT_SALT_ROUNDS=10
1726
- SPFN_AUTH_VERIFICATION_TOKEN_SECRET=separate-secret
1727
- ```
1728
-
1729
- **Next.js adapter:**
1730
- ```bash
1731
- # Required
1732
- SPFN_AUTH_SESSION_SECRET=your-32-char-secret
1733
-
1734
- # Optional
1735
- SPFN_AUTH_SESSION_TTL=7d
1736
- SPFN_API_URL=http://localhost:8790
1737
- ```
1738
-
1739
- ---
981
+ const request = decodeHandshakeRequest(admission.value);
982
+ const opened = state.openSession(request.clientId, request.keyId);
983
+ return c.newResponse(
984
+ encodeCanonicalJson(encodeHandshakeResponse(opened.sessionId, BigInt(opened.expiresAtMillis))).slice().buffer,
985
+ 200, { 'content-type': 'application/json' });
986
+ });
1740
987
 
1741
- ### Debugging
988
+ // Any route behind the guard sees clientType='mobile' and c.get('clientProof')
989
+ app.post('/v1/echo', createClientProofGuard(state), (c) => { /* handler */ });
990
+ ```
991
+
992
+ Responses and errors MUST be canonical bytes with the contract envelope — build them with
993
+ `encodeCanonicalJson`/`ClientProofRefusal`, never `c.json()` (key order and int64 differ).
994
+
995
+ ## Account Deletion & Recovery
996
+
997
+ Grace-period deletion with in-window recovery, an admin/GDPR-response entry point for immediate
998
+ purge, and a pluggable app-data cleanup hook. Not covered by this feature: re-signup email
999
+ blind-index/hashing (a purged account's email becomes reusable immediately — see the project's
1000
+ PII protection track for blind-index re-signup prevention), backup beyond-use handling, DSR
1001
+ intake/response workflows, and webhook fan-out — those are app/ops concerns.
1002
+
1003
+ ```
1004
+ active ──request (re-auth)──> pending_deletion ──grace period elapses (cron)──> deleted (anonymize) | row removed (hard-delete)
1005
+ ^ │
1006
+ └───────────cancel (re-auth)───────┘ immediate = grace period of 0, same pipeline
1007
+ ```
1008
+
1009
+ - **Request** — `POST /_auth/deletion/request` (authenticated). Step-up re-auth: password
1010
+ holders confirm with `password`; OAuth-only/passwordless accounts confirm with a
1011
+ `verificationToken` from `/_auth/codes` + `/_auth/codes/verify` (`purpose: 'account_deletion'`).
1012
+ On success: status → `pending_deletion`, every active session key is revoked, a
1013
+ `account_deletion_requests` audit row is created, `auth.deletion.requested` fires, and (if
1014
+ the user has an email and `sendNotifications` is on) a notice is sent with the scheduled purge
1015
+ date.
1016
+ - **Login is blocked while pending** — password login, OAuth login, and the `authenticate`
1017
+ middleware all reject a `pending_deletion` account with `AccountPendingDeletionError` (403,
1018
+ `details.purgeScheduledAt`) instead of the generic `AccountDisabledError`, so the client can
1019
+ show a recovery prompt.
1020
+ - **Cancel (recovery)** — `POST /_auth/deletion/cancel` (public — sessions were revoked at
1021
+ request time, so there's no Bearer token to authenticate with). Credential-based: email/phone
1022
+ plus `password` or a fresh `verificationToken`. On success, status → `active`; the user still
1023
+ needs to log in separately afterward.
1024
+ - **Purge job** — sweeps `account_deletion_requests` for rows past their grace period and
1025
+ destroys the account. Register it explicitly (see below); it is **not** wired up by
1026
+ `createAuthLifecycle()` automatically.
1027
+ - **Admin / GDPR-response entry points** — `requestAccountDeletionService(userId, { requestedBy: 'admin', immediate })`
1028
+ and `purgeUserService(userId)` are exported for app-side admin routes / DSR handling; the app
1029
+ owns the route and its authorization.
1742
1030
 
1743
- **Enable logging:**
1744
1031
  ```typescript
1745
- import { serverLogger } from '@/server/logger';
1746
-
1747
- serverLogger.info('Debug message', { context });
1748
- serverLogger.error('Error occurred', error);
1749
- ```
1750
-
1751
- **Inspect database:**
1752
- ```bash
1753
- pnpm spfn db studio
1754
- ```
1755
-
1756
- **Check migrations:**
1757
- ```bash
1758
- ls migrations/
1759
- ```
1760
-
1761
- ---
1762
-
1763
- ## Known Issues
1764
-
1765
- ### 1. Client Crypto Functions Missing
1766
-
1767
- **Issue:** README documents `generateKeyPair` and `generateClientToken` in `@spfn/auth/client`, but they only exist in `@spfn/auth/server`.
1768
-
1769
- **Workaround:** Use server-side crypto functions or implement client-side crypto separately.
1770
-
1771
- **Status:** Needs design decision - keep server-only or implement browser-compatible version.
1772
-
1773
- ---
1774
-
1775
- ### 2. Next.js Proxy Route Not Implemented
1776
-
1777
- **Issue:** Documentation mentions `@spfn/auth/nextjs/proxy` for client-side API proxying, but it doesn't exist.
1778
-
1779
- **Status:** Feature planned but not implemented. Current alternative: use server-side `createAuthInterceptor`.
1780
-
1781
- ---
1782
-
1783
- ### 3. `lib/api` Client Functions Removed
1784
-
1785
- **Issue:** Old `src/lib/api/` directory was deleted during refactoring.
1786
-
1787
- **Status:** Intentional removal. Use services or HTTP routes directly.
1788
-
1789
- ---
1790
-
1791
- ### 4. Test Coverage Below Target
1792
-
1793
- **Current:** ~83%
1794
- **Target:** 90%+
1795
-
1796
- **Areas needing tests:**
1797
- - Invitation service edge cases
1798
- - RBAC permission checks
1799
- - Key rotation scenarios
1800
- - Session expiry handling
1801
-
1802
- ---
1803
-
1804
- ## Roadmap
1805
-
1806
- ### Short-term (Alpha → Beta)
1807
-
1808
- - [ ] **Client-side crypto** - Browser-compatible key generation
1809
- - [ ] **Next.js proxy route** - Implement or remove from docs
1810
- - [x] **High-level authApi** - Simplified Next.js auth functions (implemented in `@spfn/auth`)
1811
- - [ ] **Test coverage** - Reach 90%+ coverage
1812
- - [x] **Documentation** - Sync docs with actual code
1813
-
1814
- ---
1815
-
1816
- ### Mid-term (Beta → v1.0)
1817
-
1818
- - [ ] **React hooks** - useAuth, useSession, usePermissions
1819
- - [ ] **UI components** - LoginForm, RegisterForm, AuthProvider
1820
- - [ ] **OAuth integration** - Google, GitHub, etc.
1821
- - [ ] **2FA support** - TOTP/authenticator apps
1822
- - [ ] **Password reset flow** - Complete email-based reset
1823
- - [ ] **Email change flow** - Verification for email updates
1824
- - [ ] **Phone change flow** - SMS verification for phone updates
1825
-
1826
- ---
1827
-
1828
- ### Long-term (Post v1.0)
1829
-
1830
- - [ ] **Admin UI** - User/role/permission management dashboard
1831
- - [ ] **Audit logging** - Track auth events
1832
- - [ ] **Rate limiting** - Built-in protection against brute force
1833
- - [ ] **Multi-tenancy** - Organization/workspace support
1834
- - [ ] **SSO integration** - SAML, OIDC
1835
- - [ ] **Biometric auth** - WebAuthn/FIDO2 support
1836
-
1837
- ---
1838
-
1839
- ## Contributing
1840
-
1841
- ### Before Contributing
1842
-
1843
- 1. Read this documentation thoroughly
1844
- 2. Check existing issues/PRs
1845
- 3. Understand the architecture
1846
- 4. Follow code style guidelines
1847
-
1848
- ---
1849
-
1850
- ### Pull Request Process
1851
-
1852
- 1. **Create feature branch**
1853
- ```bash
1854
- git checkout -b feature/my-feature
1855
- ```
1856
-
1857
- 2. **Make changes**
1858
- - Follow code style
1859
- - Add tests
1860
- - Update docs if needed
1861
-
1862
- 3. **Run checks**
1863
- ```bash
1864
- pnpm type-check
1865
- pnpm test
1866
- pnpm build
1867
- ```
1868
-
1869
- 4. **Commit with conventional commits**
1870
- ```bash
1871
- git commit -m "feat(auth): add password strength validation"
1872
- ```
1873
-
1874
- 5. **Push and create PR**
1875
- ```bash
1876
- git push origin feature/my-feature
1877
- ```
1878
-
1879
- ---
1880
-
1881
- ### Commit Message Format
1882
-
1883
- ```
1884
- <type>(<scope>): <subject>
1885
-
1886
- <body>
1887
-
1888
- <footer>
1889
- ```
1890
-
1891
- **Types:**
1892
- - `feat` - New feature
1893
- - `fix` - Bug fix
1894
- - `refactor` - Code refactoring
1895
- - `test` - Test changes
1896
- - `docs` - Documentation
1897
- - `chore` - Maintenance
1898
-
1899
- **Example:**
1900
- ```
1901
- feat(rbac): add permission inheritance
1902
-
1903
- Implement hierarchical permission inheritance where child roles
1904
- automatically inherit parent role permissions.
1032
+ import { defineServerConfig } from '@spfn/core/server';
1033
+ import { createAuthLifecycle, authJobRouter } from '@spfn/auth/server';
1905
1034
 
1906
- Closes #123
1035
+ export default defineServerConfig()
1036
+ .lifecycle(createAuthLifecycle({
1037
+ deletion: {
1038
+ gracePeriodDays: 30, // default; 0 = immediate
1039
+ purgeStrategy: 'anonymize', // default; or 'hard-delete'
1040
+ allowSelfImmediate: false, // default; self-service immediate: true
1041
+ sendNotifications: true, // default
1042
+ onBeforePurge: async (user) =>
1043
+ {
1044
+ // throw to skip this user for the current sweep (retried next run)
1045
+ await appDataCleanup(user.id);
1046
+ },
1047
+ },
1048
+ }))
1049
+ .jobs(authJobRouter) // registers the daily (04:00 UTC) purge sweep
1050
+ .routes(appRouter)
1051
+ .build();
1907
1052
  ```
1908
1053
 
1909
- ---
1054
+ **Purge strategies:**
1055
+
1056
+ - `anonymize` (default) — scrubs PII, keeps the row: `email` → `deleted-{publicId}@deleted.invalid`,
1057
+ `phone`/`username`/`passwordHash` → `null`, `status` → `'deleted'`, `deletedAt`/`deletedBy` set
1058
+ (`softDelete()` on `users`). Social accounts and public keys are deleted (frees the provider
1059
+ link and revokes access), the profile's PII columns are cleared, and any leftover verification
1060
+ codes for the original email/phone are removed. The freed email/phone can be re-registered
1061
+ immediately.
1062
+ - `hard-delete` — physically removes the `users` row; child rows (`user_profiles`,
1063
+ `user_public_keys`, `user_social_accounts`, `user_permissions`) cascade-delete via their FK.
1064
+ The `account_deletion_requests` audit row survives either strategy — its `userId` FK is
1065
+ `set null` (not cascade), by design, so "who requested/purged what, when" outlives the user row.
1066
+
1067
+ The final "your account has been deleted" notice is sent **after** the purge transaction commits
1068
+ (never before, and never on a purge that aborted or rolled back — see below), using the address
1069
+ captured before the destructive step ran. This holds for `hard-delete` too: the row is already
1070
+ gone by send time, but the address was captured beforehand, so the notice still goes out.
1071
+
1072
+ **Concurrency.** The purge job re-verifies the user is still `pending_deletion` on the write
1073
+ primary immediately before any destructive DML, inside the same transaction as the DML itself —
1074
+ closing the window between a stale read (the sweep's own batch, or replica lag) and a concurrent
1075
+ `cancel`. The `account_deletion_requests` claim (`markCompleted`) is a conditional `UPDATE ...
1076
+ WHERE status = 'pending'`; if a concurrent cancel already moved the row off `pending`, the claim
1077
+ matches zero rows and the purge aborts with no destructive DML and no overwritten audit row.
1078
+
1079
+ **Cron schedule caveat.** `deletion.purgeCron` (default `0 4 * * *`) is stored for reference, but
1080
+ the static `authJobRouter` export above always runs on the *default* cron — `job(...).cron(...)`
1081
+ is fixed at module-import time, which happens before `createAuthLifecycle()` runs in your
1082
+ `server.config.ts`. For a non-default schedule, build the router yourself, after the
1083
+ `createAuthLifecycle()` call, and register that instead:
1910
1084
 
1911
- ## Release Process
1912
-
1913
- ### Version Naming
1085
+ ```typescript
1086
+ import { createAuthDeletionJobRouter } from '@spfn/auth/server';
1087
+
1088
+ // ... after .lifecycle(createAuthLifecycle({ deletion: { purgeCron: '0 3 * * *' } }))
1089
+ .jobs(createAuthDeletionJobRouter({ purgeCron: '0 3 * * *' }))
1090
+ ```
1091
+
1092
+ Register **only one** of `authJobRouter` / `createAuthDeletionJobRouter(...)` — both build a job
1093
+ named `auth.deletion.purge`, so registering both (e.g. the static export *and* a custom-cron
1094
+ router) double-registers the same job name against pg-boss instead of overriding it.
1095
+
1096
+ ## FAQ
1097
+
1098
+ **How do I add one social provider?**
1099
+ Set its two environment variables. Google, GitHub, Kakao and Naver each turn on when their
1100
+ client ID and secret are both present — there is no separate registration step. Then
1101
+ register the callback URL in that provider's console, and read the next answer before you
1102
+ deploy.
1103
+
1104
+ **Social login worked locally and broke after deploying. Why?**
1105
+ Almost always the callback origin. The CSRF check is a double-submit against a host-only
1106
+ cookie set on your **web app** host, so the provider must return to the web app origin, and
1107
+ the app must forward `/_auth/*` to the API with a Next.js rewrite. Without that rewrite the
1108
+ callback 404s — including in local dev. Details in
1109
+ [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite).
1110
+
1111
+ **Does the server hold my users' private keys?**
1112
+ No. The client generates an ES256/RS256 keypair, sends only the public key on register or
1113
+ login, and signs each request itself. The server verifies with the stored public key. Keys
1114
+ expire after 90 days; `rotateKey` renews one.
1115
+
1116
+ **Does signing in on a new device sign the old one out?**
1117
+ No, and that is on purpose — keys are per-device and accumulate. `listKeys` shows the
1118
+ account owner what accumulated, `revokeKey` cuts one off, `revokeAllKeys` cuts off
1119
+ everything but the caller.
1120
+
1121
+ **How long does a session last?**
1122
+ `SPFN_AUTH_SESSION_TTL`, seven days by default. It accepts `7d`, `12h`, `45m`.
1123
+
1124
+ **Is account deletion immediate?**
1125
+ No. A request moves the account to `pending_deletion`, revokes every session key, and
1126
+ schedules the purge for 30 days later by default. The user can cancel with their
1127
+ credentials during that window. Two things need your attention: the purge sweep is a job
1128
+ you register explicitly (`.jobs(authJobRouter)`), and a purged account's email becomes
1129
+ reusable immediately. See [Account Deletion & Recovery](#account-deletion--recovery).
1130
+
1131
+ **Can an admin delete a user's account?**
1132
+ Yes, through `requestAccountDeletionService(userId, { requestedBy: 'admin', immediate })`
1133
+ and `purgeUserService(userId)`. The package exports the services; you own the route and its
1134
+ authorization.
1135
+
1136
+ **Where do my admin accounts come from?**
1137
+ The environment, seeded on startup by `createAuthLifecycle()`. Seeded accounts are email
1138
+ verified, active, and required to change their password on first login.
1139
+
1140
+ ## Pitfalls & anti-patterns
1141
+
1142
+ - **"relation \"auth.users\" does not exist" — tables come from bundled migrations, not push.**
1143
+ Package schemas are excluded from `spfn db push`'s diff; the `auth.*` tables are created by the
1144
+ migration files shipped in this package. Run `pnpm spfn db migrate` (state check:
1145
+ `pnpm spfn db status`). Installing via plain `pnpm add @spfn/auth` runs no migration — only
1146
+ `spfn add @spfn/auth` auto-applies them.
1147
+ - **Wrong entry point.** `@spfn/auth/server` and `@spfn/auth/nextjs/*` are server-only (Node /
1148
+ `server-only`). Importing them in a client component breaks the build. Entities, services, and
1149
+ repositories are on `/server`, not on root `@spfn/auth`.
1150
+ - **No `app.bind(contract, ...)`.** That contract pattern is removed. Use the route DSL
1151
+ (`route.get().handler()` + `defineRouter`). Any docs/snippets using `app.bind` are stale.
1152
+ - **Custom error classes must be registered.** Add them to an `ErrorRegistry` (mirror
1153
+ `authErrorRegistry` in `src/errors/index.ts`) and pass it to your `createApi({ errorRegistry })`,
1154
+ or the client receives a generic error instead of the typed one.
1155
+ - **Two env files, by audience.** `SPFN_AUTH_SESSION_SECRET` lives in `.env.local` (Next.js needs
1156
+ it for cookie crypto); `SPFN_AUTH_VERIFICATION_TOKEN_SECRET` and
1157
+ `SPFN_AUTH_TOKEN_ENCRYPTION_KEYS` live in `.env.server`. Token encryption keys are backend-only;
1158
+ putting them in `.env.local` unnecessarily gives the Next.js process token-decryption authority.
1159
+ - **`SPFN_AUTH_SESSION_SECRET` is validated.** Minimum 32 chars plus entropy/unique-char checks —
1160
+ a short or low-entropy value fails startup, not just a warning.
1161
+ - **Forgetting the interceptor import.** Without `import '@spfn/auth/nextjs/api'` in the RPC proxy
1162
+ route, the client sends no `Authorization` header and every protected call 401s. The
1163
+ `authenticate` middleware error message points here.
1164
+ - **Custom OAuth callback without `Transactional()`.** A failure mid-callback leaves an orphan
1165
+ user. Always wrap the callback route in `Transactional()` and call `oauthCallbackService`.
1166
+ - **`sideEffects: false` tree-shakes the google provider.** The built-in provider self-registers
1167
+ via a module side-effect; an aggressive bundler config can drop it. Don't mark this package's
1168
+ imports side-effect-free.
1169
+ - **Public routes need an explicit opt-out.** With global `authenticate`, any route without
1170
+ `.skip(['auth'])` (or `optionalAuth`, which auto-skips) requires a valid token.
1171
+ - **`SOCIAL_PROVIDERS` is plain `enumText`.** Adding a provider value needs no DB migration, but
1172
+ every `switch(provider)` over login/register events must handle the new value.
1173
+ - **Email/SMS is not here.** It moved to `@spfn/notification` (`import { sendEmail, sendSMS } from
1174
+ '@spfn/notification/server'`). Wire verification-code / invitation emails through its events.
1175
+ - **`authJobRouter` isn't registered for you.** `createAuthLifecycle()`'s `afterInfrastructure`
1176
+ hook runs *before* `@spfn/core` initializes pg-boss and registers jobs, so the lifecycle has no
1177
+ opportunity to auto-register the account-deletion purge job. Call `.jobs(authJobRouter)`
1178
+ yourself — see [Account Deletion & Recovery](#account-deletion--recovery).
1179
+ - **`USER_STATUSES` gained `pending_deletion` / `deleted`.** Any code with a `switch(user.status)`
1180
+ or an exhaustive status union must handle both — `enumText` is plain `text` with no DB `CHECK`,
1181
+ so nothing enforces this at the database layer.
1182
+
1183
+ ## Complete example
1914
1184
 
1915
- - `0.1.0-alpha.x` - Alpha releases (current)
1916
- - `0.1.0-beta.x` - Beta releases
1917
- - `1.0.0` - Stable release
1185
+ ```typescript
1186
+ // server.config.ts
1187
+ import { defineServerConfig } from '@spfn/core/server';
1188
+ import { createAuthLifecycle } from '@spfn/auth/server';
1189
+ import { appRouter } from './router';
1918
1190
 
1919
- ---
1191
+ export default defineServerConfig()
1192
+ .port(8790)
1193
+ .routes(appRouter)
1194
+ .lifecycle(createAuthLifecycle({
1195
+ roles: [{ name: 'editor', displayName: 'Editor', priority: 30 }],
1196
+ permissions: [{ name: 'post:publish', displayName: 'Publish Posts', category: 'content' }],
1197
+ rolePermissions: { editor: ['post:publish'] },
1198
+ }))
1199
+ .build();
1920
1200
 
1921
- ### Publishing
1201
+ // router.ts
1202
+ import { defineRouter } from '@spfn/core/route';
1203
+ import { authRouter, authenticate } from '@spfn/auth/server';
1204
+ import { getMe } from './routes/me';
1922
1205
 
1923
- ```bash
1924
- # Alpha release
1925
- pnpm run publish:alpha
1206
+ export const appRouter = defineRouter({ getMe })
1207
+ .packages([authRouter])
1208
+ .use([authenticate]);
1209
+ export type AppRouter = typeof appRouter;
1926
1210
 
1927
- # Beta release
1928
- pnpm run publish:beta
1211
+ // app/api/rpc/[routeName]/route.ts
1212
+ import '@spfn/auth/nextjs/api';
1213
+ import { createRpcProxy } from '@spfn/core/nextjs/server';
1214
+ import { authRouteMap } from '@spfn/auth';
1215
+ import { routeMap } from '@/generated/route-map';
1216
+ export const { GET, POST } = createRpcProxy({ routeMap: { ...routeMap, ...authRouteMap } });
1929
1217
 
1930
- # Production release
1931
- pnpm run publish:latest
1218
+ // any client component
1219
+ import { authApi } from '@spfn/auth';
1220
+ const session = await authApi.getAuthSession.call({});
1932
1221
  ```
1933
1222
 
1934
- **Pre-publish checklist:**
1935
- - [ ] All tests pass
1936
- - [ ] Type checking passes
1937
- - [ ] Build succeeds
1938
- - [ ] CHANGELOG updated
1939
- - [ ] Version bumped
1940
- - [ ] Docs updated
1941
-
1942
- ---
1943
-
1944
- ## Support
1945
-
1946
- ### Internal Team
1947
-
1948
- - **Issues:** GitHub Issues
1949
- - **Discussions:** GitHub Discussions
1950
- - **Slack:** #spfn-auth channel
1951
-
1952
- ---
1953
-
1954
- ## License
1955
-
1956
- MIT License - See LICENSE file for details.
1957
-
1958
- ---
1223
+ ## Related
1959
1224
 
1960
- **Last Updated:** 2025-12-07
1961
- **Document Version:** 2.2.0 (Technical Documentation)
1962
- **Package Version:** 0.1.0-alpha.88
1225
+ - [`@spfn/core`](../core/README.md) — route DSL (`route`, `defineRouter`), `createApi`, env
1226
+ (`@spfn/core/env`), errors (`ErrorRegistry`), db (`Transactional`), events, jobs.
1227
+ - [`@spfn/mcp`](../mcp/README.md) — exposes operations as MCP tools, so the operator half of
1228
+ this package needs no admin dashboard.
1229
+ - `@spfn/notification` — email/SMS/push (verification codes, invitation emails).
1230
+ - Full guide: `docs/guides/authentication.md`.