@spfn/auth 0.2.0-beta.64 → 0.2.0-beta.66
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/README.md +296 -2540
- package/dist/{authenticate-mfVRzeIK.d.ts → authenticate-DKGNvSsH.d.ts} +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/server.d.ts +87 -79
- package/dist/server.js +128 -47
- package/dist/server.js.map +1 -1
- package/package.json +7 -5
package/README.md
CHANGED
|
@@ -1,68 +1,43 @@
|
|
|
1
|
-
# @spfn/auth
|
|
1
|
+
# @spfn/auth — Authentication, OAuth, and RBAC for SPFN
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Asymmetric client-signed JWT auth (ES256/RS256), OTP verification, OAuth 2.0 (pluggable
|
|
4
|
+
provider registry, Google built-in), session cookies for Next.js, and runtime RBAC.
|
|
5
|
+
Routes are exposed under the `/_auth/*` namespace and reached through a type-safe `authApi`
|
|
6
|
+
client. Requires `@spfn/core`; Next.js is an optional peer (`^15 || ^16`).
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
> For user-facing documentation, see [SPFN Documentation](https://spfn.dev/docs).
|
|
8
|
+
## Install
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
- [Overview](#overview)
|
|
14
|
-
- [Installation](#installation)
|
|
15
|
-
- [Admin Account Setup](#6-admin-account-setup)
|
|
16
|
-
- [Architecture](#architecture)
|
|
17
|
-
- [Package Structure](#package-structure)
|
|
18
|
-
- [Module Exports](#module-exports)
|
|
19
|
-
- [Email & SMS Services](#email--sms-services)
|
|
20
|
-
- [Server-Side API](#server-side-api)
|
|
21
|
-
- [Events](#events)
|
|
22
|
-
- [OAuth Authentication](#oauth-authentication)
|
|
23
|
-
- [Database Schema](#database-schema)
|
|
24
|
-
- [RBAC System](#rbac-system)
|
|
25
|
-
- [Next.js Adapter](#nextjs-adapter)
|
|
26
|
-
- [Testing](#testing)
|
|
27
|
-
- [Development Workflow](#development-workflow)
|
|
28
|
-
- [Known Issues](#known-issues)
|
|
29
|
-
- [Roadmap](#roadmap)
|
|
30
|
-
|
|
31
|
-
---
|
|
32
|
-
|
|
33
|
-
## Overview
|
|
34
|
-
|
|
35
|
-
`@spfn/auth` is an authentication and authorization package for the SPFN framework, providing:
|
|
36
|
-
|
|
37
|
-
- **Asymmetric JWT Authentication** - Client-signed tokens using ES256/RS256
|
|
38
|
-
- **User Management** - Email/phone-based identity with bcrypt hashing
|
|
39
|
-
- **OAuth Authentication** - Google OAuth 2.0 (Authorization Code Flow), extensible to other providers
|
|
40
|
-
- **Multi-Factor Authentication** - OTP verification via email/SMS
|
|
41
|
-
- **Session Management** - Public key rotation with 90-day expiry
|
|
42
|
-
- **Role-Based Access Control** - Flexible RBAC with runtime role/permission management
|
|
43
|
-
- **Next.js Integration** - Session helpers, server-side guards, and OAuth interceptors
|
|
10
|
+
```bash
|
|
11
|
+
pnpm add @spfn/auth
|
|
12
|
+
```
|
|
44
13
|
|
|
45
|
-
|
|
14
|
+
## Import paths
|
|
46
15
|
|
|
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
|
|
16
|
+
Five entry points (from `package.json` `exports`). Picking the wrong one breaks the build —
|
|
17
|
+
`/server` and `/nextjs/*` pull in Node/`server-only` code and must never reach the browser bundle.
|
|
52
18
|
|
|
53
|
-
|
|
19
|
+
```typescript
|
|
20
|
+
import { authApi, authRouteMap } from '@spfn/auth'; // isomorphic: client + route map + types/constants
|
|
21
|
+
import { authRouter, authenticate } from '@spfn/auth/server'; // SERVER ONLY: router, services, repos, middleware, helpers
|
|
22
|
+
import { /* hooks/components */ } from '@spfn/auth/client'; // browser only (currently empty — WIP)
|
|
23
|
+
import { env, envSchema } from '@spfn/auth/config'; // validated env proxy + schema
|
|
24
|
+
import { InvalidCredentialsError } from '@spfn/auth/errors'; // error classes + authErrorRegistry
|
|
25
|
+
import '@spfn/auth/nextjs/api'; // SERVER: auto-registers RPC interceptors (side-effect)
|
|
26
|
+
import { RequireAuth, getSession } from '@spfn/auth/nextjs/server'; // SERVER: RSC guards, session helpers, OAuth handler
|
|
27
|
+
import { OAuthCallback } from '@spfn/auth/nextjs/client'; // 'use client' OAuth callback component
|
|
28
|
+
```
|
|
54
29
|
|
|
55
|
-
|
|
30
|
+
> Database entities (`users`, `userPublicKeys`, …) and all services/repositories are exported
|
|
31
|
+
> from `@spfn/auth/server`, **not** from the root `@spfn/auth`.
|
|
56
32
|
|
|
57
|
-
|
|
33
|
+
## Setup (4 wiring points)
|
|
58
34
|
|
|
59
|
-
|
|
60
|
-
pnpm add @spfn/auth
|
|
61
|
-
```
|
|
35
|
+
Auth needs four edits in the consuming app. All four are required for the flow to work end to end.
|
|
62
36
|
|
|
63
|
-
###
|
|
37
|
+
### 1. Lifecycle — `server.config.ts`
|
|
64
38
|
|
|
65
|
-
|
|
39
|
+
`createAuthLifecycle()` validates env before DB connect, then seeds admin accounts and
|
|
40
|
+
initializes RBAC after the DB is ready. Pass custom roles/permissions here (see RBAC below).
|
|
66
41
|
|
|
67
42
|
```typescript
|
|
68
43
|
import { defineServerConfig } from '@spfn/core/server';
|
|
@@ -71,2583 +46,364 @@ import { appRouter } from './router';
|
|
|
71
46
|
|
|
72
47
|
export default defineServerConfig()
|
|
73
48
|
.port(8790)
|
|
74
|
-
.host('0.0.0.0')
|
|
75
49
|
.routes(appRouter)
|
|
76
|
-
.lifecycle(createAuthLifecycle())
|
|
50
|
+
.lifecycle(createAuthLifecycle())
|
|
77
51
|
.build();
|
|
78
52
|
```
|
|
79
53
|
|
|
80
|
-
|
|
54
|
+
### 2. Router + global middleware — `router.ts`
|
|
55
|
+
|
|
56
|
+
`authRouter` (the package's `mainAuthRouter`) is merged via `.packages()`; `authenticate` is
|
|
57
|
+
applied globally via `.use()`. Public routes opt out per-route with `.skip(['auth'])`.
|
|
81
58
|
|
|
82
59
|
```typescript
|
|
83
60
|
import { defineRouter } from '@spfn/core/route';
|
|
84
61
|
import { authRouter, authenticate } from '@spfn/auth/server';
|
|
85
62
|
import { getHealth } from './routes/health';
|
|
86
|
-
import { createOrder } from './routes/orders';
|
|
87
63
|
|
|
88
64
|
export const appRouter = defineRouter({
|
|
89
65
|
getHealth,
|
|
90
|
-
|
|
91
|
-
// ... your other routes
|
|
66
|
+
// ...your routes
|
|
92
67
|
})
|
|
93
|
-
.packages([authRouter])
|
|
94
|
-
.use([authenticate]);
|
|
68
|
+
.packages([authRouter]) // mounts /_auth/* and exposes routes on authApi
|
|
69
|
+
.use([authenticate]); // global auth middleware
|
|
95
70
|
|
|
96
71
|
export type AppRouter = typeof appRouter;
|
|
97
72
|
```
|
|
98
73
|
|
|
99
|
-
|
|
100
|
-
> See the [Authentication Guide](https://spfn.dev/docs/guides/authentication) for details.
|
|
101
|
-
|
|
102
|
-
### 3. Configure Next.js Interceptor
|
|
74
|
+
### 3. Next.js interceptor — RPC proxy route
|
|
103
75
|
|
|
104
|
-
|
|
76
|
+
The interceptor handles session cookies, JWT signing, and key management automatically.
|
|
77
|
+
Import it for its side-effect (it self-registers); it must run before the proxy is created.
|
|
105
78
|
|
|
106
79
|
```typescript
|
|
107
80
|
// app/api/rpc/[routeName]/route.ts
|
|
108
|
-
import '@spfn/auth/nextjs/api';
|
|
81
|
+
import '@spfn/auth/nextjs/api'; // side-effect: registers auth interceptors
|
|
109
82
|
import { appRouter } from '@/server/router';
|
|
110
83
|
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
111
84
|
|
|
112
85
|
export const { GET, POST } = createRpcProxy({ router: appRouter });
|
|
113
86
|
```
|
|
114
87
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
```typescript
|
|
118
|
-
// src/lib/api-client.ts
|
|
119
|
-
import { createApi } from '@spfn/core/nextjs';
|
|
120
|
-
import type { AppRouter } from '@/server/router';
|
|
121
|
-
|
|
122
|
-
export const api = createApi<AppRouter>();
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
The built-in `authApi` is also available for auth-only calls:
|
|
126
|
-
|
|
127
|
-
```typescript
|
|
128
|
-
import { authApi } from '@spfn/auth';
|
|
129
|
-
const session = await authApi.getAuthSession.call({});
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
### 4. Environment Variables
|
|
133
|
-
|
|
134
|
-
Auth requires variables in **two separate files**: `.env.server` (SPFN backend) and `.env.local` (Next.js).
|
|
135
|
-
|
|
136
|
-
#### `.env.server` (SPFN Backend)
|
|
137
|
-
|
|
138
|
-
```bash
|
|
139
|
-
# Required
|
|
140
|
-
DATABASE_URL=postgresql://user:pass@localhost:5432/myapp_dev
|
|
141
|
-
SPFN_AUTH_VERIFICATION_TOKEN_SECRET=your-verification-secret
|
|
142
|
-
|
|
143
|
-
# Admin account (required — at least one format)
|
|
144
|
-
SPFN_AUTH_ADMIN_ACCOUNTS='[{"email":"admin@example.com","password":"Admin!@34","role":"superadmin"}]'
|
|
145
|
-
|
|
146
|
-
# Optional
|
|
147
|
-
SPFN_AUTH_JWT_SECRET=your-jwt-secret
|
|
148
|
-
SPFN_AUTH_JWT_EXPIRES_IN=7d
|
|
149
|
-
SPFN_AUTH_BCRYPT_SALT_ROUNDS=10
|
|
150
|
-
SPFN_AUTH_SESSION_TTL=7d
|
|
151
|
-
|
|
152
|
-
# Google OAuth (optional)
|
|
153
|
-
SPFN_AUTH_GOOGLE_CLIENT_ID=123456789-abc.apps.googleusercontent.com
|
|
154
|
-
SPFN_AUTH_GOOGLE_CLIENT_SECRET=GOCSPX-...
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
#### `.env.local` (Next.js)
|
|
158
|
-
|
|
159
|
-
```bash
|
|
160
|
-
# Required
|
|
161
|
-
DATABASE_URL=postgresql://user:pass@localhost:5432/myapp_dev
|
|
162
|
-
SPFN_API_URL=http://localhost:8790
|
|
163
|
-
|
|
164
|
-
# Required for session cookies (minimum 32 characters)
|
|
165
|
-
SPFN_AUTH_SESSION_SECRET=my-super-secret-session-key-at-least-32-chars-long
|
|
166
|
-
|
|
167
|
-
# Optional
|
|
168
|
-
SPFN_AUTH_SESSION_TTL=7d
|
|
169
|
-
|
|
170
|
-
# Email/SMS — configure via @spfn/notification
|
|
171
|
-
# See @spfn/notification README for AWS SES/SNS settings
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
### 5. Run Migrations
|
|
88
|
+
### 4. Run migrations
|
|
175
89
|
|
|
176
90
|
```bash
|
|
177
|
-
#
|
|
178
|
-
pnpm spfn db generate
|
|
179
|
-
|
|
180
|
-
# Run migrations
|
|
91
|
+
pnpm spfn db generate # only if entities changed
|
|
181
92
|
pnpm spfn db migrate
|
|
182
93
|
```
|
|
183
94
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
Admin accounts are automatically created on server startup via `createAuthLifecycle()`.
|
|
187
|
-
Choose one of the following methods:
|
|
188
|
-
|
|
189
|
-
#### Method 1: JSON Format (Recommended)
|
|
190
|
-
|
|
191
|
-
Best for multiple accounts with full configuration:
|
|
192
|
-
|
|
193
|
-
```bash
|
|
194
|
-
SPFN_AUTH_ADMIN_ACCOUNTS='[
|
|
195
|
-
{"email": "superadmin@example.com", "password": "secure-pass-1", "role": "superadmin"},
|
|
196
|
-
{"email": "admin@example.com", "password": "secure-pass-2", "role": "admin"},
|
|
197
|
-
{"email": "manager@example.com", "password": "secure-pass-3", "role": "user"}
|
|
198
|
-
]'
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
**JSON Schema:**
|
|
202
|
-
```typescript
|
|
203
|
-
interface AdminAccountConfig {
|
|
204
|
-
email: string; // Required
|
|
205
|
-
password: string; // Required
|
|
206
|
-
role?: string; // Default: 'user' (options: 'user', 'admin', 'superadmin')
|
|
207
|
-
phone?: string; // Optional
|
|
208
|
-
passwordChangeRequired?: boolean; // Default: true
|
|
209
|
-
}
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
#### Method 2: CSV Format
|
|
213
|
-
|
|
214
|
-
For multiple accounts with simpler configuration:
|
|
215
|
-
|
|
216
|
-
```bash
|
|
217
|
-
SPFN_AUTH_ADMIN_EMAILS=admin@example.com,manager@example.com
|
|
218
|
-
SPFN_AUTH_ADMIN_PASSWORDS=admin-pass,manager-pass
|
|
219
|
-
SPFN_AUTH_ADMIN_ROLES=superadmin,admin
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
#### Method 3: Single Account (Legacy)
|
|
223
|
-
|
|
224
|
-
Simplest format for a single superadmin:
|
|
225
|
-
|
|
226
|
-
```bash
|
|
227
|
-
SPFN_AUTH_ADMIN_EMAIL=admin@example.com
|
|
228
|
-
SPFN_AUTH_ADMIN_PASSWORD=secure-password
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
> **Note:** This method always creates a `superadmin` role account.
|
|
232
|
-
|
|
233
|
-
#### Default Behavior
|
|
234
|
-
|
|
235
|
-
All admin accounts created via environment variables have:
|
|
236
|
-
- `emailVerifiedAt`: Auto-verified (current timestamp)
|
|
237
|
-
- `passwordChangeRequired`: `true` (must change on first login)
|
|
238
|
-
- `status`: `active`
|
|
239
|
-
|
|
240
|
-
#### Programmatic Creation
|
|
241
|
-
|
|
242
|
-
You can also create admin accounts programmatically:
|
|
243
|
-
|
|
244
|
-
```typescript
|
|
245
|
-
import { usersRepository, getRoleByName, hashPassword } from '@spfn/auth/server';
|
|
246
|
-
|
|
247
|
-
// After initializeAuth() has been called
|
|
248
|
-
const role = await getRoleByName('admin');
|
|
249
|
-
const passwordHash = await hashPassword('secure-password');
|
|
250
|
-
|
|
251
|
-
await usersRepository.create({
|
|
252
|
-
email: 'admin@example.com',
|
|
253
|
-
passwordHash,
|
|
254
|
-
roleId: role.id,
|
|
255
|
-
emailVerifiedAt: new Date(),
|
|
256
|
-
passwordChangeRequired: true,
|
|
257
|
-
status: 'active',
|
|
258
|
-
});
|
|
259
|
-
```
|
|
260
|
-
|
|
261
|
-
---
|
|
262
|
-
|
|
263
|
-
## Architecture
|
|
264
|
-
|
|
265
|
-
### High-Level Overview
|
|
266
|
-
|
|
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
|
-
```
|
|
284
|
-
|
|
285
|
-
### Module Separation
|
|
286
|
-
|
|
287
|
-
The package is split into three distinct entry points to ensure proper code separation:
|
|
288
|
-
|
|
289
|
-
1. **Common Module** (`@spfn/auth`)
|
|
290
|
-
- Database entities (users, roles, permissions)
|
|
291
|
-
- TypeScript types and interfaces
|
|
292
|
-
- RBAC type definitions
|
|
293
|
-
- Can be imported anywhere (server/client)
|
|
294
|
-
|
|
295
|
-
2. **Server Module** (`@spfn/auth/server`)
|
|
296
|
-
- Server-only code (marked with Node.js APIs)
|
|
297
|
-
- Routes, services, repositories
|
|
298
|
-
- Middleware, helpers (JWT, password)
|
|
299
|
-
- RBAC initialization
|
|
300
|
-
- **Never** import in client-side code
|
|
301
|
-
|
|
302
|
-
3. **Client Module** (`@spfn/auth/client`)
|
|
303
|
-
- Client-only code (React hooks, components)
|
|
304
|
-
- Currently in development (placeholders only)
|
|
305
|
-
- **Never** import in server-side code
|
|
306
|
-
|
|
307
|
-
4. **Next.js Adapter** (`@spfn/auth/nextjs/*`)
|
|
308
|
-
- Next.js-specific integrations
|
|
309
|
-
- `@spfn/auth/nextjs/api` - Interceptors for API routes
|
|
310
|
-
- `@spfn/auth/nextjs/server` - Server Components guards & session helpers
|
|
311
|
-
|
|
312
|
-
### Asymmetric JWT Flow
|
|
313
|
-
|
|
314
|
-
```
|
|
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
|
-
|
|
351
|
-
---
|
|
352
|
-
|
|
353
|
-
## Package Structure
|
|
354
|
-
|
|
355
|
-
```
|
|
356
|
-
packages/auth/
|
|
357
|
-
├── dist/ # Compiled output (tsup)
|
|
358
|
-
│ ├── index.js # Common exports
|
|
359
|
-
│ ├── index.d.ts
|
|
360
|
-
│ ├── server.js # Server exports
|
|
361
|
-
│ ├── server.d.ts
|
|
362
|
-
│ ├── client.js # Client exports (minimal)
|
|
363
|
-
│ ├── client.d.ts
|
|
364
|
-
│ ├── config/ # Configuration module
|
|
365
|
-
│ ├── errors/ # Error classes
|
|
366
|
-
│ ├── nextjs/ # Next.js adapter
|
|
367
|
-
│ └── server/ # Server implementation
|
|
368
|
-
│
|
|
369
|
-
├── migrations/ # Drizzle database migrations
|
|
370
|
-
│ └── *.sql
|
|
371
|
-
│
|
|
372
|
-
├── src/
|
|
373
|
-
│ ├── index.ts # Common entry point
|
|
374
|
-
│ ├── server.ts # Server entry point
|
|
375
|
-
│ ├── client.ts # Client entry point
|
|
376
|
-
│ │
|
|
377
|
-
│ ├── config/ # Configuration system
|
|
378
|
-
│ │ ├── index.ts
|
|
379
|
-
│ │ ├── schema.ts # Env var schema
|
|
380
|
-
│ │ └── types.ts
|
|
381
|
-
│ │
|
|
382
|
-
│ ├── errors/ # Error definitions
|
|
383
|
-
│ │ ├── index.ts
|
|
384
|
-
│ │ └── auth-errors.ts
|
|
385
|
-
│ │
|
|
386
|
-
│ ├── lib/ # Shared code
|
|
387
|
-
│ │ └── contracts/ # Typebox schemas
|
|
388
|
-
│ │
|
|
389
|
-
│ ├── server/ # Server-side implementation
|
|
390
|
-
│ │ ├── entities/ # Drizzle ORM entities
|
|
391
|
-
│ │ ├── services/ # Business logic layer
|
|
392
|
-
│ │ ├── repositories/ # Database access layer
|
|
393
|
-
│ │ ├── routes/ # HTTP route handlers
|
|
394
|
-
│ │ ├── middleware/ # Auth middleware
|
|
395
|
-
│ │ ├── helpers/ # JWT, password, context
|
|
396
|
-
│ │ ├── rbac/ # RBAC types and builtins
|
|
397
|
-
│ │ ├── lib/ # Server utilities
|
|
398
|
-
│ │ ├── lifecycle.ts # SPFN lifecycle hooks
|
|
399
|
-
│ │ ├── setup.ts # Initialization
|
|
400
|
-
│ │ ├── logger.ts # Logging
|
|
401
|
-
│ │ └── types.ts # Server types
|
|
402
|
-
│ │
|
|
403
|
-
│ ├── nextjs/ # Next.js adapter
|
|
404
|
-
│ │ ├── api.ts # Interceptor exports
|
|
405
|
-
│ │ ├── server.ts # Server Components guards
|
|
406
|
-
│ │ ├── session-helpers.ts# Session management
|
|
407
|
-
│ │ ├── interceptors/ # Request interceptors
|
|
408
|
-
│ │ └── guards/ # Auth guards
|
|
409
|
-
│ │
|
|
410
|
-
│ └── client/ # Client-side (WIP)
|
|
411
|
-
│ ├── hooks/ # React hooks (TODO)
|
|
412
|
-
│ ├── store/ # Zustand store (TODO)
|
|
413
|
-
│ └── components/ # UI components (TODO)
|
|
414
|
-
│
|
|
415
|
-
├── package.json # Package configuration + SPFN plugin config
|
|
416
|
-
├── tsup.config.ts # Build configuration
|
|
417
|
-
├── drizzle.config.ts # Database migration config
|
|
418
|
-
└── README.md # This file
|
|
419
|
-
```
|
|
420
|
-
|
|
421
|
-
### Layer Responsibilities
|
|
422
|
-
|
|
423
|
-
#### 1. **Routes Layer** (`src/server/routes/`)
|
|
424
|
-
- Thin HTTP handlers
|
|
425
|
-
- Request validation (Typebox)
|
|
426
|
-
- Delegates to services
|
|
427
|
-
- Returns responses
|
|
428
|
-
|
|
429
|
-
#### 2. **Services Layer** (`src/server/services/`)
|
|
430
|
-
- Business logic
|
|
431
|
-
- Transaction management
|
|
432
|
-
- Reusable functions
|
|
433
|
-
- Can be used outside of routes
|
|
434
|
-
|
|
435
|
-
#### 3. **Repositories Layer** (`src/server/repositories/`)
|
|
436
|
-
- Database access only
|
|
437
|
-
- CRUD operations
|
|
438
|
-
- No business logic
|
|
439
|
-
- Drizzle ORM queries
|
|
95
|
+
The API client needs no auth-specific config. `authApi` is also available standalone:
|
|
440
96
|
|
|
441
|
-
#### 4. **Helpers Layer** (`src/server/helpers/`)
|
|
442
|
-
- Utility functions (JWT, password hashing)
|
|
443
|
-
- Context accessors (getAuth, getUser)
|
|
444
|
-
- Stateless operations
|
|
445
|
-
|
|
446
|
-
---
|
|
447
|
-
|
|
448
|
-
## Module Exports
|
|
449
|
-
|
|
450
|
-
### Common Module (`@spfn/auth`)
|
|
451
|
-
|
|
452
|
-
**API Client:**
|
|
453
97
|
```typescript
|
|
454
98
|
import { authApi } from '@spfn/auth';
|
|
99
|
+
const session = await authApi.getAuthSession.call({}); // → GET /_auth/session
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Environment variables
|
|
103
|
+
|
|
104
|
+
Set across **two files** by audience. Server-only secrets go in `.env.server`; values the
|
|
105
|
+
Next.js runtime needs (session cookie crypto) go in `.env.local`. Names only below — supply
|
|
106
|
+
real secret values out of band, never commit them.
|
|
107
|
+
|
|
108
|
+
| Var | File | Required | Notes |
|
|
109
|
+
|-----|------|----------|-------|
|
|
110
|
+
| `DATABASE_URL` | both | yes | Postgres connection |
|
|
111
|
+
| `SPFN_AUTH_VERIFICATION_TOKEN_SECRET` | `.env.server` | yes | OTP / verification token signing |
|
|
112
|
+
| `SPFN_AUTH_SESSION_SECRET` | `.env.local` | yes | ≥32 chars, AES-256 session cookie encryption (validated: entropy/unique-char checks) |
|
|
113
|
+
| `SPFN_API_URL` | `.env.local` | — | default `http://localhost:8790` |
|
|
114
|
+
| `SPFN_AUTH_SESSION_TTL` | both | — | default `7d` (e.g. `7d`, `12h`, `45m`) |
|
|
115
|
+
| `SPFN_AUTH_JWT_SECRET` / `SPFN_AUTH_JWT_EXPIRES_IN` | `.env.server` | — | legacy server-signed JWT mode only |
|
|
116
|
+
| `SPFN_AUTH_BCRYPT_SALT_ROUNDS` | `.env.server` | — | default `10` |
|
|
117
|
+
| `SPFN_AUTH_COOKIE_SECURE` | both | — | override Secure flag (defaults to `NODE_ENV==='production'`) |
|
|
118
|
+
| `SPFN_AUTH_ADMIN_*` | `.env.server` | — | admin seeding (see below) |
|
|
119
|
+
| `SPFN_AUTH_GOOGLE_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | enables Google OAuth when both set |
|
|
120
|
+
| `SPFN_AUTH_GOOGLE_SCOPES` | `.env.server` | — | comma-separated; default `email,profile` |
|
|
121
|
+
| `SPFN_AUTH_GOOGLE_REDIRECT_URI` | `.env.server` | — | default `{NEXT_PUBLIC_SPFN_API_URL\|\|SPFN_API_URL}/_auth/oauth/google/callback` |
|
|
122
|
+
| `SPFN_AUTH_OAUTH_SUCCESS_URL` | `.env.server` | — | default `/auth/callback` |
|
|
123
|
+
| `SPFN_AUTH_OAUTH_ERROR_URL` | `.env.server` | — | default `/auth/error?error={error}` |
|
|
124
|
+
| `SPFN_AUTH_RESERVED_USERNAMES` / `_USERNAME_MIN_LENGTH` / `_USERNAME_MAX_LENGTH` | `.env.server` | — | username rules |
|
|
125
|
+
| `NEXT_PUBLIC_SPFN_API_URL` / `NEXT_PUBLIC_SPFN_APP_URL` | `.env.local` | — | browser-facing URLs for OAuth redirects |
|
|
126
|
+
|
|
127
|
+
Read validated values via `import { env } from '@spfn/auth/config'` (a proxy validated at
|
|
128
|
+
startup). `envSchema` (also exported as `authEnvSchema`) carries descriptions/defaults.
|
|
129
|
+
|
|
130
|
+
### Admin seeding
|
|
131
|
+
|
|
132
|
+
`createAuthLifecycle()` creates admin accounts on startup from env, in priority order. Seeded
|
|
133
|
+
accounts are auto email-verified, `status: 'active'`, `passwordChangeRequired: true`.
|
|
134
|
+
|
|
135
|
+
- **JSON (recommended):** `SPFN_AUTH_ADMIN_ACCOUNTS` — array of `{email, password, role?, phone?, passwordChangeRequired?}`. `role` defaults to `user` (`user` | `admin` | `superadmin`).
|
|
136
|
+
- **CSV:** `SPFN_AUTH_ADMIN_EMAILS` + `SPFN_AUTH_ADMIN_PASSWORDS` + `SPFN_AUTH_ADMIN_ROLES`.
|
|
137
|
+
- **Single (legacy):** `SPFN_AUTH_ADMIN_EMAIL` + `SPFN_AUTH_ADMIN_PASSWORD` → always `superadmin`.
|
|
138
|
+
|
|
139
|
+
## Routes
|
|
140
|
+
|
|
141
|
+
All routes mount at `/_auth/*` and are reached through `authApi.<name>.call({ body })`. Public
|
|
142
|
+
routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-signed-jwt>`.
|
|
143
|
+
|
|
144
|
+
| `authApi` method | HTTP | Auth | Purpose |
|
|
145
|
+
|------------------|------|------|---------|
|
|
146
|
+
| `checkAccountExists` | POST `/_auth/exists` | public | email/phone existence check |
|
|
147
|
+
| `sendVerificationCode` | POST `/_auth/codes` | public | send 6-digit OTP |
|
|
148
|
+
| `verifyCode` | POST `/_auth/codes/verify` | public | verify OTP → verification token |
|
|
149
|
+
| `register` | POST `/_auth/register` | public | create user + register public key |
|
|
150
|
+
| `login` | POST `/_auth/login` | public | password login + new session key |
|
|
151
|
+
| `logout` | POST `/_auth/logout` | yes | revoke current key |
|
|
152
|
+
| `rotateKey` | POST `/_auth/keys/rotate` | yes | rotate public key before 90-day expiry |
|
|
153
|
+
| `changePassword` | PUT `/_auth/password` | yes | change password |
|
|
154
|
+
| `getAuthSession` | GET `/_auth/session` | yes | current session/user |
|
|
155
|
+
| `issueOneTimeToken` | POST | yes | short-lived token (e.g. SSE handshake) |
|
|
156
|
+
| `checkUsername` / `updateUsername` / `updateLocale` | — | mixed | username availability/update, locale |
|
|
157
|
+
| `getUserProfile` / `updateUserProfile` | — | yes | profile read/update |
|
|
158
|
+
| `createInvitation` / `acceptInvitation` / `listInvitations` / `cancelInvitation` / `resendInvitation` / `deleteInvitation` / `getInvitation` | — | mixed | invitation flow |
|
|
159
|
+
| `listRoles` / `createAdminRole` / `updateAdminRole` / `deleteAdminRole` / `updateUserRole` | — | superadmin | admin RBAC management |
|
|
160
|
+
| OAuth routes | — | — | see OAuth section |
|
|
161
|
+
|
|
162
|
+
Auth uses **asymmetric, client-signed JWTs**: the client generates an ES256/RS256 keypair,
|
|
163
|
+
sends the public key on register/login, signs request JWTs locally, and the server verifies
|
|
164
|
+
with the stored public key (`keyId` carried in the JWT). The server never holds a private key.
|
|
165
|
+
Keys expire after 90 days — rotate with `rotateKey`.
|
|
166
|
+
|
|
167
|
+
### Writing protected routes (route DSL)
|
|
168
|
+
|
|
169
|
+
This is the current SPFN route DSL — `route.<method>().input().use().skip().handler()` registered
|
|
170
|
+
via `defineRouter`. Access auth state through the context helpers, not by reading raw context.
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
import { route } from '@spfn/core/route';
|
|
174
|
+
import { authenticate, requirePermissions, optionalAuth } from '@spfn/auth/server';
|
|
175
|
+
import { getAuth, getOptionalAuth } from '@spfn/auth/server';
|
|
176
|
+
|
|
177
|
+
// Protected (global `authenticate` already applies; helpers read the context)
|
|
178
|
+
export const getMe = route.get('/me')
|
|
179
|
+
.handler(async (c) =>
|
|
180
|
+
{
|
|
181
|
+
const { user, userId, role, locale } = getAuth(c);
|
|
182
|
+
return { id: userId, email: user.email, role };
|
|
183
|
+
});
|
|
455
184
|
|
|
456
|
-
//
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
});
|
|
461
|
-
```
|
|
462
|
-
|
|
463
|
-
**Types:**
|
|
464
|
-
```typescript
|
|
465
|
-
import type {
|
|
466
|
-
User,
|
|
467
|
-
UserPublicKey,
|
|
468
|
-
VerificationCode,
|
|
469
|
-
Role,
|
|
470
|
-
Permission,
|
|
471
|
-
AuthSession,
|
|
472
|
-
UserProfile,
|
|
473
|
-
ProfileInfo,
|
|
474
|
-
// ... etc
|
|
475
|
-
} from '@spfn/auth';
|
|
476
|
-
```
|
|
477
|
-
|
|
478
|
-
**RBAC:**
|
|
479
|
-
```typescript
|
|
480
|
-
import {
|
|
481
|
-
BUILTIN_ROLES,
|
|
482
|
-
BUILTIN_PERMISSIONS,
|
|
483
|
-
BUILTIN_ROLE_PERMISSIONS
|
|
484
|
-
} from '@spfn/auth';
|
|
485
|
-
|
|
486
|
-
import type {
|
|
487
|
-
RoleConfig,
|
|
488
|
-
PermissionConfig,
|
|
489
|
-
InitializeAuthOptions,
|
|
490
|
-
BuiltinRoleName,
|
|
491
|
-
BuiltinPermissionName
|
|
492
|
-
} from '@spfn/auth';
|
|
493
|
-
```
|
|
494
|
-
|
|
495
|
-
**Validation Patterns:**
|
|
496
|
-
```typescript
|
|
497
|
-
import {
|
|
498
|
-
UUID_PATTERN,
|
|
499
|
-
EMAIL_PATTERN,
|
|
500
|
-
BASE64_PATTERN,
|
|
501
|
-
FINGERPRINT_PATTERN,
|
|
502
|
-
PHONE_PATTERN,
|
|
503
|
-
} from '@spfn/auth';
|
|
504
|
-
```
|
|
505
|
-
|
|
506
|
-
**Route Map (for RPC Proxy):**
|
|
507
|
-
```typescript
|
|
508
|
-
import { authRouteMap } from '@spfn/auth';
|
|
509
|
-
|
|
510
|
-
// Use in Next.js RPC proxy (app/api/rpc/[routeName]/route.ts)
|
|
511
|
-
import '@spfn/auth/nextjs/api'; // Auto-register auth interceptors
|
|
512
|
-
import { routeMap } from '@/generated/route-map';
|
|
513
|
-
import { authRouteMap } from '@spfn/auth';
|
|
514
|
-
import { createRpcProxy } from '@spfn/core/nextjs/proxy';
|
|
185
|
+
// Permission-gated (all required); use requireAnyPermission for OR, requireRole for roles
|
|
186
|
+
export const deleteUser = route.delete('/users/:id')
|
|
187
|
+
.use([authenticate, requirePermissions('user:delete')])
|
|
188
|
+
.handler(async (c) => { /* ... */ });
|
|
515
189
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
190
|
+
// Public + optional user context. optionalAuth auto-skips global 'auth' — no .skip needed
|
|
191
|
+
export const getProducts = route.get('/products')
|
|
192
|
+
.use([optionalAuth])
|
|
193
|
+
.handler(async (c) =>
|
|
194
|
+
{
|
|
195
|
+
const auth = getOptionalAuth(c); // AuthContext | undefined
|
|
196
|
+
return auth ? personalized(auth.userId) : publicList();
|
|
197
|
+
});
|
|
519
198
|
```
|
|
520
199
|
|
|
521
|
-
|
|
200
|
+
Context helpers from `@spfn/auth/server`: `getAuth`, `getOptionalAuth`, `getUser`, `getUserId`,
|
|
201
|
+
`getRole`, `getLocale`, `getKeyId`. Middleware: `authenticate`, `optionalAuth`,
|
|
202
|
+
`requirePermissions`, `requireAnyPermission`, `requireRole`, `roleGuard`, `oneTimeTokenAuth`.
|
|
522
203
|
|
|
523
|
-
|
|
204
|
+
## OAuth
|
|
524
205
|
|
|
525
|
-
|
|
206
|
+
OAuth uses a **pluggable provider registry** — not hardcoded branches. The built-in `google`
|
|
207
|
+
provider self-registers on module load. External packages add providers at runtime with
|
|
208
|
+
`registerOAuthProvider()`. Google OAuth turns on automatically once both
|
|
209
|
+
`SPFN_AUTH_GOOGLE_CLIENT_ID` and `SPFN_AUTH_GOOGLE_CLIENT_SECRET` are set.
|
|
526
210
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
211
|
+
Client flow: call `authApi.getGoogleOAuthUrl.call({ body: { returnUrl } })`, redirect the browser
|
|
212
|
+
to the returned `authUrl`, and render `OAuthCallback` on your success page. The Next.js interceptor
|
|
213
|
+
manages the keypair → pending-session-cookie → full-session handoff transparently.
|
|
530
214
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
});
|
|
215
|
+
```tsx
|
|
216
|
+
// app/auth/callback/page.tsx
|
|
217
|
+
export { OAuthCallback as default } from '@spfn/auth/nextjs/client';
|
|
535
218
|
```
|
|
536
219
|
|
|
537
|
-
**Services:**
|
|
538
220
|
```typescript
|
|
539
|
-
import {
|
|
540
|
-
|
|
541
|
-
|
|
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';
|
|
221
|
+
import { authApi } from '@spfn/auth';
|
|
222
|
+
const { authUrl } = await authApi.getGoogleOAuthUrl.call({ body: { returnUrl: '/dashboard' } });
|
|
223
|
+
window.location.href = authUrl;
|
|
607
224
|
```
|
|
608
225
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
rolesRepository,
|
|
615
|
-
permissionsRepository,
|
|
616
|
-
verificationCodesRepository,
|
|
617
|
-
invitationsRepository,
|
|
618
|
-
rolePermissionsRepository,
|
|
619
|
-
userPermissionsRepository,
|
|
620
|
-
userProfilesRepository,
|
|
621
|
-
} from '@spfn/auth/server';
|
|
622
|
-
```
|
|
226
|
+
Built-in OAuth routes: `POST /_auth/oauth/google/url`, `GET /_auth/oauth/google` (redirect),
|
|
227
|
+
`GET /_auth/oauth/google/callback`, `POST /_auth/oauth/finalize`, `GET /_auth/oauth/providers`,
|
|
228
|
+
plus the provider-generic `POST /_auth/oauth/start`. `getGoogleAccessToken(userId)` returns a
|
|
229
|
+
valid Google access token (auto-refreshing via stored refresh token when near expiry; throws if
|
|
230
|
+
no Google account is linked or no refresh token is available).
|
|
623
231
|
|
|
624
|
-
|
|
625
|
-
```typescript
|
|
626
|
-
import {
|
|
627
|
-
authenticate,
|
|
628
|
-
optionalAuth,
|
|
629
|
-
requirePermissions,
|
|
630
|
-
requireAnyPermission,
|
|
631
|
-
requireRole,
|
|
632
|
-
} from '@spfn/auth/server';
|
|
232
|
+
### Custom providers
|
|
633
233
|
|
|
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
|
-
```
|
|
234
|
+
Implement `OAuthProvider` and register it. `SOCIAL_PROVIDERS` is `['google','github','kakao','naver','superself']`.
|
|
664
235
|
|
|
665
|
-
**Helpers:**
|
|
666
236
|
```typescript
|
|
667
237
|
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,
|
|
238
|
+
registerOAuthProvider, getOAuthProvider, getRegisteredProviders,
|
|
239
|
+
oauthCallbackService,
|
|
240
|
+
type OAuthProvider, type NormalizedIdentity, type OAuthTokens,
|
|
685
241
|
} 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
|
-
|
|
698
|
-
### Client Module (`@spfn/auth/client`)
|
|
699
|
-
|
|
700
|
-
> **Status:** Work in Progress - Placeholders only
|
|
701
|
-
|
|
702
|
-
```typescript
|
|
703
|
-
// Currently empty exports
|
|
704
|
-
import {} from '@spfn/auth/client';
|
|
705
|
-
```
|
|
706
|
-
|
|
707
|
-
**Planned:**
|
|
708
|
-
- React hooks (useAuth, useSession)
|
|
709
|
-
- Zustand store
|
|
710
|
-
- UI components (LoginForm, etc.)
|
|
711
|
-
|
|
712
|
-
---
|
|
713
|
-
|
|
714
|
-
### Configuration Module (`@spfn/auth/config`)
|
|
715
|
-
|
|
716
|
-
```typescript
|
|
717
|
-
import { env, envSchema } from '@spfn/auth/config';
|
|
718
|
-
|
|
719
|
-
// Access environment variables (validated at startup)
|
|
720
|
-
console.log(env.SPFN_AUTH_JWT_SECRET);
|
|
721
|
-
console.log(env.SPFN_AUTH_JWT_EXPIRES_IN);
|
|
722
|
-
console.log(env.SPFN_AUTH_BCRYPT_SALT_ROUNDS);
|
|
723
|
-
|
|
724
|
-
// envSchema can be used for custom validation
|
|
725
|
-
```
|
|
726
|
-
|
|
727
|
-
---
|
|
728
242
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
```typescript
|
|
732
|
-
import {
|
|
733
|
-
// Auth namespace (contains all error classes)
|
|
734
|
-
AuthError,
|
|
735
|
-
|
|
736
|
-
// Individual error classes
|
|
737
|
-
InvalidCredentialsError,
|
|
738
|
-
InvalidTokenError,
|
|
739
|
-
TokenExpiredError,
|
|
740
|
-
KeyExpiredError,
|
|
741
|
-
AccountDisabledError,
|
|
742
|
-
AccountAlreadyExistsError,
|
|
743
|
-
InvalidVerificationCodeError,
|
|
744
|
-
InvalidVerificationTokenError,
|
|
745
|
-
InvalidKeyFingerprintError,
|
|
746
|
-
VerificationTokenPurposeMismatchError,
|
|
747
|
-
VerificationTokenTargetMismatchError,
|
|
748
|
-
InsufficientPermissionsError,
|
|
749
|
-
InsufficientRoleError,
|
|
750
|
-
|
|
751
|
-
// Error registry for client-side error handling
|
|
752
|
-
authErrorRegistry,
|
|
753
|
-
} from '@spfn/auth/errors';
|
|
243
|
+
registerOAuthProvider(myProvider); // same id re-registers (override)
|
|
754
244
|
```
|
|
755
245
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
### Next.js Adapter (`@spfn/auth/nextjs/*`)
|
|
246
|
+
**Integration contract for custom providers:**
|
|
759
247
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
oauthFinalizeInterceptor,
|
|
770
|
-
} from '@spfn/auth/nextjs/api';
|
|
771
|
-
|
|
772
|
-
// Auto-registers interceptors on import (including OAuth)
|
|
773
|
-
import '@spfn/auth/nextjs/api';
|
|
774
|
-
```
|
|
248
|
+
- The package only ships google's callback route. A custom provider must expose its **own**
|
|
249
|
+
callback route that calls `oauthCallbackService({ provider, code, state })`.
|
|
250
|
+
- **Wrap that callback route in `Transactional()`** (`import { Transactional } from '@spfn/core/db'`).
|
|
251
|
+
`oauthCallbackService` creates/links a user and stores the social account in sequence — without
|
|
252
|
+
a transaction, a mid-flow failure leaves an orphan user. The built-in google callback uses it.
|
|
253
|
+
- The provider `id` must be in `SOCIAL_PROVIDERS` (`enumText`, plain text — adding a value needs **no**
|
|
254
|
+
DB migration).
|
|
255
|
+
- `auth.login` / `auth.register` events now carry any `SOCIAL_PROVIDERS` value in `provider` —
|
|
256
|
+
update any `switch(provider)` in subscribers.
|
|
775
257
|
|
|
776
|
-
|
|
258
|
+
## Sessions (Next.js)
|
|
777
259
|
|
|
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
|
-
```
|
|
260
|
+
Sessions are HttpOnly cookies encrypted with `SPFN_AUTH_SESSION_SECRET` (JWE), holding the
|
|
261
|
+
client private key + `keyId` (`SessionData`: `{ userId, privateKey, keyId, algorithm }`). The
|
|
262
|
+
interceptor reads them to sign outbound RPC JWTs. From `@spfn/auth/nextjs/server`:
|
|
802
263
|
|
|
803
|
-
**Session Helpers Usage:**
|
|
804
264
|
```typescript
|
|
805
|
-
|
|
806
|
-
await saveSession({
|
|
807
|
-
userId: '123',
|
|
808
|
-
privateKey: '...',
|
|
809
|
-
keyId: 'uuid',
|
|
810
|
-
algorithm: 'ES256',
|
|
811
|
-
});
|
|
812
|
-
|
|
813
|
-
// Get session (read-only, safe in Server Components)
|
|
814
|
-
const session = await getSession();
|
|
265
|
+
import { saveSession, getSession, clearSession } from '@spfn/auth/nextjs/server';
|
|
815
266
|
|
|
816
|
-
|
|
267
|
+
await saveSession({ userId: '123', privateKey: '...', keyId: 'uuid', algorithm: 'ES256' });
|
|
268
|
+
const session = await getSession(); // read-only, safe in Server Components
|
|
817
269
|
await clearSession();
|
|
818
270
|
```
|
|
819
271
|
|
|
820
|
-
|
|
821
|
-
```typescript
|
|
822
|
-
// app/dashboard/page.tsx
|
|
823
|
-
import { RequireAuth } from '@spfn/auth/nextjs/server';
|
|
824
|
-
|
|
825
|
-
export default async function DashboardPage()
|
|
826
|
-
{
|
|
827
|
-
return (
|
|
828
|
-
<RequireAuth redirectTo="/login">
|
|
829
|
-
<div>Protected content</div>
|
|
830
|
-
</RequireAuth>
|
|
831
|
-
);
|
|
832
|
-
}
|
|
833
|
-
```
|
|
834
|
-
|
|
835
|
-
---
|
|
836
|
-
|
|
837
|
-
## Email & SMS Services
|
|
838
|
-
|
|
839
|
-
> **⚠️ DEPRECATED:** Email and SMS functionality has been moved to `@spfn/notification` package.
|
|
840
|
-
|
|
841
|
-
### Migration Guide
|
|
842
|
-
|
|
843
|
-
```typescript
|
|
844
|
-
// Before (deprecated)
|
|
845
|
-
import { sendEmail, sendSMS } from '@spfn/auth/server';
|
|
846
|
-
|
|
847
|
-
// After (recommended)
|
|
848
|
-
import { sendEmail, sendSMS } from '@spfn/notification/server';
|
|
849
|
-
```
|
|
850
|
-
|
|
851
|
-
The `@spfn/notification` package provides:
|
|
852
|
-
- Multi-channel support (Email, SMS, Slack, Push)
|
|
853
|
-
- Template system with variable substitution
|
|
854
|
-
- Multiple provider support (AWS SES, SNS, SendGrid, Twilio, etc.)
|
|
855
|
-
|
|
856
|
-
For documentation, see `@spfn/notification` package README.
|
|
857
|
-
|
|
858
|
-
---
|
|
272
|
+
RSC guards (redirect when unmet) — `RequireAuth`, `RequireRole`, `RequirePermission`:
|
|
859
273
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
### Public Routes (No Authentication)
|
|
863
|
-
|
|
864
|
-
All routes are automatically registered at `/_auth/*` via SPFN plugin system.
|
|
865
|
-
|
|
866
|
-
#### `POST /_auth/exists`
|
|
867
|
-
|
|
868
|
-
Check if account exists.
|
|
869
|
-
|
|
870
|
-
**Request:**
|
|
871
|
-
```typescript
|
|
872
|
-
{
|
|
873
|
-
email?: string;
|
|
874
|
-
phone?: string; // E.164 format
|
|
875
|
-
}
|
|
876
|
-
```
|
|
274
|
+
```tsx
|
|
275
|
+
import { RequireAuth, RequireRole } from '@spfn/auth/nextjs/server';
|
|
877
276
|
|
|
878
|
-
|
|
879
|
-
```typescript
|
|
277
|
+
export default async function AdminPage()
|
|
880
278
|
{
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
279
|
+
return (
|
|
280
|
+
<RequireAuth redirectTo="/login">
|
|
281
|
+
<RequireRole roles={['admin', 'superadmin']} redirectTo="/forbidden">
|
|
282
|
+
<Dashboard />
|
|
283
|
+
</RequireRole>
|
|
284
|
+
</RequireAuth>
|
|
285
|
+
);
|
|
884
286
|
}
|
|
885
287
|
```
|
|
886
288
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
#### `POST /_auth/codes`
|
|
289
|
+
Also exported: `getAuthSessionData`, `getUserRole`, `getUserPermissions`, `hasAnyRole`,
|
|
290
|
+
`hasAnyPermission`, the OAuth pending-session helpers, and `createOAuthCallbackHandler`.
|
|
890
291
|
|
|
891
|
-
|
|
292
|
+
## RBAC
|
|
892
293
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
targetType: 'email' | 'phone';
|
|
898
|
-
purpose: 'registration' | 'login' | 'password_reset';
|
|
899
|
-
}
|
|
900
|
-
```
|
|
294
|
+
Built-in roles: `superadmin` (priority 100), `admin` (80), `user` (10). Built-in permissions:
|
|
295
|
+
`auth:self:manage`, `user:read|write|delete|invite`, `rbac:role:manage`, `rbac:permission:manage`.
|
|
296
|
+
Custom roles/permissions are declared on the lifecycle (preferred — runs on startup) or via
|
|
297
|
+
`initializeAuth(options)`.
|
|
901
298
|
|
|
902
|
-
**Response:**
|
|
903
299
|
```typescript
|
|
904
|
-
{
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
}
|
|
300
|
+
createAuthLifecycle({
|
|
301
|
+
roles: [{ name: 'editor', displayName: 'Editor', priority: 30 }],
|
|
302
|
+
permissions: [{ name: 'post:publish', displayName: 'Publish Posts', category: 'content' }],
|
|
303
|
+
rolePermissions: { editor: ['post:publish'] },
|
|
304
|
+
});
|
|
908
305
|
```
|
|
909
306
|
|
|
910
|
-
|
|
307
|
+
Programmatic checks (server): `hasPermission`, `hasAnyPermission`, `hasAllPermissions`, `hasRole`,
|
|
308
|
+
`hasAnyRole`, `getUserRole`, `getUserPermissions`. Runtime role admin: `createRole`, `updateRole`,
|
|
309
|
+
`deleteRole`, `setRolePermissions`, `addPermissionToRole`, `removePermissionFromRole`,
|
|
310
|
+
`getAllRoles`, `getRoleByName`, `getRolePermissions`.
|
|
911
311
|
|
|
912
|
-
|
|
312
|
+
## Events
|
|
913
313
|
|
|
914
|
-
|
|
314
|
+
`@spfn/auth` emits decoupled events (via `@spfn/core/event`). Subscribe for welcome emails,
|
|
315
|
+
analytics, onboarding, etc. Client-supplied `metadata` on register/OAuth flows is forwarded verbatim.
|
|
915
316
|
|
|
916
|
-
**Request:**
|
|
917
317
|
```typescript
|
|
918
|
-
{
|
|
919
|
-
target: string;
|
|
920
|
-
targetType: 'email' | 'phone';
|
|
921
|
-
code: string; // 6 digits
|
|
922
|
-
purpose: 'registration' | 'login' | 'password_reset';
|
|
923
|
-
}
|
|
924
|
-
```
|
|
318
|
+
import { authLoginEvent, authRegisterEvent, invitationCreatedEvent, invitationAcceptedEvent } from '@spfn/auth/server';
|
|
925
319
|
|
|
926
|
-
|
|
927
|
-
```typescript
|
|
320
|
+
authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
|
|
928
321
|
{
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
}
|
|
322
|
+
if (email) await sendWelcome(email);
|
|
323
|
+
});
|
|
932
324
|
```
|
|
933
325
|
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
326
|
+
Payload types: `AuthLoginPayload`, `AuthRegisterPayload`, `InvitationCreatedPayload`,
|
|
327
|
+
`InvitationAcceptedPayload`. These events also bind to `@spfn/core/job` jobs via `.on(event)`.
|
|
328
|
+
|
|
329
|
+
## One-Time Token
|
|
330
|
+
|
|
331
|
+
For short-lived authenticated handshakes (e.g. SSE) where a `Bearer` header is awkward: issue
|
|
332
|
+
with `authApi.issueOneTimeToken`, protect the consuming route with the `oneTimeTokenAuth`
|
|
333
|
+
middleware. Call `initOneTimeTokenManager({ ttl, store })` during setup for a custom TTL/store.
|
|
334
|
+
|
|
335
|
+
## Pitfalls & anti-patterns
|
|
336
|
+
|
|
337
|
+
- **Wrong entry point.** `@spfn/auth/server` and `@spfn/auth/nextjs/*` are server-only (Node /
|
|
338
|
+
`server-only`). Importing them in a client component breaks the build. Entities, services, and
|
|
339
|
+
repositories are on `/server`, not on root `@spfn/auth`.
|
|
340
|
+
- **No `app.bind(contract, ...)`.** That contract pattern is removed. Use the route DSL
|
|
341
|
+
(`route.get().handler()` + `defineRouter`). Any docs/snippets using `app.bind` are stale.
|
|
342
|
+
- **Custom error classes must be registered.** Add them to an `ErrorRegistry` (mirror
|
|
343
|
+
`authErrorRegistry` in `src/errors/index.ts`) and pass it to your `createApi({ errorRegistry })`,
|
|
344
|
+
or the client receives a generic error instead of the typed one.
|
|
345
|
+
- **Two env files, by audience.** `SPFN_AUTH_SESSION_SECRET` lives in `.env.local` (Next.js needs
|
|
346
|
+
it for cookie crypto); `SPFN_AUTH_VERIFICATION_TOKEN_SECRET` lives in `.env.server`. Splitting
|
|
347
|
+
them wrong yields "missing secret" failures only at runtime.
|
|
348
|
+
- **`SPFN_AUTH_SESSION_SECRET` is validated.** Minimum 32 chars plus entropy/unique-char checks —
|
|
349
|
+
a short or low-entropy value fails startup, not just a warning.
|
|
350
|
+
- **Forgetting the interceptor import.** Without `import '@spfn/auth/nextjs/api'` in the RPC proxy
|
|
351
|
+
route, the client sends no `Authorization` header and every protected call 401s. The
|
|
352
|
+
`authenticate` middleware error message points here.
|
|
353
|
+
- **Custom OAuth callback without `Transactional()`.** A failure mid-callback leaves an orphan
|
|
354
|
+
user. Always wrap the callback route in `Transactional()` and call `oauthCallbackService`.
|
|
355
|
+
- **`sideEffects: false` tree-shakes the google provider.** The built-in provider self-registers
|
|
356
|
+
via a module side-effect; an aggressive bundler config can drop it. Don't mark this package's
|
|
357
|
+
imports side-effect-free.
|
|
358
|
+
- **Public routes need an explicit opt-out.** With global `authenticate`, any route without
|
|
359
|
+
`.skip(['auth'])` (or `optionalAuth`, which auto-skips) requires a valid token.
|
|
360
|
+
- **`SOCIAL_PROVIDERS` is plain `enumText`.** Adding a provider value needs no DB migration, but
|
|
361
|
+
every `switch(provider)` over login/register events must handle the new value.
|
|
362
|
+
- **Email/SMS is not here.** It moved to `@spfn/notification` (`import { sendEmail, sendSMS } from
|
|
363
|
+
'@spfn/notification/server'`). Wire verification-code / invitation emails through its events.
|
|
364
|
+
|
|
365
|
+
## Complete example
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
// server.config.ts
|
|
369
|
+
import { defineServerConfig } from '@spfn/core/server';
|
|
370
|
+
import { createAuthLifecycle } from '@spfn/auth/server';
|
|
371
|
+
import { appRouter } from './router';
|
|
954
372
|
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
}
|
|
962
|
-
|
|
373
|
+
export default defineServerConfig()
|
|
374
|
+
.port(8790)
|
|
375
|
+
.routes(appRouter)
|
|
376
|
+
.lifecycle(createAuthLifecycle({
|
|
377
|
+
roles: [{ name: 'editor', displayName: 'Editor', priority: 30 }],
|
|
378
|
+
permissions: [{ name: 'post:publish', displayName: 'Publish Posts', category: 'content' }],
|
|
379
|
+
rolePermissions: { editor: ['post:publish'] },
|
|
380
|
+
}))
|
|
381
|
+
.build();
|
|
963
382
|
|
|
964
|
-
|
|
383
|
+
// router.ts
|
|
384
|
+
import { defineRouter } from '@spfn/core/route';
|
|
385
|
+
import { authRouter, authenticate } from '@spfn/auth/server';
|
|
386
|
+
import { getMe } from './routes/me';
|
|
965
387
|
|
|
966
|
-
|
|
388
|
+
export const appRouter = defineRouter({ getMe })
|
|
389
|
+
.packages([authRouter])
|
|
390
|
+
.use([authenticate]);
|
|
391
|
+
export type AppRouter = typeof appRouter;
|
|
967
392
|
|
|
968
|
-
|
|
393
|
+
// app/api/rpc/[routeName]/route.ts
|
|
394
|
+
import '@spfn/auth/nextjs/api';
|
|
395
|
+
import { appRouter } from '@/server/router';
|
|
396
|
+
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
397
|
+
export const { GET, POST } = createRpcProxy({ router: appRouter });
|
|
969
398
|
|
|
970
|
-
|
|
971
|
-
|
|
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
|
-
}
|
|
399
|
+
// any client component
|
|
400
|
+
import { authApi } from '@spfn/auth';
|
|
401
|
+
const session = await authApi.getAuthSession.call({});
|
|
983
402
|
```
|
|
984
403
|
|
|
985
|
-
|
|
986
|
-
```typescript
|
|
987
|
-
{
|
|
988
|
-
userId: string;
|
|
989
|
-
email?: string;
|
|
990
|
-
phone?: string;
|
|
991
|
-
passwordChangeRequired: boolean;
|
|
992
|
-
}
|
|
993
|
-
```
|
|
994
|
-
|
|
995
|
-
---
|
|
996
|
-
|
|
997
|
-
### Authenticated Routes (Require JWT)
|
|
998
|
-
|
|
999
|
-
**Authentication:**
|
|
1000
|
-
- Header: `Authorization: Bearer <jwt>`
|
|
1001
|
-
- JWT payload must contain: `{ userId, keyId }`
|
|
1002
|
-
- Server extracts `keyId` from JWT, fetches public key, verifies signature
|
|
1003
|
-
|
|
1004
|
-
---
|
|
1005
|
-
|
|
1006
|
-
#### `POST /_auth/logout`
|
|
1007
|
-
|
|
1008
|
-
Logout and revoke current key.
|
|
1009
|
-
|
|
1010
|
-
**Request:**
|
|
1011
|
-
```typescript
|
|
1012
|
-
{} // Empty body
|
|
1013
|
-
```
|
|
1014
|
-
|
|
1015
|
-
**Response:**
|
|
1016
|
-
```typescript
|
|
1017
|
-
{
|
|
1018
|
-
success: boolean;
|
|
1019
|
-
}
|
|
1020
|
-
```
|
|
1021
|
-
|
|
1022
|
-
---
|
|
1023
|
-
|
|
1024
|
-
#### `POST /_auth/keys/rotate`
|
|
1025
|
-
|
|
1026
|
-
Rotate public key before expiry (90 days).
|
|
1027
|
-
|
|
1028
|
-
**Request:**
|
|
1029
|
-
```typescript
|
|
1030
|
-
{
|
|
1031
|
-
publicKey: string; // New public key
|
|
1032
|
-
keyId: string; // New UUID
|
|
1033
|
-
fingerprint: string;
|
|
1034
|
-
algorithm: 'ES256' | 'RS256';
|
|
1035
|
-
keySize?: number;
|
|
1036
|
-
}
|
|
1037
|
-
```
|
|
1038
|
-
|
|
1039
|
-
**Response:**
|
|
1040
|
-
```typescript
|
|
1041
|
-
{
|
|
1042
|
-
success: boolean;
|
|
1043
|
-
keyId: string;
|
|
1044
|
-
}
|
|
1045
|
-
```
|
|
1046
|
-
|
|
1047
|
-
---
|
|
1048
|
-
|
|
1049
|
-
#### `PUT /_auth/password`
|
|
1050
|
-
|
|
1051
|
-
Change password.
|
|
1052
|
-
|
|
1053
|
-
**Request:**
|
|
1054
|
-
```typescript
|
|
1055
|
-
{
|
|
1056
|
-
currentPassword: string;
|
|
1057
|
-
newPassword: string; // Min 8 chars
|
|
1058
|
-
}
|
|
1059
|
-
```
|
|
1060
|
-
|
|
1061
|
-
**Response:**
|
|
1062
|
-
```typescript
|
|
1063
|
-
{
|
|
1064
|
-
success: boolean;
|
|
1065
|
-
}
|
|
1066
|
-
```
|
|
1067
|
-
|
|
1068
|
-
---
|
|
1069
|
-
|
|
1070
|
-
#### `GET /_auth/users/username/check`
|
|
1071
|
-
|
|
1072
|
-
Check if a username is available.
|
|
1073
|
-
|
|
1074
|
-
**Query:**
|
|
1075
|
-
```typescript
|
|
1076
|
-
{
|
|
1077
|
-
username: string; // Min 1 char
|
|
1078
|
-
}
|
|
1079
|
-
```
|
|
1080
|
-
|
|
1081
|
-
**Response:**
|
|
1082
|
-
```typescript
|
|
1083
|
-
{
|
|
1084
|
-
available: boolean;
|
|
1085
|
-
}
|
|
1086
|
-
```
|
|
1087
|
-
|
|
1088
|
-
---
|
|
1089
|
-
|
|
1090
|
-
#### `PATCH /_auth/users/username`
|
|
1091
|
-
|
|
1092
|
-
Update authenticated user's username. Validates uniqueness before updating.
|
|
1093
|
-
|
|
1094
|
-
**Request:**
|
|
1095
|
-
```typescript
|
|
1096
|
-
{
|
|
1097
|
-
username: string | null; // New username or null to clear
|
|
1098
|
-
}
|
|
1099
|
-
```
|
|
1100
|
-
|
|
1101
|
-
**Response:** Updated user object.
|
|
1102
|
-
|
|
1103
|
-
**Errors:**
|
|
1104
|
-
- `409 UsernameAlreadyTakenError` - Username is already in use by another user
|
|
1105
|
-
|
|
1106
|
-
---
|
|
1107
|
-
|
|
1108
|
-
## Events
|
|
1109
|
-
|
|
1110
|
-
`@spfn/auth`는 `@spfn/core/event`를 사용하여 인증 관련 이벤트를 발행합니다. 이를 통해 로그인/회원가입 시 추가 로직(환영 이메일, 분석, 알림 등)을 디커플링된 방식으로 처리할 수 있습니다.
|
|
1111
|
-
|
|
1112
|
-
### Available Events
|
|
1113
|
-
|
|
1114
|
-
| Event | Description | Trigger |
|
|
1115
|
-
|-------|-------------|---------|
|
|
1116
|
-
| `auth.login` | 로그인 성공 | 이메일/전화 로그인, OAuth 기존 사용자 |
|
|
1117
|
-
| `auth.register` | 회원가입 성공 | 이메일/전화 회원가입, OAuth 신규 사용자 |
|
|
1118
|
-
| `auth.invitation.created` | 초대 생성/재발송 | createInvitation, resendInvitation |
|
|
1119
|
-
| `auth.invitation.accepted` | 초대 수락 | acceptInvitation |
|
|
1120
|
-
|
|
1121
|
-
---
|
|
1122
|
-
|
|
1123
|
-
### Event Payloads
|
|
1124
|
-
|
|
1125
|
-
#### `auth.login`
|
|
1126
|
-
|
|
1127
|
-
```typescript
|
|
1128
|
-
{
|
|
1129
|
-
userId: string;
|
|
1130
|
-
provider: 'email' | 'phone' | 'google';
|
|
1131
|
-
email?: string;
|
|
1132
|
-
phone?: string;
|
|
1133
|
-
}
|
|
1134
|
-
```
|
|
1135
|
-
|
|
1136
|
-
#### `auth.register`
|
|
1137
|
-
|
|
1138
|
-
```typescript
|
|
1139
|
-
{
|
|
1140
|
-
userId: string;
|
|
1141
|
-
provider: 'email' | 'phone' | 'google';
|
|
1142
|
-
email?: string;
|
|
1143
|
-
phone?: string;
|
|
1144
|
-
metadata?: Record<string, unknown>; // 가입 시 전달된 커스텀 메타데이터
|
|
1145
|
-
}
|
|
1146
|
-
```
|
|
1147
|
-
|
|
1148
|
-
`metadata`는 클라이언트가 register/OAuth 요청 body에 포함한 값이 그대로 전달됩니다.
|
|
1149
|
-
레퍼럴 코드, UTM 파라미터 등 앱 고유 데이터를 이벤트 구독자에게 전달할 때 사용합니다.
|
|
1150
|
-
|
|
1151
|
-
#### `auth.invitation.created`
|
|
1152
|
-
|
|
1153
|
-
```typescript
|
|
1154
|
-
{
|
|
1155
|
-
invitationId: string;
|
|
1156
|
-
email: string;
|
|
1157
|
-
token: string;
|
|
1158
|
-
roleId: number;
|
|
1159
|
-
invitedBy: string;
|
|
1160
|
-
expiresAt: string; // ISO 8601
|
|
1161
|
-
isResend: boolean; // true면 재발송
|
|
1162
|
-
metadata?: Record<string, unknown>;
|
|
1163
|
-
}
|
|
1164
|
-
```
|
|
1165
|
-
|
|
1166
|
-
#### `auth.invitation.accepted`
|
|
1167
|
-
|
|
1168
|
-
```typescript
|
|
1169
|
-
{
|
|
1170
|
-
invitationId: string;
|
|
1171
|
-
email: string;
|
|
1172
|
-
userId: string; // 생성된 사용자 ID
|
|
1173
|
-
roleId: number;
|
|
1174
|
-
invitedBy: string;
|
|
1175
|
-
metadata?: Record<string, unknown>;
|
|
1176
|
-
}
|
|
1177
|
-
```
|
|
1178
|
-
|
|
1179
|
-
---
|
|
1180
|
-
|
|
1181
|
-
### Subscribing to Events
|
|
1182
|
-
|
|
1183
|
-
```typescript
|
|
1184
|
-
import { authLoginEvent, authRegisterEvent } from '@spfn/auth/server';
|
|
1185
|
-
|
|
1186
|
-
// 로그인 이벤트 구독
|
|
1187
|
-
authLoginEvent.subscribe(async (payload) => {
|
|
1188
|
-
console.log('User logged in:', payload.userId, payload.provider);
|
|
1189
|
-
await analytics.trackLogin(payload.userId);
|
|
1190
|
-
});
|
|
1191
|
-
|
|
1192
|
-
// 회원가입 이벤트 구독 (metadata 활용)
|
|
1193
|
-
authRegisterEvent.subscribe(async (payload) => {
|
|
1194
|
-
console.log('New user registered:', payload.userId);
|
|
1195
|
-
if (payload.email) {
|
|
1196
|
-
await emailService.sendWelcome(payload.email);
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
// 레퍼럴 코드 처리
|
|
1200
|
-
const refCode = payload.metadata?.refCode as string;
|
|
1201
|
-
if (refCode) {
|
|
1202
|
-
await referralService.link(payload.userId, refCode);
|
|
1203
|
-
}
|
|
1204
|
-
});
|
|
1205
|
-
```
|
|
1206
|
-
|
|
1207
|
-
클라이언트에서 metadata를 전달하는 방법:
|
|
1208
|
-
|
|
1209
|
-
```typescript
|
|
1210
|
-
// 이메일/전화 가입
|
|
1211
|
-
authApi.register.call({
|
|
1212
|
-
body: { email, password, metadata: { refCode: 'CODE', utm_source: 'google' } }
|
|
1213
|
-
});
|
|
1214
|
-
|
|
1215
|
-
// OAuth 가입
|
|
1216
|
-
authApi.oauthStart.call({
|
|
1217
|
-
body: { provider: 'google', returnUrl: '/dashboard', metadata: { refCode: 'CODE' } }
|
|
1218
|
-
});
|
|
1219
|
-
```
|
|
1220
|
-
|
|
1221
|
-
#### 초대 이벤트 구독 (이메일 발송 연동)
|
|
1222
|
-
|
|
1223
|
-
```typescript
|
|
1224
|
-
import { invitationCreatedEvent, invitationAcceptedEvent } from '@spfn/auth/server';
|
|
1225
|
-
|
|
1226
|
-
// 초대 생성 시 이메일 발송
|
|
1227
|
-
invitationCreatedEvent.subscribe(async (payload) => {
|
|
1228
|
-
const inviteUrl = `${APP_URL}/invite/${payload.token}`;
|
|
1229
|
-
|
|
1230
|
-
await notificationService.send({
|
|
1231
|
-
channel: 'email',
|
|
1232
|
-
to: payload.email,
|
|
1233
|
-
subject: payload.isResend ? '초대가 재발송되었습니다' : '초대장이 도착했습니다',
|
|
1234
|
-
html: renderInviteEmail({
|
|
1235
|
-
inviteUrl,
|
|
1236
|
-
inviterName: payload.metadata?.inviterName,
|
|
1237
|
-
message: payload.metadata?.message,
|
|
1238
|
-
}),
|
|
1239
|
-
tracking: {
|
|
1240
|
-
category: 'invitation',
|
|
1241
|
-
metadata: { invitationId: payload.invitationId },
|
|
1242
|
-
},
|
|
1243
|
-
});
|
|
1244
|
-
});
|
|
1245
|
-
|
|
1246
|
-
// 초대 수락 시 온보딩 처리
|
|
1247
|
-
invitationAcceptedEvent.subscribe(async (payload) => {
|
|
1248
|
-
await onboardingService.start(payload.userId);
|
|
1249
|
-
});
|
|
1250
|
-
```
|
|
1251
|
-
|
|
1252
|
-
초대 생성 시 커스텀 만료 시간 지정:
|
|
1253
|
-
|
|
1254
|
-
```typescript
|
|
1255
|
-
// expiresAt이 expiresInDays보다 우선
|
|
1256
|
-
authApi.createInvitation.call({
|
|
1257
|
-
body: {
|
|
1258
|
-
email: 'user@example.com',
|
|
1259
|
-
roleId: 2,
|
|
1260
|
-
expiresAt: '2026-03-20T00:00:00Z',
|
|
1261
|
-
metadata: { inviterName: '홍길동', message: '함께 일해요!' },
|
|
1262
|
-
}
|
|
1263
|
-
});
|
|
1264
|
-
```
|
|
1265
|
-
|
|
1266
|
-
---
|
|
1267
|
-
|
|
1268
|
-
### Job Integration
|
|
1269
|
-
|
|
1270
|
-
`@spfn/core/job`과 연동하여 백그라운드 작업을 실행할 수 있습니다.
|
|
1271
|
-
|
|
1272
|
-
```typescript
|
|
1273
|
-
import { job, defineJobRouter } from '@spfn/core/job';
|
|
1274
|
-
import { authRegisterEvent } from '@spfn/auth/server';
|
|
1275
|
-
|
|
1276
|
-
// 회원가입 시 환영 이메일 발송 Job
|
|
1277
|
-
const sendWelcomeEmailJob = job('send-welcome-email')
|
|
1278
|
-
.on(authRegisterEvent)
|
|
1279
|
-
.handler(async ({ userId, email }) => {
|
|
1280
|
-
if (email) {
|
|
1281
|
-
await emailService.sendWelcome(email);
|
|
1282
|
-
}
|
|
1283
|
-
});
|
|
1284
|
-
|
|
1285
|
-
// 회원가입 시 기본 설정 생성 Job
|
|
1286
|
-
const createDefaultSettingsJob = job('create-default-settings')
|
|
1287
|
-
.on(authRegisterEvent)
|
|
1288
|
-
.handler(async ({ userId }) => {
|
|
1289
|
-
await settingsService.createDefaults(userId);
|
|
1290
|
-
});
|
|
1291
|
-
|
|
1292
|
-
export const jobRouter = defineJobRouter({
|
|
1293
|
-
sendWelcomeEmailJob,
|
|
1294
|
-
createDefaultSettingsJob,
|
|
1295
|
-
});
|
|
1296
|
-
```
|
|
1297
|
-
|
|
1298
|
-
---
|
|
1299
|
-
|
|
1300
|
-
### Event Flow
|
|
1301
|
-
|
|
1302
|
-
```
|
|
1303
|
-
┌─────────────────────────────────────────────────────────────────┐
|
|
1304
|
-
│ loginService() / registerService() │
|
|
1305
|
-
│ oauthCallbackService() │
|
|
1306
|
-
└─────────────────────────────────────────────────────────────────┘
|
|
1307
|
-
│
|
|
1308
|
-
▼
|
|
1309
|
-
authLoginEvent.emit()
|
|
1310
|
-
authRegisterEvent.emit()
|
|
1311
|
-
│
|
|
1312
|
-
┌───────────────────┼───────────────────┐
|
|
1313
|
-
▼ ▼ ▼
|
|
1314
|
-
┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
1315
|
-
│ Backend │ │ Job │ │ SSE │
|
|
1316
|
-
│ Handler │ │ Queue │ │ Stream │
|
|
1317
|
-
└──────────┘ └──────────┘ └──────────┘
|
|
1318
|
-
.subscribe() .on(event) (optional)
|
|
1319
|
-
│ │
|
|
1320
|
-
▼ ▼
|
|
1321
|
-
[Analytics, [Background
|
|
1322
|
-
Logging] Processing]
|
|
1323
|
-
```
|
|
1324
|
-
|
|
1325
|
-
---
|
|
1326
|
-
|
|
1327
|
-
### Type Exports
|
|
1328
|
-
|
|
1329
|
-
```typescript
|
|
1330
|
-
import type {
|
|
1331
|
-
AuthLoginPayload,
|
|
1332
|
-
AuthRegisterPayload,
|
|
1333
|
-
} from '@spfn/auth/server';
|
|
1334
|
-
```
|
|
1335
|
-
|
|
1336
|
-
---
|
|
1337
|
-
|
|
1338
|
-
## OAuth Authentication
|
|
1339
|
-
|
|
1340
|
-
### Overview
|
|
1341
|
-
|
|
1342
|
-
`@spfn/auth`는 OAuth 2.0 Authorization Code Flow를 지원합니다. 현재 Google OAuth가 구현되어 있으며, 다른 provider (GitHub, Kakao, Naver)는 동일한 패턴으로 확장 가능합니다.
|
|
1343
|
-
|
|
1344
|
-
**핵심 설계:**
|
|
1345
|
-
- 환경 변수만으로 설정 (`SPFN_AUTH_GOOGLE_CLIENT_ID`, `SPFN_AUTH_GOOGLE_CLIENT_SECRET`)
|
|
1346
|
-
- Next.js 인터셉터 기반 자동 세션 관리 (키쌍 생성 → pending session → full session)
|
|
1347
|
-
- 기존 이메일 계정과 자동 연결 (Google verified_email 확인 시에만)
|
|
1348
|
-
|
|
1349
|
-
---
|
|
1350
|
-
|
|
1351
|
-
### Authentication Flow
|
|
1352
|
-
|
|
1353
|
-
```
|
|
1354
|
-
┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────┐
|
|
1355
|
-
│ Client │ │ Next.js RPC │ │ Backend │ │ Google │
|
|
1356
|
-
│ (Browser)│ │ (Interceptor)│ │ (SPFN) │ │ OAuth │
|
|
1357
|
-
└────┬─────┘ └──────┬───────┘ └────┬─────┘ └────┬─────┘
|
|
1358
|
-
│ │ │ │
|
|
1359
|
-
│ 1. Click Login │ │ │
|
|
1360
|
-
├──────────────────>│ │ │
|
|
1361
|
-
│ │ │ │
|
|
1362
|
-
│ 2. Generate keypair (ES256) │ │
|
|
1363
|
-
│ 3. Create encrypted state │ │
|
|
1364
|
-
│ (publicKey, keyId in JWE) │ │
|
|
1365
|
-
│ 4. Save privateKey to │ │
|
|
1366
|
-
│ pending session cookie │ │
|
|
1367
|
-
│ │ │ │
|
|
1368
|
-
│ │ 5. Forward with │ │
|
|
1369
|
-
│ │ state in body │ │
|
|
1370
|
-
│ ├─────────────────>│ │
|
|
1371
|
-
│ │ │ │
|
|
1372
|
-
│ │ 6. Return Google │ │
|
|
1373
|
-
│ │ Auth URL │ │
|
|
1374
|
-
│ │<─────────────────┤ │
|
|
1375
|
-
│ │ │ │
|
|
1376
|
-
│ 7. Redirect to Google │ │
|
|
1377
|
-
│<──────────────────┤ │ │
|
|
1378
|
-
│ │ │ │
|
|
1379
|
-
│ 8. User consents │ │ │
|
|
1380
|
-
├───────────────────┼──────────────────┼────────────────>│
|
|
1381
|
-
│ │ │ │
|
|
1382
|
-
│ │ 9. Callback with code + state │
|
|
1383
|
-
│ │ │<────────────────┤
|
|
1384
|
-
│ │ │ │
|
|
1385
|
-
│ │ 10. Verify state, exchange code │
|
|
1386
|
-
│ │ Create/link user account │
|
|
1387
|
-
│ │ Register publicKey │
|
|
1388
|
-
│ │ │ │
|
|
1389
|
-
│ 11. Redirect to /auth/callback │ │
|
|
1390
|
-
│ ?userId=X&keyId=Y&returnUrl=/ │ │
|
|
1391
|
-
│<─────────────────────────────────────┤ │
|
|
1392
|
-
│ │ │ │
|
|
1393
|
-
│ 12. OAuthCallback │ │ │
|
|
1394
|
-
│ component │ │ │
|
|
1395
|
-
│ calls finalize│ │ │
|
|
1396
|
-
├──────────────────>│ │ │
|
|
1397
|
-
│ │ │ │
|
|
1398
|
-
│ 13. Interceptor reads pending │ │
|
|
1399
|
-
│ session cookie, verifies │ │
|
|
1400
|
-
│ keyId match, creates full │ │
|
|
1401
|
-
│ session cookie │ │
|
|
1402
|
-
│ │ │ │
|
|
1403
|
-
│ 14. Session set, │ │ │
|
|
1404
|
-
│ redirect to │ │ │
|
|
1405
|
-
│ returnUrl │ │ │
|
|
1406
|
-
│<──────────────────┤ │ │
|
|
1407
|
-
│ │ │ │
|
|
1408
|
-
```
|
|
1409
|
-
|
|
1410
|
-
---
|
|
1411
|
-
|
|
1412
|
-
### Setup
|
|
1413
|
-
|
|
1414
|
-
#### 1. Google Cloud Console
|
|
1415
|
-
|
|
1416
|
-
1. [Google Cloud Console](https://console.cloud.google.com/) > APIs & Services > Credentials
|
|
1417
|
-
2. Create OAuth 2.0 Client ID (Web application)
|
|
1418
|
-
3. Add Authorized redirect URI: `http://localhost:8790/_auth/oauth/google/callback`
|
|
1419
|
-
4. Copy Client ID and Client Secret
|
|
1420
|
-
|
|
1421
|
-
#### 2. Environment Variables
|
|
1422
|
-
|
|
1423
|
-
```bash
|
|
1424
|
-
# Required
|
|
1425
|
-
SPFN_AUTH_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
|
1426
|
-
SPFN_AUTH_GOOGLE_CLIENT_SECRET=GOCSPX-your-secret
|
|
1427
|
-
|
|
1428
|
-
# Next.js app URL (for OAuth callback redirect)
|
|
1429
|
-
SPFN_APP_URL=http://localhost:3000
|
|
1430
|
-
|
|
1431
|
-
# Optional
|
|
1432
|
-
SPFN_AUTH_GOOGLE_SCOPES=email,profile # default (comma-separated)
|
|
1433
|
-
SPFN_AUTH_GOOGLE_REDIRECT_URI=http://localhost:8790/_auth/oauth/google/callback # default
|
|
1434
|
-
SPFN_AUTH_OAUTH_SUCCESS_URL=/auth/callback # default
|
|
1435
|
-
```
|
|
1436
|
-
|
|
1437
|
-
#### 3. Next.js Callback Page
|
|
1438
|
-
|
|
1439
|
-
```tsx
|
|
1440
|
-
// app/auth/callback/page.tsx
|
|
1441
|
-
export { OAuthCallback as default } from '@spfn/auth/nextjs/client';
|
|
1442
|
-
```
|
|
1443
|
-
|
|
1444
|
-
#### 4. Login Button
|
|
1445
|
-
|
|
1446
|
-
```typescript
|
|
1447
|
-
import { authApi } from '@spfn/auth';
|
|
1448
|
-
|
|
1449
|
-
const handleGoogleLogin = async () =>
|
|
1450
|
-
{
|
|
1451
|
-
const response = await authApi.getGoogleOAuthUrl.call({
|
|
1452
|
-
body: { returnUrl: '/dashboard' },
|
|
1453
|
-
});
|
|
1454
|
-
window.location.href = response.authUrl;
|
|
1455
|
-
};
|
|
1456
|
-
```
|
|
1457
|
-
|
|
1458
|
-
---
|
|
1459
|
-
|
|
1460
|
-
### OAuth Routes
|
|
1461
|
-
|
|
1462
|
-
#### `GET /_auth/oauth/google`
|
|
1463
|
-
|
|
1464
|
-
Google OAuth 시작 (리다이렉트 방식). 브라우저를 Google 로그인 페이지로 직접 리다이렉트합니다.
|
|
1465
|
-
|
|
1466
|
-
**Query:**
|
|
1467
|
-
```typescript
|
|
1468
|
-
{
|
|
1469
|
-
state: string; // Encrypted OAuth state (JWE)
|
|
1470
|
-
}
|
|
1471
|
-
```
|
|
1472
|
-
|
|
1473
|
-
---
|
|
1474
|
-
|
|
1475
|
-
#### `POST /_auth/oauth/google/url`
|
|
1476
|
-
|
|
1477
|
-
Google OAuth URL 획득 (인터셉터 방식). 인터셉터가 state를 자동 생성하여 주입합니다.
|
|
1478
|
-
|
|
1479
|
-
**Request:**
|
|
1480
|
-
```typescript
|
|
1481
|
-
{
|
|
1482
|
-
returnUrl?: string; // Default: '/'
|
|
1483
|
-
}
|
|
1484
|
-
```
|
|
1485
|
-
|
|
1486
|
-
**Response:**
|
|
1487
|
-
```typescript
|
|
1488
|
-
{
|
|
1489
|
-
authUrl: string; // Google OAuth URL
|
|
1490
|
-
}
|
|
1491
|
-
```
|
|
1492
|
-
|
|
1493
|
-
---
|
|
1494
|
-
|
|
1495
|
-
#### `GET /_auth/oauth/google/callback`
|
|
1496
|
-
|
|
1497
|
-
Google에서 리다이렉트되는 콜백. code를 token으로 교환하고 사용자를 생성/연결합니다.
|
|
1498
|
-
|
|
1499
|
-
**Query (from Google):**
|
|
1500
|
-
```typescript
|
|
1501
|
-
{
|
|
1502
|
-
code?: string; // Authorization code
|
|
1503
|
-
state?: string; // OAuth state
|
|
1504
|
-
error?: string; // Error code
|
|
1505
|
-
error_description?: string; // Error description
|
|
1506
|
-
}
|
|
1507
|
-
```
|
|
1508
|
-
|
|
1509
|
-
**Result:** Next.js 콜백 페이지로 리다이렉트 (`/auth/callback?userId=X&keyId=Y&returnUrl=/`)
|
|
1510
|
-
|
|
1511
|
-
---
|
|
1512
|
-
|
|
1513
|
-
#### `POST /_auth/oauth/finalize`
|
|
1514
|
-
|
|
1515
|
-
OAuth 세션 완료. 인터셉터가 pending session에서 full session을 생성합니다.
|
|
1516
|
-
|
|
1517
|
-
**Request:**
|
|
1518
|
-
```typescript
|
|
1519
|
-
{
|
|
1520
|
-
userId: string;
|
|
1521
|
-
keyId: string;
|
|
1522
|
-
returnUrl?: string;
|
|
1523
|
-
}
|
|
1524
|
-
```
|
|
1525
|
-
|
|
1526
|
-
**Response:**
|
|
1527
|
-
```typescript
|
|
1528
|
-
{
|
|
1529
|
-
success: boolean;
|
|
1530
|
-
returnUrl: string;
|
|
1531
|
-
}
|
|
1532
|
-
```
|
|
1533
|
-
|
|
1534
|
-
---
|
|
1535
|
-
|
|
1536
|
-
#### `GET /_auth/oauth/providers`
|
|
1537
|
-
|
|
1538
|
-
활성화된 OAuth provider 목록을 반환합니다.
|
|
1539
|
-
|
|
1540
|
-
**Response:**
|
|
1541
|
-
```typescript
|
|
1542
|
-
{
|
|
1543
|
-
providers: ('google' | 'github' | 'kakao' | 'naver' | 'superself')[];
|
|
1544
|
-
}
|
|
1545
|
-
```
|
|
1546
|
-
|
|
1547
|
-
> 등록(`registerOAuthProvider`)되고 `isEnabled()`가 true인 provider만 반환됩니다.
|
|
1548
|
-
|
|
1549
|
-
---
|
|
1550
|
-
|
|
1551
|
-
### Google API Access
|
|
1552
|
-
|
|
1553
|
-
OAuth 로그인 후 저장된 access token으로 Google API를 호출할 수 있습니다.
|
|
1554
|
-
|
|
1555
|
-
#### Custom Scopes 설정
|
|
1556
|
-
|
|
1557
|
-
`SPFN_AUTH_GOOGLE_SCOPES` 환경변수로 추가 스코프를 요청합니다. 미설정 시 `email,profile`이 기본값입니다.
|
|
1558
|
-
|
|
1559
|
-
```bash
|
|
1560
|
-
# Gmail + Calendar 읽기 권한 추가
|
|
1561
|
-
SPFN_AUTH_GOOGLE_SCOPES=email,profile,https://www.googleapis.com/auth/gmail.readonly,https://www.googleapis.com/auth/calendar.readonly
|
|
1562
|
-
```
|
|
1563
|
-
|
|
1564
|
-
> **Note:** Google Cloud Console에서 해당 API를 활성화해야 합니다.
|
|
1565
|
-
|
|
1566
|
-
#### Access Token 사용
|
|
1567
|
-
|
|
1568
|
-
`getGoogleAccessToken(userId)`은 유효한 access token을 반환합니다. 토큰이 만료 임박(5분 이내) 또는 만료 상태이면 자동으로 refresh token을 사용하여 갱신합니다.
|
|
1569
|
-
|
|
1570
|
-
```typescript
|
|
1571
|
-
import { getGoogleAccessToken } from '@spfn/auth/server';
|
|
1572
|
-
|
|
1573
|
-
// 항상 유효한 토큰 반환 (만료 시 자동 갱신)
|
|
1574
|
-
const token = await getGoogleAccessToken(userId);
|
|
1575
|
-
|
|
1576
|
-
// Gmail API 호출
|
|
1577
|
-
const response = await fetch(
|
|
1578
|
-
'https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=10',
|
|
1579
|
-
{ headers: { Authorization: `Bearer ${token}` } }
|
|
1580
|
-
);
|
|
1581
|
-
const data = await response.json();
|
|
1582
|
-
```
|
|
1583
|
-
|
|
1584
|
-
**에러 케이스:**
|
|
1585
|
-
- Google 계정 미연결 → `'No Google account linked'`
|
|
1586
|
-
- Refresh token 없음 → `'Google refresh token not available'` (재로그인 필요)
|
|
1587
|
-
|
|
1588
|
-
---
|
|
1589
|
-
|
|
1590
|
-
### Custom OAuth Providers (Pluggable)
|
|
1591
|
-
|
|
1592
|
-
OAuth provider 분기는 하드코딩이 아니라 **registry 기반**입니다. 내장 `google` provider는 패키지 로드 시 자기 등록되며, 외부 패키지(예: `@superself/auth`)는 `registerOAuthProvider()`로 런타임에 provider를 끼울 수 있습니다.
|
|
1593
|
-
|
|
1594
|
-
#### `OAuthProvider` 인터페이스
|
|
1595
|
-
|
|
1596
|
-
```typescript
|
|
1597
|
-
import type { OAuthProvider, NormalizedIdentity, OAuthTokens } from '@spfn/auth/server';
|
|
1598
|
-
|
|
1599
|
-
interface NormalizedIdentity {
|
|
1600
|
-
providerUserId: string;
|
|
1601
|
-
email: string | null;
|
|
1602
|
-
emailVerified: boolean;
|
|
1603
|
-
name?: string;
|
|
1604
|
-
avatar?: string;
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
interface OAuthTokens {
|
|
1608
|
-
accessToken: string;
|
|
1609
|
-
refreshToken?: string;
|
|
1610
|
-
expiresIn: number; // seconds
|
|
1611
|
-
}
|
|
1612
|
-
|
|
1613
|
-
interface OAuthProvider {
|
|
1614
|
-
id: SocialProvider; // SOCIAL_PROVIDERS 중 하나
|
|
1615
|
-
isEnabled(): boolean; // 필수 설정 충족 여부
|
|
1616
|
-
getAuthUrl(state: string, scopes?: string[]): string; // authorize URL 생성
|
|
1617
|
-
exchangeCodeForTokens(code: string): Promise<OAuthTokens>; // code → token
|
|
1618
|
-
getUserInfo(accessToken: string): Promise<NormalizedIdentity>; // 사용자 정보 정규화
|
|
1619
|
-
refreshTokens?(refreshToken: string): Promise<OAuthTokens>; // (선택) 토큰 갱신
|
|
1620
|
-
}
|
|
1621
|
-
```
|
|
1622
|
-
|
|
1623
|
-
#### 등록 API
|
|
1624
|
-
|
|
1625
|
-
```typescript
|
|
1626
|
-
import { registerOAuthProvider, getOAuthProvider, getRegisteredProviders } from '@spfn/auth/server';
|
|
1627
|
-
|
|
1628
|
-
registerOAuthProvider(myProvider); // 동일 id 재등록 시 override
|
|
1629
|
-
getOAuthProvider('superself'); // OAuthProvider | undefined
|
|
1630
|
-
getRegisteredProviders(); // OAuthProvider[]
|
|
1631
|
-
```
|
|
1632
|
-
|
|
1633
|
-
provider를 등록하면 범용 시작 엔드포인트 `POST /_auth/oauth/start`(및 `oauthStartService`/`oauthCallbackService`)가 자동으로 해당 provider를 처리합니다.
|
|
1634
|
-
|
|
1635
|
-
#### 통합 계약 ⚠️
|
|
1636
|
-
|
|
1637
|
-
- **콜백 route는 소비 측 책임**입니다. 이 패키지는 `GET /_auth/oauth/google/callback`(google 고정)만 제공합니다. 커스텀 provider는 자신의 콜백 route에서 `oauthCallbackService({ provider, code, state })`를 호출해야 흐름이 완결됩니다.
|
|
1638
|
-
- **콜백 route는 `Transactional()`로 감싸세요.** `oauthCallbackService`는 사용자 생성/연결과 소셜 계정 저장을 순차로 수행하므로, 중간 실패 시 orphan user가 남지 않으려면 트랜잭션이 필요합니다. (내장 google 콜백 route도 `.use([Transactional()])`를 사용합니다.)
|
|
1639
|
-
- `SOCIAL_PROVIDERS` enum에 provider id가 포함되어 있어야 합니다. (현재: `google`, `github`, `kakao`, `naver`, `superself`)
|
|
1640
|
-
- 등록은 모듈 로드 시점의 side-effect입니다. 번들러에서 `package.json`에 `"sideEffects": false`를 추가하면 내장 google 등록이 tree-shake될 수 있으니 주의하세요.
|
|
1641
|
-
|
|
1642
|
-
> **이벤트 영향**: `auth.login` / `auth.register` 이벤트의 `provider` 필드에 이제 모든 `SOCIAL_PROVIDERS` 값이 들어올 수 있습니다. 구독자의 `switch(provider)`에 새 값 처리를 추가하세요.
|
|
1643
|
-
|
|
1644
|
-
---
|
|
1645
|
-
|
|
1646
|
-
### Security
|
|
1647
|
-
|
|
1648
|
-
- **State 암호화**: JWE (A256GCM)로 state 파라미터 암호화. CSRF 방지용 nonce 포함.
|
|
1649
|
-
- **Pending Session**: OAuth 리다이렉트 중 privateKey를 JWE로 암호화한 HttpOnly 쿠키에 저장. 10분 TTL.
|
|
1650
|
-
- **KeyId 검증**: finalize 시 pending session의 keyId와 응답의 keyId 일치 확인.
|
|
1651
|
-
- **Email 검증**: `verified_email`이 true인 경우에만 기존 계정에 자동 연결. 미검증 이메일로 기존 계정 연결 시도 시 에러.
|
|
1652
|
-
- **Session Cookie**: `HttpOnly`, `Secure` (production), `SameSite=strict`.
|
|
1653
|
-
|
|
1654
|
-
---
|
|
1655
|
-
|
|
1656
|
-
### OAuthCallback Component
|
|
1657
|
-
|
|
1658
|
-
`@spfn/auth/nextjs/client`에서 제공하는 클라이언트 컴포넌트입니다.
|
|
1659
|
-
|
|
1660
|
-
```tsx
|
|
1661
|
-
import { OAuthCallback } from '@spfn/auth/nextjs/client';
|
|
1662
|
-
|
|
1663
|
-
// 기본 사용
|
|
1664
|
-
export default function CallbackPage()
|
|
1665
|
-
{
|
|
1666
|
-
return <OAuthCallback />;
|
|
1667
|
-
}
|
|
1668
|
-
|
|
1669
|
-
// 커스터마이징
|
|
1670
|
-
export default function CallbackPage()
|
|
1671
|
-
{
|
|
1672
|
-
return (
|
|
1673
|
-
<OAuthCallback
|
|
1674
|
-
apiBasePath="/api/rpc"
|
|
1675
|
-
loadingComponent={<MySpinner />}
|
|
1676
|
-
errorComponent={(error) => <MyError message={error} />}
|
|
1677
|
-
onSuccess={(userId) => console.log('Logged in:', userId)}
|
|
1678
|
-
onError={(error) => console.error(error)}
|
|
1679
|
-
/>
|
|
1680
|
-
);
|
|
1681
|
-
}
|
|
1682
|
-
```
|
|
1683
|
-
|
|
1684
|
-
**Props:**
|
|
1685
|
-
|
|
1686
|
-
| Prop | Type | Default | Description |
|
|
1687
|
-
|------|------|---------|-------------|
|
|
1688
|
-
| `apiBasePath` | `string` | `'/api/rpc'` | RPC API base path |
|
|
1689
|
-
| `loadingComponent` | `ReactNode` | Built-in | 로딩 중 표시할 컴포넌트 |
|
|
1690
|
-
| `errorComponent` | `(error: string) => ReactNode` | Built-in | 에러 표시 컴포넌트 |
|
|
1691
|
-
| `onSuccess` | `(userId: string) => void` | - | 성공 콜백 |
|
|
1692
|
-
| `onError` | `(error: string) => void` | - | 에러 콜백 |
|
|
1693
|
-
|
|
1694
|
-
---
|
|
1695
|
-
|
|
1696
|
-
## Database Schema
|
|
1697
|
-
|
|
1698
|
-
### Core Tables
|
|
1699
|
-
|
|
1700
|
-
#### `users`
|
|
1701
|
-
|
|
1702
|
-
Main user identity table.
|
|
1703
|
-
|
|
1704
|
-
```sql
|
|
1705
|
-
CREATE TABLE users (
|
|
1706
|
-
id BIGSERIAL PRIMARY KEY,
|
|
1707
|
-
public_id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
|
|
1708
|
-
email TEXT UNIQUE,
|
|
1709
|
-
phone TEXT UNIQUE,
|
|
1710
|
-
username TEXT UNIQUE,
|
|
1711
|
-
password_hash TEXT NOT NULL,
|
|
1712
|
-
password_change_required BOOLEAN DEFAULT false,
|
|
1713
|
-
role_id BIGINT REFERENCES roles(id) NOT NULL,
|
|
1714
|
-
status TEXT NOT NULL CHECK (status IN ('active', 'inactive', 'suspended')),
|
|
1715
|
-
email_verified_at TIMESTAMP,
|
|
1716
|
-
phone_verified_at TIMESTAMP,
|
|
1717
|
-
last_login_at TIMESTAMP,
|
|
1718
|
-
created_at TIMESTAMP DEFAULT NOW(),
|
|
1719
|
-
updated_at TIMESTAMP DEFAULT NOW(),
|
|
1720
|
-
|
|
1721
|
-
CONSTRAINT users_identifier_check CHECK (
|
|
1722
|
-
(email IS NOT NULL) OR (phone IS NOT NULL)
|
|
1723
|
-
)
|
|
1724
|
-
);
|
|
1725
|
-
```
|
|
1726
|
-
|
|
1727
|
-
**Key Points:**
|
|
1728
|
-
- `public_id` is a UUID v4 for external-facing URLs and APIs (never expose internal `id`)
|
|
1729
|
-
- At least one of `email` OR `phone` required
|
|
1730
|
-
- `username` is unique and nullable (optional display/mention identifier)
|
|
1731
|
-
- `passwordHash` is bcrypt ($2b$10$..., 60 chars)
|
|
1732
|
-
- `roleId` references roles table (NOT NULL)
|
|
1733
|
-
|
|
1734
|
-
---
|
|
1735
|
-
|
|
1736
|
-
#### `user_public_keys`
|
|
1737
|
-
|
|
1738
|
-
Stores client public keys for JWT verification.
|
|
1739
|
-
|
|
1740
|
-
```sql
|
|
1741
|
-
CREATE TABLE user_public_keys (
|
|
1742
|
-
id BIGSERIAL PRIMARY KEY,
|
|
1743
|
-
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
|
|
1744
|
-
key_id TEXT UNIQUE NOT NULL,
|
|
1745
|
-
public_key TEXT NOT NULL,
|
|
1746
|
-
algorithm TEXT NOT NULL CHECK (algorithm IN ('ES256', 'RS256')),
|
|
1747
|
-
fingerprint TEXT NOT NULL,
|
|
1748
|
-
is_active BOOLEAN DEFAULT true,
|
|
1749
|
-
created_at TIMESTAMP DEFAULT NOW(),
|
|
1750
|
-
last_used_at TIMESTAMP,
|
|
1751
|
-
expires_at TIMESTAMP NOT NULL,
|
|
1752
|
-
revoked_at TIMESTAMP,
|
|
1753
|
-
revoked_reason TEXT
|
|
1754
|
-
);
|
|
1755
|
-
|
|
1756
|
-
CREATE INDEX idx_user_public_keys_user_id ON user_public_keys(user_id);
|
|
1757
|
-
CREATE INDEX idx_user_public_keys_key_id ON user_public_keys(key_id);
|
|
1758
|
-
CREATE INDEX idx_user_public_keys_is_active ON user_public_keys(is_active);
|
|
1759
|
-
```
|
|
1760
|
-
|
|
1761
|
-
**Key Points:**
|
|
1762
|
-
- `keyId` is client-generated UUID v4
|
|
1763
|
-
- `fingerprint` is SHA-256(publicKey) for verification
|
|
1764
|
-
- `expiresAt` defaults to 90 days from creation
|
|
1765
|
-
- `isActive` determines if key can be used
|
|
1766
|
-
|
|
1767
|
-
---
|
|
1768
|
-
|
|
1769
|
-
#### `verification_codes`
|
|
1770
|
-
|
|
1771
|
-
OTP codes for email/SMS verification.
|
|
1772
|
-
|
|
1773
|
-
```sql
|
|
1774
|
-
CREATE TABLE verification_codes (
|
|
1775
|
-
id BIGSERIAL PRIMARY KEY,
|
|
1776
|
-
target TEXT NOT NULL,
|
|
1777
|
-
target_type TEXT NOT NULL CHECK (target_type IN ('email', 'phone')),
|
|
1778
|
-
code TEXT NOT NULL,
|
|
1779
|
-
purpose TEXT NOT NULL CHECK (purpose IN ('registration', 'login', 'password_reset')),
|
|
1780
|
-
expires_at TIMESTAMP NOT NULL,
|
|
1781
|
-
used_at TIMESTAMP,
|
|
1782
|
-
created_at TIMESTAMP DEFAULT NOW()
|
|
1783
|
-
);
|
|
1784
|
-
|
|
1785
|
-
CREATE INDEX idx_verification_codes_target ON verification_codes(target);
|
|
1786
|
-
```
|
|
1787
|
-
|
|
1788
|
-
**Key Points:**
|
|
1789
|
-
- 6-digit numeric code
|
|
1790
|
-
- Expires in 5-10 minutes (configurable)
|
|
1791
|
-
- Single-use (marked via `usedAt`)
|
|
1792
|
-
|
|
1793
|
-
---
|
|
1794
|
-
|
|
1795
|
-
### RBAC Tables
|
|
1796
|
-
|
|
1797
|
-
#### `roles`
|
|
1798
|
-
|
|
1799
|
-
```sql
|
|
1800
|
-
CREATE TABLE roles (
|
|
1801
|
-
id BIGSERIAL PRIMARY KEY,
|
|
1802
|
-
name TEXT UNIQUE NOT NULL,
|
|
1803
|
-
display_name TEXT NOT NULL,
|
|
1804
|
-
description TEXT,
|
|
1805
|
-
is_builtin BOOLEAN DEFAULT false,
|
|
1806
|
-
is_system BOOLEAN DEFAULT false,
|
|
1807
|
-
is_active BOOLEAN DEFAULT true,
|
|
1808
|
-
priority INTEGER NOT NULL,
|
|
1809
|
-
created_at TIMESTAMP DEFAULT NOW(),
|
|
1810
|
-
updated_at TIMESTAMP DEFAULT NOW()
|
|
1811
|
-
);
|
|
1812
|
-
```
|
|
1813
|
-
|
|
1814
|
-
**Built-in Roles:**
|
|
1815
|
-
- `user` (priority 10) - Default role
|
|
1816
|
-
- `admin` (priority 80)
|
|
1817
|
-
- `superadmin` (priority 100)
|
|
1818
|
-
|
|
1819
|
-
---
|
|
1820
|
-
|
|
1821
|
-
#### `permissions`
|
|
1822
|
-
|
|
1823
|
-
```sql
|
|
1824
|
-
CREATE TABLE permissions (
|
|
1825
|
-
id BIGSERIAL PRIMARY KEY,
|
|
1826
|
-
name TEXT UNIQUE NOT NULL,
|
|
1827
|
-
display_name TEXT NOT NULL,
|
|
1828
|
-
description TEXT,
|
|
1829
|
-
category TEXT,
|
|
1830
|
-
is_builtin BOOLEAN DEFAULT false,
|
|
1831
|
-
is_system BOOLEAN DEFAULT false,
|
|
1832
|
-
is_active BOOLEAN DEFAULT true,
|
|
1833
|
-
created_at TIMESTAMP DEFAULT NOW(),
|
|
1834
|
-
updated_at TIMESTAMP DEFAULT NOW()
|
|
1835
|
-
);
|
|
1836
|
-
```
|
|
1837
|
-
|
|
1838
|
-
**Built-in Permissions:**
|
|
1839
|
-
- `auth:self:manage`
|
|
1840
|
-
- `user:read`, `user:write`, `user:delete`, `user:invite`
|
|
1841
|
-
- `rbac:role:manage`, `rbac:permission:manage`
|
|
1842
|
-
|
|
1843
|
-
---
|
|
1844
|
-
|
|
1845
|
-
#### `role_permissions`
|
|
1846
|
-
|
|
1847
|
-
Many-to-many mapping between roles and permissions.
|
|
1848
|
-
|
|
1849
|
-
```sql
|
|
1850
|
-
CREATE TABLE role_permissions (
|
|
1851
|
-
id BIGSERIAL PRIMARY KEY,
|
|
1852
|
-
role_id BIGINT REFERENCES roles(id) ON DELETE CASCADE,
|
|
1853
|
-
permission_id BIGINT REFERENCES permissions(id) ON DELETE CASCADE,
|
|
1854
|
-
created_at TIMESTAMP DEFAULT NOW(),
|
|
1855
|
-
updated_at TIMESTAMP DEFAULT NOW(),
|
|
1856
|
-
|
|
1857
|
-
UNIQUE(role_id, permission_id)
|
|
1858
|
-
);
|
|
1859
|
-
```
|
|
1860
|
-
|
|
1861
|
-
---
|
|
1862
|
-
|
|
1863
|
-
#### `user_permissions`
|
|
1864
|
-
|
|
1865
|
-
User-specific permission overrides.
|
|
1866
|
-
|
|
1867
|
-
```sql
|
|
1868
|
-
CREATE TABLE user_permissions (
|
|
1869
|
-
id BIGSERIAL PRIMARY KEY,
|
|
1870
|
-
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
|
|
1871
|
-
permission_id BIGINT REFERENCES permissions(id) ON DELETE CASCADE,
|
|
1872
|
-
granted BOOLEAN NOT NULL,
|
|
1873
|
-
reason TEXT,
|
|
1874
|
-
expires_at TIMESTAMP,
|
|
1875
|
-
created_at TIMESTAMP DEFAULT NOW(),
|
|
1876
|
-
updated_at TIMESTAMP DEFAULT NOW(),
|
|
1877
|
-
|
|
1878
|
-
UNIQUE(user_id, permission_id)
|
|
1879
|
-
);
|
|
1880
|
-
```
|
|
1881
|
-
|
|
1882
|
-
**Use Cases:**
|
|
1883
|
-
- `granted: true` - Grant permission temporarily
|
|
1884
|
-
- `granted: false` - Revoke permission (even if role has it)
|
|
1885
|
-
- `expiresAt` - Temporary access with expiration
|
|
1886
|
-
|
|
1887
|
-
---
|
|
1888
|
-
|
|
1889
|
-
### Supporting Tables
|
|
1890
|
-
|
|
1891
|
-
#### `invitations`
|
|
1892
|
-
|
|
1893
|
-
User invitation system.
|
|
1894
|
-
|
|
1895
|
-
```sql
|
|
1896
|
-
CREATE TABLE invitations (
|
|
1897
|
-
id BIGSERIAL PRIMARY KEY,
|
|
1898
|
-
email TEXT NOT NULL,
|
|
1899
|
-
token TEXT UNIQUE NOT NULL,
|
|
1900
|
-
role_id BIGINT REFERENCES roles(id),
|
|
1901
|
-
invited_by BIGINT REFERENCES users(id),
|
|
1902
|
-
status TEXT CHECK (status IN ('pending', 'accepted', 'cancelled', 'expired')),
|
|
1903
|
-
expires_at TIMESTAMP NOT NULL,
|
|
1904
|
-
accepted_at TIMESTAMP,
|
|
1905
|
-
created_at TIMESTAMP DEFAULT NOW()
|
|
1906
|
-
);
|
|
1907
|
-
```
|
|
1908
|
-
|
|
1909
|
-
---
|
|
1910
|
-
|
|
1911
|
-
#### `user_profiles`
|
|
1912
|
-
|
|
1913
|
-
Extended user profile information.
|
|
1914
|
-
|
|
1915
|
-
```sql
|
|
1916
|
-
CREATE TABLE user_profiles (
|
|
1917
|
-
id BIGSERIAL PRIMARY KEY,
|
|
1918
|
-
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE UNIQUE,
|
|
1919
|
-
first_name TEXT,
|
|
1920
|
-
last_name TEXT,
|
|
1921
|
-
display_name TEXT,
|
|
1922
|
-
avatar_url TEXT,
|
|
1923
|
-
bio TEXT,
|
|
1924
|
-
created_at TIMESTAMP DEFAULT NOW(),
|
|
1925
|
-
updated_at TIMESTAMP DEFAULT NOW()
|
|
1926
|
-
);
|
|
1927
|
-
```
|
|
1928
|
-
|
|
1929
|
-
---
|
|
1930
|
-
|
|
1931
|
-
#### `user_social_accounts`
|
|
1932
|
-
|
|
1933
|
-
OAuth provider accounts (Google, GitHub, etc.).
|
|
1934
|
-
|
|
1935
|
-
```sql
|
|
1936
|
-
CREATE TABLE user_social_accounts (
|
|
1937
|
-
id BIGSERIAL PRIMARY KEY,
|
|
1938
|
-
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
|
|
1939
|
-
provider TEXT NOT NULL,
|
|
1940
|
-
provider_id TEXT NOT NULL,
|
|
1941
|
-
access_token TEXT,
|
|
1942
|
-
refresh_token TEXT,
|
|
1943
|
-
expires_at TIMESTAMP,
|
|
1944
|
-
created_at TIMESTAMP DEFAULT NOW(),
|
|
1945
|
-
|
|
1946
|
-
UNIQUE(provider, provider_id)
|
|
1947
|
-
);
|
|
1948
|
-
```
|
|
1949
|
-
|
|
1950
|
-
---
|
|
1951
|
-
|
|
1952
|
-
## RBAC System
|
|
1953
|
-
|
|
1954
|
-
### Initialization
|
|
1955
|
-
|
|
1956
|
-
```typescript
|
|
1957
|
-
import { initializeAuth } from '@spfn/auth/server';
|
|
1958
|
-
|
|
1959
|
-
// Minimal setup (built-in roles only)
|
|
1960
|
-
await initializeAuth();
|
|
1961
|
-
|
|
1962
|
-
// With presets
|
|
1963
|
-
await initializeAuth({
|
|
1964
|
-
usePresets: true, // Adds moderator, editor, viewer roles
|
|
1965
|
-
});
|
|
1966
|
-
|
|
1967
|
-
// Custom roles and permissions
|
|
1968
|
-
await initializeAuth({
|
|
1969
|
-
roles: [
|
|
1970
|
-
{
|
|
1971
|
-
name: 'content-creator',
|
|
1972
|
-
displayName: 'Content Creator',
|
|
1973
|
-
priority: 20,
|
|
1974
|
-
},
|
|
1975
|
-
],
|
|
1976
|
-
permissions: [
|
|
1977
|
-
{
|
|
1978
|
-
name: 'post:create',
|
|
1979
|
-
displayName: 'Create Posts',
|
|
1980
|
-
category: 'content',
|
|
1981
|
-
},
|
|
1982
|
-
],
|
|
1983
|
-
rolePermissions: {
|
|
1984
|
-
'content-creator': ['post:create'],
|
|
1985
|
-
},
|
|
1986
|
-
});
|
|
1987
|
-
```
|
|
1988
|
-
|
|
1989
|
-
---
|
|
1990
|
-
|
|
1991
|
-
### Built-in System
|
|
1992
|
-
|
|
1993
|
-
**Roles:**
|
|
1994
|
-
- `superadmin` (priority 100) - Full access
|
|
1995
|
-
- `admin` (priority 80) - User management
|
|
1996
|
-
- `user` (priority 10) - Self management
|
|
1997
|
-
|
|
1998
|
-
**Permissions:**
|
|
1999
|
-
- `auth:self:manage` - Change password, rotate keys
|
|
2000
|
-
- `user:read`, `user:write`, `user:delete`, `user:invite`
|
|
2001
|
-
- `rbac:role:manage`, `rbac:permission:manage`
|
|
2002
|
-
|
|
2003
|
-
---
|
|
2004
|
-
|
|
2005
|
-
### Middleware Usage
|
|
2006
|
-
|
|
2007
|
-
```typescript
|
|
2008
|
-
import { authenticate, requirePermissions, requireAnyPermission, requireRole } from '@spfn/auth/server';
|
|
2009
|
-
|
|
2010
|
-
// Single permission
|
|
2011
|
-
app.bind(
|
|
2012
|
-
deleteUserContract,
|
|
2013
|
-
[authenticate, requirePermissions('user:delete')],
|
|
2014
|
-
async (c) => {
|
|
2015
|
-
// Only users with user:delete permission
|
|
2016
|
-
}
|
|
2017
|
-
);
|
|
2018
|
-
|
|
2019
|
-
// Multiple permissions (all required)
|
|
2020
|
-
app.bind(
|
|
2021
|
-
publishPostContract,
|
|
2022
|
-
[authenticate, requirePermissions('post:write', 'post:publish')],
|
|
2023
|
-
async (c) => {
|
|
2024
|
-
// Needs both permissions
|
|
2025
|
-
}
|
|
2026
|
-
);
|
|
2027
|
-
|
|
2028
|
-
// Any of the permissions (at least one required)
|
|
2029
|
-
app.bind(
|
|
2030
|
-
viewContentContract,
|
|
2031
|
-
[authenticate, requireAnyPermission('content:read', 'admin:access')],
|
|
2032
|
-
async (c) => {
|
|
2033
|
-
// User has either content:read OR admin:access
|
|
2034
|
-
}
|
|
2035
|
-
);
|
|
2036
|
-
|
|
2037
|
-
// Role-based
|
|
2038
|
-
app.bind(
|
|
2039
|
-
adminDashboardContract,
|
|
2040
|
-
[authenticate, requireRole('admin', 'superadmin')],
|
|
2041
|
-
async (c) => {
|
|
2042
|
-
// Admin or superadmin only
|
|
2043
|
-
}
|
|
2044
|
-
);
|
|
2045
|
-
```
|
|
2046
|
-
|
|
2047
|
-
---
|
|
2048
|
-
|
|
2049
|
-
### Programmatic Checks
|
|
2050
|
-
|
|
2051
|
-
```typescript
|
|
2052
|
-
import { hasPermission, hasRole, getUserPermissions } from '@spfn/auth/server';
|
|
2053
|
-
|
|
2054
|
-
const canPublish = await hasPermission(userId, 'post:publish');
|
|
2055
|
-
const isAdmin = await hasRole(userId, 'admin');
|
|
2056
|
-
const permissions = await getUserPermissions(userId);
|
|
2057
|
-
|
|
2058
|
-
if (canPublish)
|
|
2059
|
-
{
|
|
2060
|
-
// Allow publish
|
|
2061
|
-
}
|
|
2062
|
-
```
|
|
2063
|
-
|
|
2064
|
-
---
|
|
2065
|
-
|
|
2066
|
-
### Runtime Role Management
|
|
2067
|
-
|
|
2068
|
-
```typescript
|
|
2069
|
-
import { createRole, addPermissionToRole } from '@spfn/auth/server';
|
|
2070
|
-
|
|
2071
|
-
// Create role
|
|
2072
|
-
const role = await createRole({
|
|
2073
|
-
name: 'moderator',
|
|
2074
|
-
displayName: 'Moderator',
|
|
2075
|
-
priority: 40,
|
|
2076
|
-
permissionIds: [1n, 2n],
|
|
2077
|
-
});
|
|
2078
|
-
|
|
2079
|
-
// Add permission
|
|
2080
|
-
await addPermissionToRole(role.id, 5n);
|
|
2081
|
-
|
|
2082
|
-
// Delete (system roles protected)
|
|
2083
|
-
await deleteRole(role.id);
|
|
2084
|
-
```
|
|
2085
|
-
|
|
2086
|
-
---
|
|
2087
|
-
|
|
2088
|
-
## Next.js Adapter
|
|
2089
|
-
|
|
2090
|
-
### Session Management
|
|
2091
|
-
|
|
2092
|
-
The Next.js adapter provides encrypted HttpOnly cookie-based sessions.
|
|
2093
|
-
|
|
2094
|
-
**Configuration:**
|
|
2095
|
-
```bash
|
|
2096
|
-
# .env
|
|
2097
|
-
SPFN_AUTH_SESSION_SECRET=your-32-char-secret
|
|
2098
|
-
SPFN_AUTH_SESSION_TTL=7d # Optional, default 7d
|
|
2099
|
-
```
|
|
2100
|
-
|
|
2101
|
-
**Session Data:**
|
|
2102
|
-
```typescript
|
|
2103
|
-
interface SessionData {
|
|
2104
|
-
userId: string;
|
|
2105
|
-
privateKey: string; // Encrypted in cookie
|
|
2106
|
-
keyId: string;
|
|
2107
|
-
algorithm: 'ES256' | 'RS256';
|
|
2108
|
-
}
|
|
2109
|
-
```
|
|
2110
|
-
|
|
2111
|
-
---
|
|
2112
|
-
|
|
2113
|
-
### Server Component Guards
|
|
2114
|
-
|
|
2115
|
-
```typescript
|
|
2116
|
-
// app/admin/page.tsx
|
|
2117
|
-
import { RequireAuth, RequireRole } from '@spfn/auth/nextjs/server';
|
|
2118
|
-
|
|
2119
|
-
export default async function AdminPage()
|
|
2120
|
-
{
|
|
2121
|
-
return (
|
|
2122
|
-
<RequireAuth redirectTo="/login">
|
|
2123
|
-
<RequireRole roles={['admin', 'superadmin']} redirectTo="/forbidden">
|
|
2124
|
-
<div>Admin Dashboard</div>
|
|
2125
|
-
</RequireRole>
|
|
2126
|
-
</RequireAuth>
|
|
2127
|
-
);
|
|
2128
|
-
}
|
|
2129
|
-
```
|
|
2130
|
-
|
|
2131
|
-
---
|
|
2132
|
-
|
|
2133
|
-
### Interceptors (API Routes)
|
|
2134
|
-
|
|
2135
|
-
**Setup:**
|
|
2136
|
-
```typescript
|
|
2137
|
-
// Simply import to auto-register
|
|
2138
|
-
import '@spfn/auth/nextjs/api';
|
|
2139
|
-
```
|
|
2140
|
-
|
|
2141
|
-
**How It Works:**
|
|
2142
|
-
1. Reads `session` HttpOnly cookie
|
|
2143
|
-
2. Unseals session data
|
|
2144
|
-
3. Generates JWT signed with `privateKey`
|
|
2145
|
-
4. Injects `Authorization: Bearer <jwt>` header
|
|
2146
|
-
|
|
2147
|
-
**Target Routes:**
|
|
2148
|
-
- `/_auth/login`, `/_auth/register` - Login/register interceptor
|
|
2149
|
-
- `/_auth/keys/rotate` - Key rotation interceptor
|
|
2150
|
-
- `/_auth/oauth/:provider/url` - OAuth URL interceptor (keypair + state generation)
|
|
2151
|
-
- `/_auth/oauth/finalize` - OAuth finalize interceptor (pending session → full session)
|
|
2152
|
-
- All other authenticated routes - General auth interceptor
|
|
2153
|
-
|
|
2154
|
-
---
|
|
2155
|
-
|
|
2156
|
-
### OAuth Client Component (`@spfn/auth/nextjs/client`)
|
|
2157
|
-
|
|
2158
|
-
```typescript
|
|
2159
|
-
import { OAuthCallback, type OAuthCallbackProps } from '@spfn/auth/nextjs/client';
|
|
2160
|
-
```
|
|
2161
|
-
|
|
2162
|
-
OAuth 콜백 페이지용 `'use client'` 컴포넌트. 자세한 사용법은 [OAuth Authentication](#oauth-authentication) 섹션 참조.
|
|
2163
|
-
|
|
2164
|
-
---
|
|
2165
|
-
|
|
2166
|
-
## Testing
|
|
2167
|
-
|
|
2168
|
-
### Setup Test Environment
|
|
2169
|
-
|
|
2170
|
-
```bash
|
|
2171
|
-
# Start test database
|
|
2172
|
-
pnpm docker:test:up
|
|
2173
|
-
|
|
2174
|
-
# Generate migrations
|
|
2175
|
-
pnpm db:generate
|
|
2176
|
-
|
|
2177
|
-
# Run migrations (via @spfn/core)
|
|
2178
|
-
cd ../../
|
|
2179
|
-
pnpm spfn db migrate
|
|
2180
|
-
```
|
|
2181
|
-
|
|
2182
|
-
---
|
|
2183
|
-
|
|
2184
|
-
### Run Tests
|
|
2185
|
-
|
|
2186
|
-
```bash
|
|
2187
|
-
# All tests
|
|
2188
|
-
pnpm test
|
|
2189
|
-
|
|
2190
|
-
# With coverage
|
|
2191
|
-
pnpm test:coverage
|
|
2192
|
-
|
|
2193
|
-
# Route tests only
|
|
2194
|
-
pnpm test:routes
|
|
2195
|
-
|
|
2196
|
-
# Watch mode
|
|
2197
|
-
pnpm test --watch
|
|
2198
|
-
```
|
|
2199
|
-
|
|
2200
|
-
---
|
|
2201
|
-
|
|
2202
|
-
### Test Structure
|
|
2203
|
-
|
|
2204
|
-
```
|
|
2205
|
-
src/
|
|
2206
|
-
├── __tests__/
|
|
2207
|
-
│ └── setup.ts # Global test setup
|
|
2208
|
-
└── server/
|
|
2209
|
-
├── routes/
|
|
2210
|
-
│ └── auth/
|
|
2211
|
-
│ └── __tests__/
|
|
2212
|
-
│ ├── login.test.ts
|
|
2213
|
-
│ ├── register.test.ts
|
|
2214
|
-
│ └── ...
|
|
2215
|
-
└── services/
|
|
2216
|
-
└── __tests__/
|
|
2217
|
-
├── auth.service.test.ts
|
|
2218
|
-
└── ...
|
|
2219
|
-
```
|
|
2220
|
-
|
|
2221
|
-
---
|
|
2222
|
-
|
|
2223
|
-
### Writing Tests
|
|
2224
|
-
|
|
2225
|
-
```typescript
|
|
2226
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2227
|
-
import { loginService } from '@/server/services';
|
|
2228
|
-
|
|
2229
|
-
describe('loginService', () =>
|
|
2230
|
-
{
|
|
2231
|
-
beforeEach(async () =>
|
|
2232
|
-
{
|
|
2233
|
-
// Setup test data
|
|
2234
|
-
});
|
|
2235
|
-
|
|
2236
|
-
it('should login with valid credentials', async () =>
|
|
2237
|
-
{
|
|
2238
|
-
const result = await loginService({
|
|
2239
|
-
email: 'test@example.com',
|
|
2240
|
-
password: 'password123',
|
|
2241
|
-
publicKey: '...',
|
|
2242
|
-
keyId: '...',
|
|
2243
|
-
fingerprint: '...',
|
|
2244
|
-
algorithm: 'ES256',
|
|
2245
|
-
});
|
|
2246
|
-
|
|
2247
|
-
expect(result.userId).toBeDefined();
|
|
2248
|
-
});
|
|
2249
|
-
});
|
|
2250
|
-
```
|
|
2251
|
-
|
|
2252
|
-
---
|
|
2253
|
-
|
|
2254
|
-
### Test Database
|
|
2255
|
-
|
|
2256
|
-
**docker-compose.test.yml:**
|
|
2257
|
-
```yaml
|
|
2258
|
-
services:
|
|
2259
|
-
postgres-test:
|
|
2260
|
-
image: postgres:16-alpine
|
|
2261
|
-
environment:
|
|
2262
|
-
POSTGRES_DB: spfn_auth_test
|
|
2263
|
-
POSTGRES_USER: spfn
|
|
2264
|
-
POSTGRES_PASSWORD: spfn_dev_password
|
|
2265
|
-
ports:
|
|
2266
|
-
- "5433:5432"
|
|
2267
|
-
```
|
|
2268
|
-
|
|
2269
|
-
**Test env variables:**
|
|
2270
|
-
```bash
|
|
2271
|
-
DATABASE_URL=postgresql://spfn:spfn_dev_password@localhost:5433/spfn_auth_test
|
|
2272
|
-
```
|
|
2273
|
-
|
|
2274
|
-
---
|
|
2275
|
-
|
|
2276
|
-
## Development Workflow
|
|
2277
|
-
|
|
2278
|
-
### Initial Setup
|
|
2279
|
-
|
|
2280
|
-
```bash
|
|
2281
|
-
# Install dependencies
|
|
2282
|
-
pnpm install
|
|
2283
|
-
|
|
2284
|
-
# Generate migrations
|
|
2285
|
-
pnpm db:generate
|
|
2286
|
-
|
|
2287
|
-
# Build package
|
|
2288
|
-
pnpm build
|
|
2289
|
-
```
|
|
2290
|
-
|
|
2291
|
-
---
|
|
2292
|
-
|
|
2293
|
-
### Development
|
|
2294
|
-
|
|
2295
|
-
```bash
|
|
2296
|
-
# Watch mode (auto-rebuild on changes)
|
|
2297
|
-
pnpm dev
|
|
2298
|
-
|
|
2299
|
-
# Type checking
|
|
2300
|
-
pnpm type-check
|
|
2301
|
-
|
|
2302
|
-
# Run tests
|
|
2303
|
-
pnpm test
|
|
2304
|
-
```
|
|
2305
|
-
|
|
2306
|
-
---
|
|
2307
|
-
|
|
2308
|
-
### Build Process
|
|
2309
|
-
|
|
2310
|
-
The package uses `tsup` for building:
|
|
2311
|
-
|
|
2312
|
-
**tsup.config.ts:**
|
|
2313
|
-
```typescript
|
|
2314
|
-
export default defineConfig({
|
|
2315
|
-
entry: {
|
|
2316
|
-
index: 'src/index.ts',
|
|
2317
|
-
server: 'src/server.ts',
|
|
2318
|
-
client: 'src/client.ts',
|
|
2319
|
-
// ... more entry points
|
|
2320
|
-
},
|
|
2321
|
-
format: ['esm'],
|
|
2322
|
-
dts: true,
|
|
2323
|
-
clean: true,
|
|
2324
|
-
sourcemap: true,
|
|
2325
|
-
});
|
|
2326
|
-
```
|
|
2327
|
-
|
|
2328
|
-
**Build outputs:**
|
|
2329
|
-
- `dist/index.js` + `dist/index.d.ts`
|
|
2330
|
-
- `dist/server.js` + `dist/server.d.ts`
|
|
2331
|
-
- `dist/client.js` + `dist/client.d.ts`
|
|
2332
|
-
- `dist/config/`, `dist/errors/`, `dist/nextjs/`
|
|
2333
|
-
|
|
2334
|
-
---
|
|
2335
|
-
|
|
2336
|
-
### Database Migrations
|
|
2337
|
-
|
|
2338
|
-
```bash
|
|
2339
|
-
# Generate new migration (after entity changes)
|
|
2340
|
-
pnpm db:generate
|
|
2341
|
-
|
|
2342
|
-
# Apply migrations (via SPFN CLI)
|
|
2343
|
-
cd ../../
|
|
2344
|
-
pnpm spfn db migrate
|
|
2345
|
-
|
|
2346
|
-
# View database
|
|
2347
|
-
pnpm spfn db studio
|
|
2348
|
-
```
|
|
2349
|
-
|
|
2350
|
-
**Migration files:** `migrations/*.sql`
|
|
2351
|
-
|
|
2352
|
-
---
|
|
2353
|
-
|
|
2354
|
-
### SPFN Plugin Integration
|
|
2355
|
-
|
|
2356
|
-
**package.json:**
|
|
2357
|
-
```json
|
|
2358
|
-
{
|
|
2359
|
-
"spfn": {
|
|
2360
|
-
"schemas": ["./dist/server/entities/*.js"],
|
|
2361
|
-
"routes": {
|
|
2362
|
-
"basePath": "/_auth",
|
|
2363
|
-
"dir": "./dist/server/routes"
|
|
2364
|
-
},
|
|
2365
|
-
"migrations": {
|
|
2366
|
-
"dir": "./migrations"
|
|
2367
|
-
}
|
|
2368
|
-
}
|
|
2369
|
-
}
|
|
2370
|
-
```
|
|
2371
|
-
|
|
2372
|
-
**How it works:**
|
|
2373
|
-
1. SPFN CLI discovers packages with `spfn` field
|
|
2374
|
-
2. Auto-loads database schemas
|
|
2375
|
-
3. Auto-registers routes at `basePath`
|
|
2376
|
-
4. Includes migrations in `db migrate` command
|
|
2377
|
-
|
|
2378
|
-
---
|
|
2379
|
-
|
|
2380
|
-
### Code Style
|
|
2381
|
-
|
|
2382
|
-
Follow the project's code style (see `/Users/launchscreen/PROJECTS/SPFN/workspaces/.claude/rules.md`):
|
|
2383
|
-
|
|
2384
|
-
- **Brace placement:** Next line (Allman-style)
|
|
2385
|
-
- **Indentation:** 4 spaces
|
|
2386
|
-
- **Semicolons:** Always
|
|
2387
|
-
- **Type assertions:** Use `as`, not `<>`
|
|
2388
|
-
|
|
2389
|
-
**Example:**
|
|
2390
|
-
```typescript
|
|
2391
|
-
export async function myFunction(): Promise<void>
|
|
2392
|
-
{
|
|
2393
|
-
if (condition)
|
|
2394
|
-
{
|
|
2395
|
-
await operation();
|
|
2396
|
-
}
|
|
2397
|
-
else
|
|
2398
|
-
{
|
|
2399
|
-
handleError();
|
|
2400
|
-
}
|
|
2401
|
-
}
|
|
2402
|
-
```
|
|
2403
|
-
|
|
2404
|
-
---
|
|
2405
|
-
|
|
2406
|
-
### Environment Variables
|
|
2407
|
-
|
|
2408
|
-
**Server-side:**
|
|
2409
|
-
```bash
|
|
2410
|
-
# Required
|
|
2411
|
-
SPFN_AUTH_JWT_SECRET=your-secret-key
|
|
2412
|
-
DATABASE_URL=postgresql://...
|
|
2413
|
-
|
|
2414
|
-
# Optional
|
|
2415
|
-
SPFN_AUTH_JWT_EXPIRES_IN=7d
|
|
2416
|
-
SPFN_AUTH_BCRYPT_SALT_ROUNDS=10
|
|
2417
|
-
SPFN_AUTH_VERIFICATION_TOKEN_SECRET=separate-secret
|
|
2418
|
-
```
|
|
2419
|
-
|
|
2420
|
-
**Next.js adapter:**
|
|
2421
|
-
```bash
|
|
2422
|
-
# Required
|
|
2423
|
-
SPFN_AUTH_SESSION_SECRET=your-32-char-secret
|
|
2424
|
-
|
|
2425
|
-
# Optional
|
|
2426
|
-
SPFN_AUTH_SESSION_TTL=7d
|
|
2427
|
-
SPFN_API_URL=http://localhost:8790
|
|
2428
|
-
```
|
|
2429
|
-
|
|
2430
|
-
---
|
|
2431
|
-
|
|
2432
|
-
### Debugging
|
|
2433
|
-
|
|
2434
|
-
**Enable logging:**
|
|
2435
|
-
```typescript
|
|
2436
|
-
import { serverLogger } from '@/server/logger';
|
|
2437
|
-
|
|
2438
|
-
serverLogger.info('Debug message', { context });
|
|
2439
|
-
serverLogger.error('Error occurred', error);
|
|
2440
|
-
```
|
|
2441
|
-
|
|
2442
|
-
**Inspect database:**
|
|
2443
|
-
```bash
|
|
2444
|
-
pnpm spfn db studio
|
|
2445
|
-
```
|
|
2446
|
-
|
|
2447
|
-
**Check migrations:**
|
|
2448
|
-
```bash
|
|
2449
|
-
ls migrations/
|
|
2450
|
-
```
|
|
2451
|
-
|
|
2452
|
-
---
|
|
2453
|
-
|
|
2454
|
-
## Known Issues
|
|
2455
|
-
|
|
2456
|
-
### 1. Client Crypto Functions Missing
|
|
2457
|
-
|
|
2458
|
-
**Issue:** README documents `generateKeyPair` and `generateClientToken` in `@spfn/auth/client`, but they only exist in `@spfn/auth/server`.
|
|
2459
|
-
|
|
2460
|
-
**Workaround:** Use server-side crypto functions or implement client-side crypto separately.
|
|
2461
|
-
|
|
2462
|
-
**Status:** Needs design decision - keep server-only or implement browser-compatible version.
|
|
2463
|
-
|
|
2464
|
-
---
|
|
2465
|
-
|
|
2466
|
-
### 2. Next.js Proxy Route Not Implemented
|
|
2467
|
-
|
|
2468
|
-
**Issue:** Documentation mentions `@spfn/auth/nextjs/proxy` for client-side API proxying, but it doesn't exist.
|
|
2469
|
-
|
|
2470
|
-
**Status:** Feature planned but not implemented. Current alternative: use server-side `createAuthInterceptor`.
|
|
2471
|
-
|
|
2472
|
-
---
|
|
2473
|
-
|
|
2474
|
-
### 3. `lib/api` Client Functions Removed
|
|
2475
|
-
|
|
2476
|
-
**Issue:** Old `src/lib/api/` directory was deleted during refactoring.
|
|
2477
|
-
|
|
2478
|
-
**Status:** Intentional removal. Use services or HTTP routes directly.
|
|
2479
|
-
|
|
2480
|
-
---
|
|
2481
|
-
|
|
2482
|
-
### 4. Test Coverage Below Target
|
|
2483
|
-
|
|
2484
|
-
**Current:** ~83%
|
|
2485
|
-
**Target:** 90%+
|
|
2486
|
-
|
|
2487
|
-
**Areas needing tests:**
|
|
2488
|
-
- Invitation service edge cases
|
|
2489
|
-
- RBAC permission checks
|
|
2490
|
-
- Key rotation scenarios
|
|
2491
|
-
- Session expiry handling
|
|
2492
|
-
|
|
2493
|
-
---
|
|
2494
|
-
|
|
2495
|
-
## Roadmap
|
|
2496
|
-
|
|
2497
|
-
### Short-term (Alpha → Beta)
|
|
2498
|
-
|
|
2499
|
-
- [ ] **Client-side crypto** - Browser-compatible key generation
|
|
2500
|
-
- [ ] **Next.js proxy route** - Implement or remove from docs
|
|
2501
|
-
- [x] **High-level authApi** - Simplified Next.js auth functions (implemented in `@spfn/auth`)
|
|
2502
|
-
- [ ] **Test coverage** - Reach 90%+ coverage
|
|
2503
|
-
- [x] **Documentation** - Sync docs with actual code
|
|
2504
|
-
|
|
2505
|
-
---
|
|
2506
|
-
|
|
2507
|
-
### Mid-term (Beta → v1.0)
|
|
2508
|
-
|
|
2509
|
-
- [ ] **React hooks** - useAuth, useSession, usePermissions
|
|
2510
|
-
- [ ] **UI components** - LoginForm, RegisterForm, AuthProvider
|
|
2511
|
-
- [x] **OAuth integration** - Google (implemented), GitHub/Kakao/Naver (planned)
|
|
2512
|
-
- [ ] **2FA support** - TOTP/authenticator apps
|
|
2513
|
-
- [ ] **Password reset flow** - Complete email-based reset
|
|
2514
|
-
- [ ] **Email change flow** - Verification for email updates
|
|
2515
|
-
- [ ] **Phone change flow** - SMS verification for phone updates
|
|
2516
|
-
|
|
2517
|
-
---
|
|
2518
|
-
|
|
2519
|
-
### Long-term (Post v1.0)
|
|
2520
|
-
|
|
2521
|
-
- [ ] **Admin UI** - User/role/permission management dashboard
|
|
2522
|
-
- [ ] **Audit logging** - Track auth events
|
|
2523
|
-
- [ ] **Rate limiting** - Built-in protection against brute force
|
|
2524
|
-
- [ ] **Multi-tenancy** - Organization/workspace support
|
|
2525
|
-
- [ ] **SSO integration** - SAML, OIDC
|
|
2526
|
-
- [ ] **Biometric auth** - WebAuthn/FIDO2 support
|
|
2527
|
-
|
|
2528
|
-
---
|
|
2529
|
-
|
|
2530
|
-
## Contributing
|
|
2531
|
-
|
|
2532
|
-
### Before Contributing
|
|
2533
|
-
|
|
2534
|
-
1. Read this documentation thoroughly
|
|
2535
|
-
2. Check existing issues/PRs
|
|
2536
|
-
3. Understand the architecture
|
|
2537
|
-
4. Follow code style guidelines
|
|
2538
|
-
|
|
2539
|
-
---
|
|
2540
|
-
|
|
2541
|
-
### Pull Request Process
|
|
2542
|
-
|
|
2543
|
-
1. **Create feature branch**
|
|
2544
|
-
```bash
|
|
2545
|
-
git checkout -b feature/my-feature
|
|
2546
|
-
```
|
|
2547
|
-
|
|
2548
|
-
2. **Make changes**
|
|
2549
|
-
- Follow code style
|
|
2550
|
-
- Add tests
|
|
2551
|
-
- Update docs if needed
|
|
2552
|
-
|
|
2553
|
-
3. **Run checks**
|
|
2554
|
-
```bash
|
|
2555
|
-
pnpm type-check
|
|
2556
|
-
pnpm test
|
|
2557
|
-
pnpm build
|
|
2558
|
-
```
|
|
2559
|
-
|
|
2560
|
-
4. **Commit with conventional commits**
|
|
2561
|
-
```bash
|
|
2562
|
-
git commit -m "feat(auth): add password strength validation"
|
|
2563
|
-
```
|
|
2564
|
-
|
|
2565
|
-
5. **Push and create PR**
|
|
2566
|
-
```bash
|
|
2567
|
-
git push origin feature/my-feature
|
|
2568
|
-
```
|
|
2569
|
-
|
|
2570
|
-
---
|
|
2571
|
-
|
|
2572
|
-
### Commit Message Format
|
|
2573
|
-
|
|
2574
|
-
```
|
|
2575
|
-
<type>(<scope>): <subject>
|
|
2576
|
-
|
|
2577
|
-
<body>
|
|
2578
|
-
|
|
2579
|
-
<footer>
|
|
2580
|
-
```
|
|
2581
|
-
|
|
2582
|
-
**Types:**
|
|
2583
|
-
- `feat` - New feature
|
|
2584
|
-
- `fix` - Bug fix
|
|
2585
|
-
- `refactor` - Code refactoring
|
|
2586
|
-
- `test` - Test changes
|
|
2587
|
-
- `docs` - Documentation
|
|
2588
|
-
- `chore` - Maintenance
|
|
2589
|
-
|
|
2590
|
-
**Example:**
|
|
2591
|
-
```
|
|
2592
|
-
feat(rbac): add permission inheritance
|
|
2593
|
-
|
|
2594
|
-
Implement hierarchical permission inheritance where child roles
|
|
2595
|
-
automatically inherit parent role permissions.
|
|
2596
|
-
|
|
2597
|
-
Closes #123
|
|
2598
|
-
```
|
|
2599
|
-
|
|
2600
|
-
---
|
|
2601
|
-
|
|
2602
|
-
## Release Process
|
|
2603
|
-
|
|
2604
|
-
### Version Naming
|
|
2605
|
-
|
|
2606
|
-
- `0.1.0-alpha.x` - Alpha releases (current)
|
|
2607
|
-
- `0.1.0-beta.x` - Beta releases
|
|
2608
|
-
- `1.0.0` - Stable release
|
|
2609
|
-
|
|
2610
|
-
---
|
|
2611
|
-
|
|
2612
|
-
### Publishing
|
|
2613
|
-
|
|
2614
|
-
```bash
|
|
2615
|
-
# Alpha release
|
|
2616
|
-
pnpm run publish:alpha
|
|
2617
|
-
|
|
2618
|
-
# Beta release
|
|
2619
|
-
pnpm run publish:beta
|
|
2620
|
-
|
|
2621
|
-
# Production release
|
|
2622
|
-
pnpm run publish:latest
|
|
2623
|
-
```
|
|
2624
|
-
|
|
2625
|
-
**Pre-publish checklist:**
|
|
2626
|
-
- [ ] All tests pass
|
|
2627
|
-
- [ ] Type checking passes
|
|
2628
|
-
- [ ] Build succeeds
|
|
2629
|
-
- [ ] CHANGELOG updated
|
|
2630
|
-
- [ ] Version bumped
|
|
2631
|
-
- [ ] Docs updated
|
|
2632
|
-
|
|
2633
|
-
---
|
|
2634
|
-
|
|
2635
|
-
## Support
|
|
2636
|
-
|
|
2637
|
-
### Internal Team
|
|
2638
|
-
|
|
2639
|
-
- **Issues:** GitHub Issues
|
|
2640
|
-
- **Discussions:** GitHub Discussions
|
|
2641
|
-
- **Slack:** #spfn-auth channel
|
|
2642
|
-
|
|
2643
|
-
---
|
|
2644
|
-
|
|
2645
|
-
## License
|
|
2646
|
-
|
|
2647
|
-
MIT License - See LICENSE file for details.
|
|
2648
|
-
|
|
2649
|
-
---
|
|
404
|
+
## Related
|
|
2650
405
|
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
406
|
+
- `@spfn/core` — route DSL (`route`, `defineRouter`), `createApi`, env (`@spfn/core/env`),
|
|
407
|
+
errors (`ErrorRegistry`), db (`Transactional`), events, jobs.
|
|
408
|
+
- `@spfn/notification` — email/SMS/push (verification codes, invitation emails).
|
|
409
|
+
- Full guide: `docs/guides/authentication.md`.
|