@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,501 @@
1
+ # OAuth Integration
2
+
3
+ ## Overview
4
+
5
+ The Social Media Publisher plugin uses **Facebook OAuth 2.0** to connect Instagram Business and Facebook Page accounts. OAuth connections are managed per-client, with tokens encrypted and stored securely in the database.
6
+
7
+ **Key Features:**
8
+ - Popup-based OAuth flow (no full-page redirects)
9
+ - Per-client account management
10
+ - Automatic token encryption
11
+ - Multi-account support
12
+ - CSRF protection via state parameter
13
+ - PostMessage communication between popup and parent window
14
+
15
+ ## OAuth Flow Diagram
16
+
17
+ ```
18
+ ┌─────────────┐
19
+ │ User │
20
+ │ (Client │
21
+ │ Context) │
22
+ └──────┬──────┘
23
+ │ 1. Clicks "Connect Instagram"
24
+
25
+ ┌──────────────────────────────┐
26
+ │ /api/v1/plugin/social- │
27
+ │ media-publisher/social/ │
28
+ │ connect?platform= │
29
+ │ instagram_business& │
30
+ │ clientId={uuid} │
31
+ └──────┬───────────────────────┘
32
+ │ 2. Generates OAuth URL
33
+
34
+ ┌──────────────────────────────┐
35
+ │ OAuth Popup Window │
36
+ │ facebook.com/dialog/oauth │
37
+ └──────┬───────────────────────┘
38
+ │ 3. User authorizes
39
+
40
+ ┌──────────────────────────────┐
41
+ │ /api/v1/plugin/social- │
42
+ │ media-publisher/social/ │
43
+ │ connect/callback? │
44
+ │ code={code}&state={state} │
45
+ └──────┬───────────────────────┘
46
+ │ 4. Exchange code for token
47
+
48
+ ┌──────────────────────────────┐
49
+ │ Facebook Graph API │
50
+ │ - Get access token │
51
+ │ - Fetch user's Pages │
52
+ │ - Get Instagram accounts │
53
+ └──────┬───────────────────────┘
54
+ │ 5. Encrypt tokens
55
+
56
+ ┌──────────────────────────────┐
57
+ │ Database │
58
+ │ clients_social_platforms │
59
+ │ (encrypted tokens stored) │
60
+ └──────┬───────────────────────┘
61
+ │ 6. Return success HTML
62
+
63
+ ┌──────────────────────────────┐
64
+ │ Popup window.postMessage │
65
+ │ { type: 'oauth-success', │
66
+ │ connectedCount: 2 } │
67
+ └──────┬───────────────────────┘
68
+ │ 7. Parent receives message
69
+
70
+ ┌──────────────────────────────┐
71
+ │ Parent Window │
72
+ │ - Refresh page │
73
+ │ - Show new accounts │
74
+ │ - Close popup (auto) │
75
+ └──────────────────────────────┘
76
+ ```
77
+
78
+ ## Step-by-Step Flow
79
+
80
+ ### Step 1: Initiate OAuth
81
+
82
+ **User Action:** Clicks "Connect Instagram Business" or "Connect Facebook Page"
83
+
84
+ **Frontend Code:**
85
+ ```typescript
86
+ 'use client'
87
+
88
+ import { useRouter } from 'next/navigation'
89
+
90
+ export function ConnectSocialButton({
91
+ clientId,
92
+ platform
93
+ }: {
94
+ clientId: string
95
+ platform: 'instagram_business' | 'facebook_page'
96
+ }) {
97
+ const router = useRouter()
98
+
99
+ const handleConnect = () => {
100
+ // Build OAuth initiation URL
101
+ const baseUrl = process.env.NEXT_PUBLIC_APP_URL || window.location.origin
102
+ const oauthUrl = `${baseUrl}/api/v1/plugin/social-media-publisher/social/connect?platform=${platform}&clientId=${clientId}`
103
+
104
+ // Open popup window
105
+ const popup = window.open(
106
+ oauthUrl,
107
+ 'oauth-popup',
108
+ 'width=600,height=700,scrollbars=yes'
109
+ )
110
+
111
+ // Listen for success message from popup
112
+ const handleMessage = (event: MessageEvent) => {
113
+ // Verify origin
114
+ if (event.origin !== window.location.origin) return
115
+
116
+ if (event.data.type === 'oauth-success') {
117
+ console.log(`✅ Connected ${event.data.connectedCount} account(s)`)
118
+
119
+ // Cleanup
120
+ window.removeEventListener('message', handleMessage)
121
+
122
+ // Refresh to show new accounts
123
+ router.refresh()
124
+ }
125
+ }
126
+
127
+ window.addEventListener('message', handleMessage)
128
+
129
+ // Cleanup if popup closes without success
130
+ const checkPopup = setInterval(() => {
131
+ if (popup?.closed) {
132
+ clearInterval(checkPopup)
133
+ window.removeEventListener('message', handleMessage)
134
+ }
135
+ }, 1000)
136
+ }
137
+
138
+ return (
139
+ <button onClick={handleConnect}>
140
+ Connect {platform === 'instagram_business' ? 'Instagram' : 'Facebook Page'}
141
+ </button>
142
+ )
143
+ }
144
+ ```
145
+
146
+ ### Step 2: Generate OAuth URL
147
+
148
+ **Endpoint:** `GET /api/v1/plugin/social-media-publisher/social/connect`
149
+
150
+ **Query Parameters:**
151
+ - `platform` - `'instagram_business'` or `'facebook_page'`
152
+ - `clientId` - UUID of client to connect accounts to
153
+
154
+ **What Happens:**
155
+ 1. Generates random state value (CSRF protection)
156
+ 2. Constructs state parameter: `{randomState}&platform={platform}&clientId={clientId}`
157
+ 3. Builds Facebook OAuth URL with required scopes
158
+ 4. Redirects user to Facebook authorization page
159
+
160
+ **OAuth URL Format:**
161
+ ```
162
+ https://www.facebook.com/v18.0/dialog/oauth
163
+ ?client_id={FACEBOOK_CLIENT_ID}
164
+ &redirect_uri={callback_url}
165
+ &state={encoded_state}
166
+ &scope={permissions}
167
+ ```
168
+
169
+ **Scopes for Instagram Business:**
170
+ ```
171
+ pages_show_list,instagram_basic,instagram_content_publish,instagram_manage_insights
172
+ ```
173
+
174
+ **Scopes for Facebook Pages:**
175
+ ```
176
+ pages_show_list,pages_manage_posts,pages_read_engagement,read_insights
177
+ ```
178
+
179
+ ### Step 3: User Authorization
180
+
181
+ **What User Sees:**
182
+ 1. Facebook authorization page opens in popup
183
+ 2. Shows app name, icon, and requested permissions
184
+ 3. User can choose which Pages to grant access to
185
+ 4. User clicks "Continue" or "Cancel"
186
+
187
+ **User Can:**
188
+ - ✅ Grant all permissions
189
+ - ✅ Grant partial permissions (select specific Pages)
190
+ - ❌ Deny all permissions
191
+
192
+ ### Step 4: OAuth Callback
193
+
194
+ **Endpoint:** `GET /api/v1/plugin/social-media-publisher/social/connect/callback`
195
+
196
+ **Success Parameters:**
197
+ ```
198
+ code: Authorization code from Facebook
199
+ state: CSRF token + platform + clientId
200
+ ```
201
+
202
+ **Error Parameters:**
203
+ ```
204
+ error: Error code (e.g., 'access_denied')
205
+ error_description: Human-readable error
206
+ ```
207
+
208
+ **Callback Processing:**
209
+
210
+ 1. **Validate State Parameter:**
211
+ ```typescript
212
+ const [randomState, platformParam, clientIdParam] = state.split('&')
213
+ // Verify state matches expected format
214
+ ```
215
+
216
+ 2. **Exchange Code for Access Token:**
217
+ ```typescript
218
+ const tokenUrl = `https://graph.facebook.com/v18.0/oauth/access_token`
219
+ const params = new URLSearchParams({
220
+ client_id: FACEBOOK_CLIENT_ID,
221
+ client_secret: FACEBOOK_CLIENT_SECRET,
222
+ redirect_uri: callbackUrl,
223
+ code: authorizationCode
224
+ })
225
+
226
+ const response = await fetch(`${tokenUrl}?${params.toString()}`)
227
+ const { access_token } = await response.json()
228
+ ```
229
+
230
+ 3. **Fetch User's Facebook Pages:**
231
+ ```typescript
232
+ const pages = await FacebookAPI.getUserPages(access_token)
233
+ // Returns array of Pages with Page-specific tokens
234
+ ```
235
+
236
+ 4. **For Each Page:**
237
+ ```typescript
238
+ if (platform === 'instagram_business') {
239
+ // Check if Page has Instagram Business Account
240
+ const igAccount = await FacebookAPI.getInstagramBusinessAccount(
241
+ page.id,
242
+ page.accessToken
243
+ )
244
+
245
+ if (igAccount) {
246
+ await saveToDatabase({
247
+ parentId: clientId,
248
+ platform: 'instagram_business',
249
+ platformAccountId: igAccount.id,
250
+ platformAccountName: igAccount.username,
251
+ accessToken: await encrypt(page.accessToken),
252
+ ...
253
+ })
254
+ }
255
+ } else {
256
+ // Save Facebook Page directly
257
+ await saveToDatabase({
258
+ parentId: clientId,
259
+ platform: 'facebook_page',
260
+ platformAccountId: page.id,
261
+ platformAccountName: page.name,
262
+ accessToken: await encrypt(page.accessToken),
263
+ ...
264
+ })
265
+ }
266
+ ```
267
+
268
+ 5. **Encrypt and Store Tokens:**
269
+ ```typescript
270
+ // Encrypt access token
271
+ const { encrypted, iv, keyId } = await TokenEncryption.encrypt(accessToken)
272
+ const encryptedToken = `${encrypted}:${iv}:${keyId}`
273
+
274
+ // Calculate expiration (60 days for long-lived tokens)
275
+ const expiresAt = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000)
276
+
277
+ // Store in database
278
+ await query(`
279
+ INSERT INTO "clients_social_platforms" (
280
+ "parentId", platform, "platformAccountId",
281
+ "platformAccountName", "accessToken", "tokenExpiresAt"
282
+ ) VALUES ($1, $2, $3, $4, $5, $6)
283
+ `, [clientId, platform, accountId, accountName, encryptedToken, expiresAt])
284
+ ```
285
+
286
+ 6. **Create Audit Log:**
287
+ ```typescript
288
+ await query(`
289
+ INSERT INTO "audit_logs" (
290
+ "userId", "accountId", action, details
291
+ ) VALUES ($1, $2, 'account_connected', $3)
292
+ `, [userId, accountId, { platform, accountName }])
293
+ ```
294
+
295
+ 7. **Return Success HTML:**
296
+ ```html
297
+ <!DOCTYPE html>
298
+ <html>
299
+ <head>
300
+ <title>Authorization Successful</title>
301
+ </head>
302
+ <body>
303
+ <h1>✅ Successfully Connected</h1>
304
+ <p>Connected 2 Instagram Business account(s)</p>
305
+ <p>This window will close automatically...</p>
306
+ <script>
307
+ // Send success message to parent window
308
+ window.opener.postMessage({
309
+ type: 'oauth-success',
310
+ platform: 'instagram_business',
311
+ connectedCount: 2
312
+ }, window.location.origin)
313
+
314
+ // Auto-close after 2 seconds
315
+ setTimeout(() => window.close(), 2000)
316
+ </script>
317
+ </body>
318
+ </html>
319
+ ```
320
+
321
+ ### Step 5: PostMessage Communication
322
+
323
+ **Popup Window:**
324
+ ```javascript
325
+ // Send message to parent window
326
+ window.opener.postMessage({
327
+ type: 'oauth-success',
328
+ platform: 'instagram_business',
329
+ connectedCount: 2
330
+ }, window.location.origin)
331
+ ```
332
+
333
+ **Parent Window:**
334
+ ```javascript
335
+ window.addEventListener('message', (event) => {
336
+ // Verify origin (security)
337
+ if (event.origin !== window.location.origin) return
338
+
339
+ if (event.data.type === 'oauth-success') {
340
+ // Refresh page to show new accounts
341
+ router.refresh()
342
+ }
343
+ })
344
+ ```
345
+
346
+ ## Security Features
347
+
348
+ ### CSRF Protection (State Parameter)
349
+
350
+ **State Format:**
351
+ ```
352
+ {randomString}&platform={platform}&clientId={clientId}
353
+
354
+ Example:
355
+ a7f9b2c8d1e3f4a5&platform=instagram_business&clientId=550e8400-e29b-41d4-a716-446655440000
356
+ ```
357
+
358
+ **Validation:**
359
+ 1. Generate random state before redirect
360
+ 2. Include platform and clientId in state
361
+ 3. Facebook returns state unchanged
362
+ 4. Verify state format and extract data
363
+ 5. Reject if state is invalid
364
+
365
+ ### Token Encryption
366
+
367
+ **All tokens encrypted before storage:**
368
+ ```typescript
369
+ // Plain text token from Facebook
370
+ const accessToken = "EAABwzLixnjY..."
371
+
372
+ // Encrypt
373
+ const { encrypted, iv, keyId } = await TokenEncryption.encrypt(accessToken)
374
+
375
+ // Store format: encrypted:iv:keyId
376
+ const storedToken = `${encrypted}:${iv}:${keyId}`
377
+ ```
378
+
379
+ **Decryption (only when needed):**
380
+ ```typescript
381
+ const [encrypted, iv, keyId] = storedToken.split(':')
382
+ const decryptedToken = await TokenEncryption.decrypt(encrypted, iv, keyId)
383
+ // Use for API call
384
+ // Never log or store decrypted token
385
+ ```
386
+
387
+ ### Origin Validation
388
+
389
+ **PostMessage security:**
390
+ ```javascript
391
+ window.addEventListener('message', (event) => {
392
+ // ✅ Validate origin
393
+ if (event.origin !== window.location.origin) {
394
+ console.warn('Rejected message from untrusted origin:', event.origin)
395
+ return
396
+ }
397
+
398
+ // Process message
399
+ })
400
+ ```
401
+
402
+ ## Error Handling
403
+
404
+ ### User Denies Permission
405
+
406
+ **Error Response:**
407
+ ```
408
+ ?error=access_denied
409
+ &error_description=The+user+denied+your+request
410
+ ```
411
+
412
+ **Handling:**
413
+ ```typescript
414
+ if (error === 'access_denied') {
415
+ return NextResponse.html(`
416
+ <html>
417
+ <body>
418
+ <h1>Authorization Cancelled</h1>
419
+ <p>You chose not to connect your account.</p>
420
+ <button onclick="window.close()">Close</button>
421
+ </body>
422
+ </html>
423
+ `)
424
+ }
425
+ ```
426
+
427
+ ### Invalid OAuth Code
428
+
429
+ **Cause:** Code expired or already used
430
+
431
+ **Solution:** Restart OAuth flow
432
+
433
+ ### No Instagram Account Found
434
+
435
+ **Cause:** Facebook Page not linked to Instagram Business
436
+
437
+ **Solution:**
438
+ 1. Show clear error message
439
+ 2. Provide link to Facebook Page settings
440
+ 3. Guide user to connect Instagram
441
+
442
+ **Error HTML:**
443
+ ```html
444
+ <html>
445
+ <body>
446
+ <h1>⚠️ No Instagram Business Account Found</h1>
447
+ <p>None of your Facebook Pages have an Instagram Business Account connected.</p>
448
+ <h3>How to Connect Instagram:</h3>
449
+ <ol>
450
+ <li>Go to your Facebook Page Settings</li>
451
+ <li>Click "Instagram" in the sidebar</li>
452
+ <li>Click "Connect Account"</li>
453
+ <li>Log in to Instagram and authorize</li>
454
+ <li>Try connecting again</li>
455
+ </ol>
456
+ <button onclick="window.close()">Close</button>
457
+ </body>
458
+ </html>
459
+ ```
460
+
461
+ ## Multi-Account Support
462
+
463
+ ### Connecting Multiple Accounts
464
+
465
+ **Same Platform, Different Accounts:**
466
+ ```typescript
467
+ // User connects Instagram account @brand1
468
+ // Then connects Instagram account @brand2
469
+ // Both stored under same client
470
+ ```
471
+
472
+ **Database:**
473
+ ```sql
474
+ SELECT * FROM "clients_social_platforms" WHERE "parentId" = '{clientId}'
475
+
476
+ -- Results:
477
+ id | platform | platformAccountName | isActive
478
+ ---|----------|---------------------|----------
479
+ 1 | instagram_business | @brand1 | true
480
+ 2 | instagram_business | @brand2 | true
481
+ ```
482
+
483
+ ### Preventing Duplicates
484
+
485
+ **Unique Constraint:**
486
+ ```sql
487
+ UNIQUE("parentId", "platformAccountId")
488
+ WHERE "platformAccountId" IS NOT NULL
489
+ ```
490
+
491
+ **On Duplicate:**
492
+ - Update existing record
493
+ - Refresh token
494
+ - Update metadata
495
+ - Don't create new record
496
+
497
+ ## Next Steps
498
+
499
+ - **[Publishing](./02-publishing.md)** - Use connected accounts to publish
500
+ - **[Token Management](./03-token-management.md)** - Understand token lifecycle
501
+ - **[Audit Logging](./04-audit-logging.md)** - Track all OAuth events