@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.
- package/LICENSE +1 -1
- package/README.md +1091 -2385
- package/dist/authenticate-55LeXHqZ.d.ts +1447 -0
- package/dist/client-proof.d.ts +606 -0
- package/dist/client-proof.js +1842 -0
- package/dist/client-proof.js.map +1 -0
- package/dist/config.d.ts +319 -3
- package/dist/config.js +155 -5
- package/dist/config.js.map +1 -1
- package/dist/crypto.d.ts +61 -0
- package/dist/crypto.js +108 -0
- package/dist/crypto.js.map +1 -0
- package/dist/errors.d.ts +180 -3
- package/dist/errors.js +116 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +166 -18
- package/dist/index.js +132 -8
- package/dist/index.js.map +1 -1
- package/dist/nextjs/api.js +404 -96
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/server.d.ts +5 -4
- package/dist/nextjs/server.js +165 -26
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +2356 -1053
- package/dist/server.js +5153 -736
- package/dist/server.js.map +1 -1
- package/dist/session-DTHahDQ9.d.ts +53 -0
- package/dist/types-DYyhze28.d.ts +98 -0
- package/dist/wire-version-CtzMKvBB.d.ts +134 -0
- package/migrations/20251125021229_premium_famine/snapshot.json +2641 -0
- package/migrations/20260225130050_smooth_the_fury/snapshot.json +2686 -0
- package/migrations/20260308141417_deep_iceman/snapshot.json +2686 -0
- package/migrations/20260308151309_perfect_deathbird/snapshot.json +2731 -0
- package/migrations/20260308201135_concerned_rawhide_kid/snapshot.json +2786 -0
- package/migrations/20260629103209_lethal_lifeguard/migration.sql +32 -0
- package/migrations/20260629103209_lethal_lifeguard/snapshot.json +2786 -0
- package/migrations/20260709073531_easy_hardball/migration.sql +24 -0
- package/migrations/20260709073531_easy_hardball/snapshot.json +3119 -0
- package/migrations/20260714081434_glossy_major_mapleleaf/migration.sql +1 -0
- package/migrations/20260714081434_glossy_major_mapleleaf/snapshot.json +3112 -0
- package/migrations/20260804105939_amazing_bushwacker/migration.sql +3 -0
- package/migrations/20260804105939_amazing_bushwacker/snapshot.json +3112 -0
- package/migrations/20260804110033_fat_piledriver/migration.sql +2 -0
- package/migrations/20260804110033_fat_piledriver/snapshot.json +3138 -0
- package/migrations/20260805143152_vengeful_ravenous/migration.sql +4 -0
- package/migrations/20260805143152_vengeful_ravenous/snapshot.json +3190 -0
- package/migrations/20260807145911_mixed_invisible_woman/migration.sql +11 -0
- package/migrations/20260807145911_mixed_invisible_woman/snapshot.json +3334 -0
- package/package.json +59 -40
- package/dist/authenticate-eucncHxN.d.ts +0 -940
- package/migrations/meta/0000_snapshot.json +0 -1632
- package/migrations/meta/0001_snapshot.json +0 -1660
- package/migrations/meta/0002_snapshot.json +0 -1660
- package/migrations/meta/0003_snapshot.json +0 -1689
- package/migrations/meta/0004_snapshot.json +0 -1721
- package/migrations/meta/_journal.json +0 -41
- /package/migrations/{0000_premium_famine.sql → 20251125021229_premium_famine/migration.sql} +0 -0
- /package/migrations/{0001_smooth_the_fury.sql → 20260225130050_smooth_the_fury/migration.sql} +0 -0
- /package/migrations/{0002_deep_iceman.sql → 20260308141417_deep_iceman/migration.sql} +0 -0
- /package/migrations/{0003_perfect_deathbird.sql → 20260308151309_perfect_deathbird/migration.sql} +0 -0
- /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
|
|
1
|
+
# @spfn/auth
|
|
2
2
|
|
|
3
|
-
**
|
|
4
|
-
**Status:** Alpha - Internal Development
|
|
3
|
+
> **Two applications' worth of auth, in one package**
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
Nothing ships until people can sign in. `@spfn/auth` clears that gate twice over — once
|
|
6
|
+
for the people who use your product, and once for the people who operate it.
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
- **For your users** — registration, password and OTP login, social sign-in, sessions,
|
|
9
|
+
registered devices, and account deletion with a recovery window.
|
|
10
|
+
- **For your operators** — admin accounts seeded from the environment, roles and
|
|
11
|
+
permissions enforced on every route, invitations, and role administration your
|
|
12
|
+
superadmins can change at runtime.
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
The second half is what usually becomes a second application: an admin dashboard with its
|
|
15
|
+
own auth, its own screens, and its own maintenance, growing for as long as the product
|
|
16
|
+
does. Attach [`@spfn/mcp`](../mcp/README.md) instead and those operations become tools an
|
|
17
|
+
AI agent runs, gated by the same roles — see
|
|
18
|
+
[Can I operate the app without building an admin dashboard?](#can-i-operate-the-app-without-building-an-admin-dashboard).
|
|
12
19
|
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
- [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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
32
|
+
## Import paths
|
|
46
33
|
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
+
## How do I add auth to an SPFN app?
|
|
58
53
|
|
|
59
|
-
|
|
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
|
-
###
|
|
56
|
+
### 1. Lifecycle — `server.config.ts`
|
|
64
57
|
|
|
65
|
-
|
|
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())
|
|
69
|
+
.lifecycle(createAuthLifecycle())
|
|
77
70
|
.build();
|
|
78
71
|
```
|
|
79
72
|
|
|
80
|
-
|
|
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
|
-
|
|
91
|
-
// ... your other routes
|
|
85
|
+
// ...your routes
|
|
92
86
|
})
|
|
93
|
-
.packages([authRouter])
|
|
94
|
-
.use([authenticate]);
|
|
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
|
-
|
|
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
|
-
|
|
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';
|
|
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({
|
|
105
|
+
export const { GET, POST } = createRpcProxy({ routeMap: { ...routeMap, ...authRouteMap } });
|
|
113
106
|
```
|
|
114
107
|
|
|
115
|
-
|
|
108
|
+
### 4. Run migrations
|
|
116
109
|
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
DATABASE_URL
|
|
141
|
-
SPFN_AUTH_VERIFICATION_TOKEN_SECRET
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
Choose one of the following methods:
|
|
318
|
+
## OAuth
|
|
188
319
|
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
303
|
-
|
|
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
|
-
|
|
308
|
-
|
|
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
|
-
|
|
414
|
+
### OAuth callback origin (web app host + rewrite)
|
|
354
415
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
```
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
538
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
717
|
-
|
|
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
|
-
```
|
|
732
|
-
|
|
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
|
-
|
|
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
|
-
|
|
654
|
+
**Integration contract for custom providers:**
|
|
761
655
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
665
|
+
## How do I read the session in a Next.js page?
|
|
777
666
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
681
|
+
```tsx
|
|
682
|
+
import { RequireAuth, RequireRole } from '@spfn/auth/nextjs/server';
|
|
683
|
+
|
|
684
|
+
export default async function AdminPage()
|
|
826
685
|
{
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
##
|
|
699
|
+
## How do I define roles and permissions?
|
|
838
700
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
879
|
-
|
|
880
|
-
{
|
|
881
|
-
|
|
882
|
-
|
|
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
|
-
|
|
754
|
+
validateToken: async (token, resource) => verifyAccessToken(token, resource),
|
|
890
755
|
|
|
891
|
-
|
|
756
|
+
resolveContext: async (auth) => ({
|
|
757
|
+
userId: auth.userId,
|
|
758
|
+
role: await getUserRole(auth.userId),
|
|
759
|
+
}),
|
|
892
760
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
903
|
-
|
|
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
|
-
|
|
777
|
+
## Events
|
|
913
778
|
|
|
914
|
-
|
|
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
|
-
|
|
927
|
-
```typescript
|
|
785
|
+
authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
|
|
928
786
|
{
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
|
|
797
|
+
## Registration gate (`beforeRegister`)
|
|
937
798
|
|
|
938
|
-
|
|
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
|
-
|
|
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
|
-
|
|
956
|
-
|
|
957
|
-
{
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
986
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1037
|
+
const state = new ClientProofState({ publicKeys: { 'key-dev-0001': process.env.SPFN_CLIENT_PROOF_PUBLIC_KEY! } });
|
|
1038
|
+
const app = new Hono();
|
|
1027
1039
|
|
|
1028
|
-
|
|
1029
|
-
```typescript
|
|
1040
|
+
app.post('/v1/auth/client-proof/handshake', async (c) =>
|
|
1030
1041
|
{
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
-
|
|
1040
|
-
|
|
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
|
-
|
|
1066
|
+
## Account Deletion & Recovery
|
|
1050
1067
|
|
|
1051
|
-
|
|
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
|
-
|
|
1062
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1078
|
-
}
|
|
1079
|
-
```
|
|
1103
|
+
import { defineServerConfig } from '@spfn/core/server';
|
|
1104
|
+
import { createAuthLifecycle, authJobRouter } from '@spfn/auth/server';
|
|
1080
1105
|
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
{
|
|
1084
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2084
|
-
|
|
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
|
-
|
|
2564
|
-
|
|
1289
|
+
// any client component
|
|
1290
|
+
import { authApi } from '@spfn/auth';
|
|
1291
|
+
const session = await authApi.getAuthSession.call({});
|
|
2565
1292
|
```
|
|
2566
1293
|
|
|
2567
|
-
|
|
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
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
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`.
|