@nextsparkjs/plugin-social-media-publisher 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,661 @@
1
+ # Token Management
2
+
3
+ ## Overview
4
+
5
+ The Social Media Publisher plugin implements a comprehensive token management system that ensures OAuth tokens are securely encrypted, automatically refreshed, and never exposed. This system is critical for maintaining continuous publishing capabilities without user intervention.
6
+
7
+ **Key Features:**
8
+ - AES-256-GCM encryption for all tokens
9
+ - Automatic refresh before expiration
10
+ - Versioned encryption keys
11
+ - Immutable audit trail
12
+ - Zero downtime token rotation
13
+
14
+ ## Token Lifecycle
15
+
16
+ ### 1. Token Acquisition (OAuth)
17
+
18
+ **When:** User connects account via OAuth
19
+ **Process:** Facebook returns access token
20
+ **Token Type:** Long-lived (60 days)
21
+
22
+ ```typescript
23
+ // OAuth callback receives token
24
+ const { access_token, expires_in } = await exchangeCodeForToken(authCode)
25
+
26
+ // Calculate expiration
27
+ const expiresAt = new Date(Date.now() + expires_in * 1000)
28
+ // Result: 60 days from now
29
+ ```
30
+
31
+ ### 2. Token Encryption
32
+
33
+ **When:** Immediately after acquisition
34
+ **Process:** Encrypt with AES-256-GCM
35
+ **Storage Format:** `encrypted:iv:keyId`
36
+
37
+ ```typescript
38
+ import { TokenEncryption } from '@/core/lib/oauth/encryption'
39
+
40
+ // Plain text token from Facebook
41
+ const accessToken = "EAABwzLixnjYBAA..."
42
+
43
+ // Encrypt
44
+ const { encrypted, iv, keyId } = await TokenEncryption.encrypt(accessToken)
45
+
46
+ // Format for storage
47
+ const storedToken = `${encrypted}:${iv}:${keyId}`
48
+ // Example: a3f9b2c8...d4e5:9f7e3a...b5c2:key_2024_01
49
+ ```
50
+
51
+ **Encryption Details:**
52
+ - **Algorithm:** AES-256-GCM (Advanced Encryption Standard)
53
+ - **Key Size:** 256 bits (32 bytes)
54
+ - **IV (Initialization Vector):** 96 bits, unique per token
55
+ - **Authentication Tag:** 128 bits (prevents tampering)
56
+ - **Key Derivation:** Direct from `OAUTH_ENCRYPTION_KEY` environment variable
57
+
58
+ ### 3. Token Storage
59
+
60
+ **Where:** `clients_social_platforms` table
61
+ **Column:** `accessToken` (TEXT)
62
+ **Format:** `encrypted:iv:keyId`
63
+
64
+ ```sql
65
+ INSERT INTO "clients_social_platforms" (
66
+ "parentId",
67
+ platform,
68
+ "platformAccountId",
69
+ "platformAccountName",
70
+ "accessToken",
71
+ "tokenExpiresAt"
72
+ ) VALUES (
73
+ '550e8400-e29b-41d4-a716-446655440000',
74
+ 'instagram_business',
75
+ '17841401234567890',
76
+ '@brandname',
77
+ 'a3f9b2c8d1e3f4a5b6c7d8e9f0a1b2c3:9f7e3ab5c2:key_2024_01',
78
+ '2024-03-15 10:30:00+00'
79
+ );
80
+ ```
81
+
82
+ **Security:**
83
+ - ✅ Never stored in plain text
84
+ - ✅ Unique IV per token (prevents pattern analysis)
85
+ - ✅ Key versioning (allows key rotation)
86
+ - ✅ At-rest encryption
87
+ - ✅ RLS policies restrict access
88
+
89
+ ### 4. Token Decryption (On Use)
90
+
91
+ **When:** Before making API calls
92
+ **Process:** Decrypt in memory only
93
+ **Lifetime:** Exists only during request
94
+
95
+ ```typescript
96
+ // Read encrypted token from database
97
+ const account = await query(`
98
+ SELECT "accessToken" FROM "clients_social_platforms"
99
+ WHERE id = $1
100
+ `, [accountId])
101
+
102
+ // Decrypt
103
+ const [encrypted, iv, keyId] = account.accessToken.split(':')
104
+ const decryptedToken = await TokenEncryption.decrypt(encrypted, iv, keyId)
105
+
106
+ // Use for API call
107
+ await InstagramAPI.publishPhoto({
108
+ accessToken: decryptedToken, // Used here
109
+ // ...
110
+ })
111
+
112
+ // Token discarded after request (not stored)
113
+ ```
114
+
115
+ **Security:**
116
+ - ✅ Decrypted only when needed
117
+ - ✅ Never logged
118
+ - ✅ Never sent to client
119
+ - ✅ Exists only in server memory
120
+ - ✅ Garbage collected after use
121
+
122
+ ### 5. Token Refresh
123
+
124
+ **When:** < 10 minutes until expiration
125
+ **Trigger:** Automatic before publish operation
126
+ **Process:** Exchange old token for new long-lived token
127
+
128
+ ```typescript
129
+ // Check if refresh needed
130
+ const now = new Date()
131
+ const expiresAt = new Date(account.tokenExpiresAt)
132
+ const minutesUntilExpiry = (expiresAt.getTime() - now.getTime()) / (1000 * 60)
133
+
134
+ if (minutesUntilExpiry < 10) {
135
+ console.log('🔄 Token expiring soon, refreshing...')
136
+
137
+ // Call Meta token exchange endpoint
138
+ const response = await fetch(
139
+ `https://graph.facebook.com/v18.0/oauth/access_token?` +
140
+ `grant_type=fb_exchange_token&` +
141
+ `client_id=${FACEBOOK_CLIENT_ID}&` +
142
+ `client_secret=${FACEBOOK_CLIENT_SECRET}&` +
143
+ `fb_exchange_token=${currentToken}`
144
+ )
145
+
146
+ const { access_token, expires_in } = await response.json()
147
+
148
+ // Re-encrypt new token
149
+ const { encrypted, iv, keyId } = await TokenEncryption.encrypt(access_token)
150
+ const newEncryptedToken = `${encrypted}:${iv}:${keyId}`
151
+ const newExpiresAt = new Date(Date.now() + expires_in * 1000)
152
+
153
+ // Update database
154
+ await query(`
155
+ UPDATE "clients_social_platforms"
156
+ SET "accessToken" = $1,
157
+ "tokenExpiresAt" = $2,
158
+ "updatedAt" = NOW()
159
+ WHERE id = $3
160
+ `, [newEncryptedToken, newExpiresAt, accountId])
161
+
162
+ // Create audit log
163
+ await query(`
164
+ INSERT INTO "audit_logs" ("userId", "accountId", action, details)
165
+ VALUES ($1, $2, 'token_refreshed', $3)
166
+ `, [userId, accountId, { oldExpiry: expiresAt, newExpiry: newExpiresAt }])
167
+
168
+ console.log('✅ Token refreshed successfully')
169
+ }
170
+ ```
171
+
172
+ ### 6. Token Expiration
173
+
174
+ **Expiration Time:** 60 days (5,184,000 seconds)
175
+ **Warning Threshold:** 10 minutes before expiration
176
+ **Action:** Automatic refresh or require reconnection
177
+
178
+ **If Refresh Fails:**
179
+ ```typescript
180
+ // Publish endpoint blocks operation
181
+ return NextResponse.json({
182
+ error: 'Token expired and refresh failed',
183
+ details: 'OAuth token could not be refreshed',
184
+ suggestion: 'Please reconnect your social media account'
185
+ }, { status: 403 })
186
+ ```
187
+
188
+ **User Must:**
189
+ 1. Go to client social platforms page
190
+ 2. Disconnect expired account
191
+ 3. Reconnect via OAuth
192
+ 4. New 60-day token issued
193
+
194
+ ## Token Encryption Deep Dive
195
+
196
+ ### AES-256-GCM Algorithm
197
+
198
+ **Why GCM (Galois/Counter Mode)?**
199
+ - ✅ **Authenticated Encryption** - Prevents tampering
200
+ - ✅ **Performance** - Faster than CBC mode
201
+ - ✅ **Parallel Processing** - Can decrypt in parallel
202
+ - ✅ **NIST Approved** - Industry standard
203
+
204
+ **Encryption Process:**
205
+ ```typescript
206
+ import crypto from 'crypto'
207
+
208
+ export class TokenEncryption {
209
+ static async encrypt(plainText: string): Promise<{
210
+ encrypted: string
211
+ iv: string
212
+ keyId: string
213
+ }> {
214
+ // Get encryption key from environment
215
+ const encryptionKey = Buffer.from(process.env.OAUTH_ENCRYPTION_KEY!, 'hex')
216
+
217
+ // Generate random IV (96 bits)
218
+ const iv = crypto.randomBytes(12)
219
+
220
+ // Create cipher
221
+ const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey, iv)
222
+
223
+ // Encrypt
224
+ let encrypted = cipher.update(plainText, 'utf8', 'hex')
225
+ encrypted += cipher.final('hex')
226
+
227
+ // Get authentication tag
228
+ const authTag = cipher.getAuthTag()
229
+
230
+ // Combine encrypted + authTag
231
+ const combined = encrypted + authTag.toString('hex')
232
+
233
+ return {
234
+ encrypted: combined,
235
+ iv: iv.toString('hex'),
236
+ keyId: 'key_2024_01' // Version identifier
237
+ }
238
+ }
239
+
240
+ static async decrypt(
241
+ encryptedWithTag: string,
242
+ ivHex: string,
243
+ keyId: string
244
+ ): Promise<string> {
245
+ // Get encryption key
246
+ const encryptionKey = Buffer.from(process.env.OAUTH_ENCRYPTION_KEY!, 'hex')
247
+
248
+ // Separate encrypted data and auth tag
249
+ const authTag = Buffer.from(encryptedWithTag.slice(-32), 'hex')
250
+ const encrypted = encryptedWithTag.slice(0, -32)
251
+
252
+ // Create decipher
253
+ const decipher = crypto.createDecipheriv(
254
+ 'aes-256-gcm',
255
+ encryptionKey,
256
+ Buffer.from(ivHex, 'hex')
257
+ )
258
+
259
+ // Set auth tag
260
+ decipher.setAuthTag(authTag)
261
+
262
+ // Decrypt
263
+ let decrypted = decipher.update(encrypted, 'hex', 'utf8')
264
+ decrypted += decipher.final('utf8')
265
+
266
+ return decrypted
267
+ }
268
+ }
269
+ ```
270
+
271
+ ### Token Format Breakdown
272
+
273
+ ```
274
+ Format: encrypted:iv:keyId
275
+
276
+ Example:
277
+ a3f9b2c8d1e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9:9f7e3ab5c2d4e6:key_2024_01
278
+
279
+ Part 1: encrypted (variable length)
280
+ - Encrypted token + authentication tag
281
+ - Hex encoded
282
+ - Length depends on token length
283
+
284
+ Part 2: iv (24 hex characters)
285
+ - Initialization Vector (96 bits)
286
+ - Unique per encryption
287
+ - Required for decryption
288
+
289
+ Part 3: keyId (version identifier)
290
+ - Identifies which encryption key was used
291
+ - Allows key rotation
292
+ - Example: key_2024_01, key_2024_02
293
+ ```
294
+
295
+ ## Token Refresh Mechanism
296
+
297
+ ### Refresh Triggers
298
+
299
+ **1. Pre-Publish Check (Primary)**
300
+ ```typescript
301
+ // Every publish operation checks token expiration
302
+ if (minutesUntilExpiry < 10) {
303
+ await refreshToken()
304
+ }
305
+ ```
306
+
307
+ **2. Scheduled Cron Job (Recommended - Not Implemented)**
308
+ ```typescript
309
+ // Run daily at 2 AM
310
+ // Refresh all tokens expiring within 7 days
311
+ async function refreshExpiringTokens() {
312
+ const expiringAccounts = await query(`
313
+ SELECT * FROM "clients_social_platforms"
314
+ WHERE "tokenExpiresAt" < NOW() + INTERVAL '7 days'
315
+ AND "isActive" = true
316
+ `)
317
+
318
+ for (const account of expiringAccounts) {
319
+ await refreshAccountToken(account.id)
320
+ }
321
+ }
322
+ ```
323
+
324
+ ### Meta Token Exchange API
325
+
326
+ **Endpoint:**
327
+ ```
328
+ GET https://graph.facebook.com/v18.0/oauth/access_token
329
+ ```
330
+
331
+ **Parameters:**
332
+ ```typescript
333
+ {
334
+ grant_type: 'fb_exchange_token',
335
+ client_id: FACEBOOK_CLIENT_ID,
336
+ client_secret: FACEBOOK_CLIENT_SECRET,
337
+ fb_exchange_token: currentToken // Old token
338
+ }
339
+ ```
340
+
341
+ **Response:**
342
+ ```json
343
+ {
344
+ "access_token": "EAABwzLixnjY...", // New token
345
+ "token_type": "bearer",
346
+ "expires_in": 5184000 // 60 days (seconds)
347
+ }
348
+ ```
349
+
350
+ **Token Characteristics:**
351
+ - **Old Token:** Remains valid for ~24 hours after exchange
352
+ - **New Token:** Valid for 60 days from exchange
353
+ - **Limit:** Can exchange once per day per token
354
+ - **Rate Limit:** Subject to app rate limits
355
+
356
+ ### Refresh Threshold Configuration
357
+
358
+ **Current Setting:** 10 minutes
359
+
360
+ **Why 10 Minutes?**
361
+ - ✅ Prevents last-second failures
362
+ - ✅ Allows time for retry if refresh fails
363
+ - ✅ Minimal unnecessary refreshes
364
+ - ✅ User unaware of refresh process
365
+
366
+ **Alternative Thresholds:**
367
+
368
+ ```typescript
369
+ // Conservative (1 hour)
370
+ const REFRESH_THRESHOLD_MINUTES = 60
371
+ // More API calls, but very safe
372
+
373
+ // Balanced (10 minutes) - Default
374
+ const REFRESH_THRESHOLD_MINUTES = 10
375
+
376
+ // Aggressive (1 minute)
377
+ const REFRESH_THRESHOLD_MINUTES = 1
378
+ // Riskier, might miss some edge cases
379
+ ```
380
+
381
+ ### Refresh Failure Handling
382
+
383
+ **If Refresh Fails:**
384
+
385
+ 1. **Block Publish Operation:**
386
+ ```typescript
387
+ return NextResponse.json({
388
+ error: 'Token expired and refresh failed',
389
+ details: refreshError,
390
+ suggestion: 'Please reconnect your social media account'
391
+ }, { status: 403 })
392
+ ```
393
+
394
+ 2. **Create Audit Log:**
395
+ ```typescript
396
+ await query(`
397
+ INSERT INTO "audit_logs"
398
+ ("userId", "accountId", action, details)
399
+ VALUES ($1, $2, 'token_refresh_failed', $3)
400
+ `, [userId, accountId, { error: refreshError }])
401
+ ```
402
+
403
+ 3. **Optional: Deactivate Account:**
404
+ ```typescript
405
+ // Mark account as inactive (requires reconnection)
406
+ await query(`
407
+ UPDATE "clients_social_platforms"
408
+ SET "isActive" = false
409
+ WHERE id = $1
410
+ `, [accountId])
411
+ ```
412
+
413
+ 4. **Notify User:**
414
+ ```typescript
415
+ // Send notification (email, in-app)
416
+ await sendNotification(userId, {
417
+ type: 'token_expired',
418
+ message: 'Social media account needs reconnection',
419
+ accountName: account.platformAccountName
420
+ })
421
+ ```
422
+
423
+ ## Token Versioning & Key Rotation
424
+
425
+ ### Key Versioning
426
+
427
+ **Purpose:** Allow encryption key rotation without invalidating existing tokens
428
+
429
+ **Implementation:**
430
+ ```typescript
431
+ // Store key version with encrypted token
432
+ const keyId = 'key_2024_01'
433
+
434
+ // During decryption, use appropriate key
435
+ const decryptionKey = getKeyById(keyId)
436
+ ```
437
+
438
+ **Key Rotation Process:**
439
+
440
+ 1. **Generate New Key:**
441
+ ```bash
442
+ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
443
+ ```
444
+
445
+ 2. **Add to Environment:**
446
+ ```bash
447
+ # Keep old key
448
+ OAUTH_ENCRYPTION_KEY_2024_01=old_key_here
449
+
450
+ # Add new key
451
+ OAUTH_ENCRYPTION_KEY=new_key_here
452
+ OAUTH_ENCRYPTION_KEY_ID=key_2024_02
453
+ ```
454
+
455
+ 3. **Update Encryption Function:**
456
+ ```typescript
457
+ static async encrypt(plainText: string) {
458
+ const keyId = process.env.OAUTH_ENCRYPTION_KEY_ID || 'key_2024_02'
459
+ const encryptionKey = process.env.OAUTH_ENCRYPTION_KEY
460
+ // ... rest of encryption
461
+ }
462
+ ```
463
+
464
+ 4. **Update Decryption Function:**
465
+ ```typescript
466
+ static async decrypt(encrypted: string, iv: string, keyId: string) {
467
+ // Get key based on keyId
468
+ const keyEnvVar = keyId === 'key_2024_01'
469
+ ? 'OAUTH_ENCRYPTION_KEY_2024_01'
470
+ : 'OAUTH_ENCRYPTION_KEY'
471
+
472
+ const encryptionKey = process.env[keyEnvVar]
473
+ // ... rest of decryption
474
+ }
475
+ ```
476
+
477
+ 5. **Gradual Migration:**
478
+ ```typescript
479
+ // Migrate old tokens to new key
480
+ async function migrateTokens() {
481
+ const oldTokens = await query(`
482
+ SELECT * FROM "clients_social_platforms"
483
+ WHERE "accessToken" LIKE '%:key_2024_01'
484
+ `)
485
+
486
+ for (const account of oldTokens) {
487
+ // Decrypt with old key
488
+ const [encrypted, iv, oldKeyId] = account.accessToken.split(':')
489
+ const plainToken = await TokenEncryption.decryptWithKey(
490
+ encrypted, iv, oldKeyId
491
+ )
492
+
493
+ // Re-encrypt with new key
494
+ const { encrypted: newEncrypted, iv: newIv, keyId: newKeyId }
495
+ = await TokenEncryption.encrypt(plainToken)
496
+
497
+ // Update database
498
+ await query(`
499
+ UPDATE "clients_social_platforms"
500
+ SET "accessToken" = $1
501
+ WHERE id = $2
502
+ `, [`${newEncrypted}:${newIv}:${newKeyId}`, account.id])
503
+ }
504
+ }
505
+ ```
506
+
507
+ ## Security Best Practices
508
+
509
+ ### Do's ✅
510
+
511
+ **1. Never Log Decrypted Tokens:**
512
+ ```typescript
513
+ // ✅ Good
514
+ console.log('Using token for API call')
515
+
516
+ // ❌ Bad
517
+ console.log('Token:', decryptedToken)
518
+ ```
519
+
520
+ **2. Decrypt Only When Needed:**
521
+ ```typescript
522
+ // ✅ Good
523
+ const token = await decrypt(...)
524
+ await makeAPICall(token)
525
+ // Token discarded
526
+
527
+ // ❌ Bad
528
+ const token = await decrypt(...)
529
+ this.token = token // Stored in memory
530
+ ```
531
+
532
+ **3. Validate Before Decryption:**
533
+ ```typescript
534
+ // ✅ Good
535
+ if (!account.accessToken.includes(':')) {
536
+ throw new Error('Invalid token format')
537
+ }
538
+
539
+ // ❌ Bad
540
+ const token = await decrypt(account.accessToken) // Might fail
541
+ ```
542
+
543
+ **4. Use RLS Policies:**
544
+ ```sql
545
+ -- ✅ Good: Users can only access own clients' tokens
546
+ CREATE POLICY "rls_social_platforms"
547
+ ON "clients_social_platforms"
548
+ USING ("parentId" IN (
549
+ SELECT id FROM "clients" WHERE "userId" = current_user_id()
550
+ ));
551
+ ```
552
+
553
+ **5. Rotate Keys Periodically:**
554
+ ```
555
+ Every 6-12 months:
556
+ 1. Generate new key
557
+ 2. Deploy with both keys
558
+ 3. Migrate tokens
559
+ 4. Remove old key
560
+ ```
561
+
562
+ ### Don'ts ❌
563
+
564
+ **1. Don't Store Plain Text Tokens:**
565
+ ```typescript
566
+ // ❌ Bad
567
+ await query(`INSERT INTO accounts VALUES ($1)`, [plainTextToken])
568
+
569
+ // ✅ Good
570
+ const encrypted = await encrypt(plainTextToken)
571
+ await query(`INSERT INTO accounts VALUES ($1)`, [encrypted])
572
+ ```
573
+
574
+ **2. Don't Send Tokens to Client:**
575
+ ```typescript
576
+ // ❌ Bad
577
+ return NextResponse.json({ token: decryptedToken })
578
+
579
+ // ✅ Good
580
+ // Never send to client - use on server only
581
+ ```
582
+
583
+ **3. Don't Reuse IVs:**
584
+ ```typescript
585
+ // ❌ Bad
586
+ const iv = Buffer.from('same_iv_always')
587
+
588
+ // ✅ Good
589
+ const iv = crypto.randomBytes(12) // Unique each time
590
+ ```
591
+
592
+ **4. Don't Skip Token Validation:**
593
+ ```typescript
594
+ // ❌ Bad
595
+ const token = await decrypt(dbToken)
596
+
597
+ // ✅ Good
598
+ if (isTokenExpired(expiresAt)) {
599
+ await refreshToken()
600
+ }
601
+ const token = await decrypt(dbToken)
602
+ ```
603
+
604
+ ## Monitoring & Alerts
605
+
606
+ ### Key Metrics to Track
607
+
608
+ **1. Token Refresh Success Rate:**
609
+ ```sql
610
+ SELECT
611
+ COUNT(*) FILTER (WHERE action = 'token_refreshed') as successful,
612
+ COUNT(*) FILTER (WHERE action = 'token_refresh_failed') as failed,
613
+ ROUND(
614
+ COUNT(*) FILTER (WHERE action = 'token_refreshed')::decimal /
615
+ NULLIF(COUNT(*), 0) * 100,
616
+ 2
617
+ ) as success_rate
618
+ FROM "audit_logs"
619
+ WHERE action IN ('token_refreshed', 'token_refresh_failed')
620
+ AND "createdAt" > NOW() - INTERVAL '30 days';
621
+ ```
622
+
623
+ **2. Token Expiration Distribution:**
624
+ ```sql
625
+ SELECT
626
+ CASE
627
+ WHEN "tokenExpiresAt" < NOW() THEN 'Expired'
628
+ WHEN "tokenExpiresAt" < NOW() + INTERVAL '7 days' THEN 'Expiring Soon'
629
+ WHEN "tokenExpiresAt" < NOW() + INTERVAL '30 days' THEN 'Expiring This Month'
630
+ ELSE 'Healthy'
631
+ END as status,
632
+ COUNT(*) as count
633
+ FROM "clients_social_platforms"
634
+ WHERE "isActive" = true
635
+ GROUP BY status;
636
+ ```
637
+
638
+ **3. Failed Publish Attempts Due to Token Issues:**
639
+ ```sql
640
+ SELECT DATE("createdAt") as date, COUNT(*) as failures
641
+ FROM "audit_logs"
642
+ WHERE action = 'post_failed'
643
+ AND details->>'error' LIKE '%token%'
644
+ GROUP BY DATE("createdAt")
645
+ ORDER BY date DESC
646
+ LIMIT 30;
647
+ ```
648
+
649
+ ### Alerting Rules
650
+
651
+ **Alert if:**
652
+ - Token refresh failure rate > 5%
653
+ - More than 10 tokens expire without refresh
654
+ - Encryption/decryption errors
655
+ - API rate limits hit
656
+
657
+ ## Next Steps
658
+
659
+ - **[Audit Logging](./04-audit-logging.md)** - Track all token operations
660
+ - **[Provider APIs](../03-advanced-usage/01-provider-apis.md)** - Use tokens in API calls
661
+ - **[Publishing](./02-publishing.md)** - Token usage in publishing