@spfn/auth 0.2.0-beta.65 → 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 CHANGED
@@ -1,68 +1,43 @@
1
- # @spfn/auth - Technical Documentation
1
+ # @spfn/auth Authentication, OAuth, and RBAC for SPFN
2
2
 
3
- **Version:** 0.2.0-beta.15
4
- **Status:** Alpha - Internal Development
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
- > **Note:** This is a technical documentation for developers working on the @spfn/auth package.
7
- > For user-facing documentation, see [SPFN Documentation](https://spfn.dev/docs).
8
+ ## Install
8
9
 
9
- ---
10
-
11
- ## Table of Contents
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
- ### Design Principles
14
+ ## Import paths
46
15
 
47
- 1. **Security First** - Asymmetric cryptography, no shared secrets
48
- 2. **Type Safety** - Full TypeScript support with Typebox validation
49
- 3. **Framework Integration** - Seamless SPFN plugin architecture
50
- 4. **Extensibility** - Service layer for custom authentication flows
51
- 5. **Developer Experience** - Clear separation of concerns, reusable components
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
- ## Installation
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
- ### 1. Install Package
33
+ ## Setup (4 wiring points)
58
34
 
59
- ```bash
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
- ### 2. Configure Server
37
+ ### 1. Lifecycle — `server.config.ts`
64
38
 
65
- #### Add Lifecycle to `server.config.ts`
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()) // Add auth lifecycle
50
+ .lifecycle(createAuthLifecycle())
77
51
  .build();
78
52
  ```
79
53
 
80
- #### Register Router and Global Middleware in `router.ts`
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
- createOrder,
91
- // ... your other routes
66
+ // ...your routes
92
67
  })
93
- .packages([authRouter]) // Auth routes (/_auth/* namespace)
94
- .use([authenticate]); // Global auth middleware on all routes
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
- > **Important:** Public routes must explicitly skip auth with `.skip(['auth'])`.
100
- > See the [Authentication Guide](https://spfn.dev/docs/guides/authentication) for details.
101
-
102
- ### 3. Configure Next.js Interceptor
74
+ ### 3. Next.js interceptor RPC proxy route
103
75
 
104
- Register the auth interceptor in your RPC proxy route. This handles session cookies, JWT signing, and key management automatically.
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'; // Must be first! Registers auth interceptor
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
- Your API client needs no auth-specific configuration:
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
- # Generate migrations (if needed)
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
- ### 6. Admin Account Setup
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
- // Type-safe API calls
457
- const session = await authApi.getAuthSession.call({});
458
- const result = await authApi.login.call({
459
- body: { email, password, fingerprint, publicKey, keyId }
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
- export const { GET, POST } = createRpcProxy({
517
- routeMap: { ...routeMap, ...authRouteMap }
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
- > **Note:** Database entities (`users`, `userPublicKeys`, etc.) are exported from `@spfn/auth/server`, not the common module.
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
- ### Server Module (`@spfn/auth/server`)
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
- **Router:**
528
- ```typescript
529
- import { authRouter } from '@spfn/auth/server';
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
- // Explicit registration in your app router
532
- export const appRouter = defineRouter({
533
- auth: authRouter, // Mounts at /_auth/*
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
- // Auth
541
- checkAccountExistsService,
542
- registerService,
543
- loginService,
544
- logoutService,
545
- changePasswordService,
546
-
547
- // Verification
548
- sendVerificationCodeService,
549
- verifyCodeService,
550
-
551
- // Key Management
552
- registerPublicKeyService,
553
- rotateKeyService,
554
- revokeKeyService,
555
-
556
- // User
557
- getUserByIdService,
558
- getUserByEmailService,
559
- getUserByPhoneService,
560
- updateUserService,
561
- updateLastLoginService,
562
-
563
- // RBAC
564
- initializeAuth,
565
-
566
- // Permission
567
- getUserPermissions,
568
- hasPermission,
569
- hasAnyPermission,
570
- hasAllPermissions,
571
- hasRole,
572
- hasAnyRole,
573
-
574
- // Role
575
- createRole,
576
- updateRole,
577
- deleteRole,
578
- addPermissionToRole,
579
- removePermissionFromRole,
580
- setRolePermissions,
581
- getAllRoles,
582
- getRoleByName,
583
- getRolePermissions,
584
-
585
- // Invitation
586
- createInvitation,
587
- getInvitationByToken,
588
- getInvitationWithDetails,
589
- validateInvitation,
590
- acceptInvitation,
591
- listInvitations,
592
- cancelInvitation,
593
- deleteInvitation,
594
- expireOldInvitations,
595
- resendInvitation,
596
-
597
- // Session
598
- getAuthSessionService,
599
-
600
- // User Profile
601
- getUserProfileService,
602
- updateUserProfileService,
603
-
604
- // OAuth - Google API Access
605
- getGoogleAccessToken,
606
- } from '@spfn/auth/server';
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
- **Repositories:**
610
- ```typescript
611
- import {
612
- usersRepository,
613
- keysRepository,
614
- rolesRepository,
615
- permissionsRepository,
616
- verificationCodesRepository,
617
- invitationsRepository,
618
- rolePermissionsRepository,
619
- userPermissionsRepository,
620
- userProfilesRepository,
621
- } from '@spfn/auth/server';
622
- ```
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
- **Middleware:**
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
- // Usage - all permissions required
635
- app.bind(
636
- myContract,
637
- [authenticate, requirePermissions('user:delete')],
638
- async (c) => {
639
- // Handler
640
- }
641
- );
642
-
643
- // Usage - any of the permissions
644
- app.bind(
645
- myContract,
646
- [authenticate, requireAnyPermission('content:read', 'admin:access')],
647
- async (c) => {
648
- // User has either content:read OR admin:access
649
- }
650
- );
651
-
652
- // Usage - optional auth (public route with optional user context)
653
- // Auto-skips global 'auth' middleware — no .skip(['auth']) needed
654
- export const getProducts = route.get('/products')
655
- .use([optionalAuth])
656
- .handler(async (c) => {
657
- const auth = getOptionalAuth(c); // AuthContext | undefined
658
- if (auth) {
659
- return getPersonalizedProducts(auth.userId);
660
- }
661
- return getPublicProducts();
662
- });
663
- ```
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
- // Context
669
- getAuth,
670
- getOptionalAuth,
671
- getUser,
672
- getUserId,
673
- getKeyId,
674
-
675
- // JWT
676
- generateToken, // Legacy server-signed (deprecated)
677
- verifyToken, // Legacy server-signed (deprecated)
678
- verifyClientToken, // Client-signed asymmetric JWT
679
- decodeToken, // Decode without verification (debugging)
680
- verifyKeyFingerprint,
681
-
682
- // Password
683
- hashPassword,
684
- verifyPassword,
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
- ### Errors Module (`@spfn/auth/errors`)
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
- #### `@spfn/auth/nextjs/api`
761
-
762
- ```typescript
763
- import {
764
- authInterceptors,
765
- loginRegisterInterceptor,
766
- generalAuthInterceptor,
767
- keyRotationInterceptor,
768
- oauthUrlInterceptor,
769
- oauthFinalizeInterceptor,
770
- } from '@spfn/auth/nextjs/api';
771
-
772
- // Auto-registers interceptors on import (including OAuth)
773
- import '@spfn/auth/nextjs/api';
774
- ```
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
- #### `@spfn/auth/nextjs/server`
258
+ ## Sessions (Next.js)
777
259
 
778
- ```typescript
779
- import {
780
- // Guards (Server Components)
781
- RequireAuth,
782
- RequireRole,
783
- RequirePermission,
784
-
785
- // Auth Utils
786
- getUserRole,
787
- getUserPermissions,
788
- hasAnyRole,
789
- hasAnyPermission,
790
-
791
- // Session Helpers
792
- saveSession,
793
- getSession,
794
- clearSession,
795
-
796
- // Types
797
- type SessionData,
798
- type PublicSession,
799
- type SaveSessionOptions,
800
- } from '@spfn/auth/nextjs/server';
801
- ```
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
- // Save session (Server Actions / Route Handlers)
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
- // Clear session
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
- **Guard Usage:**
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
- ## Server-Side API
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
- **Response:**
879
- ```typescript
277
+ export default async function AdminPage()
880
278
  {
881
- exists: boolean;
882
- identifier: string;
883
- identifierType: 'email' | 'phone';
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
- Send verification code.
292
+ ## RBAC
892
293
 
893
- **Request:**
894
- ```typescript
895
- {
896
- target: string; // Email or phone
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
- success: boolean;
906
- expiresAt: string; // ISO 8601
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
- #### `POST /_auth/codes/verify`
312
+ ## Events
913
313
 
914
- Verify OTP code.
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
- **Response:**
927
- ```typescript
320
+ authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
928
321
  {
929
- valid: boolean;
930
- verificationToken?: string; // 15min JWT for registration
931
- }
322
+ if (email) await sendWelcome(email);
323
+ });
932
324
  ```
933
325
 
934
- ---
935
-
936
- #### `POST /_auth/register`
937
-
938
- Register new user.
939
-
940
- **Request:**
941
- ```typescript
942
- {
943
- email?: string;
944
- phone?: string;
945
- verificationToken: string; // From /codes/verify
946
- password: string; // Min 8 chars
947
- publicKey: string; // Base64 DER (SPKI)
948
- keyId: string; // UUID v4
949
- fingerprint: string; // SHA-256 hex (64 chars)
950
- algorithm: 'ES256' | 'RS256';
951
- keySize?: number;
952
- }
953
- ```
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
- **Response:**
956
- ```typescript
957
- {
958
- userId: string;
959
- email?: string;
960
- phone?: string;
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
- #### `POST /_auth/login`
388
+ export const appRouter = defineRouter({ getMe })
389
+ .packages([authRouter])
390
+ .use([authenticate]);
391
+ export type AppRouter = typeof appRouter;
967
392
 
968
- User login.
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
- **Request:**
971
- ```typescript
972
- {
973
- email?: string;
974
- phone?: string;
975
- password: string;
976
- publicKey: string; // New key for session
977
- keyId: string;
978
- fingerprint: string;
979
- oldKeyId?: string; // Revoke previous key
980
- algorithm: 'ES256' | 'RS256';
981
- keySize?: number;
982
- }
399
+ // any client component
400
+ import { authApi } from '@spfn/auth';
401
+ const session = await authApi.getAuthSession.call({});
983
402
  ```
984
403
 
985
- **Response:**
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
- **Last Updated:** 2026-02-23
2652
- **Document Version:** 2.6.0 (Technical Documentation)
2653
- **Package Version:** 0.2.0-beta.15
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`.