Instagram connection via Meta Graph API OAuth (no username/password)#11
Conversation
The Instagram connection now goes through Facebook OAuth: users click Connect with Facebook on /dashboard/connections, grant publishing and insights scopes, and we resolve the IG Business Account ID via the Page they pick. Username/password is gone from the schema and the UI. Tokens are AES-GCM encrypted at rest with TOKEN_ENCRYPTION_KEY. Posting moves to the Graph API two-step protocol: create media container, then media_publish. Because the Graph API requires a public image URL, every generated image is re-uploaded to Supabase Storage before publish (both the auto-publish path inside the LangGraph agent and the approval route on Vercel). DALL-E URLs and Gemini base64 are both normalized through the same helper. A weekly refresh-tokens cron exchanges each long-lived token for a fresh one before its 60-day expiry. Expired tokens flip the connection to status='expired' and the UI surfaces a Reconnect button. Migration 004 deletes legacy username/password connections and replaces graph_access_token with access_token_encrypted plus page_id, token_expires_at, and connection_status. Workflows that referenced the deleted connections lose their connection_id (ON DELETE SET NULL) and need to be re-pointed; this is the documented breaking change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 18d25eb66a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const candidates = readCandidates(); | ||
| const chosen = candidates?.find((c) => c.pageId === pageId); |
There was a problem hiding this comment.
Verify OAuth page cookie belongs to signed-in user
POST /api/auth/instagram/select-page trusts ig_oauth_pages without checking that the cookie was created for the current account, even though the callback keeps ig_oauth_user for that purpose. In a shared browser/session-switch scenario (user A starts OAuth, user B signs in before cookie expiry), user B can select A’s pending page and bind A’s page token to B’s connection. Bind the pending payload to the current userId before accepting pageId.
Useful? React with 👍 / 👎.
| if (!accessToken || !igUserId) { | ||
| throw new Error( | ||
| "Instagram credentials missing accessToken or igUserId. Reconnect via Facebook OAuth.", |
There was a problem hiding this comment.
Preserve upload_post credential compatibility
This change makes upload_post require credentials.accessToken and credentials.igUserId, but existing callers like frontend/app/api/schedule-post/route.ts still pass connection.credentials directly, and new OAuth connections are saved with credentials = {} in the callback/select-page routes. As a result, Instagram “post now” (and any path using upload_post) fails with the new missing-credentials error until those callers are updated to pass decrypted OAuth fields.
Useful? React with 👍 / 👎.
) Mirrors the Instagram OAuth pattern from PR #11 for both X and LinkedIn: X (Twitter): - OAuth 2.0 PKCE at /api/auth/twitter/{start,callback}. - Scopes: tweet.read, tweet.write, users.read, offline.access. - Refresh tokens stored encrypted; auto-refreshed inline by agent-credentials.ts when within 5 min of expiry. - TwitterOAuthClient.tweet does a bearer-auth POST /2/tweets, with optional v1.1 media/upload step for images. - LinkedIn OAuth 2.0 (OIDC) at /api/auth/linkedin/{start,callback}. - Scopes: openid, profile, w_member_social. - 60-day access tokens; no refresh path → expired connections flip to status='expired' so the UI shows Reconnect. - LinkedInOAuthClient.post does the 3-step UGC publish flow (registerUpload → PUT bytes → POST /v2/ugcPosts). Migration 006 deletes legacy Twitter API-key rows and adds two columns: refresh_token_encrypted (X only) and oauth_provider_user_id (Twitter user_id for X, OIDC sub for LinkedIn — Instagram still uses ig_business_account_id). Centralized credential prep in lib/agent-credentials.ts so trigger-workflow, queue worker, and the inline refresh path stay consistent. Connections UI replaces the API-key forms for X and LinkedIn with "Connect with X" and "Connect with LinkedIn" buttons, matching IG's pattern. upload-post graph rewritten to dispatch by platform with three new clients; content-automation-advanced's publishToInstagram is renamed publishToPlatform and routes by state.platform. Refresh-tokens cron extended: - Meta long-lived tokens: refresh within 14 days of expiry (unchanged). - X: refresh within 1 day of expiry as a backstop (inline refresh handles in-use cases). - LinkedIn: mark expired only; no refresh path exists. SETUP.md gains Step 3.5 (X) and Step 3.6 (LinkedIn) with app registration walkthroughs, redirect-URI configuration, and tier limitations. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Summary
Pivots the Instagram connection model to Facebook Login OAuth. Users click Connect with Facebook on the Connections page, grant publishing + insights scopes, and we resolve the IG Business Account via their Page picker. Username/password storage is gone — both from the schema and the UI.
Posting now uses the Meta Graph API two-step protocol (
/media→/media_publish). Because the Graph API requires a public image URL (no base64, no auth), every generated image is re-uploaded to Supabase Storage before publish — both in the auto-publish LangGraph agent path and in the approval route on Vercel.A weekly refresh-tokens cron pre-emptively exchanges long-lived tokens before their 60-day expiry; expired ones get
connection_status='expired'and the UI surfaces a Reconnect button.Breaking change
migrations/004_instagram_oauth.sqldeletes existing username/password Instagram connections. Workflows that referenced them have theirconnection_idset to NULL (existing FK) and need to be re-pointed at a new OAuth connection. Acknowledged tradeoff per the design discussion: cleanest schema, harshest UX.What's where
migrations/004_instagram_oauth.sql— drops legacy IG password rows; addsaccess_token_encrypted,page_id,token_expires_at,connection_status; drops the old plaintextgraph_access_tokencolumn.v1:iv:tag:ctwire format) keyed byTOKEN_ENCRYPTION_KEY.publishImage()to both copies; the existinglistRecentMedia/getMediaInsightskeep working.vercel.json.publishToInstagramin content-automation-advanced/index.ts and content-automation/index.ts now upload to Supabase then callInstagramGraphClient.publishImage.publishImagedirectly (no longer routes through the upload-post LangGraph).{accessToken, igUserId, pageId}to the agent. Workflows targeting non-active IG connections short-circuit with a clear error.instagram-private-apidropped frompackage.json;backend/clients/instagram/client.tsdeleted.New env required
OAuth redirect URI in your Meta App must be
${NEXT_PUBLIC_APP_URL}/api/auth/instagram/callback. Thepost-mediaSupabase Storage bucket needs Public access ON so Meta's servers can fetch image URLs.The same env vars (minus the Meta ones) need to be set on the LangGraph runtime so the agent can call
uploadImageToSupabasefrom inside a run.Test plan
yarn lint,yarn typecheck,yarn test,yarn lint:langgraph-json,yarn format:check— all cleanpnpm lint(frontend) — no new errors, only pre-existing warningspnpm test:ci(frontend) — 22 passpnpm build(frontend) — all new routes compile (/api/auth/instagram/{start,callback,select-page},/api/cron/refresh-tokens)post-mediabucket in Supabase Storage (Public ON)🤖 Generated with Claude Code