@spfn/auth 0.2.0-beta.8 → 0.2.1
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 +831 -198
- package/dist/{dto-lZmWuObc.d.ts → authenticate-eucncHxN.d.ts} +452 -157
- package/dist/config.d.ts +176 -44
- package/dist/config.js +99 -35
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +30 -2
- package/dist/errors.js +24 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +287 -109
- package/dist/index.js +59 -1
- package/dist/index.js.map +1 -1
- package/dist/nextjs/api.js +234 -12
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/client.d.ts +28 -0
- package/dist/nextjs/client.js +80 -0
- package/dist/nextjs/client.js.map +1 -0
- package/dist/nextjs/server.d.ts +90 -2
- package/dist/nextjs/server.js +146 -21
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +870 -458
- package/dist/server.js +1403 -586
- package/dist/server.js.map +1 -1
- package/migrations/0001_smooth_the_fury.sql +3 -0
- package/migrations/0002_deep_iceman.sql +11 -0
- package/migrations/0003_perfect_deathbird.sql +3 -0
- package/migrations/0004_concerned_rawhide_kid.sql +5 -0
- package/migrations/meta/0001_snapshot.json +1660 -0
- package/migrations/meta/0002_snapshot.json +1660 -0
- package/migrations/meta/0003_snapshot.json +1689 -0
- package/migrations/meta/0004_snapshot.json +1721 -0
- package/migrations/meta/_journal.json +28 -0
- package/package.json +13 -13
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @spfn/auth - Technical Documentation
|
|
2
2
|
|
|
3
|
-
**Version:** 0.
|
|
3
|
+
**Version:** 0.2.0-beta.15
|
|
4
4
|
**Status:** Alpha - Internal Development
|
|
5
5
|
|
|
6
6
|
> **Note:** This is a technical documentation for developers working on the @spfn/auth package.
|
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
|
|
13
13
|
- [Overview](#overview)
|
|
14
14
|
- [Installation](#installation)
|
|
15
|
+
- [Admin Account Setup](#6-admin-account-setup)
|
|
15
16
|
- [Architecture](#architecture)
|
|
16
17
|
- [Package Structure](#package-structure)
|
|
17
18
|
- [Module Exports](#module-exports)
|
|
18
19
|
- [Email & SMS Services](#email--sms-services)
|
|
19
|
-
- [Email Templates](#email-templates)
|
|
20
20
|
- [Server-Side API](#server-side-api)
|
|
21
|
+
- [Events](#events)
|
|
22
|
+
- [OAuth Authentication](#oauth-authentication)
|
|
21
23
|
- [Database Schema](#database-schema)
|
|
22
24
|
- [RBAC System](#rbac-system)
|
|
23
25
|
- [Next.js Adapter](#nextjs-adapter)
|
|
@@ -34,10 +36,11 @@
|
|
|
34
36
|
|
|
35
37
|
- **Asymmetric JWT Authentication** - Client-signed tokens using ES256/RS256
|
|
36
38
|
- **User Management** - Email/phone-based identity with bcrypt hashing
|
|
39
|
+
- **OAuth Authentication** - Google OAuth 2.0 (Authorization Code Flow), extensible to other providers
|
|
37
40
|
- **Multi-Factor Authentication** - OTP verification via email/SMS
|
|
38
41
|
- **Session Management** - Public key rotation with 90-day expiry
|
|
39
42
|
- **Role-Based Access Control** - Flexible RBAC with runtime role/permission management
|
|
40
|
-
- **Next.js Integration** - Session helpers
|
|
43
|
+
- **Next.js Integration** - Session helpers, server-side guards, and OAuth interceptors
|
|
41
44
|
|
|
42
45
|
### Design Principles
|
|
43
46
|
|
|
@@ -74,64 +77,98 @@ export default defineServerConfig()
|
|
|
74
77
|
.build();
|
|
75
78
|
```
|
|
76
79
|
|
|
77
|
-
#### Register Router in `router.ts`
|
|
80
|
+
#### Register Router and Global Middleware in `router.ts`
|
|
78
81
|
|
|
79
82
|
```typescript
|
|
80
83
|
import { defineRouter } from '@spfn/core/route';
|
|
81
|
-
import { authRouter } from '@spfn/auth/server';
|
|
84
|
+
import { authRouter, authenticate } from '@spfn/auth/server';
|
|
85
|
+
import { getHealth } from './routes/health';
|
|
86
|
+
import { createOrder } from './routes/orders';
|
|
82
87
|
|
|
83
88
|
export const appRouter = defineRouter({
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
89
|
+
getHealth,
|
|
90
|
+
createOrder,
|
|
87
91
|
// ... your other routes
|
|
88
|
-
})
|
|
92
|
+
})
|
|
93
|
+
.packages([authRouter]) // Auth routes (/_auth/* namespace)
|
|
94
|
+
.use([authenticate]); // Global auth middleware on all routes
|
|
95
|
+
|
|
96
|
+
export type AppRouter = typeof appRouter;
|
|
89
97
|
```
|
|
90
98
|
|
|
91
|
-
|
|
99
|
+
> **Important:** Public routes must explicitly skip auth with `.skip(['auth'])`.
|
|
100
|
+
> See the [Authentication Guide](https://spfn.dev/docs/guides/authentication) for details.
|
|
92
101
|
|
|
93
|
-
|
|
102
|
+
### 3. Configure Next.js Interceptor
|
|
103
|
+
|
|
104
|
+
Register the auth interceptor in your RPC proxy route. This handles session cookies, JWT signing, and key management automatically.
|
|
94
105
|
|
|
95
106
|
```typescript
|
|
107
|
+
// app/api/rpc/[routeName]/route.ts
|
|
108
|
+
import '@spfn/auth/nextjs/api'; // Must be first! Registers auth interceptor
|
|
109
|
+
import { appRouter } from '@/server/router';
|
|
110
|
+
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
111
|
+
|
|
112
|
+
export const { GET, POST } = createRpcProxy({ router: appRouter });
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Your API client needs no auth-specific configuration:
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
// src/lib/api-client.ts
|
|
96
119
|
import { createApi } from '@spfn/core/nextjs';
|
|
97
120
|
import type { AppRouter } from '@/server/router';
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
});
|
|
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({});
|
|
107
130
|
```
|
|
108
131
|
|
|
109
132
|
### 4. Environment Variables
|
|
110
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
|
+
|
|
111
138
|
```bash
|
|
112
139
|
# Required
|
|
113
|
-
|
|
140
|
+
DATABASE_URL=postgresql://user:pass@localhost:5432/myapp_dev
|
|
114
141
|
SPFN_AUTH_VERIFICATION_TOKEN_SECRET=your-verification-secret
|
|
115
|
-
DATABASE_URL=postgresql://...
|
|
116
142
|
|
|
117
|
-
#
|
|
118
|
-
|
|
143
|
+
# Admin account (required — at least one format)
|
|
144
|
+
SPFN_AUTH_ADMIN_ACCOUNTS='[{"email":"admin@example.com","password":"Admin!@34","role":"superadmin"}]'
|
|
119
145
|
|
|
120
146
|
# Optional
|
|
147
|
+
SPFN_AUTH_JWT_SECRET=your-jwt-secret
|
|
121
148
|
SPFN_AUTH_JWT_EXPIRES_IN=7d
|
|
122
149
|
SPFN_AUTH_BCRYPT_SALT_ROUNDS=10
|
|
123
150
|
SPFN_AUTH_SESSION_TTL=7d
|
|
124
151
|
|
|
125
|
-
#
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
130
166
|
|
|
131
|
-
#
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
135
172
|
```
|
|
136
173
|
|
|
137
174
|
### 5. Run Migrations
|
|
@@ -144,6 +181,83 @@ pnpm spfn db generate
|
|
|
144
181
|
pnpm spfn db migrate
|
|
145
182
|
```
|
|
146
183
|
|
|
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
|
+
|
|
147
261
|
---
|
|
148
262
|
|
|
149
263
|
## Architecture
|
|
@@ -335,20 +449,15 @@ packages/auth/
|
|
|
335
449
|
|
|
336
450
|
### Common Module (`@spfn/auth`)
|
|
337
451
|
|
|
338
|
-
**
|
|
452
|
+
**API Client:**
|
|
339
453
|
```typescript
|
|
340
|
-
import {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
userPermissions,
|
|
348
|
-
userInvitations,
|
|
349
|
-
userSocialAccounts,
|
|
350
|
-
userProfiles
|
|
351
|
-
} from '@spfn/auth';
|
|
454
|
+
import { authApi } from '@spfn/auth';
|
|
455
|
+
|
|
456
|
+
// Type-safe API calls
|
|
457
|
+
const session = await authApi.getAuthSession.call({});
|
|
458
|
+
const result = await authApi.login.call({
|
|
459
|
+
body: { email, password, fingerprint, publicKey, keyId }
|
|
460
|
+
});
|
|
352
461
|
```
|
|
353
462
|
|
|
354
463
|
**Types:**
|
|
@@ -359,6 +468,9 @@ import type {
|
|
|
359
468
|
VerificationCode,
|
|
360
469
|
Role,
|
|
361
470
|
Permission,
|
|
471
|
+
AuthSession,
|
|
472
|
+
UserProfile,
|
|
473
|
+
ProfileInfo,
|
|
362
474
|
// ... etc
|
|
363
475
|
} from '@spfn/auth';
|
|
364
476
|
```
|
|
@@ -380,6 +492,34 @@ import type {
|
|
|
380
492
|
} from '@spfn/auth';
|
|
381
493
|
```
|
|
382
494
|
|
|
495
|
+
**Validation Patterns:**
|
|
496
|
+
```typescript
|
|
497
|
+
import {
|
|
498
|
+
UUID_PATTERN,
|
|
499
|
+
EMAIL_PATTERN,
|
|
500
|
+
BASE64_PATTERN,
|
|
501
|
+
FINGERPRINT_PATTERN,
|
|
502
|
+
PHONE_PATTERN,
|
|
503
|
+
} from '@spfn/auth';
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
**Route Map (for RPC Proxy):**
|
|
507
|
+
```typescript
|
|
508
|
+
import { authRouteMap } from '@spfn/auth';
|
|
509
|
+
|
|
510
|
+
// Use in Next.js RPC proxy (app/api/rpc/[routeName]/route.ts)
|
|
511
|
+
import '@spfn/auth/nextjs/api'; // Auto-register auth interceptors
|
|
512
|
+
import { routeMap } from '@/generated/route-map';
|
|
513
|
+
import { authRouteMap } from '@spfn/auth';
|
|
514
|
+
import { createRpcProxy } from '@spfn/core/nextjs/proxy';
|
|
515
|
+
|
|
516
|
+
export const { GET, POST } = createRpcProxy({
|
|
517
|
+
routeMap: { ...routeMap, ...authRouteMap }
|
|
518
|
+
});
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
> **Note:** Database entities (`users`, `userPublicKeys`, etc.) are exported from `@spfn/auth/server`, not the common module.
|
|
522
|
+
|
|
383
523
|
---
|
|
384
524
|
|
|
385
525
|
### Server Module (`@spfn/auth/server`)
|
|
@@ -456,22 +596,13 @@ import {
|
|
|
456
596
|
|
|
457
597
|
// Session
|
|
458
598
|
getAuthSessionService,
|
|
459
|
-
getUserProfileService,
|
|
460
599
|
|
|
461
|
-
//
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
// SMS
|
|
466
|
-
sendSMS,
|
|
467
|
-
registerSMSProvider,
|
|
600
|
+
// User Profile
|
|
601
|
+
getUserProfileService,
|
|
602
|
+
updateUserProfileService,
|
|
468
603
|
|
|
469
|
-
//
|
|
470
|
-
|
|
471
|
-
getVerificationCodeTemplate,
|
|
472
|
-
getWelcomeTemplate,
|
|
473
|
-
getPasswordResetTemplate,
|
|
474
|
-
getInvitationTemplate,
|
|
604
|
+
// OAuth - Google API Access
|
|
605
|
+
getGoogleAccessToken,
|
|
475
606
|
} from '@spfn/auth/server';
|
|
476
607
|
```
|
|
477
608
|
|
|
@@ -494,11 +625,13 @@ import {
|
|
|
494
625
|
```typescript
|
|
495
626
|
import {
|
|
496
627
|
authenticate,
|
|
628
|
+
optionalAuth,
|
|
497
629
|
requirePermissions,
|
|
630
|
+
requireAnyPermission,
|
|
498
631
|
requireRole,
|
|
499
632
|
} from '@spfn/auth/server';
|
|
500
633
|
|
|
501
|
-
// Usage
|
|
634
|
+
// Usage - all permissions required
|
|
502
635
|
app.bind(
|
|
503
636
|
myContract,
|
|
504
637
|
[authenticate, requirePermissions('user:delete')],
|
|
@@ -506,6 +639,27 @@ app.bind(
|
|
|
506
639
|
// Handler
|
|
507
640
|
}
|
|
508
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
|
+
});
|
|
509
663
|
```
|
|
510
664
|
|
|
511
665
|
**Helpers:**
|
|
@@ -513,6 +667,7 @@ app.bind(
|
|
|
513
667
|
import {
|
|
514
668
|
// Context
|
|
515
669
|
getAuth,
|
|
670
|
+
getOptionalAuth,
|
|
516
671
|
getUser,
|
|
517
672
|
getUserId,
|
|
518
673
|
getKeyId,
|
|
@@ -610,9 +765,11 @@ import {
|
|
|
610
765
|
loginRegisterInterceptor,
|
|
611
766
|
generalAuthInterceptor,
|
|
612
767
|
keyRotationInterceptor,
|
|
768
|
+
oauthUrlInterceptor,
|
|
769
|
+
oauthFinalizeInterceptor,
|
|
613
770
|
} from '@spfn/auth/nextjs/api';
|
|
614
771
|
|
|
615
|
-
// Auto-registers interceptors on import
|
|
772
|
+
// Auto-registers interceptors on import (including OAuth)
|
|
616
773
|
import '@spfn/auth/nextjs/api';
|
|
617
774
|
```
|
|
618
775
|
|
|
@@ -679,141 +836,24 @@ export default async function DashboardPage()
|
|
|
679
836
|
|
|
680
837
|
## Email & SMS Services
|
|
681
838
|
|
|
682
|
-
|
|
839
|
+
> **⚠️ DEPRECATED:** Email and SMS functionality has been moved to `@spfn/notification` package.
|
|
683
840
|
|
|
684
|
-
|
|
841
|
+
### Migration Guide
|
|
685
842
|
|
|
686
|
-
**Send Email:**
|
|
687
843
|
```typescript
|
|
688
|
-
|
|
844
|
+
// Before (deprecated)
|
|
845
|
+
import { sendEmail, sendSMS } from '@spfn/auth/server';
|
|
689
846
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
subject: 'Welcome!',
|
|
693
|
-
text: 'Plain text content',
|
|
694
|
-
html: '<h1>HTML content</h1>',
|
|
695
|
-
purpose: 'welcome', // for logging
|
|
696
|
-
});
|
|
847
|
+
// After (recommended)
|
|
848
|
+
import { sendEmail, sendSMS } from '@spfn/notification/server';
|
|
697
849
|
```
|
|
698
850
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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.)
|
|
702
855
|
|
|
703
|
-
|
|
704
|
-
registerEmailProvider({
|
|
705
|
-
name: 'sendgrid',
|
|
706
|
-
sendEmail: async ({ to, subject, text, html }) => {
|
|
707
|
-
// Your SendGrid implementation
|
|
708
|
-
return { success: true, messageId: '...' };
|
|
709
|
-
},
|
|
710
|
-
});
|
|
711
|
-
```
|
|
712
|
-
|
|
713
|
-
---
|
|
714
|
-
|
|
715
|
-
### SMS Service
|
|
716
|
-
|
|
717
|
-
The SMS service uses AWS SNS by default.
|
|
718
|
-
|
|
719
|
-
**Send SMS:**
|
|
720
|
-
```typescript
|
|
721
|
-
import { sendSMS } from '@spfn/auth/server';
|
|
722
|
-
|
|
723
|
-
await sendSMS({
|
|
724
|
-
phone: '+821012345678', // E.164 format
|
|
725
|
-
message: 'Your code is: 123456',
|
|
726
|
-
purpose: 'verification',
|
|
727
|
-
});
|
|
728
|
-
```
|
|
729
|
-
|
|
730
|
-
**Custom SMS Provider:**
|
|
731
|
-
```typescript
|
|
732
|
-
import { registerSMSProvider } from '@spfn/auth/server';
|
|
733
|
-
|
|
734
|
-
// Register Twilio provider
|
|
735
|
-
registerSMSProvider({
|
|
736
|
-
name: 'twilio',
|
|
737
|
-
sendSMS: async ({ phone, message }) => {
|
|
738
|
-
// Your Twilio implementation
|
|
739
|
-
return { success: true, messageId: '...' };
|
|
740
|
-
},
|
|
741
|
-
});
|
|
742
|
-
```
|
|
743
|
-
|
|
744
|
-
---
|
|
745
|
-
|
|
746
|
-
## Email Templates
|
|
747
|
-
|
|
748
|
-
### Built-in Templates
|
|
749
|
-
|
|
750
|
-
| Template | Function | Purpose |
|
|
751
|
-
|----------|----------|---------|
|
|
752
|
-
| `verificationCode` | `getVerificationCodeTemplate` | Verification codes (registration, login, password reset) |
|
|
753
|
-
| `welcome` | `getWelcomeTemplate` | Welcome email after registration |
|
|
754
|
-
| `passwordReset` | `getPasswordResetTemplate` | Password reset link |
|
|
755
|
-
| `invitation` | `getInvitationTemplate` | User invitation |
|
|
756
|
-
|
|
757
|
-
**Usage:**
|
|
758
|
-
```typescript
|
|
759
|
-
import { getVerificationCodeTemplate, sendEmail } from '@spfn/auth/server';
|
|
760
|
-
|
|
761
|
-
const { subject, text, html } = getVerificationCodeTemplate({
|
|
762
|
-
code: '123456',
|
|
763
|
-
purpose: 'registration',
|
|
764
|
-
expiresInMinutes: 5,
|
|
765
|
-
appName: 'MyApp',
|
|
766
|
-
});
|
|
767
|
-
|
|
768
|
-
await sendEmail({ to: 'user@example.com', subject, text, html });
|
|
769
|
-
```
|
|
770
|
-
|
|
771
|
-
---
|
|
772
|
-
|
|
773
|
-
### Custom Templates
|
|
774
|
-
|
|
775
|
-
Register custom templates to override defaults with your brand design:
|
|
776
|
-
|
|
777
|
-
```typescript
|
|
778
|
-
import { registerEmailTemplates } from '@spfn/auth/server';
|
|
779
|
-
|
|
780
|
-
// Register at app initialization (e.g., server.config.ts)
|
|
781
|
-
registerEmailTemplates({
|
|
782
|
-
// Override verification code template
|
|
783
|
-
verificationCode: ({ code, purpose, expiresInMinutes, appName }) => ({
|
|
784
|
-
subject: `[${appName}] Your verification code`,
|
|
785
|
-
text: `Your code: ${code}\nExpires in ${expiresInMinutes} minutes.`,
|
|
786
|
-
html: `
|
|
787
|
-
<div style="font-family: Arial, sans-serif;">
|
|
788
|
-
<img src="https://myapp.com/logo.png" alt="Logo" />
|
|
789
|
-
<h1>Verification Code</h1>
|
|
790
|
-
<div style="font-size: 32px; font-weight: bold;">${code}</div>
|
|
791
|
-
<p>This code expires in ${expiresInMinutes} minutes.</p>
|
|
792
|
-
</div>
|
|
793
|
-
`,
|
|
794
|
-
}),
|
|
795
|
-
|
|
796
|
-
// Override invitation template
|
|
797
|
-
invitation: ({ inviteLink, inviterName, roleName, appName }) => ({
|
|
798
|
-
subject: `${inviterName} invited you to ${appName}`,
|
|
799
|
-
text: `Accept invitation: ${inviteLink}`,
|
|
800
|
-
html: `
|
|
801
|
-
<h1>You're Invited!</h1>
|
|
802
|
-
<p>${inviterName} invited you to join ${appName} as ${roleName}.</p>
|
|
803
|
-
<a href="${inviteLink}">Accept Invitation</a>
|
|
804
|
-
`,
|
|
805
|
-
}),
|
|
806
|
-
});
|
|
807
|
-
```
|
|
808
|
-
|
|
809
|
-
**Template Parameters:**
|
|
810
|
-
|
|
811
|
-
| Template | Parameters |
|
|
812
|
-
|----------|------------|
|
|
813
|
-
| `verificationCode` | `code`, `purpose`, `expiresInMinutes?`, `appName?` |
|
|
814
|
-
| `welcome` | `email`, `appName?` |
|
|
815
|
-
| `passwordReset` | `resetLink`, `expiresInMinutes?`, `appName?` |
|
|
816
|
-
| `invitation` | `inviteLink`, `inviterName?`, `roleName?`, `appName?` |
|
|
856
|
+
For documentation, see `@spfn/notification` package README.
|
|
817
857
|
|
|
818
858
|
---
|
|
819
859
|
|
|
@@ -1027,6 +1067,574 @@ Change password.
|
|
|
1027
1067
|
|
|
1028
1068
|
---
|
|
1029
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')[];
|
|
1544
|
+
}
|
|
1545
|
+
```
|
|
1546
|
+
|
|
1547
|
+
---
|
|
1548
|
+
|
|
1549
|
+
### Google API Access
|
|
1550
|
+
|
|
1551
|
+
OAuth 로그인 후 저장된 access token으로 Google API를 호출할 수 있습니다.
|
|
1552
|
+
|
|
1553
|
+
#### Custom Scopes 설정
|
|
1554
|
+
|
|
1555
|
+
`SPFN_AUTH_GOOGLE_SCOPES` 환경변수로 추가 스코프를 요청합니다. 미설정 시 `email,profile`이 기본값입니다.
|
|
1556
|
+
|
|
1557
|
+
```bash
|
|
1558
|
+
# Gmail + Calendar 읽기 권한 추가
|
|
1559
|
+
SPFN_AUTH_GOOGLE_SCOPES=email,profile,https://www.googleapis.com/auth/gmail.readonly,https://www.googleapis.com/auth/calendar.readonly
|
|
1560
|
+
```
|
|
1561
|
+
|
|
1562
|
+
> **Note:** Google Cloud Console에서 해당 API를 활성화해야 합니다.
|
|
1563
|
+
|
|
1564
|
+
#### Access Token 사용
|
|
1565
|
+
|
|
1566
|
+
`getGoogleAccessToken(userId)`은 유효한 access token을 반환합니다. 토큰이 만료 임박(5분 이내) 또는 만료 상태이면 자동으로 refresh token을 사용하여 갱신합니다.
|
|
1567
|
+
|
|
1568
|
+
```typescript
|
|
1569
|
+
import { getGoogleAccessToken } from '@spfn/auth/server';
|
|
1570
|
+
|
|
1571
|
+
// 항상 유효한 토큰 반환 (만료 시 자동 갱신)
|
|
1572
|
+
const token = await getGoogleAccessToken(userId);
|
|
1573
|
+
|
|
1574
|
+
// Gmail API 호출
|
|
1575
|
+
const response = await fetch(
|
|
1576
|
+
'https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=10',
|
|
1577
|
+
{ headers: { Authorization: `Bearer ${token}` } }
|
|
1578
|
+
);
|
|
1579
|
+
const data = await response.json();
|
|
1580
|
+
```
|
|
1581
|
+
|
|
1582
|
+
**에러 케이스:**
|
|
1583
|
+
- Google 계정 미연결 → `'No Google account linked'`
|
|
1584
|
+
- Refresh token 없음 → `'Google refresh token not available'` (재로그인 필요)
|
|
1585
|
+
|
|
1586
|
+
---
|
|
1587
|
+
|
|
1588
|
+
### Security
|
|
1589
|
+
|
|
1590
|
+
- **State 암호화**: JWE (A256GCM)로 state 파라미터 암호화. CSRF 방지용 nonce 포함.
|
|
1591
|
+
- **Pending Session**: OAuth 리다이렉트 중 privateKey를 JWE로 암호화한 HttpOnly 쿠키에 저장. 10분 TTL.
|
|
1592
|
+
- **KeyId 검증**: finalize 시 pending session의 keyId와 응답의 keyId 일치 확인.
|
|
1593
|
+
- **Email 검증**: `verified_email`이 true인 경우에만 기존 계정에 자동 연결. 미검증 이메일로 기존 계정 연결 시도 시 에러.
|
|
1594
|
+
- **Session Cookie**: `HttpOnly`, `Secure` (production), `SameSite=strict`.
|
|
1595
|
+
|
|
1596
|
+
---
|
|
1597
|
+
|
|
1598
|
+
### OAuthCallback Component
|
|
1599
|
+
|
|
1600
|
+
`@spfn/auth/nextjs/client`에서 제공하는 클라이언트 컴포넌트입니다.
|
|
1601
|
+
|
|
1602
|
+
```tsx
|
|
1603
|
+
import { OAuthCallback } from '@spfn/auth/nextjs/client';
|
|
1604
|
+
|
|
1605
|
+
// 기본 사용
|
|
1606
|
+
export default function CallbackPage()
|
|
1607
|
+
{
|
|
1608
|
+
return <OAuthCallback />;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// 커스터마이징
|
|
1612
|
+
export default function CallbackPage()
|
|
1613
|
+
{
|
|
1614
|
+
return (
|
|
1615
|
+
<OAuthCallback
|
|
1616
|
+
apiBasePath="/api/rpc"
|
|
1617
|
+
loadingComponent={<MySpinner />}
|
|
1618
|
+
errorComponent={(error) => <MyError message={error} />}
|
|
1619
|
+
onSuccess={(userId) => console.log('Logged in:', userId)}
|
|
1620
|
+
onError={(error) => console.error(error)}
|
|
1621
|
+
/>
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1624
|
+
```
|
|
1625
|
+
|
|
1626
|
+
**Props:**
|
|
1627
|
+
|
|
1628
|
+
| Prop | Type | Default | Description |
|
|
1629
|
+
|------|------|---------|-------------|
|
|
1630
|
+
| `apiBasePath` | `string` | `'/api/rpc'` | RPC API base path |
|
|
1631
|
+
| `loadingComponent` | `ReactNode` | Built-in | 로딩 중 표시할 컴포넌트 |
|
|
1632
|
+
| `errorComponent` | `(error: string) => ReactNode` | Built-in | 에러 표시 컴포넌트 |
|
|
1633
|
+
| `onSuccess` | `(userId: string) => void` | - | 성공 콜백 |
|
|
1634
|
+
| `onError` | `(error: string) => void` | - | 에러 콜백 |
|
|
1635
|
+
|
|
1636
|
+
---
|
|
1637
|
+
|
|
1030
1638
|
## Database Schema
|
|
1031
1639
|
|
|
1032
1640
|
### Core Tables
|
|
@@ -1038,8 +1646,10 @@ Main user identity table.
|
|
|
1038
1646
|
```sql
|
|
1039
1647
|
CREATE TABLE users (
|
|
1040
1648
|
id BIGSERIAL PRIMARY KEY,
|
|
1649
|
+
public_id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
|
|
1041
1650
|
email TEXT UNIQUE,
|
|
1042
1651
|
phone TEXT UNIQUE,
|
|
1652
|
+
username TEXT UNIQUE,
|
|
1043
1653
|
password_hash TEXT NOT NULL,
|
|
1044
1654
|
password_change_required BOOLEAN DEFAULT false,
|
|
1045
1655
|
role_id BIGINT REFERENCES roles(id) NOT NULL,
|
|
@@ -1057,7 +1667,9 @@ CREATE TABLE users (
|
|
|
1057
1667
|
```
|
|
1058
1668
|
|
|
1059
1669
|
**Key Points:**
|
|
1670
|
+
- `public_id` is a UUID v4 for external-facing URLs and APIs (never expose internal `id`)
|
|
1060
1671
|
- At least one of `email` OR `phone` required
|
|
1672
|
+
- `username` is unique and nullable (optional display/mention identifier)
|
|
1061
1673
|
- `passwordHash` is bcrypt ($2b$10$..., 60 chars)
|
|
1062
1674
|
- `roleId` references roles table (NOT NULL)
|
|
1063
1675
|
|
|
@@ -1167,7 +1779,7 @@ CREATE TABLE permissions (
|
|
|
1167
1779
|
|
|
1168
1780
|
**Built-in Permissions:**
|
|
1169
1781
|
- `auth:self:manage`
|
|
1170
|
-
- `user:read`, `user:write`, `user:delete`
|
|
1782
|
+
- `user:read`, `user:write`, `user:delete`, `user:invite`
|
|
1171
1783
|
- `rbac:role:manage`, `rbac:permission:manage`
|
|
1172
1784
|
|
|
1173
1785
|
---
|
|
@@ -1260,7 +1872,7 @@ CREATE TABLE user_profiles (
|
|
|
1260
1872
|
|
|
1261
1873
|
#### `user_social_accounts`
|
|
1262
1874
|
|
|
1263
|
-
OAuth provider accounts (
|
|
1875
|
+
OAuth provider accounts (Google, GitHub, etc.).
|
|
1264
1876
|
|
|
1265
1877
|
```sql
|
|
1266
1878
|
CREATE TABLE user_social_accounts (
|
|
@@ -1327,7 +1939,7 @@ await initializeAuth({
|
|
|
1327
1939
|
|
|
1328
1940
|
**Permissions:**
|
|
1329
1941
|
- `auth:self:manage` - Change password, rotate keys
|
|
1330
|
-
- `user:read`, `user:write`, `user:delete`
|
|
1942
|
+
- `user:read`, `user:write`, `user:delete`, `user:invite`
|
|
1331
1943
|
- `rbac:role:manage`, `rbac:permission:manage`
|
|
1332
1944
|
|
|
1333
1945
|
---
|
|
@@ -1335,7 +1947,7 @@ await initializeAuth({
|
|
|
1335
1947
|
### Middleware Usage
|
|
1336
1948
|
|
|
1337
1949
|
```typescript
|
|
1338
|
-
import { authenticate, requirePermissions, requireRole } from '@spfn/auth/server';
|
|
1950
|
+
import { authenticate, requirePermissions, requireAnyPermission, requireRole } from '@spfn/auth/server';
|
|
1339
1951
|
|
|
1340
1952
|
// Single permission
|
|
1341
1953
|
app.bind(
|
|
@@ -1355,6 +1967,15 @@ app.bind(
|
|
|
1355
1967
|
}
|
|
1356
1968
|
);
|
|
1357
1969
|
|
|
1970
|
+
// Any of the permissions (at least one required)
|
|
1971
|
+
app.bind(
|
|
1972
|
+
viewContentContract,
|
|
1973
|
+
[authenticate, requireAnyPermission('content:read', 'admin:access')],
|
|
1974
|
+
async (c) => {
|
|
1975
|
+
// User has either content:read OR admin:access
|
|
1976
|
+
}
|
|
1977
|
+
);
|
|
1978
|
+
|
|
1358
1979
|
// Role-based
|
|
1359
1980
|
app.bind(
|
|
1360
1981
|
adminDashboardContract,
|
|
@@ -1468,10 +2089,22 @@ import '@spfn/auth/nextjs/api';
|
|
|
1468
2089
|
**Target Routes:**
|
|
1469
2090
|
- `/_auth/login`, `/_auth/register` - Login/register interceptor
|
|
1470
2091
|
- `/_auth/keys/rotate` - Key rotation interceptor
|
|
2092
|
+
- `/_auth/oauth/:provider/url` - OAuth URL interceptor (keypair + state generation)
|
|
2093
|
+
- `/_auth/oauth/finalize` - OAuth finalize interceptor (pending session → full session)
|
|
1471
2094
|
- All other authenticated routes - General auth interceptor
|
|
1472
2095
|
|
|
1473
2096
|
---
|
|
1474
2097
|
|
|
2098
|
+
### OAuth Client Component (`@spfn/auth/nextjs/client`)
|
|
2099
|
+
|
|
2100
|
+
```typescript
|
|
2101
|
+
import { OAuthCallback, type OAuthCallbackProps } from '@spfn/auth/nextjs/client';
|
|
2102
|
+
```
|
|
2103
|
+
|
|
2104
|
+
OAuth 콜백 페이지용 `'use client'` 컴포넌트. 자세한 사용법은 [OAuth Authentication](#oauth-authentication) 섹션 참조.
|
|
2105
|
+
|
|
2106
|
+
---
|
|
2107
|
+
|
|
1475
2108
|
## Testing
|
|
1476
2109
|
|
|
1477
2110
|
### Setup Test Environment
|
|
@@ -1817,7 +2450,7 @@ ls migrations/
|
|
|
1817
2450
|
|
|
1818
2451
|
- [ ] **React hooks** - useAuth, useSession, usePermissions
|
|
1819
2452
|
- [ ] **UI components** - LoginForm, RegisterForm, AuthProvider
|
|
1820
|
-
- [
|
|
2453
|
+
- [x] **OAuth integration** - Google (implemented), GitHub/Kakao/Naver (planned)
|
|
1821
2454
|
- [ ] **2FA support** - TOTP/authenticator apps
|
|
1822
2455
|
- [ ] **Password reset flow** - Complete email-based reset
|
|
1823
2456
|
- [ ] **Email change flow** - Verification for email updates
|
|
@@ -1957,6 +2590,6 @@ MIT License - See LICENSE file for details.
|
|
|
1957
2590
|
|
|
1958
2591
|
---
|
|
1959
2592
|
|
|
1960
|
-
**Last Updated:**
|
|
1961
|
-
**Document Version:** 2.
|
|
1962
|
-
**Package Version:** 0.
|
|
2593
|
+
**Last Updated:** 2026-02-23
|
|
2594
|
+
**Document Version:** 2.6.0 (Technical Documentation)
|
|
2595
|
+
**Package Version:** 0.2.0-beta.15
|