From dc24590f8b66a549adc267a0b844c1e5ab988246 Mon Sep 17 00:00:00 2001 From: Evrhet Milam Date: Sun, 8 Mar 2026 22:01:52 -0700 Subject: [PATCH] Track OAuth scopes and prompt re-auth when new permissions needed Add granted_scopes tracking to Instagram and Facebook connections to detect when users need to re-authorize after new scopes are added. Fetch and store granted scopes during OAuth callbacks, display reauth warnings on account page when required scopes are missing, and consolidate scope definitions into a shared module to prevent drift. - Add migration to create granted_scopes columns on connection tables - Fetch granted scopes from /me/permissions API during OAuth flows - Store scopes with connections to track what user authorized - Add needsReauth() helper to check if new scopes are needed - Display reauth warnings on account page with one-click fix - Consolidate REQUIRED_*_SCOPES constants into shared module - Improve error handling in OAuth callbacks for better debugging Co-Authored-By: Claude Haiku 4.5 --- src/app/account/account-view.tsx | 89 +++++++++++++------ src/app/api/facebook/callback/route.ts | 12 ++- src/app/api/facebook/select-page/route.ts | 1 + src/app/api/instagram/callback/route.ts | 15 +++- src/lib/core/scopes.ts | 12 +++ src/lib/db/facebook.ts | 11 ++- src/lib/db/instagram.ts | 1 + src/lib/facebook/auth.ts | 27 +++++- src/lib/instagram/auth.ts | 28 +++++- src/lib/supabase/types.ts | 2 + .../migrations/013_add_granted_scopes.sql | 11 +++ 11 files changed, 171 insertions(+), 38 deletions(-) create mode 100644 src/lib/core/scopes.ts create mode 100644 supabase/migrations/013_add_granted_scopes.sql diff --git a/src/app/account/account-view.tsx b/src/app/account/account-view.tsx index ab964af..410a230 100644 --- a/src/app/account/account-view.tsx +++ b/src/app/account/account-view.tsx @@ -7,6 +7,11 @@ import { createClient } from "@/lib/supabase/client"; import type { Profile } from "@/lib/db/profiles"; import type { InstagramConnection } from "@/lib/db/instagram"; import type { FacebookConnection } from "@/lib/db/facebook"; +import { + REQUIRED_INSTAGRAM_SCOPES, + REQUIRED_FACEBOOK_SCOPES, + needsReauth, +} from "@/lib/core/scopes"; export default function AccountView() { return ( @@ -84,13 +89,13 @@ function AccountContent() { setCaptionStyle(p.caption_style || "polished"); setTargetAudience(p.target_audience || ""); - const [{ data: ig }, { data: fb }] = await Promise.all([ + const [igResult, fbResult] = await Promise.all([ supabase.from("instagram_connections").select("*").eq("profile_id", user.id).maybeSingle(), supabase.from("facebook_connections").select("*").eq("profile_id", user.id).maybeSingle(), ]); - setInstagram(ig); - setFacebook(fb); + setInstagram(igResult.data); + setFacebook(fbResult.data); setChecking(false); } load(); @@ -320,19 +325,34 @@ function AccountContent() {
{igError}
)} {instagram ? ( -
-
-

@{instagram.instagram_username || "Connected"}

-

- Connected {new Date(instagram.created_at).toLocaleDateString()} -

+
+
+
+

@{instagram.instagram_username || "Connected"}

+

+ Connected {new Date(instagram.created_at).toLocaleDateString()} +

+
+ + Reconnect +
- - Reconnect - + {needsReauth(instagram.granted_scopes, REQUIRED_INSTAGRAM_SCOPES) && ( +
+

+ New permissions required. Please re-authorize to continue using all features. +

+ + Re-authorize + +
+ )}
) : ( {fbError}
)} {facebook ? ( -
-
-

{facebook.page_name || "Connected"}

-

- Connected {new Date(facebook.created_at).toLocaleDateString()} -

+
+ - - Reconnect - + {needsReauth(facebook.granted_scopes, REQUIRED_FACEBOOK_SCOPES) && ( +
+

+ New permissions required. Please re-authorize to continue using all features. +

+ + Re-authorize + +
+ )}
) : ( !grantedScopes.includes(s)); +} diff --git a/src/lib/db/facebook.ts b/src/lib/db/facebook.ts index 331855a..68dbfae 100644 --- a/src/lib/db/facebook.ts +++ b/src/lib/db/facebook.ts @@ -24,6 +24,7 @@ export async function upsertFacebookConnection( facebook_page_id: string; page_name: string | null; page_access_token: string; + granted_scopes?: string[] | null; }, ): Promise { const { error } = await client @@ -37,12 +38,14 @@ export async function savePendingFacebookToken( profileId: string, facebookUserId: string, userAccessToken: string, + grantedScopes?: string[], ): Promise { const { error } = await client.from("pending_facebook_tokens").upsert( { profile_id: profileId, facebook_user_id: facebookUserId, user_access_token: userAccessToken, + granted_scopes: grantedScopes || null, }, { onConflict: "profile_id" }, ); @@ -52,10 +55,14 @@ export async function savePendingFacebookToken( export async function getPendingFacebookToken( client: DbClient, profileId: string, -): Promise<{ facebook_user_id: string; user_access_token: string } | null> { +): Promise<{ + facebook_user_id: string; + user_access_token: string; + granted_scopes: string[] | null; +} | null> { const { data, error } = await client .from("pending_facebook_tokens") - .select("facebook_user_id, user_access_token") + .select("facebook_user_id, user_access_token, granted_scopes") .eq("profile_id", profileId) .single(); if (error) return null; diff --git a/src/lib/db/instagram.ts b/src/lib/db/instagram.ts index 95d71f0..39b8257 100644 --- a/src/lib/db/instagram.ts +++ b/src/lib/db/instagram.ts @@ -40,6 +40,7 @@ export async function upsertInstagramConnection( access_token: string; token_expires_at: string; instagram_username: string | null; + granted_scopes?: string[] | null; }, ): Promise { const { error } = await client diff --git a/src/lib/facebook/auth.ts b/src/lib/facebook/auth.ts index eb4da3f..2fc51e9 100644 --- a/src/lib/facebook/auth.ts +++ b/src/lib/facebook/auth.ts @@ -7,12 +7,16 @@ export interface FacebookPage { access_token: string; } +import { REQUIRED_FACEBOOK_SCOPES } from "@/lib/core/scopes"; + +export { REQUIRED_FACEBOOK_SCOPES }; + export function getFacebookAuthorizationUrl(state: string, baseUrl: string): string { const redirectUri = `${baseUrl}/api/facebook/callback`; const params = new URLSearchParams({ client_id: FACEBOOK_APP_ID, redirect_uri: redirectUri, - scope: "pages_manage_posts,pages_read_engagement", + scope: REQUIRED_FACEBOOK_SCOPES.join(","), response_type: "code", state, }); @@ -77,6 +81,27 @@ export async function exchangeCodeForToken( }; } +export async function getGrantedScopes(accessToken: string): Promise { + try { + const response = await fetch( + `https://graph.facebook.com/v21.0/me/permissions?access_token=${accessToken}`, + ); + const data = await response.json(); + + if (data.error) { + console.error("[getGrantedScopes] Facebook permissions error:", data.error.message); + return null; + } + + return (data.data || []) + .filter((p: { status: string }) => p.status === "granted") + .map((p: { permission: string }) => p.permission); + } catch (err) { + console.error("[getGrantedScopes] Facebook permissions fetch failed:", err); + return null; + } +} + export async function listPages(userAccessToken: string): Promise { const response = await fetch( `https://graph.facebook.com/v21.0/me/accounts?fields=id,name,access_token&access_token=${userAccessToken}`, diff --git a/src/lib/instagram/auth.ts b/src/lib/instagram/auth.ts index 9515c24..7f095de 100644 --- a/src/lib/instagram/auth.ts +++ b/src/lib/instagram/auth.ts @@ -1,13 +1,16 @@ const INSTAGRAM_APP_ID = process.env.INSTAGRAM_APP_ID!; const INSTAGRAM_APP_SECRET = process.env.INSTAGRAM_APP_SECRET!; +import { REQUIRED_INSTAGRAM_SCOPES } from "@/lib/core/scopes"; + +export { REQUIRED_INSTAGRAM_SCOPES }; + export function getAuthorizationUrl(state: string, baseUrl: string): string { const redirectUri = `${baseUrl}/api/instagram/callback`; const params = new URLSearchParams({ client_id: INSTAGRAM_APP_ID, redirect_uri: redirectUri, - scope: - "instagram_business_basic,instagram_business_content_publish,instagram_business_manage_insights", + scope: REQUIRED_INSTAGRAM_SCOPES.join(","), response_type: "code", state, }); @@ -112,6 +115,27 @@ export async function refreshInstagramToken( }; } +export async function getGrantedScopes(accessToken: string): Promise { + try { + const response = await fetch( + `https://graph.instagram.com/v21.0/me/permissions?access_token=${accessToken}`, + ); + const data = await response.json(); + + if (data.error) { + console.error("[getGrantedScopes] Instagram permissions error:", data.error.message); + return null; + } + + return (data.data || []) + .filter((p: { status: string }) => p.status === "granted") + .map((p: { permission: string }) => p.permission); + } catch (err) { + console.error("[getGrantedScopes] Instagram permissions fetch failed:", err); + return null; + } +} + export async function getInstagramUsername( userId: string, accessToken: string, diff --git a/src/lib/supabase/types.ts b/src/lib/supabase/types.ts index dc37063..affac58 100644 --- a/src/lib/supabase/types.ts +++ b/src/lib/supabase/types.ts @@ -19,6 +19,7 @@ export interface InstagramConnection { access_token: string; token_expires_at: string | null; instagram_username: string | null; + granted_scopes: string[] | null; created_at: string; updated_at: string; } @@ -30,6 +31,7 @@ export interface FacebookConnection { facebook_page_id: string; page_name: string | null; page_access_token: string; + granted_scopes: string[] | null; created_at: string; updated_at: string; } diff --git a/supabase/migrations/013_add_granted_scopes.sql b/supabase/migrations/013_add_granted_scopes.sql new file mode 100644 index 0000000..5518378 --- /dev/null +++ b/supabase/migrations/013_add_granted_scopes.sql @@ -0,0 +1,11 @@ +-- Add granted_scopes to track which OAuth scopes each connection was authorized with. +-- NULL means "unknown / pre-migration" — treat as needing re-auth when new scopes are required. + +ALTER TABLE instagram_connections + ADD COLUMN IF NOT EXISTS granted_scopes text[]; + +ALTER TABLE facebook_connections + ADD COLUMN IF NOT EXISTS granted_scopes text[]; + +ALTER TABLE pending_facebook_tokens + ADD COLUMN IF NOT EXISTS granted_scopes text[];