@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.
- package/.env.example +76 -0
- package/README.md +423 -0
- package/api/social/connect/callback/route.ts +669 -0
- package/api/social/connect/route.ts +327 -0
- package/api/social/disconnect/route.ts +187 -0
- package/api/social/publish/route.ts +402 -0
- package/docs/01-getting-started/01-introduction.md +471 -0
- package/docs/01-getting-started/02-installation.md +471 -0
- package/docs/01-getting-started/03-configuration.md +515 -0
- package/docs/02-core-features/01-oauth-integration.md +501 -0
- package/docs/02-core-features/02-publishing.md +527 -0
- package/docs/02-core-features/03-token-management.md +661 -0
- package/docs/02-core-features/04-audit-logging.md +646 -0
- package/docs/03-advanced-usage/01-provider-apis.md +764 -0
- package/docs/03-advanced-usage/02-custom-integrations.md +695 -0
- package/docs/03-advanced-usage/03-per-client-architecture.md +575 -0
- package/docs/04-use-cases/01-agency-management.md +661 -0
- package/docs/04-use-cases/02-content-publishing.md +668 -0
- package/docs/04-use-cases/03-analytics-reporting.md +748 -0
- package/entities/audit-logs/audit-logs.config.ts +150 -0
- package/lib/oauth-helper.ts +167 -0
- package/lib/providers/facebook.ts +672 -0
- package/lib/providers/index.ts +21 -0
- package/lib/providers/instagram.ts +791 -0
- package/lib/validation.ts +155 -0
- package/migrations/001_social_media_tables.sql +167 -0
- package/package.json +15 -0
- package/plugin.config.ts +81 -0
- package/tsconfig.json +47 -0
- package/types/social.types.ts +171 -0
|
@@ -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
|