@spfn/auth 0.2.1 → 0.3.0-beta.2

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 (61) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +1091 -2385
  3. package/dist/authenticate-55LeXHqZ.d.ts +1447 -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 +319 -3
  8. package/dist/config.js +155 -5
  9. package/dist/config.js.map +1 -1
  10. package/dist/crypto.d.ts +61 -0
  11. package/dist/crypto.js +108 -0
  12. package/dist/crypto.js.map +1 -0
  13. package/dist/errors.d.ts +180 -3
  14. package/dist/errors.js +116 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/index.d.ts +166 -18
  17. package/dist/index.js +132 -8
  18. package/dist/index.js.map +1 -1
  19. package/dist/nextjs/api.js +404 -96
  20. package/dist/nextjs/api.js.map +1 -1
  21. package/dist/nextjs/server.d.ts +5 -4
  22. package/dist/nextjs/server.js +165 -26
  23. package/dist/nextjs/server.js.map +1 -1
  24. package/dist/server.d.ts +2356 -1053
  25. package/dist/server.js +5153 -736
  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/snapshot.json +2686 -0
  32. package/migrations/20260308141417_deep_iceman/snapshot.json +2686 -0
  33. package/migrations/20260308151309_perfect_deathbird/snapshot.json +2731 -0
  34. package/migrations/20260308201135_concerned_rawhide_kid/snapshot.json +2786 -0
  35. package/migrations/20260629103209_lethal_lifeguard/migration.sql +32 -0
  36. package/migrations/20260629103209_lethal_lifeguard/snapshot.json +2786 -0
  37. package/migrations/20260709073531_easy_hardball/migration.sql +24 -0
  38. package/migrations/20260709073531_easy_hardball/snapshot.json +3119 -0
  39. package/migrations/20260714081434_glossy_major_mapleleaf/migration.sql +1 -0
  40. package/migrations/20260714081434_glossy_major_mapleleaf/snapshot.json +3112 -0
  41. package/migrations/20260804105939_amazing_bushwacker/migration.sql +3 -0
  42. package/migrations/20260804105939_amazing_bushwacker/snapshot.json +3112 -0
  43. package/migrations/20260804110033_fat_piledriver/migration.sql +2 -0
  44. package/migrations/20260804110033_fat_piledriver/snapshot.json +3138 -0
  45. package/migrations/20260805143152_vengeful_ravenous/migration.sql +4 -0
  46. package/migrations/20260805143152_vengeful_ravenous/snapshot.json +3190 -0
  47. package/migrations/20260807145911_mixed_invisible_woman/migration.sql +11 -0
  48. package/migrations/20260807145911_mixed_invisible_woman/snapshot.json +3334 -0
  49. package/package.json +59 -40
  50. package/dist/authenticate-eucncHxN.d.ts +0 -940
  51. package/migrations/meta/0000_snapshot.json +0 -1632
  52. package/migrations/meta/0001_snapshot.json +0 -1660
  53. package/migrations/meta/0002_snapshot.json +0 -1660
  54. package/migrations/meta/0003_snapshot.json +0 -1689
  55. package/migrations/meta/0004_snapshot.json +0 -1721
  56. package/migrations/meta/_journal.json +0 -41
  57. /package/migrations/{0000_premium_famine.sql → 20251125021229_premium_famine/migration.sql} +0 -0
  58. /package/migrations/{0001_smooth_the_fury.sql → 20260225130050_smooth_the_fury/migration.sql} +0 -0
  59. /package/migrations/{0002_deep_iceman.sql → 20260308141417_deep_iceman/migration.sql} +0 -0
  60. /package/migrations/{0003_perfect_deathbird.sql → 20260308151309_perfect_deathbird/migration.sql} +0 -0
  61. /package/migrations/{0004_concerned_rawhide_kid.sql → 20260308201135_concerned_rawhide_kid/migration.sql} +0 -0
package/README.md CHANGED
@@ -1,68 +1,62 @@
1
- # @spfn/auth - Technical Documentation
1
+ # @spfn/auth
2
2
 
3
- **Version:** 0.2.0-beta.15
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
- - [Admin Account Setup](#6-admin-account-setup)
16
- - [Architecture](#architecture)
17
- - [Package Structure](#package-structure)
18
- - [Module Exports](#module-exports)
19
- - [Email & SMS Services](#email--sms-services)
20
- - [Server-Side API](#server-side-api)
21
- - [Events](#events)
22
- - [OAuth Authentication](#oauth-authentication)
23
- - [Database Schema](#database-schema)
24
- - [RBAC System](#rbac-system)
25
- - [Next.js Adapter](#nextjs-adapter)
26
- - [Testing](#testing)
27
- - [Development Workflow](#development-workflow)
28
- - [Known Issues](#known-issues)
29
- - [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`).
30
25
 
31
- ---
26
+ ## Install
32
27
 
33
- ## Overview
34
-
35
- `@spfn/auth` is an authentication and authorization package for the SPFN framework, providing:
36
-
37
- - **Asymmetric JWT Authentication** - Client-signed tokens using ES256/RS256
38
- - **User Management** - Email/phone-based identity with bcrypt hashing
39
- - **OAuth Authentication** - Google OAuth 2.0 (Authorization Code Flow), extensible to other providers
40
- - **Multi-Factor Authentication** - OTP verification via email/SMS
41
- - **Session Management** - Public key rotation with 90-day expiry
42
- - **Role-Based Access Control** - Flexible RBAC with runtime role/permission management
43
- - **Next.js Integration** - Session helpers, server-side guards, and OAuth interceptors
28
+ ```bash
29
+ pnpm add @spfn/auth drizzle-orm@1.0.0-rc.4
30
+ ```
44
31
 
45
- ### Design Principles
32
+ ## Import paths
46
33
 
47
- 1. **Security First** - Asymmetric cryptography, no shared secrets
48
- 2. **Type Safety** - Full TypeScript support with Typebox validation
49
- 3. **Framework Integration** - Seamless SPFN plugin architecture
50
- 4. **Extensibility** - Service layer for custom authentication flows
51
- 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.
52
36
 
53
- ---
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
+ ```
54
48
 
55
- ## Installation
49
+ > Database entities (`users`, `userPublicKeys`, …) and all services/repositories are exported
50
+ > from `@spfn/auth/server`, **not** from the root `@spfn/auth`.
56
51
 
57
- ### 1. Install Package
52
+ ## How do I add auth to an SPFN app?
58
53
 
59
- ```bash
60
- pnpm add @spfn/auth
61
- ```
54
+ Four edits in the consuming app. All four are required for the flow to work end to end.
62
55
 
63
- ### 2. Configure Server
56
+ ### 1. Lifecycle — `server.config.ts`
64
57
 
65
- #### 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).
66
60
 
67
61
  ```typescript
68
62
  import { defineServerConfig } from '@spfn/core/server';
@@ -71,2525 +65,1237 @@ import { appRouter } from './router';
71
65
 
72
66
  export default defineServerConfig()
73
67
  .port(8790)
74
- .host('0.0.0.0')
75
68
  .routes(appRouter)
76
- .lifecycle(createAuthLifecycle()) // Add auth lifecycle
69
+ .lifecycle(createAuthLifecycle())
77
70
  .build();
78
71
  ```
79
72
 
80
- #### Register Router and Global Middleware 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'])`.
81
77
 
82
78
  ```typescript
83
79
  import { defineRouter } from '@spfn/core/route';
84
80
  import { authRouter, authenticate } from '@spfn/auth/server';
85
81
  import { getHealth } from './routes/health';
86
- import { createOrder } from './routes/orders';
87
82
 
88
83
  export const appRouter = defineRouter({
89
84
  getHealth,
90
- createOrder,
91
- // ... your other routes
85
+ // ...your routes
92
86
  })
93
- .packages([authRouter]) // Auth routes (/_auth/* namespace)
94
- .use([authenticate]); // Global auth middleware on all routes
87
+ .packages([authRouter]) // mounts /_auth/* and exposes routes on authApi
88
+ .use([authenticate]); // global auth middleware
95
89
 
96
90
  export type AppRouter = typeof appRouter;
97
91
  ```
98
92
 
99
- > **Important:** Public routes must explicitly skip auth with `.skip(['auth'])`.
100
- > See the [Authentication Guide](https://spfn.dev/docs/guides/authentication) for details.
101
-
102
- ### 3. Configure Next.js Interceptor
93
+ ### 3. Next.js interceptor RPC proxy route
103
94
 
104
- Register the auth interceptor in your RPC proxy route. This handles session cookies, JWT signing, and key management automatically.
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.
105
97
 
106
98
  ```typescript
107
99
  // app/api/rpc/[routeName]/route.ts
108
- import '@spfn/auth/nextjs/api'; // Must be first! Registers auth interceptor
109
- import { appRouter } from '@/server/router';
100
+ import '@spfn/auth/nextjs/api'; // side-effect: registers auth interceptors
110
101
  import { createRpcProxy } from '@spfn/core/nextjs/server';
102
+ import { authRouteMap } from '@spfn/auth';
103
+ import { routeMap } from '@/generated/route-map';
111
104
 
112
- export const { GET, POST } = createRpcProxy({ router: appRouter });
105
+ export const { GET, POST } = createRpcProxy({ routeMap: { ...routeMap, ...authRouteMap } });
113
106
  ```
114
107
 
115
- Your API client needs no auth-specific configuration:
108
+ ### 4. Run migrations
116
109
 
117
- ```typescript
118
- // src/lib/api-client.ts
119
- import { createApi } from '@spfn/core/nextjs';
120
- import type { AppRouter } from '@/server/router';
121
-
122
- export const api = createApi<AppRouter>();
110
+ ```bash
111
+ pnpm spfn db generate # only if entities changed
112
+ pnpm spfn db migrate
123
113
  ```
124
114
 
125
- The built-in `authApi` is also available for auth-only calls:
115
+ The API client needs no auth-specific config. `authApi` is also available standalone:
126
116
 
127
117
  ```typescript
128
118
  import { authApi } from '@spfn/auth';
129
- const session = await authApi.getAuthSession.call({});
130
- ```
131
-
132
- ### 4. Environment Variables
133
-
134
- Auth requires variables in **two separate files**: `.env.server` (SPFN backend) and `.env.local` (Next.js).
135
-
136
- #### `.env.server` (SPFN Backend)
137
-
138
- ```bash
139
- # Required
140
- DATABASE_URL=postgresql://user:pass@localhost:5432/myapp_dev
141
- SPFN_AUTH_VERIFICATION_TOKEN_SECRET=your-verification-secret
142
-
143
- # Admin account (required at least one format)
144
- SPFN_AUTH_ADMIN_ACCOUNTS='[{"email":"admin@example.com","password":"Admin!@34","role":"superadmin"}]'
145
-
146
- # Optional
147
- SPFN_AUTH_JWT_SECRET=your-jwt-secret
148
- SPFN_AUTH_JWT_EXPIRES_IN=7d
149
- SPFN_AUTH_BCRYPT_SALT_ROUNDS=10
150
- SPFN_AUTH_SESSION_TTL=7d
151
-
152
- # Google OAuth (optional)
153
- SPFN_AUTH_GOOGLE_CLIENT_ID=123456789-abc.apps.googleusercontent.com
154
- SPFN_AUTH_GOOGLE_CLIENT_SECRET=GOCSPX-...
155
- ```
156
-
157
- #### `.env.local` (Next.js)
158
-
159
- ```bash
160
- # Required
161
- DATABASE_URL=postgresql://user:pass@localhost:5432/myapp_dev
162
- SPFN_API_URL=http://localhost:8790
163
-
164
- # Required for session cookies (minimum 32 characters)
165
- SPFN_AUTH_SESSION_SECRET=my-super-secret-session-key-at-least-32-chars-long
166
-
167
- # Optional
168
- SPFN_AUTH_SESSION_TTL=7d
169
-
170
- # Email/SMS configure via @spfn/notification
171
- # See @spfn/notification README for AWS SES/SNS settings
172
- ```
173
-
174
- ### 5. Run Migrations
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.
213
+
214
+ ```typescript
215
+ const { keys } = await authApi.listKeys.call({ body: {} });
216
+ // → [{ keyId, deviceName?, platform?, algorithm, fingerprintPrefix, createdAtMillis,
217
+ // lastUsedAtMillis?, expiresAtMillis?, isExpired, isActive, revokedAtMillis? }]
218
+
219
+ await authApi.listKeys.call({ body: { includeRevoked: true } }); // also what was cut off
220
+ ```
221
+
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`.
226
+
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.
232
+
233
+ ```typescript
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.
285
+
286
+ ```typescript
287
+ import { route } from '@spfn/core/route';
288
+ import { authenticate, requirePermissions, optionalAuth } from '@spfn/auth/server';
289
+ import { getAuth, getOptionalAuth } from '@spfn/auth/server';
290
+
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
+ });
175
298
 
176
- ```bash
177
- # Generate migrations (if needed)
178
- pnpm spfn db generate
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) => { /* ... */ });
179
303
 
180
- # Run migrations
181
- pnpm spfn db migrate
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
+ });
182
312
  ```
183
313
 
184
- ### 6. Admin Account Setup
314
+ Context helpers from `@spfn/auth/server`: `getAuth`, `getOptionalAuth`, `getUser`, `getUserId`,
315
+ `getRole`, `getLocale`, `getKeyId`. Middleware: `authenticate`, `optionalAuth`,
316
+ `requirePermissions`, `requireAnyPermission`, `requireRole`, `roleGuard`, `oneTimeTokenAuth`.
185
317
 
186
- Admin accounts are automatically created on server startup via `createAuthLifecycle()`.
187
- Choose one of the following methods:
318
+ ## OAuth
188
319
 
189
- #### Method 1: JSON Format (Recommended)
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).
190
325
 
191
- Best for multiple accounts with full configuration:
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.
192
329
 
193
- ```bash
194
- SPFN_AUTH_ADMIN_ACCOUNTS='[
195
- {"email": "superadmin@example.com", "password": "secure-pass-1", "role": "superadmin"},
196
- {"email": "admin@example.com", "password": "secure-pass-2", "role": "admin"},
197
- {"email": "manager@example.com", "password": "secure-pass-3", "role": "user"}
198
- ]'
330
+ ```tsx
331
+ // app/auth/callback/page.tsx
332
+ export { OAuthCallback as default } from '@spfn/auth/nextjs/client';
199
333
  ```
200
334
 
201
- **JSON Schema:**
202
335
  ```typescript
203
- interface AdminAccountConfig {
204
- email: string; // Required
205
- password: string; // Required
206
- role?: string; // Default: 'user' (options: 'user', 'admin', 'superadmin')
207
- phone?: string; // Optional
208
- passwordChangeRequired?: boolean; // Default: true
209
- }
210
- ```
211
-
212
- #### Method 2: CSV Format
213
-
214
- For multiple accounts with simpler configuration:
215
-
216
- ```bash
217
- SPFN_AUTH_ADMIN_EMAILS=admin@example.com,manager@example.com
218
- SPFN_AUTH_ADMIN_PASSWORDS=admin-pass,manager-pass
219
- SPFN_AUTH_ADMIN_ROLES=superadmin,admin
220
- ```
221
-
222
- #### Method 3: Single Account (Legacy)
223
-
224
- Simplest format for a single superadmin:
225
-
226
- ```bash
227
- SPFN_AUTH_ADMIN_EMAIL=admin@example.com
228
- SPFN_AUTH_ADMIN_PASSWORD=secure-password
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;
229
344
  ```
230
345
 
231
- > **Note:** This method always creates a `superadmin` role account.
232
-
233
- #### Default Behavior
234
-
235
- All admin accounts created via environment variables have:
236
- - `emailVerifiedAt`: Auto-verified (current timestamp)
237
- - `passwordChangeRequired`: `true` (must change on first login)
238
- - `status`: `active`
239
-
240
- #### Programmatic Creation
241
-
242
- You can also create admin accounts programmatically:
346
+ GitHub, Kakao, and Naver use the provider-generic URL route:
243
347
 
244
348
  ```typescript
245
- import { usersRepository, getRoleByName, hashPassword } from '@spfn/auth/server';
246
-
247
- // After initializeAuth() has been called
248
- const role = await getRoleByName('admin');
249
- const passwordHash = await hashPassword('secure-password');
250
-
251
- await usersRepository.create({
252
- email: 'admin@example.com',
253
- passwordHash,
254
- roleId: role.id,
255
- emailVerifiedAt: new Date(),
256
- passwordChangeRequired: true,
257
- status: 'active',
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
+ },
258
355
  });
356
+ window.location.href = authUrl;
259
357
  ```
260
358
 
261
- ---
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.
262
362
 
263
- ## Architecture
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).
264
368
 
265
- ### High-Level Overview
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).
266
379
 
267
- ```
268
- ┌─────────────────────────────────────────────────────────────┐
269
- │ @spfn/auth Package │
270
- ├─────────────────────────────────────────────────────────────┤
271
- │ │
272
- │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
273
- │ │ Server │ │ Next.js │ │ Client │ │
274
- │ │ (server.ts) │ │ (nextjs/*) │ │ (client.ts) │ │
275
- │ └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ │
276
- │ │ │ │ │
277
- │ ┌───────▼───────────────────▼───────────────────▼───────┐ │
278
- │ │ Common Types & Entities │ │
279
- │ │ (index.ts) │ │
280
- │ └────────────────────────────────────────────────────────┘ │
281
- │ │
282
- └─────────────────────────────────────────────────────────────┘
283
- ```
380
+ ### Provider-initiated unlink notifications (`unlink-notify`)
284
381
 
285
- ### Module Separation
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).
286
386
 
287
- The package is split into three distinct entry points to ensure proper code separation:
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.
288
391
 
289
- 1. **Common Module** (`@spfn/auth`)
290
- - Database entities (users, roles, permissions)
291
- - TypeScript types and interfaces
292
- - RBAC type definitions
293
- - Can be imported anywhere (server/client)
392
+ Register in the provider console:
294
393
 
295
- 2. **Server Module** (`@spfn/auth/server`)
296
- - Server-only code (marked with Node.js APIs)
297
- - Routes, services, repositories
298
- - Middleware, helpers (JWT, password)
299
- - RBAC initialization
300
- - **Never** import in client-side code
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 |
301
398
 
302
- 3. **Client Module** (`@spfn/auth/client`)
303
- - Client-only code (React hooks, components)
304
- - Currently in development (placeholders only)
305
- - **Never** import in server-side code
399
+ The framework only severs the link. What happens next (keep the account, start account
400
+ deletion, …) is app policy — subscribe to the event:
306
401
 
307
- 4. **Next.js Adapter** (`@spfn/auth/nextjs/*`)
308
- - Next.js-specific integrations
309
- - `@spfn/auth/nextjs/api` - Interceptors for API routes
310
- - `@spfn/auth/nextjs/server` - Server Components guards & session helpers
311
-
312
- ### Asymmetric JWT Flow
402
+ ```typescript
403
+ import { oauthUnlinkedEvent } from '@spfn/auth/server';
313
404
 
405
+ oauthUnlinkedEvent.subscribe(async ({ userId, provider, providerUserId, reason }) =>
406
+ {
407
+ // e.g. delete the account when the social link was its only credential
408
+ });
314
409
  ```
315
- ┌──────────┐ ┌──────────┐
316
- │ Client │ │ Server │
317
- └────┬─────┘ └────┬─────┘
318
- │ │
319
- │ 1. Generate ES256 keypair │
320
- │ (privateKey stored locally) │
321
- │ │
322
- │ 2. POST /_auth/register │
323
- │ { email, password, publicKey, keyId } │
324
- ├──────────────────────────────────────────────>│
325
- │ │
326
- │ 3. Store publicKey │
327
- │ (user_public_keys)
328
- │ │
329
- │ 4. Sign JWT with privateKey │
330
- │ payload: { userId, keyId } │
331
- │ │
332
- │ 5. Request with Authorization header │
333
- │ Authorization: Bearer <jwt> │
334
- ├──────────────────────────────────────────────>│
335
- │ │
336
- │ 6. Decode JWT → keyId │
337
- │ Fetch publicKey │
338
- │ Verify signature │
339
- │ │
340
- │ 7. Success │
341
- │<──────────────────────────────────────────────┤
342
- │ │
343
- ```
344
-
345
- **Key Points:**
346
- - Server **never** knows the private key
347
- - Each client has a unique keypair
348
- - JWT verification uses stored public key
349
- - No shared secrets (unlike HMAC-based JWT)
350
410
 
351
- ---
411
+ Custom providers opt in by implementing `verifyUnlinkNotification()` (and optionally
412
+ `unlinkNotifyAckStatus`) — providers without it answer 404 on this route.
352
413
 
353
- ## Package Structure
414
+ ### OAuth callback origin (web app host + rewrite)
354
415
 
355
- ```
356
- packages/auth/
357
- ├── dist/ # Compiled output (tsup)
358
- │ ├── index.js # Common exports
359
- │ ├── index.d.ts
360
- │ ├── server.js # Server exports
361
- │ ├── server.d.ts
362
- │ ├── client.js # Client exports (minimal)
363
- │ ├── client.d.ts
364
- │ ├── config/ # Configuration module
365
- │ ├── errors/ # Error classes
366
- │ ├── nextjs/ # Next.js adapter
367
- │ └── server/ # Server implementation
368
-
369
- ├── migrations/ # Drizzle database migrations
370
- │ └── *.sql
371
-
372
- ├── src/
373
- │ ├── index.ts # Common entry point
374
- │ ├── server.ts # Server entry point
375
- │ ├── client.ts # Client entry point
376
- │ │
377
- │ ├── config/ # Configuration system
378
- │ │ ├── index.ts
379
- │ │ ├── schema.ts # Env var schema
380
- │ │ └── types.ts
381
- │ │
382
- │ ├── errors/ # Error definitions
383
- │ │ ├── index.ts
384
- │ │ └── auth-errors.ts
385
- │ │
386
- │ ├── lib/ # Shared code
387
- │ │ └── contracts/ # Typebox schemas
388
- │ │
389
- │ ├── server/ # Server-side implementation
390
- │ │ ├── entities/ # Drizzle ORM entities
391
- │ │ ├── services/ # Business logic layer
392
- │ │ ├── repositories/ # Database access layer
393
- │ │ ├── routes/ # HTTP route handlers
394
- │ │ ├── middleware/ # Auth middleware
395
- │ │ ├── helpers/ # JWT, password, context
396
- │ │ ├── rbac/ # RBAC types and builtins
397
- │ │ ├── lib/ # Server utilities
398
- │ │ ├── lifecycle.ts # SPFN lifecycle hooks
399
- │ │ ├── setup.ts # Initialization
400
- │ │ ├── logger.ts # Logging
401
- │ │ └── types.ts # Server types
402
- │ │
403
- │ ├── nextjs/ # Next.js adapter
404
- │ │ ├── api.ts # Interceptor exports
405
- │ │ ├── server.ts # Server Components guards
406
- │ │ ├── session-helpers.ts# Session management
407
- │ │ ├── interceptors/ # Request interceptors
408
- │ │ └── guards/ # Auth guards
409
- │ │
410
- │ └── client/ # Client-side (WIP)
411
- │ ├── hooks/ # React hooks (TODO)
412
- │ ├── store/ # Zustand store (TODO)
413
- │ └── components/ # UI components (TODO)
414
-
415
- ├── package.json # Package configuration + SPFN plugin config
416
- ├── tsup.config.ts # Build configuration
417
- ├── drizzle.config.ts # Database migration config
418
- └── README.md # This file
419
- ```
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`.
420
421
 
421
- ### Layer Responsibilities
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):
422
424
 
423
- #### 1. **Routes Layer** (`src/server/routes/`)
424
- - Thin HTTP handlers
425
- - Request validation (Typebox)
426
- - Delegates to services
427
- - Returns responses
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
+ };
438
+ ```
428
439
 
429
- #### 2. **Services Layer** (`src/server/services/`)
430
- - Business logic
431
- - Transaction management
432
- - Reusable functions
433
- - Can be used outside of routes
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`.
434
443
 
435
- #### 3. **Repositories Layer** (`src/server/repositories/`)
436
- - Database access only
437
- - CRUD operations
438
- - No business logic
439
- - Drizzle ORM queries
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.
440
448
 
441
- #### 4. **Helpers Layer** (`src/server/helpers/`)
442
- - Utility functions (JWT, password hashing)
443
- - Context accessors (getAuth, getUser)
444
- - Stateless operations
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.
445
452
 
446
- ---
453
+ ### Native social sign-in (mobile / web id_token)
447
454
 
448
- ## Module Exports
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).
449
462
 
450
- ### Common Module (`@spfn/auth`)
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.
451
468
 
452
- **API Client:**
453
469
  ```typescript
454
- import { authApi } from '@spfn/auth';
455
-
456
- // Type-safe API calls
457
- const session = await authApi.getAuthSession.call({});
458
- const result = await authApi.login.call({
459
- body: { email, password, fingerprint, publicKey, keyId }
470
+ await authApi.oauthNative.call({
471
+ params: { provider: 'apple' }, // or 'google', 'kakao'
472
+ body: { idToken, nonce, publicKey, keyId, fingerprint, algorithm: 'ES256', profile: { name } },
460
473
  });
461
- ```
462
-
463
- **Types:**
464
- ```typescript
465
- import type {
466
- User,
467
- UserPublicKey,
468
- VerificationCode,
469
- Role,
470
- Permission,
471
- AuthSession,
472
- UserProfile,
473
- ProfileInfo,
474
- // ... etc
475
- } from '@spfn/auth';
476
- ```
477
-
478
- **RBAC:**
479
- ```typescript
480
- import {
481
- BUILTIN_ROLES,
482
- BUILTIN_PERMISSIONS,
483
- BUILTIN_ROLE_PERMISSIONS
484
- } from '@spfn/auth';
485
-
486
- import type {
487
- RoleConfig,
488
- PermissionConfig,
489
- InitializeAuthOptions,
490
- BuiltinRoleName,
491
- BuiltinPermissionName
492
- } from '@spfn/auth';
493
- ```
494
-
495
- **Validation Patterns:**
496
- ```typescript
497
- import {
498
- UUID_PATTERN,
499
- EMAIL_PATTERN,
500
- BASE64_PATTERN,
501
- FINGERPRINT_PATTERN,
502
- PHONE_PATTERN,
503
- } from '@spfn/auth';
504
- ```
505
-
506
- **Route Map (for RPC Proxy):**
507
- ```typescript
508
- import { authRouteMap } from '@spfn/auth';
509
-
510
- // Use in Next.js RPC proxy (app/api/rpc/[routeName]/route.ts)
511
- import '@spfn/auth/nextjs/api'; // Auto-register auth interceptors
512
- import { routeMap } from '@/generated/route-map';
513
- import { authRouteMap } from '@spfn/auth';
514
- import { createRpcProxy } from '@spfn/core/nextjs/proxy';
515
-
516
- export const { GET, POST } = createRpcProxy({
517
- routeMap: { ...routeMap, ...authRouteMap }
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.
567
+
568
+ ```typescript
569
+ await authApi.oauthNative.call({
570
+ params: { provider: 'kakao' },
571
+ body: { idToken, nonce, accessToken, publicKey, keyId, fingerprint, algorithm: 'ES256' },
518
572
  });
519
573
  ```
520
574
 
521
- > **Note:** Database entities (`users`, `userPublicKeys`, etc.) are exported from `@spfn/auth/server`, not the common module.
522
-
523
- ---
524
-
525
- ### Server Module (`@spfn/auth/server`)
526
-
527
- **Router:**
528
- ```typescript
529
- import { authRouter } from '@spfn/auth/server';
530
-
531
- // Explicit registration in your app router
532
- export const appRouter = defineRouter({
533
- auth: authRouter, // Mounts at /_auth/*
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.
601
+
602
+ ```typescript
603
+ await authApi.oauthNative.call({
604
+ params: { provider: 'naver' },
605
+ body: { idToken, nonce, accessToken, publicKey, keyId, fingerprint, algorithm: 'ES256' },
534
606
  });
535
607
  ```
536
608
 
537
- **Services:**
538
- ```typescript
539
- import {
540
- // Auth
541
- checkAccountExistsService,
542
- registerService,
543
- loginService,
544
- logoutService,
545
- changePasswordService,
546
-
547
- // Verification
548
- sendVerificationCodeService,
549
- verifyCodeService,
550
-
551
- // Key Management
552
- registerPublicKeyService,
553
- rotateKeyService,
554
- revokeKeyService,
555
-
556
- // User
557
- getUserByIdService,
558
- getUserByEmailService,
559
- getUserByPhoneService,
560
- updateUserService,
561
- updateLastLoginService,
562
-
563
- // RBAC
564
- initializeAuth,
565
-
566
- // Permission
567
- getUserPermissions,
568
- hasPermission,
569
- hasAnyPermission,
570
- hasAllPermissions,
571
- hasRole,
572
- hasAnyRole,
573
-
574
- // Role
575
- createRole,
576
- updateRole,
577
- deleteRole,
578
- addPermissionToRole,
579
- removePermissionFromRole,
580
- setRolePermissions,
581
- getAllRoles,
582
- getRoleByName,
583
- getRolePermissions,
584
-
585
- // Invitation
586
- createInvitation,
587
- getInvitationByToken,
588
- getInvitationWithDetails,
589
- validateInvitation,
590
- acceptInvitation,
591
- listInvitations,
592
- cancelInvitation,
593
- deleteInvitation,
594
- expireOldInvitations,
595
- resendInvitation,
596
-
597
- // Session
598
- getAuthSessionService,
599
-
600
- // User Profile
601
- getUserProfileService,
602
- updateUserProfileService,
603
-
604
- // OAuth - Google API Access
605
- getGoogleAccessToken,
606
- } from '@spfn/auth/server';
607
- ```
608
-
609
- **Repositories:**
610
- ```typescript
611
- import {
612
- usersRepository,
613
- keysRepository,
614
- rolesRepository,
615
- permissionsRepository,
616
- verificationCodesRepository,
617
- invitationsRepository,
618
- rolePermissionsRepository,
619
- userPermissionsRepository,
620
- userProfilesRepository,
621
- } from '@spfn/auth/server';
622
- ```
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.
623
611
 
624
- **Middleware:**
625
- ```typescript
626
- import {
627
- authenticate,
628
- optionalAuth,
629
- requirePermissions,
630
- requireAnyPermission,
631
- requireRole,
632
- } from '@spfn/auth/server';
612
+ ### Custom providers
633
613
 
634
- // Usage - all permissions required
635
- app.bind(
636
- myContract,
637
- [authenticate, requirePermissions('user:delete')],
638
- async (c) => {
639
- // Handler
640
- }
641
- );
642
-
643
- // Usage - any of the permissions
644
- app.bind(
645
- myContract,
646
- [authenticate, requireAnyPermission('content:read', 'admin:access')],
647
- async (c) => {
648
- // User has either content:read OR admin:access
649
- }
650
- );
651
-
652
- // Usage - optional auth (public route with optional user context)
653
- // Auto-skips global 'auth' middleware — no .skip(['auth']) needed
654
- export const getProducts = route.get('/products')
655
- .use([optionalAuth])
656
- .handler(async (c) => {
657
- const auth = getOptionalAuth(c); // AuthContext | undefined
658
- if (auth) {
659
- return getPersonalizedProducts(auth.userId);
660
- }
661
- return getPublicProducts();
662
- });
663
- ```
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.
664
615
 
665
- **Helpers:**
666
616
  ```typescript
667
617
  import {
668
- // Context
669
- getAuth,
670
- getOptionalAuth,
671
- getUser,
672
- getUserId,
673
- getKeyId,
674
-
675
- // JWT
676
- generateToken, // Legacy server-signed (deprecated)
677
- verifyToken, // Legacy server-signed (deprecated)
678
- verifyClientToken, // Client-signed asymmetric JWT
679
- decodeToken, // Decode without verification (debugging)
680
- verifyKeyFingerprint,
681
-
682
- // Password
683
- hashPassword,
684
- verifyPassword,
618
+ registerOAuthProvider, getOAuthProvider, getRegisteredProviders,
619
+ oauthCallbackService,
620
+ type OAuthProvider, type NormalizedIdentity, type OAuthTokens,
685
621
  } from '@spfn/auth/server';
686
- ```
687
-
688
- **Lifecycle:**
689
- ```typescript
690
- import { createAuthLifecycle } from '@spfn/auth/server';
691
-
692
- // SPFN plugin lifecycle hooks
693
- const lifecycle = createAuthLifecycle();
694
- ```
695
-
696
- ---
697
622
 
698
- ### Client Module (`@spfn/auth/client`)
699
-
700
- > **Status:** Work in Progress - Placeholders only
701
-
702
- ```typescript
703
- // Currently empty exports
704
- import {} from '@spfn/auth/client';
623
+ registerOAuthProvider(myProvider); // same id re-registers (override)
705
624
  ```
706
625
 
707
- **Planned:**
708
- - React hooks (useAuth, useSession)
709
- - Zustand store
710
- - UI components (LoginForm, etc.)
711
-
712
- ---
626
+ ### OAuth token encryption and key rotation
713
627
 
714
- ### Configuration Module (`@spfn/auth/config`)
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:
715
632
 
716
- ```typescript
717
- import { env, envSchema } from '@spfn/auth/config';
718
-
719
- // Access environment variables (validated at startup)
720
- console.log(env.SPFN_AUTH_JWT_SECRET);
721
- console.log(env.SPFN_AUTH_JWT_EXPIRES_IN);
722
- console.log(env.SPFN_AUTH_BCRYPT_SALT_ROUNDS);
723
-
724
- // envSchema can be used for custom validation
633
+ ```dotenv
634
+ SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v2:<base64-32-byte-key>
725
635
  ```
726
636
 
727
- ---
728
-
729
- ### Errors Module (`@spfn/auth/errors`)
637
+ For zero-downtime rotation, prepend the new key and retain old keys for decryption:
730
638
 
731
- ```typescript
732
- import {
733
- // Auth namespace (contains all error classes)
734
- AuthError,
735
-
736
- // Individual error classes
737
- InvalidCredentialsError,
738
- InvalidTokenError,
739
- TokenExpiredError,
740
- KeyExpiredError,
741
- AccountDisabledError,
742
- AccountAlreadyExistsError,
743
- InvalidVerificationCodeError,
744
- InvalidVerificationTokenError,
745
- InvalidKeyFingerprintError,
746
- VerificationTokenPurposeMismatchError,
747
- VerificationTokenTargetMismatchError,
748
- InsufficientPermissionsError,
749
- InsufficientRoleError,
750
-
751
- // Error registry for client-side error handling
752
- authErrorRegistry,
753
- } from '@spfn/auth/errors';
639
+ ```dotenv
640
+ SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v3:<new-key>,v2:<old-key>
754
641
  ```
755
642
 
756
- ---
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.
757
649
 
758
- ### Next.js Adapter (`@spfn/auth/nextjs/*`)
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.
759
653
 
760
- #### `@spfn/auth/nextjs/api`
654
+ **Integration contract for custom providers:**
761
655
 
762
- ```typescript
763
- import {
764
- authInterceptors,
765
- loginRegisterInterceptor,
766
- generalAuthInterceptor,
767
- keyRotationInterceptor,
768
- oauthUrlInterceptor,
769
- oauthFinalizeInterceptor,
770
- } from '@spfn/auth/nextjs/api';
771
-
772
- // Auto-registers interceptors on import (including OAuth)
773
- import '@spfn/auth/nextjs/api';
774
- ```
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.
775
664
 
776
- #### `@spfn/auth/nextjs/server`
665
+ ## How do I read the session in a Next.js page?
777
666
 
778
- ```typescript
779
- import {
780
- // Guards (Server Components)
781
- RequireAuth,
782
- RequireRole,
783
- RequirePermission,
784
-
785
- // Auth Utils
786
- getUserRole,
787
- getUserPermissions,
788
- hasAnyRole,
789
- hasAnyPermission,
790
-
791
- // Session Helpers
792
- saveSession,
793
- getSession,
794
- clearSession,
795
-
796
- // Types
797
- type SessionData,
798
- type PublicSession,
799
- type SaveSessionOptions,
800
- } from '@spfn/auth/nextjs/server';
801
- ```
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`:
802
670
 
803
- **Session Helpers Usage:**
804
671
  ```typescript
805
- // Save session (Server Actions / Route Handlers)
806
- await saveSession({
807
- userId: '123',
808
- privateKey: '...',
809
- keyId: 'uuid',
810
- algorithm: 'ES256',
811
- });
672
+ import { saveSession, getSession, clearSession } from '@spfn/auth/nextjs/server';
812
673
 
813
- // Get session (read-only, safe in Server Components)
814
- const session = await getSession();
815
-
816
- // Clear session
674
+ await saveSession({ userId: '123', privateKey: '...', keyId: 'uuid', algorithm: 'ES256' });
675
+ const session = await getSession(); // read-only, safe in Server Components
817
676
  await clearSession();
818
677
  ```
819
678
 
820
- **Guard Usage:**
821
- ```typescript
822
- // app/dashboard/page.tsx
823
- import { RequireAuth } from '@spfn/auth/nextjs/server';
679
+ RSC guards (redirect when unmet) — `RequireAuth`, `RequireRole`, `RequirePermission`:
824
680
 
825
- export default async function DashboardPage()
681
+ ```tsx
682
+ import { RequireAuth, RequireRole } from '@spfn/auth/nextjs/server';
683
+
684
+ export default async function AdminPage()
826
685
  {
827
- return (
828
- <RequireAuth redirectTo="/login">
829
- <div>Protected content</div>
830
- </RequireAuth>
831
- );
686
+ return (
687
+ <RequireAuth redirectTo="/login">
688
+ <RequireRole roles={['admin', 'superadmin']} redirectTo="/forbidden">
689
+ <Dashboard />
690
+ </RequireRole>
691
+ </RequireAuth>
692
+ );
832
693
  }
833
694
  ```
834
695
 
835
- ---
696
+ Also exported: `getAuthSessionData`, `getUserRole`, `getUserPermissions`, `hasAnyRole`,
697
+ `hasAnyPermission`, the OAuth pending-session helpers, and `createOAuthCallbackHandler`.
836
698
 
837
- ## Email & SMS Services
699
+ ## How do I define roles and permissions?
838
700
 
839
- > **⚠️ DEPRECATED:** Email and SMS functionality has been moved to `@spfn/notification` package.
840
-
841
- ### Migration Guide
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)`.
842
705
 
843
706
  ```typescript
844
- // Before (deprecated)
845
- import { sendEmail, sendSMS } from '@spfn/auth/server';
846
-
847
- // After (recommended)
848
- import { sendEmail, sendSMS } from '@spfn/notification/server';
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
+ });
849
712
  ```
850
713
 
851
- The `@spfn/notification` package provides:
852
- - Multi-channel support (Email, SMS, Slack, Push)
853
- - Template system with variable substitution
854
- - Multiple provider support (AWS SES, SNS, SendGrid, Twilio, etc.)
855
-
856
- For documentation, see `@spfn/notification` package README.
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`.
857
718
 
858
- ---
719
+ ## Can I operate the app without building an admin dashboard?
859
720
 
860
- ## Server-Side API
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's answer is to expose those
724
+ operations to an agent instead, and there are two transports for that:
861
725
 
862
- ### Public Routes (No Authentication)
726
+ - **CLI-first (the default)**: develop ops as routes with
727
+ [`createOpsRouter`](../core/README.md#how-do-i-operate-the-app-from-the-terminal),
728
+ authenticate them with [ops tokens](#ops-tokens-spfn-ops), and drive them with
729
+ `spfn ops` from the same terminal the app was built in.
730
+ - **MCP**: [`@spfn/mcp`](../mcp/README.md) turns operations into tools a chat client's
731
+ agent can run — the fit when operators work outside a terminal.
863
732
 
864
- All routes are automatically registered at `/_auth/*` via SPFN plugin system.
733
+ `@spfn/auth` already knows who your operators are and which of them may do what; the MCP
734
+ wiring below shows how those answers reach `@spfn/mcp`.
865
735
 
866
- #### `POST /_auth/exists`
736
+ The connection is app code, deliberately. `@spfn/mcp` does not read this package's RBAC on
737
+ its own — it asks you for a `validateToken` and a `listTools`, and those are where auth's
738
+ answers go:
867
739
 
868
- Check if account exists.
869
-
870
- **Request:**
871
740
  ```typescript
872
- {
873
- email?: string;
874
- phone?: string; // E.164 format
875
- }
876
- ```
741
+ import { createMcpRoute } from '@spfn/mcp/server';
742
+ import { hasPermission, getUserRole } from '@spfn/auth/server';
877
743
 
878
- **Response:**
879
- ```typescript
880
- {
881
- exists: boolean;
882
- identifier: string;
883
- identifierType: 'email' | 'phone';
884
- }
885
- ```
744
+ // one required permission per tool — the same permission names your routes check
745
+ const allTools = [
746
+ { name: 'orders.refund', permission: 'order:refund', /* … */ },
747
+ { name: 'content.publish', permission: 'post:publish', /* … */ },
748
+ ];
886
749
 
887
- ---
750
+ export const mcpRouter = createMcpRoute({
751
+ appUrl: 'https://app.example.com',
752
+ serverInfo: { name: 'example-app', version: '1.0.0' },
888
753
 
889
- #### `POST /_auth/codes`
754
+ validateToken: async (token, resource) => verifyAccessToken(token, resource),
890
755
 
891
- Send verification code.
756
+ resolveContext: async (auth) => ({
757
+ userId: auth.userId,
758
+ role: await getUserRole(auth.userId),
759
+ }),
892
760
 
893
- **Request:**
894
- ```typescript
895
- {
896
- target: string; // Email or phone
897
- targetType: 'email' | 'phone';
898
- purpose: 'registration' | 'login' | 'password_reset';
899
- }
900
- ```
761
+ listTools: async (ctx) =>
762
+ {
763
+ const allowed = await Promise.all(
764
+ allTools.map(t => hasPermission(ctx.userId, t.permission)),
765
+ );
901
766
 
902
- **Response:**
903
- ```typescript
904
- {
905
- success: boolean;
906
- expiresAt: string; // ISO 8601
907
- }
767
+ return allTools.filter((_, i) => allowed[i]);
768
+ },
769
+ });
908
770
  ```
909
771
 
910
- ---
772
+ Two rules keep this safe. **Expose operations, not tables** — `orders.refund` carries an
773
+ authorization rule; a generic `db.query` carries none. And **check the permission inside
774
+ the handler too**, not only in `listTools`: hiding a tool from the list is discovery
775
+ control, not authorization.
911
776
 
912
- #### `POST /_auth/codes/verify`
777
+ ## Events
913
778
 
914
- Verify OTP code.
779
+ `@spfn/auth` emits decoupled events (via `@spfn/core/event`). Subscribe for welcome emails,
780
+ analytics, onboarding, etc. Client-supplied `metadata` on register/OAuth flows is forwarded verbatim.
915
781
 
916
- **Request:**
917
782
  ```typescript
918
- {
919
- target: string;
920
- targetType: 'email' | 'phone';
921
- code: string; // 6 digits
922
- purpose: 'registration' | 'login' | 'password_reset';
923
- }
924
- ```
783
+ import { authLoginEvent, authRegisterEvent, invitationCreatedEvent, invitationAcceptedEvent } from '@spfn/auth/server';
925
784
 
926
- **Response:**
927
- ```typescript
785
+ authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
928
786
  {
929
- valid: boolean;
930
- verificationToken?: string; // 15min JWT for registration
931
- }
787
+ if (email) await sendWelcome(email);
788
+ });
932
789
  ```
933
790
 
934
- ---
791
+ Payload types: `AuthLoginPayload`, `AuthRegisterPayload`, `InvitationCreatedPayload`,
792
+ `InvitationAcceptedPayload`, `AuthDeletionRequestedPayload`, `AuthDeletionCancelledPayload`,
793
+ `AuthDeletionCompletedPayload`, `OAuthUnlinkedPayload` (`auth.oauth.unlinked` — provider-side
794
+ disconnect, see the OAuth unlink-notify section). These events also bind to `@spfn/core/job`
795
+ jobs via `.on(event)`.
935
796
 
936
- #### `POST /_auth/register`
797
+ ## Registration gate (`beforeRegister`)
937
798
 
938
- Register new user.
799
+ Events fire *after* the user exists — they cannot reject a registration. For server-enforced
800
+ signup policy (age gate, invite-only domains, block lists) inject a validator with
801
+ `configureAuth`; it runs **before the user row is created** on every registration channel:
802
+ `credentials` (email/phone register), `oauth` (new-user social signup, web + native), and
803
+ `invitation` (acceptance). Throwing rejects the registration; `RegistrationRejectedError` (403)
804
+ is the recommended error. The hook receives the same `metadata` the app supplied to
805
+ `register` / OAuth start / the invitation — never credentials.
939
806
 
940
- **Request:**
941
807
  ```typescript
942
- {
943
- email?: string;
944
- phone?: string;
945
- verificationToken: string; // From /codes/verify
946
- password: string; // Min 8 chars
947
- publicKey: string; // Base64 DER (SPKI)
948
- keyId: string; // UUID v4
949
- fingerprint: string; // SHA-256 hex (64 chars)
950
- algorithm: 'ES256' | 'RS256';
951
- keySize?: number;
952
- }
953
- ```
808
+ import { configureAuth } from '@spfn/auth/server';
809
+ import { RegistrationRejectedError } from '@spfn/auth/errors';
954
810
 
955
- **Response:**
956
- ```typescript
957
- {
958
- userId: string;
959
- email?: string;
960
- phone?: string;
961
- }
811
+ configureAuth({
812
+ beforeRegister: async ({ channel, provider, email, phone, metadata }) =>
813
+ {
814
+ if (!isOldEnough(metadata?.birthDate))
815
+ {
816
+ throw new RegistrationRejectedError({ message: 'Age requirement not met' });
817
+ }
818
+ },
819
+ });
962
820
  ```
963
821
 
964
- ---
965
-
966
- #### `POST /_auth/login`
822
+ Notes:
823
+ - Runs after built-in checks (verification token, duplicate account) — existing error
824
+ precedence is unchanged, and the hook cannot be probed without a valid verification token.
825
+ - Not called when an OAuth login links a social account to an existing user, nor for admin
826
+ seeding in `initializeAuth()`.
827
+ - OAuth signups have no client-typed fields unless you pass `metadata` at OAuth start — decide
828
+ per channel (reject, or allow and collect during onboarding).
829
+ - On the `oauth` channel `email` is the provider-reported address and may be **unverified**
830
+ (the created account then stores `email` as `null`). The context carries
831
+ `emailVerified` — an email-based allow/block policy must check it before trusting `email`.
832
+ - The hook runs **inside the registration DB transaction** on every channel — keep it fast.
833
+ A slow call (e.g. an external policy API) holds a pooled DB connection open per signup.
834
+ - On the **web** OAuth flow a rejection surfaces as the standard OAuth error redirect
835
+ (302 to the app's OAuth error URL, message only) — not a 403 JSON response. The native
836
+ OAuth flow, credentials, and invitation channels return the error status (403) directly.
967
837
 
968
- User login.
969
-
970
- **Request:**
971
- ```typescript
972
- {
973
- email?: string;
974
- phone?: string;
975
- password: string;
976
- publicKey: string; // New key for session
977
- keyId: string;
978
- fingerprint: string;
979
- oldKeyId?: string; // Revoke previous key
980
- algorithm: 'ES256' | 'RS256';
981
- keySize?: number;
982
- }
983
- ```
838
+ ## One-Time Token
984
839
 
985
- **Response:**
986
- ```typescript
987
- {
988
- userId: string;
989
- email?: string;
990
- phone?: string;
991
- passwordChangeRequired: boolean;
992
- }
993
- ```
840
+ For short-lived authenticated handshakes (e.g. SSE) where a `Bearer` header is awkward: issue
841
+ with `authApi.issueOneTimeToken`, protect the consuming route with the `oneTimeTokenAuth`
842
+ middleware. Call `initOneTimeTokenManager({ ttl, store })` during setup for a custom TTL/store.
994
843
 
995
- ---
844
+ ## Ops tokens (`spfn ops`)
996
845
 
997
- ### Authenticated Routes (Require JWT)
846
+ The machine credential behind the CLI-first ops surface
847
+ ([`@spfn/core/ops`](../core/README.md#how-do-i-operate-the-app-from-the-terminal)). An ops
848
+ token is not a user session: it carries a label and a scope list, only its SHA-256 hash is
849
+ stored, and the secret is shown exactly once at issuance.
998
850
 
999
- **Authentication:**
1000
- - Header: `Authorization: Bearer <jwt>`
1001
- - JWT payload must contain: `{ userId, keyId }`
1002
- - Server extracts `keyId` from JWT, fetches public key, verifies signature
851
+ ```typescript
852
+ // src/server/ops.ts the app develops its own ops as routes
853
+ import { createOpsRouter, opsRoute } from '@spfn/core/ops';
854
+ import { opsTokenAuth, requireOpsScope } from '@spfn/auth/server';
1003
855
 
1004
- ---
856
+ export const opsRouter = createOpsRouter({
857
+ listSignups: opsRoute.get('/signups')
858
+ .use([requireOpsScope('waitlist:read')])
859
+ .handler(async () => signupsRepository.list()),
860
+ }, { auth: opsTokenAuth });
861
+ ```
1005
862
 
1006
- #### `POST /_auth/logout`
863
+ `opsRoute` comes from `@spfn/core` **0.3.0-beta.2** onwards; before that release an ops
864
+ route spelled its own `/_ops/` prefix with `route`.
1007
865
 
1008
- Logout and revoke current key.
866
+ Issue and manage tokens against the running app, signed in as an administrator. The CLI
867
+ prompts for the administrator's email and password, so nothing here needs database access:
1009
868
 
1010
- **Request:**
1011
- ```typescript
1012
- {} // Empty body
869
+ ```bash
870
+ spfn ops token issue --name laptop --scopes 'waitlist:read' --app https://api.example.com
871
+ spfn ops token issue --name laptop --scopes '*' --to-keychain --app https://api.example.com
872
+ spfn ops token list --app https://api.example.com
873
+ spfn ops token revoke 3 --app https://api.example.com
874
+ ```
875
+
876
+ Behind those commands are three admin-only routes, mounted with the rest of the auth
877
+ router:
878
+
879
+ | Route | What it does |
880
+ | --- | --- |
881
+ | `POST /_auth/ops-tokens` | Issue. The secret is in this answer and nowhere else. |
882
+ | `GET /_auth/ops-tokens` | List. Only hashes were stored, so no secret can be returned. |
883
+ | `DELETE /_auth/ops-tokens/:id` | Revoke. Permanent, and effective immediately. |
884
+
885
+ Each requires `authenticate` plus `requireRole('admin', 'superadmin')`. The administrator
886
+ seeded from `SPFN_AUTH_ADMIN_*` (see [Admin seeding](#admin-seeding))
887
+ signs in with a password, so this works in an app whose end users only sign in socially.
888
+
889
+ Issuance takes `expiresInDays` from 1 to 36500 (about a century), or `null` for a token that
890
+ never expires. There is an upper bound because a day count becomes a date by arithmetic, and
891
+ a big enough count produces an invalid date rather than a distant one — a refusal the route
892
+ should answer with a message, not with whatever the driver says about a value it cannot store.
893
+
894
+ SPFN authenticates a request with a JWT the client signs itself, so the CLI generates a key
895
+ pair, hands the public half over at login, signs the one call it needs, and revokes the key
896
+ before the command ends — on the failing path as much as the succeeding one.
897
+ `@spfn/auth/crypto` exports the two functions that take part (`generateKeyPair`,
898
+ `generateClientToken`) without pulling in the auth server; it exists from **0.3.0-beta.2**,
899
+ which is the floor the `spfn` CLI declares for this package.
900
+
901
+ Verification refuses uniformly: an expired, revoked, or never-issued token all answer the
902
+ same 401, so whether a presented secret ever existed is not inferable. A valid token
903
+ missing a route's scope answers 403 naming only the missing scope. `'*'` grants every
904
+ scope.
905
+
906
+ ## Mobile clientProofV1 (`@spfn/auth/client-proof`)
907
+
908
+ Server side of the spfn-mobile native SDK auth profile (issue #46; asymmetric revision in
909
+ contract 0.2.0). Implements the pinned mobile contract exactly: SPFN-CANON-JSON-1 canonical
910
+ JSON (custom parser/encoder — int64 via BigInt, duplicate-key rejection, UTF-8 byte key
911
+ order), SPFN-PROOF-INPUT-1 proof assembly with ECDSA P-256 + SHA-256 signature verification
912
+ (wire form: raw `r‖s`, 64 bytes, base16-lower; DER is rejected, low-S is not required — the
913
+ nonce + replay window own uniqueness), the contract admission order (revoked → session →
914
+ expired → replayed → signature; a nonce is spent only on admission), in-memory session
915
+ issuance/expiry, and
916
+ the fixed-string contract error envelope (`PROOF_INVALID` · `PROOF_REPLAYED` · `PROOF_EXPIRED` ·
917
+ `SESSION_REVOKED` · `PROFILE_REJECTED` · `CONTRACT_UNSUPPORTED` — SDKs classify by code, never
918
+ HTTP status).
919
+
920
+ - Wire headers (D23, ratified): `x-spfn-auth-profile`, `x-spfn-client-id`, `x-spfn-key-id`,
921
+ `x-spfn-nonce`, `x-spfn-issued-at`, `x-spfn-proof`, `x-spfn-session`.
922
+ - A request body must be **byte-canonical** — a body that parses but re-encodes differently is
923
+ refused even when its proof verifies (the proof binds the received bytes).
924
+ - `createClientProofDevHandler(...)` — framework-free `fetch(Request) → Response` dev surface
925
+ with the three contract operations and the `/control` test hooks the spfn-mobile integration
926
+ suites drive (`examples/04-mobile-contract-dev` is the runnable wiring).
927
+ - `createClientProofGuard(state)` — Hono middleware for mounting `requiresSession` operations
928
+ on an SPFN server; tags admitted requests `clientType: 'mobile'` (the attestation slot
929
+ proxy-guard reserved). hono is a type-only import here.
930
+ - A refusal is **answered**, never thrown: `authenticate` / `optionalAuth` answer a request that
931
+ named this profile with the canonical envelope (`error.code` is one of the six codes, and the
932
+ body carries nothing else), and the guard and dev handler do the same. Handing the refusal to
933
+ the generic error handler instead would put the carrying error class's name in `error.code`
934
+ (`UnauthorizedError`) — a code no generated SDK can classify (#106). Errors raised **after**
935
+ admission (account status, application errors) are ordinary SPFN errors and keep the REST
936
+ envelope.
937
+ - Replay ledger is module-local, NOT core's `NonceStore` — `checkAndSet` records on check,
938
+ which would spend a nonce on a refused request; the contract requires spending only on
939
+ admission.
940
+ - Conformance: spfn-mobile fixtures are vendored under
941
+ `src/server/client-proof/__tests__/fixtures/` (digest-pinned to upstream `MANIFEST.json`,
942
+ dev bundle sha256 `07fd8268…a433e45`) and run in the unit suite.
943
+ - Dev/test scope: public keys (SPKI DER base64, keyed by `x-spfn-key-id`) are registered at
944
+ construction or through the `/control/register-key` hook; the private half never reaches
945
+ the server. No persistence — a production enrollment/rotation story is phase 2.
946
+
947
+ ### The contract version on the wire (contract 0.6.0)
948
+
949
+ A client compiled and shipped separately from the server cannot be fixed by redeploying. Until
950
+ 0.6.0 a mismatch between what that client was generated against and what the server serves
951
+ surfaced as an undecodable body: the app looked broken and nothing said why.
952
+
953
+ Both ends now say what they are.
954
+
955
+ | Header | Direction | Sent by |
956
+ |--------|-----------|---------|
957
+ | `x-spfn-client-kind` | request | every client — `web`, `ios` or `android` |
958
+ | `x-spfn-client-version` | request | the client's own release: a store version, or a bundle build |
959
+ | `x-spfn-client-contract-version` | request | `ios` and `android` only |
960
+ | `x-spfn-server-contract-version` | response | the server, on every response including a refusal |
961
+ | `x-spfn-supported-contract-range` | response | the server, likewise |
962
+
963
+ ```typescript
964
+ import { createClientVersionMiddleware } from '@spfn/auth/client-proof';
965
+
966
+ // Mount before authentication: enrollment and login carry no proof, and they are
967
+ // where a stale client arrives first.
968
+ app.use('*', createClientVersionMiddleware());
969
+ ```
970
+
971
+ - **`web` states no contract version**, because a browser bundle is deployed with the server that
972
+ serves it and has no second version to reconcile. It is exempt by construction, not by leniency.
973
+ - **An `ios` or `android` client that states no contract version, or one outside the range, is
974
+ refused** `CONTRACT_UNSUPPORTED` (409) with the usual envelope.
975
+ - **A request naming no kind passes** — a curl, a health probe, a server-to-server call is not a
976
+ deployed client this rule is about.
977
+ - **None of it enters the proof input.** These are diagnostic; `PROOF_INPUT_FIELDS` is unchanged.
978
+ - **The server states facts and stops there.** Comparing the announced range against its own version
979
+ and deciding a user should see an update prompt is the client's judgment, made in the client. The
980
+ server has no way to make an app update and does not pretend to.
981
+
982
+ Response header names are deliberately distinct from the request ones: a proxy that echoes a request
983
+ header into the response would otherwise make the client's own version look like the server's.
984
+
985
+ ### When each operation became available (contract 0.6.1)
986
+
987
+ Every operation in the exported bundle carries `since` — the contract version it first appeared in.
988
+ `deprecatedIn` and `removedIn` are optional and absent today, because nothing has been deprecated.
989
+
990
+ | Operation | `since` |
991
+ |-----------|---------|
992
+ | `auth.clientProof.handshake`, `echo.send`, `items.list` | 0.1.0 |
993
+ | `auth.enroll.register`, `auth.enroll.login`, `auth.enroll.oauthNative`, `auth.keys.rotate` | 0.3.0 |
994
+ | `auth.keys.list`, `auth.keys.revoke`, `auth.keys.revokeAll` | 0.4.1 |
995
+
996
+ - **This is history, not policy.** The mobile contract's compatibility policy is `allOrNothing`: one
997
+ contract version passes or refuses the whole surface, so these three fields change no verdict here.
998
+ An app contract generated from SPFN routes decides `perOperation` and reads the same fields as an
999
+ input — the shape is shared so the two never diverge.
1000
+ - **A removal is mark, then wait, then remove.** `deprecatedIn` in one version with the operation
1001
+ still served, `removedIn` in a later one. Nothing is removed in the version that deprecates it.
1002
+ - **A removed operation leaves the operations list**, so no entry carries `removedIn` today. It is
1003
+ where the fact gets recorded when the first removal happens.
1004
+
1005
+ ### Usage — dev surface (mobile integration target)
1006
+
1007
+ The fastest path: run the packaged dev handler, which already serves the three contract
1008
+ operations and `/control`. `examples/04-mobile-contract-dev` is exactly this, runnable.
1009
+
1010
+ ```typescript
1011
+ import { serve } from '@hono/node-server';
1012
+ import { createClientProofDevHandler } from '@spfn/auth/client-proof';
1013
+
1014
+ const handler = createClientProofDevHandler({
1015
+ // keyId → registered public key (SPKI DER base64); the private key stays on the client
1016
+ publicKeys: { 'key-dev-0001': process.env.SPFN_CLIENT_PROOF_PUBLIC_KEY! },
1017
+ sessionTtlMillis: 600_000,
1018
+ });
1019
+ serve({ fetch: handler.fetch, port: 8791, hostname: '127.0.0.1' });
1020
+ // handler.controlToken — pass to the test harness for /control routes
1021
+ // handler.state — revokeKey() / expireSessions() / stats() from code
1013
1022
  ```
1014
1023
 
1015
- **Response:**
1016
- ```typescript
1017
- {
1018
- success: boolean;
1019
- }
1020
- ```
1024
+ ### Usage — mounting on your own Hono/SPFN server
1021
1025
 
1022
- ---
1026
+ Protect `requiresSession` operations with the guard, and assemble the handshake route from
1027
+ the exported primitives (`admitClientProofRequest` + `state.openSession`):
1023
1028
 
1024
- #### `POST /_auth/keys/rotate`
1029
+ ```typescript
1030
+ import { Hono } from 'hono';
1031
+ import {
1032
+ ClientProofState, createClientProofGuard, admitClientProofRequest,
1033
+ decodeHandshakeRequest, encodeHandshakeResponse, encodeCanonicalJson,
1034
+ ClientProofRefusal, newHexId,
1035
+ } from '@spfn/auth/client-proof';
1025
1036
 
1026
- Rotate public key before expiry (90 days).
1037
+ const state = new ClientProofState({ publicKeys: { 'key-dev-0001': process.env.SPFN_CLIENT_PROOF_PUBLIC_KEY! } });
1038
+ const app = new Hono();
1027
1039
 
1028
- **Request:**
1029
- ```typescript
1040
+ app.post('/v1/auth/client-proof/handshake', async (c) =>
1030
1041
  {
1031
- publicKey: string; // New public key
1032
- keyId: string; // New UUID
1033
- fingerprint: string;
1034
- algorithm: 'ES256' | 'RS256';
1035
- keySize?: number;
1036
- }
1037
- ```
1042
+ const body = new Uint8Array(await c.req.arrayBuffer());
1043
+ const admission = admitClientProofRequest({
1044
+ state, headers: c.req.raw.headers, method: 'POST',
1045
+ path: '/v1/auth/client-proof/handshake', requiresSession: false, body,
1046
+ });
1047
+ if (!admission.admitted)
1048
+ {
1049
+ return c.newResponse(admission.refusal.envelopeBytes(newHexId()).slice().buffer,
1050
+ admission.refusal.httpStatus as 401, { 'content-type': 'application/json' });
1051
+ }
1052
+ const request = decodeHandshakeRequest(admission.value);
1053
+ const opened = state.openSession(request.clientId, request.keyId);
1054
+ return c.newResponse(
1055
+ encodeCanonicalJson(encodeHandshakeResponse(opened.sessionId, BigInt(opened.expiresAtMillis))).slice().buffer,
1056
+ 200, { 'content-type': 'application/json' });
1057
+ });
1038
1058
 
1039
- **Response:**
1040
- ```typescript
1041
- {
1042
- success: boolean;
1043
- keyId: string;
1044
- }
1059
+ // Any route behind the guard sees clientType='mobile' and c.get('clientProof')
1060
+ app.post('/v1/echo', createClientProofGuard(state), (c) => { /* handler */ });
1045
1061
  ```
1046
1062
 
1047
- ---
1063
+ Responses and errors MUST be canonical bytes with the contract envelope — build them with
1064
+ `encodeCanonicalJson`/`ClientProofRefusal`, never `c.json()` (key order and int64 differ).
1048
1065
 
1049
- #### `PUT /_auth/password`
1066
+ ## Account Deletion & Recovery
1050
1067
 
1051
- Change password.
1068
+ Grace-period deletion with in-window recovery, an admin/GDPR-response entry point for immediate
1069
+ purge, and a pluggable app-data cleanup hook. Not covered by this feature: re-signup email
1070
+ blind-index/hashing (a purged account's email becomes reusable immediately — see the project's
1071
+ PII protection track for blind-index re-signup prevention), backup beyond-use handling, DSR
1072
+ intake/response workflows, and webhook fan-out — those are app/ops concerns.
1052
1073
 
1053
- **Request:**
1054
- ```typescript
1055
- {
1056
- currentPassword: string;
1057
- newPassword: string; // Min 8 chars
1058
- }
1059
1074
  ```
1060
-
1061
- **Response:**
1062
- ```typescript
1063
- {
1064
- success: boolean;
1065
- }
1075
+ active ──request (re-auth)──> pending_deletion ──grace period elapses (cron)──> deleted (anonymize) | row removed (hard-delete)
1076
+ ^ │
1077
+ └───────────cancel (re-auth)───────┘ immediate = grace period of 0, same pipeline
1066
1078
  ```
1067
1079
 
1068
- ---
1069
-
1070
- #### `GET /_auth/users/username/check`
1080
+ - **Request** — `POST /_auth/deletion/request` (authenticated). Step-up re-auth: password
1081
+ holders confirm with `password`; OAuth-only/passwordless accounts confirm with a
1082
+ `verificationToken` from `/_auth/codes` + `/_auth/codes/verify` (`purpose: 'account_deletion'`).
1083
+ On success: status → `pending_deletion`, every active session key is revoked, a
1084
+ `account_deletion_requests` audit row is created, `auth.deletion.requested` fires, and (if
1085
+ the user has an email and `sendNotifications` is on) a notice is sent with the scheduled purge
1086
+ date.
1087
+ - **Login is blocked while pending** — password login, OAuth login, and the `authenticate`
1088
+ middleware all reject a `pending_deletion` account with `AccountPendingDeletionError` (403,
1089
+ `details.purgeScheduledAt`) instead of the generic `AccountDisabledError`, so the client can
1090
+ show a recovery prompt.
1091
+ - **Cancel (recovery)** — `POST /_auth/deletion/cancel` (public — sessions were revoked at
1092
+ request time, so there's no Bearer token to authenticate with). Credential-based: email/phone
1093
+ plus `password` or a fresh `verificationToken`. On success, status → `active`; the user still
1094
+ needs to log in separately afterward.
1095
+ - **Purge job** — sweeps `account_deletion_requests` for rows past their grace period and
1096
+ destroys the account. Register it explicitly (see below); it is **not** wired up by
1097
+ `createAuthLifecycle()` automatically.
1098
+ - **Admin / GDPR-response entry points** — `requestAccountDeletionService(userId, { requestedBy: 'admin', immediate })`
1099
+ and `purgeUserService(userId)` are exported for app-side admin routes / DSR handling; the app
1100
+ owns the route and its authorization.
1071
1101
 
1072
- Check if a username is available.
1073
-
1074
- **Query:**
1075
1102
  ```typescript
1076
- {
1077
- username: string; // Min 1 char
1078
- }
1079
- ```
1103
+ import { defineServerConfig } from '@spfn/core/server';
1104
+ import { createAuthLifecycle, authJobRouter } from '@spfn/auth/server';
1080
1105
 
1081
- **Response:**
1082
- ```typescript
1083
- {
1084
- available: boolean;
1085
- }
1106
+ export default defineServerConfig()
1107
+ .lifecycle(createAuthLifecycle({
1108
+ deletion: {
1109
+ gracePeriodDays: 30, // default; 0 = immediate
1110
+ purgeStrategy: 'anonymize', // default; or 'hard-delete'
1111
+ allowSelfImmediate: false, // default; self-service immediate: true
1112
+ sendNotifications: true, // default
1113
+ onBeforePurge: async (user) =>
1114
+ {
1115
+ // throw to skip this user for the current sweep (retried next run)
1116
+ await appDataCleanup(user.id);
1117
+ },
1118
+ },
1119
+ }))
1120
+ .jobs(authJobRouter) // registers the daily (04:00 UTC) purge sweep
1121
+ .routes(appRouter)
1122
+ .build();
1086
1123
  ```
1087
1124
 
1088
- ---
1125
+ **Purge strategies:**
1126
+
1127
+ - `anonymize` (default) — scrubs PII, keeps the row: `email` → `deleted-{publicId}@deleted.invalid`,
1128
+ `phone`/`username`/`passwordHash` → `null`, `status` → `'deleted'`, `deletedAt`/`deletedBy` set
1129
+ (`softDelete()` on `users`). Social accounts and public keys are deleted (frees the provider
1130
+ link and revokes access), the profile's PII columns are cleared, and any leftover verification
1131
+ codes for the original email/phone are removed. The freed email/phone can be re-registered
1132
+ immediately.
1133
+ - `hard-delete` — physically removes the `users` row; child rows (`user_profiles`,
1134
+ `user_public_keys`, `user_social_accounts`, `user_permissions`) cascade-delete via their FK.
1135
+ The `account_deletion_requests` audit row survives either strategy — its `userId` FK is
1136
+ `set null` (not cascade), by design, so "who requested/purged what, when" outlives the user row.
1137
+
1138
+ The final "your account has been deleted" notice is sent **after** the purge transaction commits
1139
+ (never before, and never on a purge that aborted or rolled back — see below), using the address
1140
+ captured before the destructive step ran. This holds for `hard-delete` too: the row is already
1141
+ gone by send time, but the address was captured beforehand, so the notice still goes out.
1142
+
1143
+ **Concurrency.** The purge job re-verifies the user is still `pending_deletion` on the write
1144
+ primary immediately before any destructive DML, inside the same transaction as the DML itself —
1145
+ closing the window between a stale read (the sweep's own batch, or replica lag) and a concurrent
1146
+ `cancel`. The `account_deletion_requests` claim (`markCompleted`) is a conditional `UPDATE ...
1147
+ WHERE status = 'pending'`; if a concurrent cancel already moved the row off `pending`, the claim
1148
+ matches zero rows and the purge aborts with no destructive DML and no overwritten audit row.
1149
+
1150
+ **Cron schedule caveat.** `deletion.purgeCron` (default `0 4 * * *`) is stored for reference, but
1151
+ the static `authJobRouter` export above always runs on the *default* cron — `job(...).cron(...)`
1152
+ is fixed at module-import time, which happens before `createAuthLifecycle()` runs in your
1153
+ `server.config.ts`. For a non-default schedule, build the router yourself, after the
1154
+ `createAuthLifecycle()` call, and register that instead:
1155
+
1156
+ ```typescript
1157
+ import { createAuthDeletionJobRouter } from '@spfn/auth/server';
1158
+
1159
+ // ... after .lifecycle(createAuthLifecycle({ deletion: { purgeCron: '0 3 * * *' } }))
1160
+ .jobs(createAuthDeletionJobRouter({ purgeCron: '0 3 * * *' }))
1161
+ ```
1162
+
1163
+ Register **only one** of `authJobRouter` / `createAuthDeletionJobRouter(...)` — both build a job
1164
+ named `auth.deletion.purge`, so registering both (e.g. the static export *and* a custom-cron
1165
+ router) double-registers the same job name against pg-boss instead of overriding it.
1166
+
1167
+ ## FAQ
1168
+
1169
+ **How do I add one social provider?**
1170
+ Set its two environment variables. Google, GitHub, Kakao and Naver each turn on when their
1171
+ client ID and secret are both present — there is no separate registration step. Then
1172
+ register the callback URL in that provider's console, and read the next answer before you
1173
+ deploy.
1174
+
1175
+ **Social login worked locally and broke after deploying. Why?**
1176
+ Almost always the callback origin. The CSRF check is a double-submit against a host-only
1177
+ cookie set on your **web app** host, so the provider must return to the web app origin, and
1178
+ the app must forward `/_auth/*` to the API with a Next.js rewrite. Without that rewrite the
1179
+ callback 404s — including in local dev. Details in
1180
+ [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite).
1181
+
1182
+ **Does the server hold my users' private keys?**
1183
+ No. The client generates an ES256/RS256 keypair, sends only the public key on register or
1184
+ login, and signs each request itself. The server verifies with the stored public key. Keys
1185
+ expire after 90 days; `rotateKey` renews one.
1186
+
1187
+ **Does signing in on a new device sign the old one out?**
1188
+ No, and that is on purpose — keys are per-device and accumulate. `listKeys` shows the
1189
+ account owner what accumulated, `revokeKey` cuts one off, `revokeAllKeys` cuts off
1190
+ everything but the caller.
1191
+
1192
+ **How long does a session last?**
1193
+ `SPFN_AUTH_SESSION_TTL`, seven days by default. It accepts `7d`, `12h`, `45m`.
1194
+
1195
+ **Is account deletion immediate?**
1196
+ No. A request moves the account to `pending_deletion`, revokes every session key, and
1197
+ schedules the purge for 30 days later by default. The user can cancel with their
1198
+ credentials during that window. Two things need your attention: the purge sweep is a job
1199
+ you register explicitly (`.jobs(authJobRouter)`), and a purged account's email becomes
1200
+ reusable immediately. See [Account Deletion & Recovery](#account-deletion--recovery).
1201
+
1202
+ **Can an admin delete a user's account?**
1203
+ Yes, through `requestAccountDeletionService(userId, { requestedBy: 'admin', immediate })`
1204
+ and `purgeUserService(userId)`. The package exports the services; you own the route and its
1205
+ authorization.
1206
+
1207
+ **Where do my admin accounts come from?**
1208
+ The environment, seeded on startup by `createAuthLifecycle()`. Seeded accounts are email
1209
+ verified, active, and required to change their password on first login.
1210
+
1211
+ ## Pitfalls & anti-patterns
1212
+
1213
+ - **"relation \"auth.users\" does not exist" — tables come from bundled migrations, not push.**
1214
+ Package schemas are excluded from `spfn db push`'s diff; the `auth.*` tables are created by the
1215
+ migration files shipped in this package. Run `pnpm spfn db migrate` (state check:
1216
+ `pnpm spfn db status`). Installing via plain `pnpm add @spfn/auth` runs no migration — only
1217
+ `spfn add @spfn/auth` auto-applies them.
1218
+ - **Wrong entry point.** `@spfn/auth/server` and `@spfn/auth/nextjs/*` are server-only (Node /
1219
+ `server-only`). Importing them in a client component breaks the build. Entities, services, and
1220
+ repositories are on `/server`, not on root `@spfn/auth`.
1221
+ - **No `app.bind(contract, ...)`.** That contract pattern is removed. Use the route DSL
1222
+ (`route.get().handler()` + `defineRouter`). Any docs/snippets using `app.bind` are stale.
1223
+ - **Custom error classes must be registered.** Add them to an `ErrorRegistry` (mirror
1224
+ `authErrorRegistry` in `src/errors/index.ts`) and pass it to your `createApi({ errorRegistry })`,
1225
+ or the client receives a generic error instead of the typed one.
1226
+ - **Two env files, by audience.** `SPFN_AUTH_SESSION_SECRET` lives in `.env.local` (Next.js needs
1227
+ it for cookie crypto); `SPFN_AUTH_VERIFICATION_TOKEN_SECRET` and
1228
+ `SPFN_AUTH_TOKEN_ENCRYPTION_KEYS` live in `.env.server`. Token encryption keys are backend-only;
1229
+ putting them in `.env.local` unnecessarily gives the Next.js process token-decryption authority.
1230
+ - **`SPFN_AUTH_SESSION_SECRET` is validated.** Minimum 32 chars plus entropy/unique-char checks —
1231
+ a short or low-entropy value fails startup, not just a warning.
1232
+ - **Forgetting the interceptor import.** Without `import '@spfn/auth/nextjs/api'` in the RPC proxy
1233
+ route, the client sends no `Authorization` header and every protected call 401s. The
1234
+ `authenticate` middleware error message points here.
1235
+ - **Custom OAuth callback without `Transactional()`.** A failure mid-callback leaves an orphan
1236
+ user. Always wrap the callback route in `Transactional()` and call `oauthCallbackService`.
1237
+ - **`sideEffects: false` tree-shakes the google provider.** The built-in provider self-registers
1238
+ via a module side-effect; an aggressive bundler config can drop it. Don't mark this package's
1239
+ imports side-effect-free.
1240
+ - **Public routes need an explicit opt-out.** With global `authenticate`, any route without
1241
+ `.skip(['auth'])` (or `optionalAuth`, which auto-skips) requires a valid token.
1242
+ - **`SOCIAL_PROVIDERS` is plain `enumText`.** Adding a provider value needs no DB migration, but
1243
+ every `switch(provider)` over login/register events must handle the new value.
1244
+ - **Email/SMS is not here.** It moved to `@spfn/notification` (`import { sendEmail, sendSMS } from
1245
+ '@spfn/notification/server'`). Wire verification-code / invitation emails through its events.
1246
+ - **`authJobRouter` isn't registered for you.** `createAuthLifecycle()`'s `afterInfrastructure`
1247
+ hook runs *before* `@spfn/core` initializes pg-boss and registers jobs, so the lifecycle has no
1248
+ opportunity to auto-register the account-deletion purge job. Call `.jobs(authJobRouter)`
1249
+ yourself — see [Account Deletion & Recovery](#account-deletion--recovery).
1250
+ - **`USER_STATUSES` gained `pending_deletion` / `deleted`.** Any code with a `switch(user.status)`
1251
+ or an exhaustive status union must handle both — `enumText` is plain `text` with no DB `CHECK`,
1252
+ so nothing enforces this at the database layer.
1253
+
1254
+ ## Complete example
1255
+
1256
+ ```typescript
1257
+ // server.config.ts
1258
+ import { defineServerConfig } from '@spfn/core/server';
1259
+ import { createAuthLifecycle } from '@spfn/auth/server';
1260
+ import { appRouter } from './router';
1089
1261
 
1090
- #### `PATCH /_auth/users/username`
1262
+ export default defineServerConfig()
1263
+ .port(8790)
1264
+ .routes(appRouter)
1265
+ .lifecycle(createAuthLifecycle({
1266
+ roles: [{ name: 'editor', displayName: 'Editor', priority: 30 }],
1267
+ permissions: [{ name: 'post:publish', displayName: 'Publish Posts', category: 'content' }],
1268
+ rolePermissions: { editor: ['post:publish'] },
1269
+ }))
1270
+ .build();
1091
1271
 
1092
- Update authenticated user's username. Validates uniqueness before updating.
1272
+ // router.ts
1273
+ import { defineRouter } from '@spfn/core/route';
1274
+ import { authRouter, authenticate } from '@spfn/auth/server';
1275
+ import { getMe } from './routes/me';
1093
1276
 
1094
- **Request:**
1095
- ```typescript
1096
- {
1097
- username: string | null; // New username or null to clear
1098
- }
1099
- ```
1100
-
1101
- **Response:** Updated user object.
1102
-
1103
- **Errors:**
1104
- - `409 UsernameAlreadyTakenError` - Username is already in use by another user
1105
-
1106
- ---
1107
-
1108
- ## Events
1109
-
1110
- `@spfn/auth`는 `@spfn/core/event`를 사용하여 인증 관련 이벤트를 발행합니다. 이를 통해 로그인/회원가입 시 추가 로직(환영 이메일, 분석, 알림 등)을 디커플링된 방식으로 처리할 수 있습니다.
1111
-
1112
- ### Available Events
1113
-
1114
- | Event | Description | Trigger |
1115
- |-------|-------------|---------|
1116
- | `auth.login` | 로그인 성공 | 이메일/전화 로그인, OAuth 기존 사용자 |
1117
- | `auth.register` | 회원가입 성공 | 이메일/전화 회원가입, OAuth 신규 사용자 |
1118
- | `auth.invitation.created` | 초대 생성/재발송 | createInvitation, resendInvitation |
1119
- | `auth.invitation.accepted` | 초대 수락 | acceptInvitation |
1120
-
1121
- ---
1122
-
1123
- ### Event Payloads
1124
-
1125
- #### `auth.login`
1126
-
1127
- ```typescript
1128
- {
1129
- userId: string;
1130
- provider: 'email' | 'phone' | 'google';
1131
- email?: string;
1132
- phone?: string;
1133
- }
1134
- ```
1135
-
1136
- #### `auth.register`
1137
-
1138
- ```typescript
1139
- {
1140
- userId: string;
1141
- provider: 'email' | 'phone' | 'google';
1142
- email?: string;
1143
- phone?: string;
1144
- metadata?: Record<string, unknown>; // 가입 시 전달된 커스텀 메타데이터
1145
- }
1146
- ```
1147
-
1148
- `metadata`는 클라이언트가 register/OAuth 요청 body에 포함한 값이 그대로 전달됩니다.
1149
- 레퍼럴 코드, UTM 파라미터 등 앱 고유 데이터를 이벤트 구독자에게 전달할 때 사용합니다.
1150
-
1151
- #### `auth.invitation.created`
1152
-
1153
- ```typescript
1154
- {
1155
- invitationId: string;
1156
- email: string;
1157
- token: string;
1158
- roleId: number;
1159
- invitedBy: string;
1160
- expiresAt: string; // ISO 8601
1161
- isResend: boolean; // true면 재발송
1162
- metadata?: Record<string, unknown>;
1163
- }
1164
- ```
1165
-
1166
- #### `auth.invitation.accepted`
1167
-
1168
- ```typescript
1169
- {
1170
- invitationId: string;
1171
- email: string;
1172
- userId: string; // 생성된 사용자 ID
1173
- roleId: number;
1174
- invitedBy: string;
1175
- metadata?: Record<string, unknown>;
1176
- }
1177
- ```
1178
-
1179
- ---
1180
-
1181
- ### Subscribing to Events
1182
-
1183
- ```typescript
1184
- import { authLoginEvent, authRegisterEvent } from '@spfn/auth/server';
1185
-
1186
- // 로그인 이벤트 구독
1187
- authLoginEvent.subscribe(async (payload) => {
1188
- console.log('User logged in:', payload.userId, payload.provider);
1189
- await analytics.trackLogin(payload.userId);
1190
- });
1191
-
1192
- // 회원가입 이벤트 구독 (metadata 활용)
1193
- authRegisterEvent.subscribe(async (payload) => {
1194
- console.log('New user registered:', payload.userId);
1195
- if (payload.email) {
1196
- await emailService.sendWelcome(payload.email);
1197
- }
1198
-
1199
- // 레퍼럴 코드 처리
1200
- const refCode = payload.metadata?.refCode as string;
1201
- if (refCode) {
1202
- await referralService.link(payload.userId, refCode);
1203
- }
1204
- });
1205
- ```
1206
-
1207
- 클라이언트에서 metadata를 전달하는 방법:
1208
-
1209
- ```typescript
1210
- // 이메일/전화 가입
1211
- authApi.register.call({
1212
- body: { email, password, metadata: { refCode: 'CODE', utm_source: 'google' } }
1213
- });
1214
-
1215
- // OAuth 가입
1216
- authApi.oauthStart.call({
1217
- body: { provider: 'google', returnUrl: '/dashboard', metadata: { refCode: 'CODE' } }
1218
- });
1219
- ```
1220
-
1221
- #### 초대 이벤트 구독 (이메일 발송 연동)
1222
-
1223
- ```typescript
1224
- import { invitationCreatedEvent, invitationAcceptedEvent } from '@spfn/auth/server';
1225
-
1226
- // 초대 생성 시 이메일 발송
1227
- invitationCreatedEvent.subscribe(async (payload) => {
1228
- const inviteUrl = `${APP_URL}/invite/${payload.token}`;
1229
-
1230
- await notificationService.send({
1231
- channel: 'email',
1232
- to: payload.email,
1233
- subject: payload.isResend ? '초대가 재발송되었습니다' : '초대장이 도착했습니다',
1234
- html: renderInviteEmail({
1235
- inviteUrl,
1236
- inviterName: payload.metadata?.inviterName,
1237
- message: payload.metadata?.message,
1238
- }),
1239
- tracking: {
1240
- category: 'invitation',
1241
- metadata: { invitationId: payload.invitationId },
1242
- },
1243
- });
1244
- });
1245
-
1246
- // 초대 수락 시 온보딩 처리
1247
- invitationAcceptedEvent.subscribe(async (payload) => {
1248
- await onboardingService.start(payload.userId);
1249
- });
1250
- ```
1251
-
1252
- 초대 생성 시 커스텀 만료 시간 지정:
1253
-
1254
- ```typescript
1255
- // expiresAt이 expiresInDays보다 우선
1256
- authApi.createInvitation.call({
1257
- body: {
1258
- email: 'user@example.com',
1259
- roleId: 2,
1260
- expiresAt: '2026-03-20T00:00:00Z',
1261
- metadata: { inviterName: '홍길동', message: '함께 일해요!' },
1262
- }
1263
- });
1264
- ```
1265
-
1266
- ---
1267
-
1268
- ### Job Integration
1269
-
1270
- `@spfn/core/job`과 연동하여 백그라운드 작업을 실행할 수 있습니다.
1271
-
1272
- ```typescript
1273
- import { job, defineJobRouter } from '@spfn/core/job';
1274
- import { authRegisterEvent } from '@spfn/auth/server';
1275
-
1276
- // 회원가입 시 환영 이메일 발송 Job
1277
- const sendWelcomeEmailJob = job('send-welcome-email')
1278
- .on(authRegisterEvent)
1279
- .handler(async ({ userId, email }) => {
1280
- if (email) {
1281
- await emailService.sendWelcome(email);
1282
- }
1283
- });
1284
-
1285
- // 회원가입 시 기본 설정 생성 Job
1286
- const createDefaultSettingsJob = job('create-default-settings')
1287
- .on(authRegisterEvent)
1288
- .handler(async ({ userId }) => {
1289
- await settingsService.createDefaults(userId);
1290
- });
1291
-
1292
- export const jobRouter = defineJobRouter({
1293
- sendWelcomeEmailJob,
1294
- createDefaultSettingsJob,
1295
- });
1296
- ```
1297
-
1298
- ---
1299
-
1300
- ### Event Flow
1301
-
1302
- ```
1303
- ┌─────────────────────────────────────────────────────────────────┐
1304
- │ loginService() / registerService() │
1305
- │ oauthCallbackService() │
1306
- └─────────────────────────────────────────────────────────────────┘
1307
-
1308
-
1309
- authLoginEvent.emit()
1310
- authRegisterEvent.emit()
1311
-
1312
- ┌───────────────────┼───────────────────┐
1313
- ▼ ▼ ▼
1314
- ┌──────────┐ ┌──────────┐ ┌──────────┐
1315
- │ Backend │ │ Job │ │ SSE │
1316
- │ Handler │ │ Queue │ │ Stream │
1317
- └──────────┘ └──────────┘ └──────────┘
1318
- .subscribe() .on(event) (optional)
1319
- │ │
1320
- ▼ ▼
1321
- [Analytics, [Background
1322
- Logging] Processing]
1323
- ```
1324
-
1325
- ---
1326
-
1327
- ### Type Exports
1328
-
1329
- ```typescript
1330
- import type {
1331
- AuthLoginPayload,
1332
- AuthRegisterPayload,
1333
- } from '@spfn/auth/server';
1334
- ```
1335
-
1336
- ---
1337
-
1338
- ## OAuth Authentication
1339
-
1340
- ### Overview
1341
-
1342
- `@spfn/auth`는 OAuth 2.0 Authorization Code Flow를 지원합니다. 현재 Google OAuth가 구현되어 있으며, 다른 provider (GitHub, Kakao, Naver)는 동일한 패턴으로 확장 가능합니다.
1343
-
1344
- **핵심 설계:**
1345
- - 환경 변수만으로 설정 (`SPFN_AUTH_GOOGLE_CLIENT_ID`, `SPFN_AUTH_GOOGLE_CLIENT_SECRET`)
1346
- - Next.js 인터셉터 기반 자동 세션 관리 (키쌍 생성 → pending session → full session)
1347
- - 기존 이메일 계정과 자동 연결 (Google verified_email 확인 시에만)
1348
-
1349
- ---
1350
-
1351
- ### Authentication Flow
1352
-
1353
- ```
1354
- ┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────┐
1355
- │ Client │ │ Next.js RPC │ │ Backend │ │ Google │
1356
- │ (Browser)│ │ (Interceptor)│ │ (SPFN) │ │ OAuth │
1357
- └────┬─────┘ └──────┬───────┘ └────┬─────┘ └────┬─────┘
1358
- │ │ │ │
1359
- │ 1. Click Login │ │ │
1360
- ├──────────────────>│ │ │
1361
- │ │ │ │
1362
- │ 2. Generate keypair (ES256) │ │
1363
- │ 3. Create encrypted state │ │
1364
- │ (publicKey, keyId in JWE) │ │
1365
- │ 4. Save privateKey to │ │
1366
- │ pending session cookie │ │
1367
- │ │ │ │
1368
- │ │ 5. Forward with │ │
1369
- │ │ state in body │ │
1370
- │ ├─────────────────>│ │
1371
- │ │ │ │
1372
- │ │ 6. Return Google │ │
1373
- │ │ Auth URL │ │
1374
- │ │<─────────────────┤ │
1375
- │ │ │ │
1376
- │ 7. Redirect to Google │ │
1377
- │<──────────────────┤ │ │
1378
- │ │ │ │
1379
- │ 8. User consents │ │ │
1380
- ├───────────────────┼──────────────────┼────────────────>│
1381
- │ │ │ │
1382
- │ │ 9. Callback with code + state │
1383
- │ │ │<────────────────┤
1384
- │ │ │ │
1385
- │ │ 10. Verify state, exchange code │
1386
- │ │ Create/link user account │
1387
- │ │ Register publicKey │
1388
- │ │ │ │
1389
- │ 11. Redirect to /auth/callback │ │
1390
- │ ?userId=X&keyId=Y&returnUrl=/ │ │
1391
- │<─────────────────────────────────────┤ │
1392
- │ │ │ │
1393
- │ 12. OAuthCallback │ │ │
1394
- │ component │ │ │
1395
- │ calls finalize│ │ │
1396
- ├──────────────────>│ │ │
1397
- │ │ │ │
1398
- │ 13. Interceptor reads pending │ │
1399
- │ session cookie, verifies │ │
1400
- │ keyId match, creates full │ │
1401
- │ session cookie │ │
1402
- │ │ │ │
1403
- │ 14. Session set, │ │ │
1404
- │ redirect to │ │ │
1405
- │ returnUrl │ │ │
1406
- │<──────────────────┤ │ │
1407
- │ │ │ │
1408
- ```
1409
-
1410
- ---
1411
-
1412
- ### Setup
1413
-
1414
- #### 1. Google Cloud Console
1415
-
1416
- 1. [Google Cloud Console](https://console.cloud.google.com/) > APIs & Services > Credentials
1417
- 2. Create OAuth 2.0 Client ID (Web application)
1418
- 3. Add Authorized redirect URI: `http://localhost:8790/_auth/oauth/google/callback`
1419
- 4. Copy Client ID and Client Secret
1420
-
1421
- #### 2. Environment Variables
1422
-
1423
- ```bash
1424
- # Required
1425
- SPFN_AUTH_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
1426
- SPFN_AUTH_GOOGLE_CLIENT_SECRET=GOCSPX-your-secret
1427
-
1428
- # Next.js app URL (for OAuth callback redirect)
1429
- SPFN_APP_URL=http://localhost:3000
1430
-
1431
- # Optional
1432
- SPFN_AUTH_GOOGLE_SCOPES=email,profile # default (comma-separated)
1433
- SPFN_AUTH_GOOGLE_REDIRECT_URI=http://localhost:8790/_auth/oauth/google/callback # default
1434
- SPFN_AUTH_OAUTH_SUCCESS_URL=/auth/callback # default
1435
- ```
1436
-
1437
- #### 3. Next.js Callback Page
1438
-
1439
- ```tsx
1440
- // app/auth/callback/page.tsx
1441
- export { OAuthCallback as default } from '@spfn/auth/nextjs/client';
1442
- ```
1443
-
1444
- #### 4. Login Button
1445
-
1446
- ```typescript
1447
- import { authApi } from '@spfn/auth';
1448
-
1449
- const handleGoogleLogin = async () =>
1450
- {
1451
- const response = await authApi.getGoogleOAuthUrl.call({
1452
- body: { returnUrl: '/dashboard' },
1453
- });
1454
- window.location.href = response.authUrl;
1455
- };
1456
- ```
1457
-
1458
- ---
1459
-
1460
- ### OAuth Routes
1461
-
1462
- #### `GET /_auth/oauth/google`
1463
-
1464
- Google OAuth 시작 (리다이렉트 방식). 브라우저를 Google 로그인 페이지로 직접 리다이렉트합니다.
1465
-
1466
- **Query:**
1467
- ```typescript
1468
- {
1469
- state: string; // Encrypted OAuth state (JWE)
1470
- }
1471
- ```
1472
-
1473
- ---
1474
-
1475
- #### `POST /_auth/oauth/google/url`
1476
-
1477
- Google OAuth URL 획득 (인터셉터 방식). 인터셉터가 state를 자동 생성하여 주입합니다.
1478
-
1479
- **Request:**
1480
- ```typescript
1481
- {
1482
- returnUrl?: string; // Default: '/'
1483
- }
1484
- ```
1485
-
1486
- **Response:**
1487
- ```typescript
1488
- {
1489
- authUrl: string; // Google OAuth URL
1490
- }
1491
- ```
1492
-
1493
- ---
1494
-
1495
- #### `GET /_auth/oauth/google/callback`
1496
-
1497
- Google에서 리다이렉트되는 콜백. code를 token으로 교환하고 사용자를 생성/연결합니다.
1498
-
1499
- **Query (from Google):**
1500
- ```typescript
1501
- {
1502
- code?: string; // Authorization code
1503
- state?: string; // OAuth state
1504
- error?: string; // Error code
1505
- error_description?: string; // Error description
1506
- }
1507
- ```
1508
-
1509
- **Result:** Next.js 콜백 페이지로 리다이렉트 (`/auth/callback?userId=X&keyId=Y&returnUrl=/`)
1510
-
1511
- ---
1512
-
1513
- #### `POST /_auth/oauth/finalize`
1514
-
1515
- OAuth 세션 완료. 인터셉터가 pending session에서 full session을 생성합니다.
1516
-
1517
- **Request:**
1518
- ```typescript
1519
- {
1520
- userId: string;
1521
- keyId: string;
1522
- returnUrl?: string;
1523
- }
1524
- ```
1525
-
1526
- **Response:**
1527
- ```typescript
1528
- {
1529
- success: boolean;
1530
- returnUrl: string;
1531
- }
1532
- ```
1533
-
1534
- ---
1535
-
1536
- #### `GET /_auth/oauth/providers`
1537
-
1538
- 활성화된 OAuth provider 목록을 반환합니다.
1539
-
1540
- **Response:**
1541
- ```typescript
1542
- {
1543
- providers: ('google' | 'github' | 'kakao' | 'naver')[];
1544
- }
1545
- ```
1546
-
1547
- ---
1548
-
1549
- ### Google API Access
1550
-
1551
- OAuth 로그인 후 저장된 access token으로 Google API를 호출할 수 있습니다.
1552
-
1553
- #### Custom Scopes 설정
1554
-
1555
- `SPFN_AUTH_GOOGLE_SCOPES` 환경변수로 추가 스코프를 요청합니다. 미설정 시 `email,profile`이 기본값입니다.
1556
-
1557
- ```bash
1558
- # Gmail + Calendar 읽기 권한 추가
1559
- SPFN_AUTH_GOOGLE_SCOPES=email,profile,https://www.googleapis.com/auth/gmail.readonly,https://www.googleapis.com/auth/calendar.readonly
1560
- ```
1561
-
1562
- > **Note:** Google Cloud Console에서 해당 API를 활성화해야 합니다.
1563
-
1564
- #### Access Token 사용
1565
-
1566
- `getGoogleAccessToken(userId)`은 유효한 access token을 반환합니다. 토큰이 만료 임박(5분 이내) 또는 만료 상태이면 자동으로 refresh token을 사용하여 갱신합니다.
1567
-
1568
- ```typescript
1569
- import { getGoogleAccessToken } from '@spfn/auth/server';
1570
-
1571
- // 항상 유효한 토큰 반환 (만료 시 자동 갱신)
1572
- const token = await getGoogleAccessToken(userId);
1573
-
1574
- // Gmail API 호출
1575
- const response = await fetch(
1576
- 'https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=10',
1577
- { headers: { Authorization: `Bearer ${token}` } }
1578
- );
1579
- const data = await response.json();
1580
- ```
1581
-
1582
- **에러 케이스:**
1583
- - Google 계정 미연결 → `'No Google account linked'`
1584
- - Refresh token 없음 → `'Google refresh token not available'` (재로그인 필요)
1585
-
1586
- ---
1587
-
1588
- ### Security
1589
-
1590
- - **State 암호화**: JWE (A256GCM)로 state 파라미터 암호화. CSRF 방지용 nonce 포함.
1591
- - **Pending Session**: OAuth 리다이렉트 중 privateKey를 JWE로 암호화한 HttpOnly 쿠키에 저장. 10분 TTL.
1592
- - **KeyId 검증**: finalize 시 pending session의 keyId와 응답의 keyId 일치 확인.
1593
- - **Email 검증**: `verified_email`이 true인 경우에만 기존 계정에 자동 연결. 미검증 이메일로 기존 계정 연결 시도 시 에러.
1594
- - **Session Cookie**: `HttpOnly`, `Secure` (production), `SameSite=strict`.
1595
-
1596
- ---
1597
-
1598
- ### OAuthCallback Component
1599
-
1600
- `@spfn/auth/nextjs/client`에서 제공하는 클라이언트 컴포넌트입니다.
1601
-
1602
- ```tsx
1603
- import { OAuthCallback } from '@spfn/auth/nextjs/client';
1604
-
1605
- // 기본 사용
1606
- export default function CallbackPage()
1607
- {
1608
- return <OAuthCallback />;
1609
- }
1610
-
1611
- // 커스터마이징
1612
- export default function CallbackPage()
1613
- {
1614
- return (
1615
- <OAuthCallback
1616
- apiBasePath="/api/rpc"
1617
- loadingComponent={<MySpinner />}
1618
- errorComponent={(error) => <MyError message={error} />}
1619
- onSuccess={(userId) => console.log('Logged in:', userId)}
1620
- onError={(error) => console.error(error)}
1621
- />
1622
- );
1623
- }
1624
- ```
1625
-
1626
- **Props:**
1627
-
1628
- | Prop | Type | Default | Description |
1629
- |------|------|---------|-------------|
1630
- | `apiBasePath` | `string` | `'/api/rpc'` | RPC API base path |
1631
- | `loadingComponent` | `ReactNode` | Built-in | 로딩 중 표시할 컴포넌트 |
1632
- | `errorComponent` | `(error: string) => ReactNode` | Built-in | 에러 표시 컴포넌트 |
1633
- | `onSuccess` | `(userId: string) => void` | - | 성공 콜백 |
1634
- | `onError` | `(error: string) => void` | - | 에러 콜백 |
1635
-
1636
- ---
1637
-
1638
- ## Database Schema
1639
-
1640
- ### Core Tables
1641
-
1642
- #### `users`
1643
-
1644
- Main user identity table.
1645
-
1646
- ```sql
1647
- CREATE TABLE users (
1648
- id BIGSERIAL PRIMARY KEY,
1649
- public_id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
1650
- email TEXT UNIQUE,
1651
- phone TEXT UNIQUE,
1652
- username TEXT UNIQUE,
1653
- password_hash TEXT NOT NULL,
1654
- password_change_required BOOLEAN DEFAULT false,
1655
- role_id BIGINT REFERENCES roles(id) NOT NULL,
1656
- status TEXT NOT NULL CHECK (status IN ('active', 'inactive', 'suspended')),
1657
- email_verified_at TIMESTAMP,
1658
- phone_verified_at TIMESTAMP,
1659
- last_login_at TIMESTAMP,
1660
- created_at TIMESTAMP DEFAULT NOW(),
1661
- updated_at TIMESTAMP DEFAULT NOW(),
1662
-
1663
- CONSTRAINT users_identifier_check CHECK (
1664
- (email IS NOT NULL) OR (phone IS NOT NULL)
1665
- )
1666
- );
1667
- ```
1668
-
1669
- **Key Points:**
1670
- - `public_id` is a UUID v4 for external-facing URLs and APIs (never expose internal `id`)
1671
- - At least one of `email` OR `phone` required
1672
- - `username` is unique and nullable (optional display/mention identifier)
1673
- - `passwordHash` is bcrypt ($2b$10$..., 60 chars)
1674
- - `roleId` references roles table (NOT NULL)
1675
-
1676
- ---
1677
-
1678
- #### `user_public_keys`
1679
-
1680
- Stores client public keys for JWT verification.
1681
-
1682
- ```sql
1683
- CREATE TABLE user_public_keys (
1684
- id BIGSERIAL PRIMARY KEY,
1685
- user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
1686
- key_id TEXT UNIQUE NOT NULL,
1687
- public_key TEXT NOT NULL,
1688
- algorithm TEXT NOT NULL CHECK (algorithm IN ('ES256', 'RS256')),
1689
- fingerprint TEXT NOT NULL,
1690
- is_active BOOLEAN DEFAULT true,
1691
- created_at TIMESTAMP DEFAULT NOW(),
1692
- last_used_at TIMESTAMP,
1693
- expires_at TIMESTAMP NOT NULL,
1694
- revoked_at TIMESTAMP,
1695
- revoked_reason TEXT
1696
- );
1697
-
1698
- CREATE INDEX idx_user_public_keys_user_id ON user_public_keys(user_id);
1699
- CREATE INDEX idx_user_public_keys_key_id ON user_public_keys(key_id);
1700
- CREATE INDEX idx_user_public_keys_is_active ON user_public_keys(is_active);
1701
- ```
1702
-
1703
- **Key Points:**
1704
- - `keyId` is client-generated UUID v4
1705
- - `fingerprint` is SHA-256(publicKey) for verification
1706
- - `expiresAt` defaults to 90 days from creation
1707
- - `isActive` determines if key can be used
1708
-
1709
- ---
1710
-
1711
- #### `verification_codes`
1712
-
1713
- OTP codes for email/SMS verification.
1714
-
1715
- ```sql
1716
- CREATE TABLE verification_codes (
1717
- id BIGSERIAL PRIMARY KEY,
1718
- target TEXT NOT NULL,
1719
- target_type TEXT NOT NULL CHECK (target_type IN ('email', 'phone')),
1720
- code TEXT NOT NULL,
1721
- purpose TEXT NOT NULL CHECK (purpose IN ('registration', 'login', 'password_reset')),
1722
- expires_at TIMESTAMP NOT NULL,
1723
- used_at TIMESTAMP,
1724
- created_at TIMESTAMP DEFAULT NOW()
1725
- );
1726
-
1727
- CREATE INDEX idx_verification_codes_target ON verification_codes(target);
1728
- ```
1729
-
1730
- **Key Points:**
1731
- - 6-digit numeric code
1732
- - Expires in 5-10 minutes (configurable)
1733
- - Single-use (marked via `usedAt`)
1734
-
1735
- ---
1736
-
1737
- ### RBAC Tables
1738
-
1739
- #### `roles`
1740
-
1741
- ```sql
1742
- CREATE TABLE roles (
1743
- id BIGSERIAL PRIMARY KEY,
1744
- name TEXT UNIQUE NOT NULL,
1745
- display_name TEXT NOT NULL,
1746
- description TEXT,
1747
- is_builtin BOOLEAN DEFAULT false,
1748
- is_system BOOLEAN DEFAULT false,
1749
- is_active BOOLEAN DEFAULT true,
1750
- priority INTEGER NOT NULL,
1751
- created_at TIMESTAMP DEFAULT NOW(),
1752
- updated_at TIMESTAMP DEFAULT NOW()
1753
- );
1754
- ```
1755
-
1756
- **Built-in Roles:**
1757
- - `user` (priority 10) - Default role
1758
- - `admin` (priority 80)
1759
- - `superadmin` (priority 100)
1760
-
1761
- ---
1762
-
1763
- #### `permissions`
1764
-
1765
- ```sql
1766
- CREATE TABLE permissions (
1767
- id BIGSERIAL PRIMARY KEY,
1768
- name TEXT UNIQUE NOT NULL,
1769
- display_name TEXT NOT NULL,
1770
- description TEXT,
1771
- category TEXT,
1772
- is_builtin BOOLEAN DEFAULT false,
1773
- is_system BOOLEAN DEFAULT false,
1774
- is_active BOOLEAN DEFAULT true,
1775
- created_at TIMESTAMP DEFAULT NOW(),
1776
- updated_at TIMESTAMP DEFAULT NOW()
1777
- );
1778
- ```
1779
-
1780
- **Built-in Permissions:**
1781
- - `auth:self:manage`
1782
- - `user:read`, `user:write`, `user:delete`, `user:invite`
1783
- - `rbac:role:manage`, `rbac:permission:manage`
1784
-
1785
- ---
1786
-
1787
- #### `role_permissions`
1788
-
1789
- Many-to-many mapping between roles and permissions.
1790
-
1791
- ```sql
1792
- CREATE TABLE role_permissions (
1793
- id BIGSERIAL PRIMARY KEY,
1794
- role_id BIGINT REFERENCES roles(id) ON DELETE CASCADE,
1795
- permission_id BIGINT REFERENCES permissions(id) ON DELETE CASCADE,
1796
- created_at TIMESTAMP DEFAULT NOW(),
1797
- updated_at TIMESTAMP DEFAULT NOW(),
1798
-
1799
- UNIQUE(role_id, permission_id)
1800
- );
1801
- ```
1802
-
1803
- ---
1804
-
1805
- #### `user_permissions`
1806
-
1807
- User-specific permission overrides.
1808
-
1809
- ```sql
1810
- CREATE TABLE user_permissions (
1811
- id BIGSERIAL PRIMARY KEY,
1812
- user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
1813
- permission_id BIGINT REFERENCES permissions(id) ON DELETE CASCADE,
1814
- granted BOOLEAN NOT NULL,
1815
- reason TEXT,
1816
- expires_at TIMESTAMP,
1817
- created_at TIMESTAMP DEFAULT NOW(),
1818
- updated_at TIMESTAMP DEFAULT NOW(),
1819
-
1820
- UNIQUE(user_id, permission_id)
1821
- );
1822
- ```
1823
-
1824
- **Use Cases:**
1825
- - `granted: true` - Grant permission temporarily
1826
- - `granted: false` - Revoke permission (even if role has it)
1827
- - `expiresAt` - Temporary access with expiration
1828
-
1829
- ---
1830
-
1831
- ### Supporting Tables
1832
-
1833
- #### `invitations`
1834
-
1835
- User invitation system.
1836
-
1837
- ```sql
1838
- CREATE TABLE invitations (
1839
- id BIGSERIAL PRIMARY KEY,
1840
- email TEXT NOT NULL,
1841
- token TEXT UNIQUE NOT NULL,
1842
- role_id BIGINT REFERENCES roles(id),
1843
- invited_by BIGINT REFERENCES users(id),
1844
- status TEXT CHECK (status IN ('pending', 'accepted', 'cancelled', 'expired')),
1845
- expires_at TIMESTAMP NOT NULL,
1846
- accepted_at TIMESTAMP,
1847
- created_at TIMESTAMP DEFAULT NOW()
1848
- );
1849
- ```
1850
-
1851
- ---
1852
-
1853
- #### `user_profiles`
1854
-
1855
- Extended user profile information.
1856
-
1857
- ```sql
1858
- CREATE TABLE user_profiles (
1859
- id BIGSERIAL PRIMARY KEY,
1860
- user_id BIGINT REFERENCES users(id) ON DELETE CASCADE UNIQUE,
1861
- first_name TEXT,
1862
- last_name TEXT,
1863
- display_name TEXT,
1864
- avatar_url TEXT,
1865
- bio TEXT,
1866
- created_at TIMESTAMP DEFAULT NOW(),
1867
- updated_at TIMESTAMP DEFAULT NOW()
1868
- );
1869
- ```
1870
-
1871
- ---
1872
-
1873
- #### `user_social_accounts`
1874
-
1875
- OAuth provider accounts (Google, GitHub, etc.).
1876
-
1877
- ```sql
1878
- CREATE TABLE user_social_accounts (
1879
- id BIGSERIAL PRIMARY KEY,
1880
- user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
1881
- provider TEXT NOT NULL,
1882
- provider_id TEXT NOT NULL,
1883
- access_token TEXT,
1884
- refresh_token TEXT,
1885
- expires_at TIMESTAMP,
1886
- created_at TIMESTAMP DEFAULT NOW(),
1887
-
1888
- UNIQUE(provider, provider_id)
1889
- );
1890
- ```
1891
-
1892
- ---
1893
-
1894
- ## RBAC System
1895
-
1896
- ### Initialization
1897
-
1898
- ```typescript
1899
- import { initializeAuth } from '@spfn/auth/server';
1900
-
1901
- // Minimal setup (built-in roles only)
1902
- await initializeAuth();
1903
-
1904
- // With presets
1905
- await initializeAuth({
1906
- usePresets: true, // Adds moderator, editor, viewer roles
1907
- });
1908
-
1909
- // Custom roles and permissions
1910
- await initializeAuth({
1911
- roles: [
1912
- {
1913
- name: 'content-creator',
1914
- displayName: 'Content Creator',
1915
- priority: 20,
1916
- },
1917
- ],
1918
- permissions: [
1919
- {
1920
- name: 'post:create',
1921
- displayName: 'Create Posts',
1922
- category: 'content',
1923
- },
1924
- ],
1925
- rolePermissions: {
1926
- 'content-creator': ['post:create'],
1927
- },
1928
- });
1929
- ```
1930
-
1931
- ---
1932
-
1933
- ### Built-in System
1934
-
1935
- **Roles:**
1936
- - `superadmin` (priority 100) - Full access
1937
- - `admin` (priority 80) - User management
1938
- - `user` (priority 10) - Self management
1939
-
1940
- **Permissions:**
1941
- - `auth:self:manage` - Change password, rotate keys
1942
- - `user:read`, `user:write`, `user:delete`, `user:invite`
1943
- - `rbac:role:manage`, `rbac:permission:manage`
1944
-
1945
- ---
1946
-
1947
- ### Middleware Usage
1948
-
1949
- ```typescript
1950
- import { authenticate, requirePermissions, requireAnyPermission, requireRole } from '@spfn/auth/server';
1951
-
1952
- // Single permission
1953
- app.bind(
1954
- deleteUserContract,
1955
- [authenticate, requirePermissions('user:delete')],
1956
- async (c) => {
1957
- // Only users with user:delete permission
1958
- }
1959
- );
1960
-
1961
- // Multiple permissions (all required)
1962
- app.bind(
1963
- publishPostContract,
1964
- [authenticate, requirePermissions('post:write', 'post:publish')],
1965
- async (c) => {
1966
- // Needs both permissions
1967
- }
1968
- );
1969
-
1970
- // Any of the permissions (at least one required)
1971
- app.bind(
1972
- viewContentContract,
1973
- [authenticate, requireAnyPermission('content:read', 'admin:access')],
1974
- async (c) => {
1975
- // User has either content:read OR admin:access
1976
- }
1977
- );
1978
-
1979
- // Role-based
1980
- app.bind(
1981
- adminDashboardContract,
1982
- [authenticate, requireRole('admin', 'superadmin')],
1983
- async (c) => {
1984
- // Admin or superadmin only
1985
- }
1986
- );
1987
- ```
1988
-
1989
- ---
1990
-
1991
- ### Programmatic Checks
1992
-
1993
- ```typescript
1994
- import { hasPermission, hasRole, getUserPermissions } from '@spfn/auth/server';
1995
-
1996
- const canPublish = await hasPermission(userId, 'post:publish');
1997
- const isAdmin = await hasRole(userId, 'admin');
1998
- const permissions = await getUserPermissions(userId);
1999
-
2000
- if (canPublish)
2001
- {
2002
- // Allow publish
2003
- }
2004
- ```
2005
-
2006
- ---
2007
-
2008
- ### Runtime Role Management
2009
-
2010
- ```typescript
2011
- import { createRole, addPermissionToRole } from '@spfn/auth/server';
2012
-
2013
- // Create role
2014
- const role = await createRole({
2015
- name: 'moderator',
2016
- displayName: 'Moderator',
2017
- priority: 40,
2018
- permissionIds: [1n, 2n],
2019
- });
2020
-
2021
- // Add permission
2022
- await addPermissionToRole(role.id, 5n);
2023
-
2024
- // Delete (system roles protected)
2025
- await deleteRole(role.id);
2026
- ```
2027
-
2028
- ---
2029
-
2030
- ## Next.js Adapter
2031
-
2032
- ### Session Management
2033
-
2034
- The Next.js adapter provides encrypted HttpOnly cookie-based sessions.
2035
-
2036
- **Configuration:**
2037
- ```bash
2038
- # .env
2039
- SPFN_AUTH_SESSION_SECRET=your-32-char-secret
2040
- SPFN_AUTH_SESSION_TTL=7d # Optional, default 7d
2041
- ```
2042
-
2043
- **Session Data:**
2044
- ```typescript
2045
- interface SessionData {
2046
- userId: string;
2047
- privateKey: string; // Encrypted in cookie
2048
- keyId: string;
2049
- algorithm: 'ES256' | 'RS256';
2050
- }
2051
- ```
2052
-
2053
- ---
2054
-
2055
- ### Server Component Guards
2056
-
2057
- ```typescript
2058
- // app/admin/page.tsx
2059
- import { RequireAuth, RequireRole } from '@spfn/auth/nextjs/server';
2060
-
2061
- export default async function AdminPage()
2062
- {
2063
- return (
2064
- <RequireAuth redirectTo="/login">
2065
- <RequireRole roles={['admin', 'superadmin']} redirectTo="/forbidden">
2066
- <div>Admin Dashboard</div>
2067
- </RequireRole>
2068
- </RequireAuth>
2069
- );
2070
- }
2071
- ```
2072
-
2073
- ---
2074
-
2075
- ### Interceptors (API Routes)
1277
+ export const appRouter = defineRouter({ getMe })
1278
+ .packages([authRouter])
1279
+ .use([authenticate]);
1280
+ export type AppRouter = typeof appRouter;
2076
1281
 
2077
- **Setup:**
2078
- ```typescript
2079
- // Simply import to auto-register
1282
+ // app/api/rpc/[routeName]/route.ts
2080
1283
  import '@spfn/auth/nextjs/api';
2081
- ```
2082
-
2083
- **How It Works:**
2084
- 1. Reads `session` HttpOnly cookie
2085
- 2. Unseals session data
2086
- 3. Generates JWT signed with `privateKey`
2087
- 4. Injects `Authorization: Bearer <jwt>` header
2088
-
2089
- **Target Routes:**
2090
- - `/_auth/login`, `/_auth/register` - Login/register interceptor
2091
- - `/_auth/keys/rotate` - Key rotation interceptor
2092
- - `/_auth/oauth/:provider/url` - OAuth URL interceptor (keypair + state generation)
2093
- - `/_auth/oauth/finalize` - OAuth finalize interceptor (pending session → full session)
2094
- - All other authenticated routes - General auth interceptor
2095
-
2096
- ---
2097
-
2098
- ### OAuth Client Component (`@spfn/auth/nextjs/client`)
2099
-
2100
- ```typescript
2101
- import { OAuthCallback, type OAuthCallbackProps } from '@spfn/auth/nextjs/client';
2102
- ```
2103
-
2104
- OAuth 콜백 페이지용 `'use client'` 컴포넌트. 자세한 사용법은 [OAuth Authentication](#oauth-authentication) 섹션 참조.
2105
-
2106
- ---
2107
-
2108
- ## Testing
2109
-
2110
- ### Setup Test Environment
2111
-
2112
- ```bash
2113
- # Start test database
2114
- pnpm docker:test:up
2115
-
2116
- # Generate migrations
2117
- pnpm db:generate
2118
-
2119
- # Run migrations (via @spfn/core)
2120
- cd ../../
2121
- pnpm spfn db migrate
2122
- ```
2123
-
2124
- ---
2125
-
2126
- ### Run Tests
2127
-
2128
- ```bash
2129
- # All tests
2130
- pnpm test
2131
-
2132
- # With coverage
2133
- pnpm test:coverage
2134
-
2135
- # Route tests only
2136
- pnpm test:routes
2137
-
2138
- # Watch mode
2139
- pnpm test --watch
2140
- ```
2141
-
2142
- ---
2143
-
2144
- ### Test Structure
2145
-
2146
- ```
2147
- src/
2148
- ├── __tests__/
2149
- │ └── setup.ts # Global test setup
2150
- └── server/
2151
- ├── routes/
2152
- │ └── auth/
2153
- │ └── __tests__/
2154
- │ ├── login.test.ts
2155
- │ ├── register.test.ts
2156
- │ └── ...
2157
- └── services/
2158
- └── __tests__/
2159
- ├── auth.service.test.ts
2160
- └── ...
2161
- ```
2162
-
2163
- ---
2164
-
2165
- ### Writing Tests
2166
-
2167
- ```typescript
2168
- import { describe, it, expect, beforeEach } from 'vitest';
2169
- import { loginService } from '@/server/services';
2170
-
2171
- describe('loginService', () =>
2172
- {
2173
- beforeEach(async () =>
2174
- {
2175
- // Setup test data
2176
- });
2177
-
2178
- it('should login with valid credentials', async () =>
2179
- {
2180
- const result = await loginService({
2181
- email: 'test@example.com',
2182
- password: 'password123',
2183
- publicKey: '...',
2184
- keyId: '...',
2185
- fingerprint: '...',
2186
- algorithm: 'ES256',
2187
- });
2188
-
2189
- expect(result.userId).toBeDefined();
2190
- });
2191
- });
2192
- ```
2193
-
2194
- ---
2195
-
2196
- ### Test Database
2197
-
2198
- **docker-compose.test.yml:**
2199
- ```yaml
2200
- services:
2201
- postgres-test:
2202
- image: postgres:16-alpine
2203
- environment:
2204
- POSTGRES_DB: spfn_auth_test
2205
- POSTGRES_USER: spfn
2206
- POSTGRES_PASSWORD: spfn_dev_password
2207
- ports:
2208
- - "5433:5432"
2209
- ```
2210
-
2211
- **Test env variables:**
2212
- ```bash
2213
- DATABASE_URL=postgresql://spfn:spfn_dev_password@localhost:5433/spfn_auth_test
2214
- ```
2215
-
2216
- ---
2217
-
2218
- ## Development Workflow
2219
-
2220
- ### Initial Setup
2221
-
2222
- ```bash
2223
- # Install dependencies
2224
- pnpm install
2225
-
2226
- # Generate migrations
2227
- pnpm db:generate
2228
-
2229
- # Build package
2230
- pnpm build
2231
- ```
2232
-
2233
- ---
2234
-
2235
- ### Development
2236
-
2237
- ```bash
2238
- # Watch mode (auto-rebuild on changes)
2239
- pnpm dev
2240
-
2241
- # Type checking
2242
- pnpm type-check
2243
-
2244
- # Run tests
2245
- pnpm test
2246
- ```
2247
-
2248
- ---
2249
-
2250
- ### Build Process
2251
-
2252
- The package uses `tsup` for building:
2253
-
2254
- **tsup.config.ts:**
2255
- ```typescript
2256
- export default defineConfig({
2257
- entry: {
2258
- index: 'src/index.ts',
2259
- server: 'src/server.ts',
2260
- client: 'src/client.ts',
2261
- // ... more entry points
2262
- },
2263
- format: ['esm'],
2264
- dts: true,
2265
- clean: true,
2266
- sourcemap: true,
2267
- });
2268
- ```
2269
-
2270
- **Build outputs:**
2271
- - `dist/index.js` + `dist/index.d.ts`
2272
- - `dist/server.js` + `dist/server.d.ts`
2273
- - `dist/client.js` + `dist/client.d.ts`
2274
- - `dist/config/`, `dist/errors/`, `dist/nextjs/`
2275
-
2276
- ---
2277
-
2278
- ### Database Migrations
2279
-
2280
- ```bash
2281
- # Generate new migration (after entity changes)
2282
- pnpm db:generate
2283
-
2284
- # Apply migrations (via SPFN CLI)
2285
- cd ../../
2286
- pnpm spfn db migrate
2287
-
2288
- # View database
2289
- pnpm spfn db studio
2290
- ```
2291
-
2292
- **Migration files:** `migrations/*.sql`
2293
-
2294
- ---
2295
-
2296
- ### SPFN Plugin Integration
2297
-
2298
- **package.json:**
2299
- ```json
2300
- {
2301
- "spfn": {
2302
- "schemas": ["./dist/server/entities/*.js"],
2303
- "routes": {
2304
- "basePath": "/_auth",
2305
- "dir": "./dist/server/routes"
2306
- },
2307
- "migrations": {
2308
- "dir": "./migrations"
2309
- }
2310
- }
2311
- }
2312
- ```
2313
-
2314
- **How it works:**
2315
- 1. SPFN CLI discovers packages with `spfn` field
2316
- 2. Auto-loads database schemas
2317
- 3. Auto-registers routes at `basePath`
2318
- 4. Includes migrations in `db migrate` command
2319
-
2320
- ---
2321
-
2322
- ### Code Style
2323
-
2324
- Follow the project's code style (see `/Users/launchscreen/PROJECTS/SPFN/workspaces/.claude/rules.md`):
2325
-
2326
- - **Brace placement:** Next line (Allman-style)
2327
- - **Indentation:** 4 spaces
2328
- - **Semicolons:** Always
2329
- - **Type assertions:** Use `as`, not `<>`
2330
-
2331
- **Example:**
2332
- ```typescript
2333
- export async function myFunction(): Promise<void>
2334
- {
2335
- if (condition)
2336
- {
2337
- await operation();
2338
- }
2339
- else
2340
- {
2341
- handleError();
2342
- }
2343
- }
2344
- ```
2345
-
2346
- ---
2347
-
2348
- ### Environment Variables
2349
-
2350
- **Server-side:**
2351
- ```bash
2352
- # Required
2353
- SPFN_AUTH_JWT_SECRET=your-secret-key
2354
- DATABASE_URL=postgresql://...
2355
-
2356
- # Optional
2357
- SPFN_AUTH_JWT_EXPIRES_IN=7d
2358
- SPFN_AUTH_BCRYPT_SALT_ROUNDS=10
2359
- SPFN_AUTH_VERIFICATION_TOKEN_SECRET=separate-secret
2360
- ```
2361
-
2362
- **Next.js adapter:**
2363
- ```bash
2364
- # Required
2365
- SPFN_AUTH_SESSION_SECRET=your-32-char-secret
2366
-
2367
- # Optional
2368
- SPFN_AUTH_SESSION_TTL=7d
2369
- SPFN_API_URL=http://localhost:8790
2370
- ```
2371
-
2372
- ---
2373
-
2374
- ### Debugging
2375
-
2376
- **Enable logging:**
2377
- ```typescript
2378
- import { serverLogger } from '@/server/logger';
2379
-
2380
- serverLogger.info('Debug message', { context });
2381
- serverLogger.error('Error occurred', error);
2382
- ```
2383
-
2384
- **Inspect database:**
2385
- ```bash
2386
- pnpm spfn db studio
2387
- ```
2388
-
2389
- **Check migrations:**
2390
- ```bash
2391
- ls migrations/
2392
- ```
2393
-
2394
- ---
2395
-
2396
- ## Known Issues
2397
-
2398
- ### 1. Client Crypto Functions Missing
2399
-
2400
- **Issue:** README documents `generateKeyPair` and `generateClientToken` in `@spfn/auth/client`, but they only exist in `@spfn/auth/server`.
2401
-
2402
- **Workaround:** Use server-side crypto functions or implement client-side crypto separately.
2403
-
2404
- **Status:** Needs design decision - keep server-only or implement browser-compatible version.
2405
-
2406
- ---
2407
-
2408
- ### 2. Next.js Proxy Route Not Implemented
2409
-
2410
- **Issue:** Documentation mentions `@spfn/auth/nextjs/proxy` for client-side API proxying, but it doesn't exist.
2411
-
2412
- **Status:** Feature planned but not implemented. Current alternative: use server-side `createAuthInterceptor`.
2413
-
2414
- ---
2415
-
2416
- ### 3. `lib/api` Client Functions Removed
2417
-
2418
- **Issue:** Old `src/lib/api/` directory was deleted during refactoring.
2419
-
2420
- **Status:** Intentional removal. Use services or HTTP routes directly.
2421
-
2422
- ---
2423
-
2424
- ### 4. Test Coverage Below Target
2425
-
2426
- **Current:** ~83%
2427
- **Target:** 90%+
2428
-
2429
- **Areas needing tests:**
2430
- - Invitation service edge cases
2431
- - RBAC permission checks
2432
- - Key rotation scenarios
2433
- - Session expiry handling
2434
-
2435
- ---
2436
-
2437
- ## Roadmap
2438
-
2439
- ### Short-term (Alpha → Beta)
2440
-
2441
- - [ ] **Client-side crypto** - Browser-compatible key generation
2442
- - [ ] **Next.js proxy route** - Implement or remove from docs
2443
- - [x] **High-level authApi** - Simplified Next.js auth functions (implemented in `@spfn/auth`)
2444
- - [ ] **Test coverage** - Reach 90%+ coverage
2445
- - [x] **Documentation** - Sync docs with actual code
2446
-
2447
- ---
2448
-
2449
- ### Mid-term (Beta → v1.0)
2450
-
2451
- - [ ] **React hooks** - useAuth, useSession, usePermissions
2452
- - [ ] **UI components** - LoginForm, RegisterForm, AuthProvider
2453
- - [x] **OAuth integration** - Google (implemented), GitHub/Kakao/Naver (planned)
2454
- - [ ] **2FA support** - TOTP/authenticator apps
2455
- - [ ] **Password reset flow** - Complete email-based reset
2456
- - [ ] **Email change flow** - Verification for email updates
2457
- - [ ] **Phone change flow** - SMS verification for phone updates
2458
-
2459
- ---
2460
-
2461
- ### Long-term (Post v1.0)
2462
-
2463
- - [ ] **Admin UI** - User/role/permission management dashboard
2464
- - [ ] **Audit logging** - Track auth events
2465
- - [ ] **Rate limiting** - Built-in protection against brute force
2466
- - [ ] **Multi-tenancy** - Organization/workspace support
2467
- - [ ] **SSO integration** - SAML, OIDC
2468
- - [ ] **Biometric auth** - WebAuthn/FIDO2 support
2469
-
2470
- ---
2471
-
2472
- ## Contributing
2473
-
2474
- ### Before Contributing
2475
-
2476
- 1. Read this documentation thoroughly
2477
- 2. Check existing issues/PRs
2478
- 3. Understand the architecture
2479
- 4. Follow code style guidelines
2480
-
2481
- ---
2482
-
2483
- ### Pull Request Process
2484
-
2485
- 1. **Create feature branch**
2486
- ```bash
2487
- git checkout -b feature/my-feature
2488
- ```
2489
-
2490
- 2. **Make changes**
2491
- - Follow code style
2492
- - Add tests
2493
- - Update docs if needed
2494
-
2495
- 3. **Run checks**
2496
- ```bash
2497
- pnpm type-check
2498
- pnpm test
2499
- pnpm build
2500
- ```
2501
-
2502
- 4. **Commit with conventional commits**
2503
- ```bash
2504
- git commit -m "feat(auth): add password strength validation"
2505
- ```
2506
-
2507
- 5. **Push and create PR**
2508
- ```bash
2509
- git push origin feature/my-feature
2510
- ```
2511
-
2512
- ---
2513
-
2514
- ### Commit Message Format
2515
-
2516
- ```
2517
- <type>(<scope>): <subject>
2518
-
2519
- <body>
2520
-
2521
- <footer>
2522
- ```
2523
-
2524
- **Types:**
2525
- - `feat` - New feature
2526
- - `fix` - Bug fix
2527
- - `refactor` - Code refactoring
2528
- - `test` - Test changes
2529
- - `docs` - Documentation
2530
- - `chore` - Maintenance
2531
-
2532
- **Example:**
2533
- ```
2534
- feat(rbac): add permission inheritance
2535
-
2536
- Implement hierarchical permission inheritance where child roles
2537
- automatically inherit parent role permissions.
2538
-
2539
- Closes #123
2540
- ```
2541
-
2542
- ---
2543
-
2544
- ## Release Process
2545
-
2546
- ### Version Naming
2547
-
2548
- - `0.1.0-alpha.x` - Alpha releases (current)
2549
- - `0.1.0-beta.x` - Beta releases
2550
- - `1.0.0` - Stable release
2551
-
2552
- ---
2553
-
2554
- ### Publishing
2555
-
2556
- ```bash
2557
- # Alpha release
2558
- pnpm run publish:alpha
2559
-
2560
- # Beta release
2561
- pnpm run publish:beta
1284
+ import { createRpcProxy } from '@spfn/core/nextjs/server';
1285
+ import { authRouteMap } from '@spfn/auth';
1286
+ import { routeMap } from '@/generated/route-map';
1287
+ export const { GET, POST } = createRpcProxy({ routeMap: { ...routeMap, ...authRouteMap } });
2562
1288
 
2563
- # Production release
2564
- pnpm run publish:latest
1289
+ // any client component
1290
+ import { authApi } from '@spfn/auth';
1291
+ const session = await authApi.getAuthSession.call({});
2565
1292
  ```
2566
1293
 
2567
- **Pre-publish checklist:**
2568
- - [ ] All tests pass
2569
- - [ ] Type checking passes
2570
- - [ ] Build succeeds
2571
- - [ ] CHANGELOG updated
2572
- - [ ] Version bumped
2573
- - [ ] Docs updated
2574
-
2575
- ---
2576
-
2577
- ## Support
2578
-
2579
- ### Internal Team
2580
-
2581
- - **Issues:** GitHub Issues
2582
- - **Discussions:** GitHub Discussions
2583
- - **Slack:** #spfn-auth channel
2584
-
2585
- ---
2586
-
2587
- ## License
2588
-
2589
- MIT License - See LICENSE file for details.
2590
-
2591
- ---
1294
+ ## Related
2592
1295
 
2593
- **Last Updated:** 2026-02-23
2594
- **Document Version:** 2.6.0 (Technical Documentation)
2595
- **Package Version:** 0.2.0-beta.15
1296
+ - [`@spfn/core`](../core/README.md) — route DSL (`route`, `defineRouter`), `createApi`, env
1297
+ (`@spfn/core/env`), errors (`ErrorRegistry`), db (`Transactional`), events, jobs.
1298
+ - [`@spfn/mcp`](../mcp/README.md) — exposes operations as MCP tools, so the operator half of
1299
+ this package needs no admin dashboard.
1300
+ - `@spfn/notification` — email/SMS/push (verification codes, invitation emails).
1301
+ - Full guide: `docs/guides/authentication.md`.