@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.
Files changed (2) hide show
  1. package/README.md +1468 -0
  2. package/package.json +91 -0
package/README.md ADDED
@@ -0,0 +1,1468 @@
1
+ # @spfn/auth
2
+
3
+ ![Coverage](https://img.shields.io/badge/coverage-85%2B%25-green)
4
+ ![Tests](https://img.shields.io/badge/tests-226%20passed-brightgreen)
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