@spfn/auth 0.1.0-alpha.0
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 +1468 -0
- package/package.json +91 -0
package/README.md
ADDED
|
@@ -0,0 +1,1468 @@
|
|
|
1
|
+
# @spfn/auth
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+
|
|
6
|
+
Authentication, authorization, and comprehensive RBAC module for SPFN.
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- **Asymmetric JWT Authentication** - Client-signed tokens with ES256/RS256
|
|
11
|
+
- **User Management** - Email/phone-based identity with bcrypt password hashing
|
|
12
|
+
- **Multi-Factor Authentication** - 6-digit OTP via email/SMS
|
|
13
|
+
- **Session Management** - Public key rotation and revocation (90-day expiry)
|
|
14
|
+
- **Role-Based Access Control (RBAC)** - superadmin, admin, user roles
|
|
15
|
+
- **Account Status Management** - active, inactive, suspended states
|
|
16
|
+
- **Verification Flow** - Temporary tokens (15min) for secure operations
|
|
17
|
+
- **Type-Safe API Contracts** - Built with Typebox validation
|
|
18
|
+
|
|
19
|
+
## Architecture
|
|
20
|
+
|
|
21
|
+
### Asymmetric JWT Authentication
|
|
22
|
+
|
|
23
|
+
This package uses **client-signed JWT tokens** for enhanced security compared to traditional symmetric JWT:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
┌─────────────┐ ┌─────────────┐
|
|
27
|
+
│ Client │ │ Server │
|
|
28
|
+
│ │ │ │
|
|
29
|
+
│ 1. Generate│ │ │
|
|
30
|
+
│ keypair │ │ │
|
|
31
|
+
│ (ES256) │ │ │
|
|
32
|
+
│ │ │ │
|
|
33
|
+
│ 2. Register│──────────────────────────>│ 3. Store │
|
|
34
|
+
│ publicKey │ publicKey│
|
|
35
|
+
│ + fingerprint │ (verify │
|
|
36
|
+
│ │ fingerprint)
|
|
37
|
+
│ │ │ │
|
|
38
|
+
│ 4. Sign JWT│ │ │
|
|
39
|
+
│ with │ │ │
|
|
40
|
+
│ privateKey │ │
|
|
41
|
+
│ │ │ │
|
|
42
|
+
│ 5. Request │──────────────────────────>│ 6. Verify │
|
|
43
|
+
│ + JWT │ Authorization: Bearer │ signature│
|
|
44
|
+
│ + keyId │ X-Key-Id: uuid │ with │
|
|
45
|
+
│ │ │ publicKey│
|
|
46
|
+
│ │ │ │
|
|
47
|
+
│ │<──────────────────────────│ 7. Success │
|
|
48
|
+
│ │ { success: true } │ │
|
|
49
|
+
└─────────────┘ └─────────────┘
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Key Benefits:**
|
|
53
|
+
- Server never knows the private key
|
|
54
|
+
- No shared secrets (unlike HMAC)
|
|
55
|
+
- Each client has unique key pair
|
|
56
|
+
- Easy key rotation without global impact
|
|
57
|
+
- Automatic 90-day key expiry
|
|
58
|
+
|
|
59
|
+
**Supported Algorithms:**
|
|
60
|
+
- **ES256** (ECDSA P-256) - Recommended, ~91 bytes, compact and fast
|
|
61
|
+
- **RS256** (RSA 2048) - Fallback, ~294 bytes, wider compatibility
|
|
62
|
+
|
|
63
|
+
## Installation
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pnpm add @spfn/auth
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Quick Start
|
|
70
|
+
|
|
71
|
+
### 1. Client-Side Key Generation
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
import { generateKeyPair } from '@spfn/auth/client';
|
|
75
|
+
|
|
76
|
+
// Generate ES256 key pair (recommended)
|
|
77
|
+
const keyPair = generateKeyPair('ES256');
|
|
78
|
+
|
|
79
|
+
console.log(keyPair);
|
|
80
|
+
// {
|
|
81
|
+
// privateKey: 'MIG...', // Base64 DER (store securely!)
|
|
82
|
+
// publicKey: 'MFkw...', // Base64 DER (send to server)
|
|
83
|
+
// keyId: '550e8400-...', // UUID v4
|
|
84
|
+
// fingerprint: 'a1b2c3...', // SHA-256 (64 hex chars)
|
|
85
|
+
// algorithm: 'ES256'
|
|
86
|
+
// }
|
|
87
|
+
|
|
88
|
+
// Store privateKey securely in localStorage/sessionStorage
|
|
89
|
+
localStorage.setItem('auth.privateKey', keyPair.privateKey);
|
|
90
|
+
localStorage.setItem('auth.keyId', keyPair.keyId);
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 2. User Registration
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
import { authRegister } from '@spfn/auth/api';
|
|
97
|
+
|
|
98
|
+
// Step 1: Send verification code
|
|
99
|
+
await authSendCode({
|
|
100
|
+
target: 'user@example.com',
|
|
101
|
+
targetType: 'email',
|
|
102
|
+
purpose: 'registration'
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Step 2: Verify code and get temporary token
|
|
106
|
+
const { verificationToken } = await authVerifyCode({
|
|
107
|
+
target: 'user@example.com',
|
|
108
|
+
targetType: 'email',
|
|
109
|
+
code: '123456',
|
|
110
|
+
purpose: 'registration'
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Step 3: Register with verification token
|
|
114
|
+
const result = await authRegister({
|
|
115
|
+
email: 'user@example.com',
|
|
116
|
+
password: 'securePassword123',
|
|
117
|
+
verificationToken,
|
|
118
|
+
publicKey: keyPair.publicKey,
|
|
119
|
+
keyId: keyPair.keyId,
|
|
120
|
+
fingerprint: keyPair.fingerprint,
|
|
121
|
+
algorithm: 'ES256'
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
console.log(result);
|
|
125
|
+
// { userId: '42', email: 'user@example.com' }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 3. User Login
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
import { authLogin } from '@spfn/auth/api';
|
|
132
|
+
|
|
133
|
+
// Generate new key pair for this session
|
|
134
|
+
const newKeyPair = generateKeyPair('ES256');
|
|
135
|
+
|
|
136
|
+
const result = await authLogin({
|
|
137
|
+
email: 'user@example.com',
|
|
138
|
+
password: 'securePassword123',
|
|
139
|
+
publicKey: newKeyPair.publicKey,
|
|
140
|
+
keyId: newKeyPair.keyId,
|
|
141
|
+
fingerprint: newKeyPair.fingerprint,
|
|
142
|
+
oldKeyId: localStorage.getItem('auth.keyId'), // Revoke old key
|
|
143
|
+
algorithm: 'ES256'
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Store new credentials
|
|
147
|
+
localStorage.setItem('auth.privateKey', newKeyPair.privateKey);
|
|
148
|
+
localStorage.setItem('auth.keyId', newKeyPair.keyId);
|
|
149
|
+
localStorage.setItem('auth.userId', result.userId);
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### 4. Making Authenticated Requests
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
import { generateClientToken } from '@spfn/auth/client';
|
|
156
|
+
|
|
157
|
+
// Sign JWT with your private key
|
|
158
|
+
const privateKey = localStorage.getItem('auth.privateKey');
|
|
159
|
+
const keyId = localStorage.getItem('auth.keyId');
|
|
160
|
+
const userId = localStorage.getItem('auth.userId');
|
|
161
|
+
|
|
162
|
+
const token = generateClientToken(
|
|
163
|
+
{ userId, keyId, timestamp: Date.now() },
|
|
164
|
+
privateKey,
|
|
165
|
+
'ES256',
|
|
166
|
+
{ expiresIn: '15m', issuer: 'spfn-client' }
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// Send request with Authorization header
|
|
170
|
+
const response = await fetch('/_auth/logout', {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: {
|
|
173
|
+
'Authorization': `Bearer ${token}`,
|
|
174
|
+
'X-Key-Id': keyId,
|
|
175
|
+
'Content-Type': 'application/json'
|
|
176
|
+
},
|
|
177
|
+
body: JSON.stringify({})
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### 5. Server-Side Middleware
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
import { createApp } from '@spfn/core/route';
|
|
185
|
+
import { authenticate } from '@spfn/auth/server';
|
|
186
|
+
import { getAuth, getUser } from '@spfn/auth/server';
|
|
187
|
+
|
|
188
|
+
const app = createApp();
|
|
189
|
+
|
|
190
|
+
// Apply authentication middleware
|
|
191
|
+
app.bind(myProtectedRoute, [authenticate], async (c) => {
|
|
192
|
+
// Get authenticated user
|
|
193
|
+
const { user, userId, keyId } = getAuth(c);
|
|
194
|
+
|
|
195
|
+
// Or just get user directly
|
|
196
|
+
const user = getUser(c);
|
|
197
|
+
|
|
198
|
+
console.log(user.email, user.role, user.status);
|
|
199
|
+
|
|
200
|
+
return c.success({ message: 'Authenticated!' });
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Service Layer (Reusable Business Logic)
|
|
207
|
+
|
|
208
|
+
The `@spfn/auth` package provides **service functions** that encapsulate all business logic, making it easy to create custom authentication flows while reusing the same secure logic.
|
|
209
|
+
|
|
210
|
+
### Why Service Layer?
|
|
211
|
+
|
|
212
|
+
Instead of being locked into predefined API routes, you can:
|
|
213
|
+
- **Create custom authentication flows** that match your app's UX
|
|
214
|
+
- **Add custom logic** before/after authentication operations
|
|
215
|
+
- **Integrate with external systems** (CRM, analytics, Slack notifications)
|
|
216
|
+
- **Build complex workflows** combining multiple auth operations
|
|
217
|
+
- **Maintain consistency** by reusing the same secure business logic
|
|
218
|
+
|
|
219
|
+
### Available Services
|
|
220
|
+
|
|
221
|
+
#### Authentication Services
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
import {
|
|
225
|
+
checkAccountExistsService,
|
|
226
|
+
registerService,
|
|
227
|
+
loginService,
|
|
228
|
+
logoutService,
|
|
229
|
+
changePasswordService,
|
|
230
|
+
} from '@spfn/auth/server';
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
#### Verification Services
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
import {
|
|
237
|
+
sendVerificationCodeService,
|
|
238
|
+
verifyCodeService,
|
|
239
|
+
} from '@spfn/auth/server';
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
#### Key Management Services
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
import {
|
|
246
|
+
registerPublicKeyService,
|
|
247
|
+
rotateKeyService,
|
|
248
|
+
revokeKeyService,
|
|
249
|
+
} from '@spfn/auth/server';
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
#### User Services
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
import {
|
|
256
|
+
getUserByIdService,
|
|
257
|
+
getUserByEmailService,
|
|
258
|
+
getUserByPhoneService,
|
|
259
|
+
updateLastLoginService,
|
|
260
|
+
updateUserService,
|
|
261
|
+
} from '@spfn/auth/server';
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
### Example 1: Custom Login with Slack Notification
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
import { createApp } from '@spfn/core/route';
|
|
270
|
+
import { loginService } from '@spfn/auth/server';
|
|
271
|
+
|
|
272
|
+
const app = createApp();
|
|
273
|
+
|
|
274
|
+
app.post('/custom-login', async (c) => {
|
|
275
|
+
const body = await c.req.json();
|
|
276
|
+
|
|
277
|
+
// Log login attempt
|
|
278
|
+
console.log(`Login attempt: ${body.email}`);
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
// Reuse auth service
|
|
282
|
+
const result = await loginService({
|
|
283
|
+
email: body.email,
|
|
284
|
+
password: body.password,
|
|
285
|
+
publicKey: body.publicKey,
|
|
286
|
+
keyId: body.keyId,
|
|
287
|
+
fingerprint: body.fingerprint,
|
|
288
|
+
algorithm: body.algorithm,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Send Slack notification
|
|
292
|
+
await fetch('https://hooks.slack.com/services/YOUR/WEBHOOK/URL', {
|
|
293
|
+
method: 'POST',
|
|
294
|
+
body: JSON.stringify({
|
|
295
|
+
text: `✅ User ${result.email} logged in successfully!`,
|
|
296
|
+
}),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Track analytics
|
|
300
|
+
await trackEvent('user_login', {
|
|
301
|
+
userId: result.userId,
|
|
302
|
+
email: result.email,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
return c.json(result);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
// Custom error handling
|
|
308
|
+
await trackEvent('login_failed', { email: body.email });
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
### Example 2: Custom Registration with CRM Integration
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
import {
|
|
320
|
+
verifyCodeService,
|
|
321
|
+
registerService,
|
|
322
|
+
} from '@spfn/auth/server';
|
|
323
|
+
|
|
324
|
+
app.post('/custom-register', async (c) => {
|
|
325
|
+
const body = await c.req.json();
|
|
326
|
+
|
|
327
|
+
// Step 1: Verify OTP code
|
|
328
|
+
const { verificationToken } = await verifyCodeService({
|
|
329
|
+
target: body.email,
|
|
330
|
+
targetType: 'email',
|
|
331
|
+
code: body.otp,
|
|
332
|
+
purpose: 'registration',
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Step 2: Register user
|
|
336
|
+
const user = await registerService({
|
|
337
|
+
email: body.email,
|
|
338
|
+
password: body.password,
|
|
339
|
+
verificationToken,
|
|
340
|
+
publicKey: body.publicKey,
|
|
341
|
+
keyId: body.keyId,
|
|
342
|
+
fingerprint: body.fingerprint,
|
|
343
|
+
algorithm: 'ES256',
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Step 3: Add to CRM
|
|
347
|
+
await fetch('https://api.your-crm.com/contacts', {
|
|
348
|
+
method: 'POST',
|
|
349
|
+
headers: { 'Authorization': `Bearer ${process.env.CRM_API_KEY}` },
|
|
350
|
+
body: JSON.stringify({
|
|
351
|
+
email: user.email,
|
|
352
|
+
userId: user.userId,
|
|
353
|
+
source: 'registration',
|
|
354
|
+
createdAt: new Date().toISOString(),
|
|
355
|
+
}),
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Step 4: Send welcome email
|
|
359
|
+
await sendWelcomeEmail(user.email);
|
|
360
|
+
|
|
361
|
+
return c.json({
|
|
362
|
+
success: true,
|
|
363
|
+
userId: user.userId,
|
|
364
|
+
message: 'Registration complete! Check your email for next steps.',
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
### Example 3: Complex Multi-Step Flow
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
import {
|
|
375
|
+
checkAccountExistsService,
|
|
376
|
+
sendVerificationCodeService,
|
|
377
|
+
verifyCodeService,
|
|
378
|
+
registerService,
|
|
379
|
+
} from '@spfn/auth/server';
|
|
380
|
+
|
|
381
|
+
app.post('/signup-wizard', async (c) => {
|
|
382
|
+
const { step, email, code, password, publicKey, keyId, fingerprint } = await c.req.json();
|
|
383
|
+
|
|
384
|
+
if (step === 1) {
|
|
385
|
+
// Check if account already exists
|
|
386
|
+
const { exists } = await checkAccountExistsService({ email });
|
|
387
|
+
|
|
388
|
+
if (exists) {
|
|
389
|
+
return c.json({ error: 'Account already exists', suggestLogin: true }, 409);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Send verification code
|
|
393
|
+
const result = await sendVerificationCodeService({
|
|
394
|
+
target: email,
|
|
395
|
+
targetType: 'email',
|
|
396
|
+
purpose: 'registration',
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
return c.json({ step: 2, expiresAt: result.expiresAt });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (step === 2) {
|
|
403
|
+
// Verify code
|
|
404
|
+
const { verificationToken } = await verifyCodeService({
|
|
405
|
+
target: email,
|
|
406
|
+
targetType: 'email',
|
|
407
|
+
code,
|
|
408
|
+
purpose: 'registration',
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// Store token temporarily
|
|
412
|
+
return c.json({ step: 3, verificationToken });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (step === 3) {
|
|
416
|
+
// Complete registration
|
|
417
|
+
const user = await registerService({
|
|
418
|
+
email,
|
|
419
|
+
password,
|
|
420
|
+
verificationToken: body.verificationToken,
|
|
421
|
+
publicKey,
|
|
422
|
+
keyId,
|
|
423
|
+
fingerprint,
|
|
424
|
+
algorithm: 'ES256',
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
return c.json({ success: true, userId: user.userId });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return c.json({ error: 'Invalid step' }, 400);
|
|
431
|
+
});
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
### Example 4: Check User Without Creating Route
|
|
437
|
+
|
|
438
|
+
```typescript
|
|
439
|
+
import { getUserByEmailService } from '@spfn/auth/server';
|
|
440
|
+
|
|
441
|
+
// Use in any server code
|
|
442
|
+
async function sendNotificationToAdmin(email: string) {
|
|
443
|
+
const user = await getUserByEmailService(email);
|
|
444
|
+
|
|
445
|
+
if (user && user.role === 'admin') {
|
|
446
|
+
await sendEmail(user.email, 'Admin Notification', '...');
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
---
|
|
452
|
+
|
|
453
|
+
### Service Function Signatures
|
|
454
|
+
|
|
455
|
+
#### `loginService(params)`
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
await loginService({
|
|
459
|
+
email?: string; // One of email or phone required
|
|
460
|
+
phone?: string;
|
|
461
|
+
password: string;
|
|
462
|
+
publicKey: string;
|
|
463
|
+
keyId: string;
|
|
464
|
+
fingerprint: string;
|
|
465
|
+
oldKeyId?: string; // Optional: revoke old key
|
|
466
|
+
algorithm?: 'ES256' | 'RS256';
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// Returns: { userId, email?, phone?, passwordChangeRequired }
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
#### `registerService(params)`
|
|
473
|
+
|
|
474
|
+
```typescript
|
|
475
|
+
await registerService({
|
|
476
|
+
email?: string;
|
|
477
|
+
phone?: string;
|
|
478
|
+
verificationToken: string; // From verifyCodeService
|
|
479
|
+
password: string;
|
|
480
|
+
publicKey: string;
|
|
481
|
+
keyId: string;
|
|
482
|
+
fingerprint: string;
|
|
483
|
+
algorithm?: 'ES256' | 'RS256';
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// Returns: { userId, email?, phone? }
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
#### `verifyCodeService(params)`
|
|
490
|
+
|
|
491
|
+
```typescript
|
|
492
|
+
await verifyCodeService({
|
|
493
|
+
target: string; // Email or phone
|
|
494
|
+
targetType: 'email' | 'phone';
|
|
495
|
+
code: string; // 6-digit code
|
|
496
|
+
purpose: 'registration' | 'login' | 'password_reset';
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Returns: { valid: true, verificationToken: string }
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
---
|
|
503
|
+
|
|
504
|
+
## API Reference
|
|
505
|
+
|
|
506
|
+
### Public Endpoints (No Authentication Required)
|
|
507
|
+
|
|
508
|
+
#### `POST /_auth/codes`
|
|
509
|
+
Send a 6-digit verification code to email or phone.
|
|
510
|
+
|
|
511
|
+
**Request:**
|
|
512
|
+
```typescript
|
|
513
|
+
{
|
|
514
|
+
target: string; // Email or phone number in E.164
|
|
515
|
+
targetType: 'email' | 'phone';
|
|
516
|
+
purpose: 'registration' | 'login' | 'password_reset';
|
|
517
|
+
}
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
**Response:**
|
|
521
|
+
```typescript
|
|
522
|
+
{
|
|
523
|
+
success: boolean;
|
|
524
|
+
expiresAt: string; // ISO 8601 timestamp
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
---
|
|
529
|
+
|
|
530
|
+
#### `POST /_auth/codes/verify`
|
|
531
|
+
Verify the 6-digit code and receive a temporary token (15min validity).
|
|
532
|
+
|
|
533
|
+
**Request:**
|
|
534
|
+
```typescript
|
|
535
|
+
{
|
|
536
|
+
target: string;
|
|
537
|
+
targetType: 'email' | 'phone';
|
|
538
|
+
code: string; // 6 digits
|
|
539
|
+
purpose: 'registration' | 'login' | 'password_reset';
|
|
540
|
+
}
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
**Response:**
|
|
544
|
+
```typescript
|
|
545
|
+
{
|
|
546
|
+
valid: boolean;
|
|
547
|
+
verificationToken?: string; // Use for registration/password reset
|
|
548
|
+
}
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
---
|
|
552
|
+
|
|
553
|
+
#### `POST /_auth/exists`
|
|
554
|
+
Check if an account with given email/phone already exists.
|
|
555
|
+
|
|
556
|
+
**Request:**
|
|
557
|
+
```typescript
|
|
558
|
+
{
|
|
559
|
+
email?: string; // Email address
|
|
560
|
+
phone?: string; // E.164 format (e.g., +821012345678)
|
|
561
|
+
}
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
**Response:**
|
|
565
|
+
```typescript
|
|
566
|
+
{
|
|
567
|
+
exists: boolean;
|
|
568
|
+
identifier: string; // The checked value
|
|
569
|
+
identifierType: 'email' | 'phone';
|
|
570
|
+
}
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
---
|
|
574
|
+
|
|
575
|
+
#### `POST /_auth/register`
|
|
576
|
+
Register a new user account.
|
|
577
|
+
|
|
578
|
+
**Request:**
|
|
579
|
+
```typescript
|
|
580
|
+
{
|
|
581
|
+
email?: string; // One of email or phone required
|
|
582
|
+
phone?: string; // E.164 format
|
|
583
|
+
verificationToken: string; // From /codes/verify
|
|
584
|
+
password: string; // Minimum 8 characters
|
|
585
|
+
publicKey: string; // Base64 DER (SPKI format)
|
|
586
|
+
keyId: string; // UUID v4
|
|
587
|
+
fingerprint: string; // SHA-256 hex (64 chars)
|
|
588
|
+
algorithm: 'ES256' | 'RS256';
|
|
589
|
+
keySize?: number; // Optional, for logging
|
|
590
|
+
}
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
**Response:**
|
|
594
|
+
```typescript
|
|
595
|
+
{
|
|
596
|
+
userId: string;
|
|
597
|
+
email?: string;
|
|
598
|
+
phone?: string;
|
|
599
|
+
}
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
---
|
|
603
|
+
|
|
604
|
+
#### `POST /_auth/login`
|
|
605
|
+
Authenticate user and register new public key.
|
|
606
|
+
|
|
607
|
+
**Request:**
|
|
608
|
+
```typescript
|
|
609
|
+
{
|
|
610
|
+
email?: string; // One of email or phone required
|
|
611
|
+
phone?: string;
|
|
612
|
+
password: string;
|
|
613
|
+
publicKey: string; // New key for this session
|
|
614
|
+
keyId: string; // UUID v4
|
|
615
|
+
fingerprint: string; // SHA-256 hex
|
|
616
|
+
oldKeyId?: string; // Previous key to revoke
|
|
617
|
+
algorithm: 'ES256' | 'RS256';
|
|
618
|
+
keySize?: number;
|
|
619
|
+
}
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
**Response:**
|
|
623
|
+
```typescript
|
|
624
|
+
{
|
|
625
|
+
userId: string;
|
|
626
|
+
email?: string;
|
|
627
|
+
phone?: string;
|
|
628
|
+
passwordChangeRequired: boolean; // If true, must change password
|
|
629
|
+
}
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
---
|
|
633
|
+
|
|
634
|
+
### Authenticated Endpoints (Require JWT + X-Key-Id Headers)
|
|
635
|
+
|
|
636
|
+
#### `POST /_auth/logout`
|
|
637
|
+
Revoke current key and logout.
|
|
638
|
+
|
|
639
|
+
**Request:**
|
|
640
|
+
```typescript
|
|
641
|
+
{} // Empty body
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
**Response:**
|
|
645
|
+
```typescript
|
|
646
|
+
{
|
|
647
|
+
success: boolean;
|
|
648
|
+
}
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
---
|
|
652
|
+
|
|
653
|
+
#### `POST /_auth/keys/rotate`
|
|
654
|
+
Replace current key with a new one (before 90-day expiry).
|
|
655
|
+
|
|
656
|
+
**Request:**
|
|
657
|
+
```typescript
|
|
658
|
+
{
|
|
659
|
+
publicKey: string; // New public key
|
|
660
|
+
keyId: string; // New UUID v4
|
|
661
|
+
fingerprint: string; // New fingerprint
|
|
662
|
+
algorithm: 'ES256' | 'RS256';
|
|
663
|
+
keySize?: number;
|
|
664
|
+
}
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
**Response:**
|
|
668
|
+
```typescript
|
|
669
|
+
{
|
|
670
|
+
success: boolean;
|
|
671
|
+
keyId: string; // New key ID
|
|
672
|
+
}
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
---
|
|
676
|
+
|
|
677
|
+
#### `PUT /_auth/password`
|
|
678
|
+
Change user password (requires current password).
|
|
679
|
+
|
|
680
|
+
**Request:**
|
|
681
|
+
```typescript
|
|
682
|
+
{
|
|
683
|
+
currentPassword: string;
|
|
684
|
+
newPassword: string; // Minimum 8 characters
|
|
685
|
+
}
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
**Response:**
|
|
689
|
+
```typescript
|
|
690
|
+
{
|
|
691
|
+
success: boolean;
|
|
692
|
+
}
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
---
|
|
696
|
+
|
|
697
|
+
## Database Schema
|
|
698
|
+
|
|
699
|
+
### Table: `users`
|
|
700
|
+
|
|
701
|
+
Main user identity table.
|
|
702
|
+
|
|
703
|
+
| Column | Type | Description |
|
|
704
|
+
|--------|------|-------------|
|
|
705
|
+
| `id` | bigserial | Primary key |
|
|
706
|
+
| `email` | text | Email address (unique, nullable) |
|
|
707
|
+
| `phone` | text | Phone in E.164 format (unique, nullable) |
|
|
708
|
+
| `passwordHash` | text | bcrypt hash ($2b$10$..., 60 chars) |
|
|
709
|
+
| `passwordChangeRequired` | boolean | Force password change on next login |
|
|
710
|
+
| `roleId` | bigint | Foreign key to roles.id |
|
|
711
|
+
| `status` | enum | `active`, `inactive`, `suspended` |
|
|
712
|
+
| `emailVerifiedAt` | timestamp | Email verification time |
|
|
713
|
+
| `phoneVerifiedAt` | timestamp | Phone verification time |
|
|
714
|
+
| `lastLoginAt` | timestamp | Last successful login |
|
|
715
|
+
| `createdAt` | timestamp | Account creation time |
|
|
716
|
+
| `updatedAt` | timestamp | Last update time |
|
|
717
|
+
|
|
718
|
+
**Constraints:**
|
|
719
|
+
- At least one of `email` OR `phone` must be provided
|
|
720
|
+
- Email and phone are unique when not null
|
|
721
|
+
- `roleId` references roles.id (NOT NULL)
|
|
722
|
+
|
|
723
|
+
---
|
|
724
|
+
|
|
725
|
+
### Table: `user_public_keys`
|
|
726
|
+
|
|
727
|
+
Stores client public keys for JWT verification.
|
|
728
|
+
|
|
729
|
+
| Column | Type | Description |
|
|
730
|
+
|--------|------|-------------|
|
|
731
|
+
| `id` | bigserial | Primary key |
|
|
732
|
+
| `userId` | bigint | Foreign key to users.id |
|
|
733
|
+
| `keyId` | text | Client-generated UUID (unique) |
|
|
734
|
+
| `publicKey` | text | Base64 DER encoded (SPKI) |
|
|
735
|
+
| `algorithm` | enum | `ES256`, `RS256` |
|
|
736
|
+
| `fingerprint` | text | SHA-256 hex (64 chars) |
|
|
737
|
+
| `isActive` | boolean | Key status (true = active) |
|
|
738
|
+
| `createdAt` | timestamp | Key creation time |
|
|
739
|
+
| `lastUsedAt` | timestamp | Last authentication time |
|
|
740
|
+
| `expiresAt` | timestamp | Expiry time (90 days default) |
|
|
741
|
+
| `revokedAt` | timestamp | Revocation time |
|
|
742
|
+
| `revokedReason` | text | Revocation reason |
|
|
743
|
+
|
|
744
|
+
**Indexes:**
|
|
745
|
+
- `userId`, `keyId`, `isActive`, `fingerprint`
|
|
746
|
+
|
|
747
|
+
---
|
|
748
|
+
|
|
749
|
+
### Table: `verification_codes`
|
|
750
|
+
|
|
751
|
+
Stores OTP codes for email/SMS verification.
|
|
752
|
+
|
|
753
|
+
| Column | Type | Description |
|
|
754
|
+
|--------|------|-------------|
|
|
755
|
+
| `id` | bigserial | Primary key |
|
|
756
|
+
| `target` | text | Email or phone number |
|
|
757
|
+
| `targetType` | enum | `email`, `phone` |
|
|
758
|
+
| `code` | text | 6-digit code |
|
|
759
|
+
| `purpose` | enum | `registration`, `login`, `password_reset`, etc. |
|
|
760
|
+
| `expiresAt` | timestamp | Code expiry (5-10 minutes) |
|
|
761
|
+
| `usedAt` | timestamp | Time code was used |
|
|
762
|
+
| `createdAt` | timestamp | Code creation time |
|
|
763
|
+
|
|
764
|
+
---
|
|
765
|
+
|
|
766
|
+
### Table: `user_social_accounts`
|
|
767
|
+
|
|
768
|
+
OAuth provider accounts (future feature).
|
|
769
|
+
|
|
770
|
+
| Column | Type | Description |
|
|
771
|
+
|--------|------|-------------|
|
|
772
|
+
| `id` | bigserial | Primary key |
|
|
773
|
+
| `userId` | bigint | Foreign key to users.id |
|
|
774
|
+
| `provider` | text | OAuth provider (google, github, etc.) |
|
|
775
|
+
| `providerId` | text | Provider's user ID |
|
|
776
|
+
| `accessToken` | text | OAuth access token |
|
|
777
|
+
| `refreshToken` | text | OAuth refresh token |
|
|
778
|
+
| `expiresAt` | timestamp | Token expiry |
|
|
779
|
+
| `createdAt` | timestamp | Account link time |
|
|
780
|
+
|
|
781
|
+
---
|
|
782
|
+
|
|
783
|
+
### Table: `roles`
|
|
784
|
+
|
|
785
|
+
Role definitions for RBAC system.
|
|
786
|
+
|
|
787
|
+
| Column | Type | Description |
|
|
788
|
+
|--------|------|-------------|
|
|
789
|
+
| `id` | bigserial | Primary key |
|
|
790
|
+
| `name` | text | Role name (unique, e.g., 'admin', 'user') |
|
|
791
|
+
| `displayName` | text | Human-readable name |
|
|
792
|
+
| `description` | text | Role description |
|
|
793
|
+
| `isBuiltin` | boolean | Cannot be deleted (user, admin, superadmin) |
|
|
794
|
+
| `isSystem` | boolean | System role (cannot be deleted) |
|
|
795
|
+
| `isActive` | boolean | Role status |
|
|
796
|
+
| `priority` | integer | Role hierarchy (higher = more privileged) |
|
|
797
|
+
| `createdAt` | timestamp | Creation time |
|
|
798
|
+
| `updatedAt` | timestamp | Last update time |
|
|
799
|
+
|
|
800
|
+
**Built-in roles:**
|
|
801
|
+
- `user` (priority 10) - Default role
|
|
802
|
+
- `admin` (priority 80) - Admin role
|
|
803
|
+
- `superadmin` (priority 100) - Super admin
|
|
804
|
+
|
|
805
|
+
---
|
|
806
|
+
|
|
807
|
+
### Table: `permissions`
|
|
808
|
+
|
|
809
|
+
Permission definitions for RBAC system.
|
|
810
|
+
|
|
811
|
+
| Column | Type | Description |
|
|
812
|
+
|--------|------|-------------|
|
|
813
|
+
| `id` | bigserial | Primary key |
|
|
814
|
+
| `name` | text | Permission name (unique, e.g., 'user:delete') |
|
|
815
|
+
| `displayName` | text | Human-readable name |
|
|
816
|
+
| `description` | text | Permission description |
|
|
817
|
+
| `category` | text | Permission category (e.g., 'user', 'content') |
|
|
818
|
+
| `isBuiltin` | boolean | Built-in permission |
|
|
819
|
+
| `isSystem` | boolean | System permission |
|
|
820
|
+
| `isActive` | boolean | Permission status |
|
|
821
|
+
| `createdAt` | timestamp | Creation time |
|
|
822
|
+
| `updatedAt` | timestamp | Last update time |
|
|
823
|
+
|
|
824
|
+
**Built-in permissions:**
|
|
825
|
+
- `auth:self:manage` - Self auth management
|
|
826
|
+
- `user:read`, `user:write`, `user:delete` - User management
|
|
827
|
+
- `rbac:role:manage`, `rbac:permission:manage` - RBAC management
|
|
828
|
+
|
|
829
|
+
---
|
|
830
|
+
|
|
831
|
+
### Table: `role_permissions`
|
|
832
|
+
|
|
833
|
+
Maps roles to permissions (many-to-many).
|
|
834
|
+
|
|
835
|
+
| Column | Type | Description |
|
|
836
|
+
|--------|------|-------------|
|
|
837
|
+
| `id` | bigserial | Primary key |
|
|
838
|
+
| `roleId` | bigint | Foreign key to roles.id |
|
|
839
|
+
| `permissionId` | bigint | Foreign key to permissions.id |
|
|
840
|
+
| `createdAt` | timestamp | Creation time |
|
|
841
|
+
| `updatedAt` | timestamp | Last update time |
|
|
842
|
+
|
|
843
|
+
**Constraints:**
|
|
844
|
+
- `UNIQUE(roleId, permissionId)`
|
|
845
|
+
- `ON DELETE CASCADE` for both foreign keys
|
|
846
|
+
|
|
847
|
+
---
|
|
848
|
+
|
|
849
|
+
### Table: `user_permissions`
|
|
850
|
+
|
|
851
|
+
User-specific permission overrides.
|
|
852
|
+
|
|
853
|
+
| Column | Type | Description |
|
|
854
|
+
|--------|------|-------------|
|
|
855
|
+
| `id` | bigserial | Primary key |
|
|
856
|
+
| `userId` | bigint | Foreign key to users.id |
|
|
857
|
+
| `permissionId` | bigint | Foreign key to permissions.id |
|
|
858
|
+
| `granted` | boolean | true = grant, false = revoke |
|
|
859
|
+
| `reason` | text | Reason for override |
|
|
860
|
+
| `expiresAt` | timestamp | Optional expiration time |
|
|
861
|
+
| `createdAt` | timestamp | Creation time |
|
|
862
|
+
| `updatedAt` | timestamp | Last update time |
|
|
863
|
+
|
|
864
|
+
**Constraints:**
|
|
865
|
+
- `UNIQUE(userId, permissionId)`
|
|
866
|
+
- `ON DELETE CASCADE` for both foreign keys
|
|
867
|
+
|
|
868
|
+
**Use cases:**
|
|
869
|
+
- Temporary admin access (with `expiresAt`)
|
|
870
|
+
- Revoke specific permission (even if role has it)
|
|
871
|
+
|
|
872
|
+
---
|
|
873
|
+
|
|
874
|
+
## Role-Based Access Control (RBAC)
|
|
875
|
+
|
|
876
|
+
The `@spfn/auth` package provides a flexible, extensible RBAC system that combines **code-defined system roles** with **runtime-created custom roles** and **granular permissions**.
|
|
877
|
+
|
|
878
|
+
### Built-in Roles
|
|
879
|
+
|
|
880
|
+
These roles are automatically created and cannot be deleted:
|
|
881
|
+
|
|
882
|
+
| Role | Priority | Built-in Permissions |
|
|
883
|
+
|------|----------|---------------------|
|
|
884
|
+
| `superadmin` | 100 | Full system access + RBAC management |
|
|
885
|
+
| `admin` | 80 | User management |
|
|
886
|
+
| `user` | 10 | Self auth management (default) |
|
|
887
|
+
|
|
888
|
+
### Built-in Permissions
|
|
889
|
+
|
|
890
|
+
Required permissions for auth package functionality:
|
|
891
|
+
|
|
892
|
+
- `auth:self:manage` - Change own password, rotate keys
|
|
893
|
+
- `user:read` - View user information
|
|
894
|
+
- `user:write` - Create and update users
|
|
895
|
+
- `user:delete` - Delete users
|
|
896
|
+
- `rbac:role:manage` - Create, update, delete roles
|
|
897
|
+
- `rbac:permission:manage` - Assign permissions
|
|
898
|
+
|
|
899
|
+
### Initialization
|
|
900
|
+
|
|
901
|
+
#### Minimal Setup (Built-in Only)
|
|
902
|
+
|
|
903
|
+
```typescript
|
|
904
|
+
import { initializeAuth } from '@spfn/auth/server';
|
|
905
|
+
|
|
906
|
+
// Only built-in roles: user, admin, superadmin
|
|
907
|
+
await initializeAuth();
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
#### With Presets
|
|
911
|
+
|
|
912
|
+
```typescript
|
|
913
|
+
await initializeAuth({
|
|
914
|
+
usePresets: true, // Adds: moderator, editor, viewer + content permissions
|
|
915
|
+
});
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
#### Custom Roles & Permissions
|
|
919
|
+
|
|
920
|
+
```typescript
|
|
921
|
+
await initializeAuth({
|
|
922
|
+
roles: [
|
|
923
|
+
{
|
|
924
|
+
name: 'content-creator',
|
|
925
|
+
displayName: 'Content Creator',
|
|
926
|
+
priority: 20,
|
|
927
|
+
},
|
|
928
|
+
{
|
|
929
|
+
name: 'subscriber',
|
|
930
|
+
displayName: 'Subscriber',
|
|
931
|
+
priority: 15,
|
|
932
|
+
},
|
|
933
|
+
],
|
|
934
|
+
permissions: [
|
|
935
|
+
{
|
|
936
|
+
name: 'post:create',
|
|
937
|
+
displayName: 'Create Posts',
|
|
938
|
+
category: 'content',
|
|
939
|
+
},
|
|
940
|
+
{
|
|
941
|
+
name: 'post:publish',
|
|
942
|
+
displayName: 'Publish Posts',
|
|
943
|
+
category: 'content',
|
|
944
|
+
},
|
|
945
|
+
{
|
|
946
|
+
name: 'video:upload',
|
|
947
|
+
displayName: 'Upload Videos',
|
|
948
|
+
category: 'media',
|
|
949
|
+
},
|
|
950
|
+
],
|
|
951
|
+
rolePermissions: {
|
|
952
|
+
// Extend built-in admin role
|
|
953
|
+
admin: ['post:create', 'post:publish', 'video:upload'],
|
|
954
|
+
|
|
955
|
+
// Custom role permissions
|
|
956
|
+
'content-creator': ['post:create', 'post:publish', 'video:upload'],
|
|
957
|
+
subscriber: ['post:create'],
|
|
958
|
+
},
|
|
959
|
+
});
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
### Permission Middleware
|
|
963
|
+
|
|
964
|
+
```typescript
|
|
965
|
+
import { authenticate, requirePermissions, requireRole } from '@spfn/auth/server';
|
|
966
|
+
|
|
967
|
+
// Require specific permission
|
|
968
|
+
app.bind(
|
|
969
|
+
deleteUserContract,
|
|
970
|
+
[authenticate, requirePermissions('user:delete')],
|
|
971
|
+
async (c) => {
|
|
972
|
+
// Only users with user:delete permission
|
|
973
|
+
}
|
|
974
|
+
);
|
|
975
|
+
|
|
976
|
+
// Require multiple permissions (all)
|
|
977
|
+
app.bind(
|
|
978
|
+
publishPostContract,
|
|
979
|
+
[authenticate, requirePermissions('post:write', 'post:publish')],
|
|
980
|
+
async (c) => {
|
|
981
|
+
// Needs both permissions
|
|
982
|
+
}
|
|
983
|
+
);
|
|
984
|
+
|
|
985
|
+
// Require role
|
|
986
|
+
app.bind(
|
|
987
|
+
adminDashboardContract,
|
|
988
|
+
[authenticate, requireRole('admin', 'superadmin')],
|
|
989
|
+
async (c) => {
|
|
990
|
+
// Only admin or superadmin
|
|
991
|
+
}
|
|
992
|
+
);
|
|
993
|
+
|
|
994
|
+
// Require any of these permissions
|
|
995
|
+
import { requireAnyPermission } from '@spfn/auth/server';
|
|
996
|
+
|
|
997
|
+
app.bind(
|
|
998
|
+
viewContentContract,
|
|
999
|
+
[authenticate, requireAnyPermission('content:read', 'admin:access')],
|
|
1000
|
+
async (c) => {
|
|
1001
|
+
// Has either permission
|
|
1002
|
+
}
|
|
1003
|
+
);
|
|
1004
|
+
```
|
|
1005
|
+
|
|
1006
|
+
### Permission Checking in Code
|
|
1007
|
+
|
|
1008
|
+
```typescript
|
|
1009
|
+
import { hasPermission, hasRole, getUserPermissions } from '@spfn/auth/server';
|
|
1010
|
+
|
|
1011
|
+
app.bind(createPostContract, [authenticate], async (c) => {
|
|
1012
|
+
const { userId } = getAuth(c);
|
|
1013
|
+
|
|
1014
|
+
// Check single permission
|
|
1015
|
+
const canPublish = await hasPermission(userId, 'post:publish');
|
|
1016
|
+
|
|
1017
|
+
// Check role
|
|
1018
|
+
const isAdmin = await hasRole(userId, 'admin');
|
|
1019
|
+
|
|
1020
|
+
// Get all permissions
|
|
1021
|
+
const perms = await getUserPermissions(userId);
|
|
1022
|
+
|
|
1023
|
+
// Conditional logic
|
|
1024
|
+
const post = await createPost({
|
|
1025
|
+
...body,
|
|
1026
|
+
status: canPublish ? 'published' : 'draft',
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
return c.success(post);
|
|
1030
|
+
});
|
|
1031
|
+
```
|
|
1032
|
+
|
|
1033
|
+
### Runtime Role Management
|
|
1034
|
+
|
|
1035
|
+
```typescript
|
|
1036
|
+
import { createRole, addPermissionToRole } from '@spfn/auth/server';
|
|
1037
|
+
|
|
1038
|
+
// Create custom role at runtime
|
|
1039
|
+
const role = await createRole({
|
|
1040
|
+
name: 'moderator',
|
|
1041
|
+
displayName: 'Community Moderator',
|
|
1042
|
+
description: 'Manages community content',
|
|
1043
|
+
priority: 40,
|
|
1044
|
+
permissionIds: [1n, 2n, 3n], // Permission IDs
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
// Add permission to role
|
|
1048
|
+
await addPermissionToRole(role.id, 5n);
|
|
1049
|
+
|
|
1050
|
+
// Update role
|
|
1051
|
+
await updateRole(role.id, {
|
|
1052
|
+
displayName: 'Senior Moderator',
|
|
1053
|
+
priority: 45,
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
// Delete role (system roles protected)
|
|
1057
|
+
await deleteRole(role.id);
|
|
1058
|
+
```
|
|
1059
|
+
|
|
1060
|
+
### Preset Roles & Permissions
|
|
1061
|
+
|
|
1062
|
+
Available presets (opt-in):
|
|
1063
|
+
|
|
1064
|
+
**Roles:**
|
|
1065
|
+
- `moderator` (priority 50) - Content moderation
|
|
1066
|
+
- `editor` (priority 30) - Content creation
|
|
1067
|
+
- `viewer` (priority 5) - Read-only access
|
|
1068
|
+
|
|
1069
|
+
**Permissions:**
|
|
1070
|
+
- `content:read`, `content:write`, `content:delete`, `content:publish`
|
|
1071
|
+
- `comment:moderate`
|
|
1072
|
+
- `system:config`
|
|
1073
|
+
- `analytics:view`
|
|
1074
|
+
|
|
1075
|
+
Use individually:
|
|
1076
|
+
|
|
1077
|
+
```typescript
|
|
1078
|
+
import { PRESET_ROLES, PRESET_PERMISSIONS } from '@spfn/auth/server';
|
|
1079
|
+
|
|
1080
|
+
await initializeAuth({
|
|
1081
|
+
presetRoles: ['MODERATOR', 'EDITOR'],
|
|
1082
|
+
presetPermissions: ['CONTENT_READ', 'CONTENT_WRITE', 'CONTENT_PUBLISH'],
|
|
1083
|
+
rolePermissions: {
|
|
1084
|
+
moderator: ['content:read', 'content:write', 'comment:moderate'],
|
|
1085
|
+
editor: ['content:read', 'content:write', 'content:publish'],
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
### User-Specific Permissions
|
|
1091
|
+
|
|
1092
|
+
Grant or revoke permissions for individual users:
|
|
1093
|
+
|
|
1094
|
+
```typescript
|
|
1095
|
+
import { userPermissions } from '@spfn/auth';
|
|
1096
|
+
import { getDatabase } from '@spfn/core/db';
|
|
1097
|
+
|
|
1098
|
+
const db = getDatabase()!;
|
|
1099
|
+
|
|
1100
|
+
// Grant temporary permission
|
|
1101
|
+
await db.insert(userPermissions).values({
|
|
1102
|
+
userId: 123n,
|
|
1103
|
+
permissionId: 5n,
|
|
1104
|
+
granted: true,
|
|
1105
|
+
reason: 'Temporary admin access for migration',
|
|
1106
|
+
expiresAt: new Date('2025-12-31'),
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
// Revoke permission (even if role has it)
|
|
1110
|
+
await db.insert(userPermissions).values({
|
|
1111
|
+
userId: 456n,
|
|
1112
|
+
permissionId: 3n,
|
|
1113
|
+
granted: false,
|
|
1114
|
+
reason: 'Security violation',
|
|
1115
|
+
});
|
|
1116
|
+
```
|
|
1117
|
+
|
|
1118
|
+
### Account Status
|
|
1119
|
+
|
|
1120
|
+
| Status | Description | Login Allowed |
|
|
1121
|
+
|--------|-------------|---------------|
|
|
1122
|
+
| `active` | Normal operation | Yes |
|
|
1123
|
+
| `inactive` | User deactivated account | No |
|
|
1124
|
+
| `suspended` | Locked due to security/ToS violation | No |
|
|
1125
|
+
|
|
1126
|
+
---
|
|
1127
|
+
|
|
1128
|
+
## Security
|
|
1129
|
+
|
|
1130
|
+
### Key Management Best Practices
|
|
1131
|
+
|
|
1132
|
+
1. **Store Private Keys Securely**
|
|
1133
|
+
- Use `sessionStorage` for session-only keys
|
|
1134
|
+
- Use `localStorage` for persistent keys
|
|
1135
|
+
- Never send private keys to server
|
|
1136
|
+
- Never expose in logs or error messages
|
|
1137
|
+
|
|
1138
|
+
2. **Rotate Keys Before Expiry**
|
|
1139
|
+
- Keys expire after 90 days
|
|
1140
|
+
- Rotate keys when `daysRemaining <= 7`
|
|
1141
|
+
- Use `POST /_auth/keys/rotate` endpoint
|
|
1142
|
+
|
|
1143
|
+
```typescript
|
|
1144
|
+
import { shouldRotateKey } from '@spfn/auth/client';
|
|
1145
|
+
|
|
1146
|
+
const createdAt = new Date(localStorage.getItem('auth.keyCreatedAt'));
|
|
1147
|
+
const { shouldRotate, daysRemaining } = shouldRotateKey(createdAt, 90);
|
|
1148
|
+
|
|
1149
|
+
if (shouldRotate) {
|
|
1150
|
+
console.warn(`Key expires in ${daysRemaining} days - rotate soon!`);
|
|
1151
|
+
// Call rotation endpoint...
|
|
1152
|
+
}
|
|
1153
|
+
```
|
|
1154
|
+
|
|
1155
|
+
3. **Fingerprint Verification**
|
|
1156
|
+
- Always send fingerprint with public key
|
|
1157
|
+
- Server validates fingerprint = SHA-256(publicKey)
|
|
1158
|
+
- Prevents key tampering during transmission
|
|
1159
|
+
|
|
1160
|
+
4. **Token Expiry**
|
|
1161
|
+
- JWT tokens expire after 15 minutes by default
|
|
1162
|
+
- Use short expiry for sensitive operations
|
|
1163
|
+
- Generate new token for each request or cache for <15min
|
|
1164
|
+
|
|
1165
|
+
5. **Environment Variables**
|
|
1166
|
+
|
|
1167
|
+
```bash
|
|
1168
|
+
# .env
|
|
1169
|
+
JWT_SECRET=your-secret-key-change-in-production # For legacy tokens
|
|
1170
|
+
JWT_EXPIRES_IN=7d # Token expiry
|
|
1171
|
+
```
|
|
1172
|
+
|
|
1173
|
+
---
|
|
1174
|
+
|
|
1175
|
+
## Setup
|
|
1176
|
+
|
|
1177
|
+
### 1. Run Database Migrations
|
|
1178
|
+
|
|
1179
|
+
```bash
|
|
1180
|
+
npx spfn db migrate
|
|
1181
|
+
```
|
|
1182
|
+
|
|
1183
|
+
This creates the auth schema with 8 tables:
|
|
1184
|
+
|
|
1185
|
+
**Core Tables:**
|
|
1186
|
+
- `users` - User accounts and profiles
|
|
1187
|
+
- `user_public_keys` - Client public keys for JWT
|
|
1188
|
+
- `verification_codes` - OTP verification codes
|
|
1189
|
+
- `user_social_accounts` - OAuth provider accounts
|
|
1190
|
+
|
|
1191
|
+
**RBAC Tables:**
|
|
1192
|
+
- `roles` - System and custom roles
|
|
1193
|
+
- `permissions` - System and custom permissions
|
|
1194
|
+
- `role_permissions` - Role-permission mappings
|
|
1195
|
+
- `user_permissions` - User-specific permission overrides
|
|
1196
|
+
|
|
1197
|
+
### 2. Configure Environment Variables
|
|
1198
|
+
|
|
1199
|
+
```bash
|
|
1200
|
+
# .env
|
|
1201
|
+
JWT_SECRET=your-secret-key-change-in-production
|
|
1202
|
+
JWT_EXPIRES_IN=7d
|
|
1203
|
+
```
|
|
1204
|
+
|
|
1205
|
+
### 3. Create Initial Admin Accounts (Optional)
|
|
1206
|
+
|
|
1207
|
+
You can automatically create admin accounts on server startup using environment variables. Three formats are supported:
|
|
1208
|
+
|
|
1209
|
+
#### Option 1: JSON Format (Most Flexible)
|
|
1210
|
+
|
|
1211
|
+
Allows full control over each account's configuration.
|
|
1212
|
+
|
|
1213
|
+
```bash
|
|
1214
|
+
# .env
|
|
1215
|
+
ADMIN_ACCOUNTS='[
|
|
1216
|
+
{
|
|
1217
|
+
"email": "super@example.com",
|
|
1218
|
+
"password": "super-password",
|
|
1219
|
+
"role": "superadmin",
|
|
1220
|
+
"phone": "+821012345678",
|
|
1221
|
+
"passwordChangeRequired": true
|
|
1222
|
+
},
|
|
1223
|
+
{
|
|
1224
|
+
"email": "admin@example.com",
|
|
1225
|
+
"password": "admin-password",
|
|
1226
|
+
"role": "admin"
|
|
1227
|
+
},
|
|
1228
|
+
{
|
|
1229
|
+
"email": "user@example.com",
|
|
1230
|
+
"password": "user-password",
|
|
1231
|
+
"role": "user",
|
|
1232
|
+
"passwordChangeRequired": false
|
|
1233
|
+
}
|
|
1234
|
+
]'
|
|
1235
|
+
```
|
|
1236
|
+
|
|
1237
|
+
**JSON Fields:**
|
|
1238
|
+
- `email` (required): Email address
|
|
1239
|
+
- `password` (required): Initial password
|
|
1240
|
+
- `role` (optional): `superadmin`, `admin`, or `user` (default: `user`)
|
|
1241
|
+
- `phone` (optional): Phone number in E.164 format
|
|
1242
|
+
- `passwordChangeRequired` (optional): Force password change on first login (default: `true`)
|
|
1243
|
+
|
|
1244
|
+
---
|
|
1245
|
+
|
|
1246
|
+
#### Option 2: Comma-Separated Format (Simple)
|
|
1247
|
+
|
|
1248
|
+
Quick setup for multiple accounts with basic configuration.
|
|
1249
|
+
|
|
1250
|
+
```bash
|
|
1251
|
+
# .env
|
|
1252
|
+
ADMIN_EMAILS=super@example.com,admin@example.com,user@example.com
|
|
1253
|
+
ADMIN_PASSWORDS=super-pass,admin-pass,user-pass
|
|
1254
|
+
ADMIN_ROLES=superadmin,admin,user # Optional, defaults to 'user'
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
**Requirements:**
|
|
1258
|
+
- `ADMIN_EMAILS` and `ADMIN_PASSWORDS` must have the same number of items
|
|
1259
|
+
- `ADMIN_ROLES` is optional (defaults to `user` for each account)
|
|
1260
|
+
- All accounts will have `passwordChangeRequired: true`
|
|
1261
|
+
|
|
1262
|
+
---
|
|
1263
|
+
|
|
1264
|
+
#### Option 3: Single Account (Legacy)
|
|
1265
|
+
|
|
1266
|
+
For backward compatibility, you can create a single superadmin account.
|
|
1267
|
+
|
|
1268
|
+
```bash
|
|
1269
|
+
# .env
|
|
1270
|
+
ADMIN_EMAIL=admin@example.com
|
|
1271
|
+
ADMIN_PASSWORD=secure-password
|
|
1272
|
+
```
|
|
1273
|
+
|
|
1274
|
+
This creates a single account with:
|
|
1275
|
+
- `role: 'superadmin'`
|
|
1276
|
+
- `passwordChangeRequired: true`
|
|
1277
|
+
|
|
1278
|
+
---
|
|
1279
|
+
|
|
1280
|
+
#### Usage in Server Code
|
|
1281
|
+
|
|
1282
|
+
Call `ensureAdminExists()` in your server startup code:
|
|
1283
|
+
|
|
1284
|
+
```typescript
|
|
1285
|
+
// src/server/index.ts or app initialization
|
|
1286
|
+
import { ensureAdminExists } from '@spfn/auth/server';
|
|
1287
|
+
|
|
1288
|
+
// Call during server startup
|
|
1289
|
+
await ensureAdminExists();
|
|
1290
|
+
```
|
|
1291
|
+
|
|
1292
|
+
**Output Example:**
|
|
1293
|
+
```
|
|
1294
|
+
[Auth] Creating 3 admin account(s)...
|
|
1295
|
+
[Auth] ✅ Admin account created: super@example.com (superadmin)
|
|
1296
|
+
[Auth] ✅ Admin account created: admin@example.com (admin)
|
|
1297
|
+
[Auth] ⚠️ Account already exists: user@example.com (skipped)
|
|
1298
|
+
[Auth] 📊 Summary: 2 created, 1 skipped, 0 failed
|
|
1299
|
+
[Auth] ⚠️ Please change passwords on first login!
|
|
1300
|
+
```
|
|
1301
|
+
|
|
1302
|
+
**Behavior:**
|
|
1303
|
+
- Accounts are only created if they don't already exist
|
|
1304
|
+
- All created accounts are auto-verified (`emailVerifiedAt` is set)
|
|
1305
|
+
- By default, password change is required on first login
|
|
1306
|
+
- If no environment variables are set, the function silently returns
|
|
1307
|
+
|
|
1308
|
+
---
|
|
1309
|
+
|
|
1310
|
+
### 4. Import in Your SPFN Project
|
|
1311
|
+
|
|
1312
|
+
```typescript
|
|
1313
|
+
// Server-side only
|
|
1314
|
+
import { authenticate, getAuth, getUser } from '@spfn/auth/server';
|
|
1315
|
+
import { users, userPublicKeys } from '@spfn/auth'; // Entities
|
|
1316
|
+
|
|
1317
|
+
// Client-side only
|
|
1318
|
+
import { generateKeyPair, generateClientToken } from '@spfn/auth/client';
|
|
1319
|
+
|
|
1320
|
+
// Common (both sides)
|
|
1321
|
+
import type { User, UserRole, UserStatus } from '@spfn/auth';
|
|
1322
|
+
```
|
|
1323
|
+
|
|
1324
|
+
---
|
|
1325
|
+
|
|
1326
|
+
## Testing
|
|
1327
|
+
|
|
1328
|
+
### Run All Tests
|
|
1329
|
+
|
|
1330
|
+
```bash
|
|
1331
|
+
pnpm test
|
|
1332
|
+
```
|
|
1333
|
+
|
|
1334
|
+
### Run Tests with Coverage
|
|
1335
|
+
|
|
1336
|
+
```bash
|
|
1337
|
+
pnpm test:coverage
|
|
1338
|
+
```
|
|
1339
|
+
|
|
1340
|
+
Current coverage: **83.01%** (25 tests passing)
|
|
1341
|
+
|
|
1342
|
+
### Run Route Tests Only
|
|
1343
|
+
|
|
1344
|
+
```bash
|
|
1345
|
+
pnpm test:routes
|
|
1346
|
+
```
|
|
1347
|
+
|
|
1348
|
+
### Start Test Database
|
|
1349
|
+
|
|
1350
|
+
```bash
|
|
1351
|
+
pnpm docker:test:up
|
|
1352
|
+
```
|
|
1353
|
+
|
|
1354
|
+
### Stop Test Database
|
|
1355
|
+
|
|
1356
|
+
```bash
|
|
1357
|
+
pnpm docker:test:down
|
|
1358
|
+
```
|
|
1359
|
+
|
|
1360
|
+
---
|
|
1361
|
+
|
|
1362
|
+
## Package Structure
|
|
1363
|
+
|
|
1364
|
+
```
|
|
1365
|
+
@spfn/auth/
|
|
1366
|
+
├── dist/
|
|
1367
|
+
│ ├── index.js # Common exports (types, entities)
|
|
1368
|
+
│ ├── server.js # Server-only exports (routes, middleware, helpers, services)
|
|
1369
|
+
│ └── client.js # Client-only exports (crypto, hooks, store)
|
|
1370
|
+
├── migrations/ # Drizzle database migrations
|
|
1371
|
+
└── src/
|
|
1372
|
+
├── index.ts # Common entry point
|
|
1373
|
+
├── server.ts # Server entry point
|
|
1374
|
+
├── client.ts # Client entry point
|
|
1375
|
+
├── lib/ # Shared code
|
|
1376
|
+
│ ├── api/ # API client functions
|
|
1377
|
+
│ ├── contracts/ # Type-safe API contracts
|
|
1378
|
+
│ └── types/ # Shared TypeScript types
|
|
1379
|
+
├── server/ # Server-only code
|
|
1380
|
+
│ ├── entities/ # Drizzle ORM entities
|
|
1381
|
+
│ ├── services/ # 🆕 Business logic layer (reusable functions)
|
|
1382
|
+
│ │ ├── auth.service.ts
|
|
1383
|
+
│ │ ├── verification.service.ts
|
|
1384
|
+
│ │ ├── key.service.ts
|
|
1385
|
+
│ │ ├── user.service.ts
|
|
1386
|
+
│ │ └── index.ts
|
|
1387
|
+
│ ├── routes/ # API route handlers (thin layer calling services)
|
|
1388
|
+
│ ├── middleware/ # Authentication middleware
|
|
1389
|
+
│ ├── helpers/ # JWT, password, verification utils
|
|
1390
|
+
│ └── repositories/ # Database access layer
|
|
1391
|
+
└── client/ # Client-only code
|
|
1392
|
+
├── lib/ # Crypto helpers (key generation, JWT signing)
|
|
1393
|
+
├── hooks/ # React hooks (TODO)
|
|
1394
|
+
├── store/ # Zustand state management (TODO)
|
|
1395
|
+
└── components/ # React components (TODO)
|
|
1396
|
+
```
|
|
1397
|
+
|
|
1398
|
+
---
|
|
1399
|
+
|
|
1400
|
+
## SPFN Framework Integration
|
|
1401
|
+
|
|
1402
|
+
This package automatically integrates with SPFN via `package.json`:
|
|
1403
|
+
|
|
1404
|
+
```json
|
|
1405
|
+
{
|
|
1406
|
+
"spfn": {
|
|
1407
|
+
"prefix": "/_auth",
|
|
1408
|
+
"schemas": ["./dist/server/entities/*.js"],
|
|
1409
|
+
"routes": {
|
|
1410
|
+
"basePath": "/auth",
|
|
1411
|
+
"dir": "./dist/server/routes"
|
|
1412
|
+
},
|
|
1413
|
+
"migrations": {
|
|
1414
|
+
"dir": "./migrations"
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
```
|
|
1419
|
+
|
|
1420
|
+
Routes are automatically registered:
|
|
1421
|
+
- `/_auth/codes` → Send verification code
|
|
1422
|
+
- `/_auth/codes/verify` → Verify code
|
|
1423
|
+
- `/_auth/exists` → Check account existence
|
|
1424
|
+
- `/_auth/register` → Register user
|
|
1425
|
+
- `/_auth/login` → Login
|
|
1426
|
+
- `/_auth/logout` → Logout (authenticated)
|
|
1427
|
+
- `/_auth/keys/rotate` → Rotate key (authenticated)
|
|
1428
|
+
- `/_auth/password` → Change password (authenticated)
|
|
1429
|
+
|
|
1430
|
+
---
|
|
1431
|
+
|
|
1432
|
+
## Development Status
|
|
1433
|
+
|
|
1434
|
+
**Version:** 0.1.0-alpha.0 (Alpha)
|
|
1435
|
+
|
|
1436
|
+
**Completed:**
|
|
1437
|
+
- Asymmetric JWT authentication (ES256/RS256)
|
|
1438
|
+
- User registration and login
|
|
1439
|
+
- OTP verification flow (email/SMS)
|
|
1440
|
+
- Session management with key rotation
|
|
1441
|
+
- Password change functionality
|
|
1442
|
+
- RBAC roles and account status
|
|
1443
|
+
- Comprehensive test coverage (83%)
|
|
1444
|
+
|
|
1445
|
+
**In Progress:**
|
|
1446
|
+
- Client-side React hooks (useAuth, useSession)
|
|
1447
|
+
- Client-side Zustand store
|
|
1448
|
+
- React UI components (LoginForm, RegisterForm)
|
|
1449
|
+
|
|
1450
|
+
**Roadmap:**
|
|
1451
|
+
- OAuth provider integration (Google, GitHub)
|
|
1452
|
+
- Two-factor authentication (2FA)
|
|
1453
|
+
- Password reset flow
|
|
1454
|
+
- Email change flow
|
|
1455
|
+
- Phone change flow
|
|
1456
|
+
- Admin management APIs
|
|
1457
|
+
|
|
1458
|
+
---
|
|
1459
|
+
|
|
1460
|
+
## Contributing
|
|
1461
|
+
|
|
1462
|
+
This is an internal SPFN package. Please follow the monorepo conventions when contributing.
|
|
1463
|
+
|
|
1464
|
+
---
|
|
1465
|
+
|
|
1466
|
+
## License
|
|
1467
|
+
|
|
1468
|
+
MIT
|