From a27a88d9fad3d91ee38c998c1ea79c72c2fd6cca Mon Sep 17 00:00:00 2001 From: YueTwo <1364856580@qq.com> Date: Thu, 31 Jul 2025 13:31:05 +0800 Subject: [PATCH 01/13] feat: Add Instagram OAuth2 integration with 2 REST functions --- backend/aci/server/oauth2_manager.py | 19 ++- backend/aci/server/routes/linked_accounts.py | 5 + backend/apps/instagram/app.json | 26 ++++ backend/apps/instagram/functions.json | 148 +++++++++++++++++++ 4 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 backend/apps/instagram/app.json create mode 100644 backend/apps/instagram/functions.json diff --git a/backend/aci/server/oauth2_manager.py b/backend/aci/server/oauth2_manager.py index 512a53df..69785062 100644 --- a/backend/aci/server/oauth2_manager.py +++ b/backend/aci/server/oauth2_manager.py @@ -130,14 +130,25 @@ async def fetch_token( Token response dictionary """ try: + # Prepare token request parameters + token_params = { + "redirect_uri": redirect_uri, + "code": code, + "code_verifier": code_verifier, + "scope": self.scope, + } + + # Add client_id for Instagram (and potentially other apps that require it) + if self.app_name == "INSTAGRAM": + token_params["client_id"] = self.client_id + token_params["client_secret"] = self.client_secret + logger.info(f"Adding client_id and client_secret for Instagram OAuth2 request: {self.client_id}") + token = cast( dict[str, Any], await self.oauth2_client.fetch_token( self.access_token_url, - redirect_uri=redirect_uri, - code=code, - code_verifier=code_verifier, - scope=self.scope, + **token_params, ), ) return token diff --git a/backend/aci/server/routes/linked_accounts.py b/backend/aci/server/routes/linked_accounts.py index 62cf6a4d..cb9a4103 100644 --- a/backend/aci/server/routes/linked_accounts.py +++ b/backend/aci/server/routes/linked_accounts.py @@ -433,6 +433,11 @@ async def linked_accounts_oauth2_callback( # check for state state_jwt = request.query_params.get("state") + # Special handling for Instagram: remove #_ suffix if present + if state_jwt.endswith("#_"): + state_jwt = state_jwt[:-2] # Remove the last 2 characters (#_) + logger.info(f"Removed Instagram #_ suffix from state") + if not state_jwt: logger.error( "OAuth2 account linking callback received, missing state", diff --git a/backend/apps/instagram/app.json b/backend/apps/instagram/app.json new file mode 100644 index 00000000..a7e05f96 --- /dev/null +++ b/backend/apps/instagram/app.json @@ -0,0 +1,26 @@ +{ + "name": "INSTAGRAM", + "display_name": "Instagram", + "logo": "https://raw.githubusercontent.com/aipotheosis-labs/aipolabs-icons/refs/heads/main/apps/instagram.svg", + "provider": "Meta Platforms, Inc.", + "version": "1.0.0", + "description": "The Instagram API allows developers to access and manage Instagram resources programmatically. It provides functionality for publishing content, retrieving user information, fetching post data, tracking user feeds, and managing direct messages through RESTful HTTP calls.", + "security_schemes": { + "oauth2": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer", + "client_id": "{{ AIPOLABS_INSTAGRAM_APP_CLIENT_ID }}", + "client_secret": "{{ AIPOLABS_INSTAGRAM_APP_CLIENT_SECRET }}", + "scope": "instagram_business_basic instagram_business_content_publish instagram_business_manage_messages instagram_business_manage_comments", + "authorize_url": "https://www.instagram.com/oauth/authorize", + "access_token_url": "https://graph.instagram.com/oauth/access_token", + "refresh_token_url": "https://graph.instagram.com/refresh_access_token" + } + }, + "default_security_credentials_by_scheme": {}, + "categories": ["Social Media", "User Data"], + "visibility": "public", + "active": true + } + \ No newline at end of file diff --git a/backend/apps/instagram/functions.json b/backend/apps/instagram/functions.json new file mode 100644 index 00000000..73c35275 --- /dev/null +++ b/backend/apps/instagram/functions.json @@ -0,0 +1,148 @@ +[ + { + "name": "INSTAGRAM__MEDIA_CONTAINER_CREATE", + "description": "Creates a media container on Instagram for publishing a post. This is the first step to publish a media object (photo, video, etc.) to an Instagram Business account.", + "tags": [ + "instagram", + "media", + "publish", + "container" + ], + "visibility": "public", + "active": true, + "protocol": "rest", + "protocol_data": { + "method": "POST", + "path": "/v23.0/me/media", + "server_url": "https://graph.instagram.com" + }, + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "object", + "description": "Query parameters for creating a media container", + "properties": { + "image_url": { + "type": "string", + "description": "URL of the image to be published" + }, + "is_carousel_item": { + "type": "boolean", + "description": "Whether this media is part of a carousel post", + "default": false + }, + "alt_text": { + "type": "string", + "description": "Alternative text for the image for accessibility" + }, + "caption": { + "type": "string", + "description": "Caption text for the Instagram post" + }, + "location_id": { + "type": "string", + "description": "Location page ID to tag the post with a location" + }, + "user_tags": { + "type": "array", + "description": "Array of users to tag in the post", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "Username to tag" + }, + "x": { + "type": "number", + "description": "X coordinate for tag placement (0.0 to 1.0)" + }, + "y": { + "type": "number", + "description": "Y coordinate for tag placement (0.0 to 1.0)" + } + }, + "required": ["username", "x", "y"] + } + }, + "product_tags": { + "type": "array", + "description": "Array of products to tag in the post", + "items": { + "type": "object", + "properties": { + "product_id": { + "type": "string", + "description": "Product ID to tag" + }, + "x": { + "type": "number", + "description": "X coordinate for tag placement (0.0 to 1.0)" + }, + "y": { + "type": "number", + "description": "Y coordinate for tag placement (0.0 to 1.0)" + } + }, + "required": ["product_id", "x", "y"] + } + } + }, + "required": ["image_url"], + "visible": [ + "image_url", + "is_carousel_item", + "alt_text", + "caption", + "location_id", + "user_tags", + "product_tags" + ], + "additionalProperties": false + } + }, + "required": ["query"], + "visible": ["query"], + "additionalProperties": false + } + }, + { + "name": "INSTAGRAM__MEDIA_CONTAINER_PUBLISH", + "description": "Publishes a previously created media container to the Instagram Business account. This is the final step to make the media object (photo, video, etc.) visible on the user's Instagram feed.", + "tags": [ + "instagram", + "media", + "publish" + ], + "visibility": "public", + "active": true, + "protocol": "rest", + "protocol_data": { + "method": "POST", + "path": "/v23.0/me/media_publish", + "server_url": "https://graph.instagram.com" + }, + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "object", + "description": "Query parameters for publishing the media container", + "properties": { + "creation_id": { + "type": "string", + "description": "The ID of the media container to be published" + } + }, + "required": ["creation_id"], + "visible": ["creation_id"], + "additionalProperties": false + } + }, + "required": ["query"], + "visible": ["query"], + "additionalProperties": false + } + } +] \ No newline at end of file From ac569ce247173737c62be2b0fe6bc29a60a6ecde Mon Sep 17 00:00:00 2001 From: YueTwo <1364856580@qq.com> Date: Sun, 3 Aug 2025 15:58:25 +0800 Subject: [PATCH 02/13] feat: add Instagram short-lived token exchange to long-lived toke --- backend/aci/server/oauth2_manager.py | 74 +++++++++++++++----- backend/aci/server/routes/linked_accounts.py | 8 +-- 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/backend/aci/server/oauth2_manager.py b/backend/aci/server/oauth2_manager.py index 69785062..7c871e66 100644 --- a/backend/aci/server/oauth2_manager.py +++ b/backend/aci/server/oauth2_manager.py @@ -130,25 +130,14 @@ async def fetch_token( Token response dictionary """ try: - # Prepare token request parameters - token_params = { - "redirect_uri": redirect_uri, - "code": code, - "code_verifier": code_verifier, - "scope": self.scope, - } - - # Add client_id for Instagram (and potentially other apps that require it) - if self.app_name == "INSTAGRAM": - token_params["client_id"] = self.client_id - token_params["client_secret"] = self.client_secret - logger.info(f"Adding client_id and client_secret for Instagram OAuth2 request: {self.client_id}") - token = cast( dict[str, Any], await self.oauth2_client.fetch_token( self.access_token_url, - **token_params, + redirect_uri=redirect_uri, + code=code, + code_verifier=code_verifier, + scope=self.scope, ), ) return token @@ -172,7 +161,44 @@ async def refresh_token( logger.error(f"Failed to refresh access token, app_name={self.app_name}, error={e}") raise OAuth2Error("Failed to refresh access token") from e - def parse_fetch_token_response(self, token: dict) -> OAuth2SchemeCredentials: + async def exchange_short_lived_token(self, short_lived_token: str) -> dict[str, Any]: + """ + Exchange short-lived access token for long-lived access token. + This is specific to Instagram's API requirements. + + Args: + short_lived_token: The short-lived access token from the initial OAuth flow + + Returns: + Token response dictionary with long-lived access token + """ + if self.app_name != "INSTAGRAM": + raise OAuth2Error("Token exchange is only supported for Instagram") + + try: + response = await self.oauth2_client.get( + "https://graph.instagram.com/access_token", + params={ + "grant_type": "ig_exchange_token", + "client_secret": self.client_secret, + "access_token": short_lived_token, + }, + ) + response.raise_for_status() + + token_data = cast(dict[str, Any], response.json()) + logger.info( + f"Successfully exchanged short-lived token for long-lived token, app_name={self.app_name}" + ) + return token_data + + except Exception as e: + logger.error( + f"Failed to exchange short-lived token, app_name={self.app_name}, error={e}" + ) + raise OAuth2Error("Failed to exchange short-lived token for long-lived token") from e + + async def parse_fetch_token_response(self, token: dict) -> OAuth2SchemeCredentials: """ Parse OAuth2SchemeCredentials from token response with app-specific handling. @@ -192,6 +218,22 @@ def parse_fetch_token_response(self, token: dict) -> OAuth2SchemeCredentials: logger.error(f"Missing authed_user in Slack OAuth response, app={self.app_name}") raise OAuth2Error("Missing access_token in Slack OAuth response") + # handle Instagram's special case - exchange short-lived token for long-lived token + if self.app_name == "INSTAGRAM": + if "access_token" in data: + short_lived_token = data["access_token"] + logger.info( + f"Exchanging short-lived token for long-lived token, app_name={self.app_name}" + ) + long_lived_token_response = await self.exchange_short_lived_token(short_lived_token) + # Update data with long-lived token response: add expires_in and token_type, update access_token + data.update(long_lived_token_response) + else: + logger.error( + f"Missing access_token in Instagram OAuth response, app={self.app_name}" + ) + raise OAuth2Error("Missing access_token in Instagram OAuth response") + if "access_token" not in data: logger.error(f"Missing access_token in OAuth response, app={self.app_name}") raise OAuth2Error("Missing access_token in OAuth response") diff --git a/backend/aci/server/routes/linked_accounts.py b/backend/aci/server/routes/linked_accounts.py index cb9a4103..8e66b015 100644 --- a/backend/aci/server/routes/linked_accounts.py +++ b/backend/aci/server/routes/linked_accounts.py @@ -434,10 +434,10 @@ async def linked_accounts_oauth2_callback( # check for state state_jwt = request.query_params.get("state") # Special handling for Instagram: remove #_ suffix if present - if state_jwt.endswith("#_"): + if state_jwt and state_jwt.endswith("#_"): state_jwt = state_jwt[:-2] # Remove the last 2 characters (#_) - logger.info(f"Removed Instagram #_ suffix from state") - + logger.info("Removed Instagram #_ suffix from state") + if not state_jwt: logger.error( "OAuth2 account linking callback received, missing state", @@ -513,7 +513,7 @@ async def linked_accounts_oauth2_callback( code=code, code_verifier=state.code_verifier, ) - security_credentials = oauth2_manager.parse_fetch_token_response(token_response) + security_credentials = await oauth2_manager.parse_fetch_token_response(token_response) # if the linked account already exists, update it, otherwise create a new one # TODO: consider separating the logic for updating and creating a linked account or give warning to clients From 58e62761bd95d87f256d8bd0306eac37be66a797 Mon Sep 17 00:00:00 2001 From: YueTwo <1364856580@qq.com> Date: Sun, 3 Aug 2025 16:47:13 +0800 Subject: [PATCH 03/13] feat: add configurable exchange_token_url to OAuth2Scheme for Instagram --- backend/aci/common/schemas/security_scheme.py | 6 ++++++ backend/aci/server/oauth2_manager.py | 8 +++++++- backend/aci/server/routes/linked_accounts.py | 2 ++ backend/aci/server/security_credentials_manager.py | 1 + backend/apps/instagram/app.json | 7 ++++--- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/backend/aci/common/schemas/security_scheme.py b/backend/aci/common/schemas/security_scheme.py index 74130127..ee77d332 100644 --- a/backend/aci/common/schemas/security_scheme.py +++ b/backend/aci/common/schemas/security_scheme.py @@ -79,6 +79,12 @@ class OAuth2Scheme(BaseModel): redirect_url: str | None = Field( default=None, min_length=1, max_length=2048, description="Redirect URL for OAuth2 callback." ) + exchange_token_url: str | None = Field( + default=None, + min_length=1, + max_length=2048, + description="URL for exchanging short-lived tokens to long-lived tokens (Instagram specific)", + ) # NOTE: need to show these fields for custom oauth2 app feature. diff --git a/backend/aci/server/oauth2_manager.py b/backend/aci/server/oauth2_manager.py index 7c871e66..3cb56abd 100644 --- a/backend/aci/server/oauth2_manager.py +++ b/backend/aci/server/oauth2_manager.py @@ -24,6 +24,7 @@ def __init__( access_token_url: str, refresh_token_url: str, token_endpoint_auth_method: str | None = None, + exchange_token_url: str | None = None, ): """ Initialize the OAuth2Manager @@ -39,6 +40,7 @@ def __init__( token_endpoint_auth_method: client_secret_basic (default) | client_secret_post | none Additional options can be achieved by registering a custom auth method + exchange_token_url: The URL for exchanging short-lived tokens to long-lived tokens (Instagram specific) """ self.app_name = app_name self.client_id = client_id @@ -48,6 +50,7 @@ def __init__( self.access_token_url = access_token_url self.refresh_token_url = refresh_token_url self.token_endpoint_auth_method = token_endpoint_auth_method + self.exchange_token_url = exchange_token_url # TODO: need to close the client after use # Add an aclose() helper (or implement __aenter__/__aexit__) and make callers invoke it during shutdown. @@ -175,9 +178,12 @@ async def exchange_short_lived_token(self, short_lived_token: str) -> dict[str, if self.app_name != "INSTAGRAM": raise OAuth2Error("Token exchange is only supported for Instagram") + if not self.exchange_token_url: + raise OAuth2Error("Exchange token URL is not configured for Instagram") + try: response = await self.oauth2_client.get( - "https://graph.instagram.com/access_token", + self.exchange_token_url, params={ "grant_type": "ig_exchange_token", "client_secret": self.client_secret, diff --git a/backend/aci/server/routes/linked_accounts.py b/backend/aci/server/routes/linked_accounts.py index 8e66b015..b059108c 100644 --- a/backend/aci/server/routes/linked_accounts.py +++ b/backend/aci/server/routes/linked_accounts.py @@ -360,6 +360,7 @@ async def link_oauth2_account( access_token_url=oauth2_scheme.access_token_url, refresh_token_url=oauth2_scheme.refresh_token_url, token_endpoint_auth_method=oauth2_scheme.token_endpoint_auth_method, + exchange_token_url=oauth2_scheme.exchange_token_url, ) path = request.url_for(LINKED_ACCOUNTS_OAUTH2_CALLBACK_ROUTE_NAME).path @@ -506,6 +507,7 @@ async def linked_accounts_oauth2_callback( access_token_url=oauth2_scheme.access_token_url, refresh_token_url=oauth2_scheme.refresh_token_url, token_endpoint_auth_method=oauth2_scheme.token_endpoint_auth_method, + exchange_token_url=oauth2_scheme.exchange_token_url, ) token_response = await oauth2_manager.fetch_token( diff --git a/backend/aci/server/security_credentials_manager.py b/backend/aci/server/security_credentials_manager.py index 70d2cc45..1619bf18 100644 --- a/backend/aci/server/security_credentials_manager.py +++ b/backend/aci/server/security_credentials_manager.py @@ -157,6 +157,7 @@ async def _refresh_oauth2_access_token( access_token_url=oauth2_scheme.access_token_url, refresh_token_url=oauth2_scheme.refresh_token_url, token_endpoint_auth_method=oauth2_scheme.token_endpoint_auth_method, + exchange_token_url=oauth2_scheme.exchange_token_url, ) return await oauth2_manager.refresh_token(refresh_token) diff --git a/backend/apps/instagram/app.json b/backend/apps/instagram/app.json index a7e05f96..5d7a3cae 100644 --- a/backend/apps/instagram/app.json +++ b/backend/apps/instagram/app.json @@ -14,8 +14,10 @@ "client_secret": "{{ AIPOLABS_INSTAGRAM_APP_CLIENT_SECRET }}", "scope": "instagram_business_basic instagram_business_content_publish instagram_business_manage_messages instagram_business_manage_comments", "authorize_url": "https://www.instagram.com/oauth/authorize", - "access_token_url": "https://graph.instagram.com/oauth/access_token", - "refresh_token_url": "https://graph.instagram.com/refresh_access_token" + "access_token_url": "https://api.instagram.com/oauth/access_token", + "exchange_token_url": "https://graph.instagram.com/access_token", + "refresh_token_url": "https://graph.instagram.com/refresh_access_token", + "token_endpoint_auth_method": "client_secret_post" } }, "default_security_credentials_by_scheme": {}, @@ -23,4 +25,3 @@ "visibility": "public", "active": true } - \ No newline at end of file From b79b1927fcf56fce98c86c08440554df419eb523 Mon Sep 17 00:00:00 2001 From: YueTwo <1364856580@qq.com> Date: Sun, 3 Aug 2025 17:33:46 +0800 Subject: [PATCH 04/13] feat: handle Instagram token expiration without refresh --- backend/aci/server/security_credentials_manager.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/backend/aci/server/security_credentials_manager.py b/backend/aci/server/security_credentials_manager.py index 1619bf18..2b2592ab 100644 --- a/backend/aci/server/security_credentials_manager.py +++ b/backend/aci/server/security_credentials_manager.py @@ -17,6 +17,7 @@ OAuth2SchemeCredentials, SecuritySchemeOverrides, ) +from aci.server import config from aci.server.oauth2_manager import OAuth2Manager logger = get_logger(__name__) @@ -96,6 +97,17 @@ async def _get_oauth2_credentials( linked_account.security_credentials ) if _access_token_is_expired(oauth2_scheme_credentials): + if app.name == "INSTAGRAM": + logger.error( + f"Access token expired, please re-authorize, linked_account_id={linked_account.id}, " + f"security_scheme={linked_account.security_scheme}, app={app.name}" + ) + # NOTE: this error message could be used by the frontend to guide the user to re-authorize + raise OAuth2Error( + f"Access token expired. Please re-authorize at: " + f"{config.DEV_PORTAL_URL}/appconfigs/{app.name}" + ) + logger.warning( f"Access token expired, trying to refresh linked_account_id={linked_account.id}, " f"security_scheme={linked_account.security_scheme}, app={app.name}" @@ -155,9 +167,9 @@ async def _refresh_oauth2_access_token( scope=oauth2_scheme_credentials.scope, authorize_url=oauth2_scheme.authorize_url, access_token_url=oauth2_scheme.access_token_url, + exchange_token_url=oauth2_scheme.exchange_token_url, refresh_token_url=oauth2_scheme.refresh_token_url, token_endpoint_auth_method=oauth2_scheme.token_endpoint_auth_method, - exchange_token_url=oauth2_scheme.exchange_token_url, ) return await oauth2_manager.refresh_token(refresh_token) From a4502ba41085d9c01e3433c91da09f78843bbd78 Mon Sep 17 00:00:00 2001 From: YueTwo <1364856580@qq.com> Date: Thu, 7 Aug 2025 18:37:26 +0800 Subject: [PATCH 05/13] update the instagram oauth2 scope --- backend/apps/instagram/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/apps/instagram/app.json b/backend/apps/instagram/app.json index 5d7a3cae..4b89df83 100644 --- a/backend/apps/instagram/app.json +++ b/backend/apps/instagram/app.json @@ -12,7 +12,7 @@ "prefix": "Bearer", "client_id": "{{ AIPOLABS_INSTAGRAM_APP_CLIENT_ID }}", "client_secret": "{{ AIPOLABS_INSTAGRAM_APP_CLIENT_SECRET }}", - "scope": "instagram_business_basic instagram_business_content_publish instagram_business_manage_messages instagram_business_manage_comments", + "scope": "instagram_business_basic instagram_business_content_publish instagram_business_manage_messages instagram_business_manage_comments instagram_business_manage_insights", "authorize_url": "https://www.instagram.com/oauth/authorize", "access_token_url": "https://api.instagram.com/oauth/access_token", "exchange_token_url": "https://graph.instagram.com/access_token", From 9a1ae77fefa6a4cf437217062a95303b315c272c Mon Sep 17 00:00:00 2001 From: YueTwo <1364856580@qq.com> Date: Thu, 7 Aug 2025 18:42:31 +0800 Subject: [PATCH 06/13] feat: add 18 Instagram functions --- backend/apps/instagram/functions.json | 941 +++++++++++++++++++++++++- 1 file changed, 938 insertions(+), 3 deletions(-) diff --git a/backend/apps/instagram/functions.json b/backend/apps/instagram/functions.json index 73c35275..f1072aba 100644 --- a/backend/apps/instagram/functions.json +++ b/backend/apps/instagram/functions.json @@ -1,7 +1,7 @@ [ { "name": "INSTAGRAM__MEDIA_CONTAINER_CREATE", - "description": "Creates a media container on Instagram for publishing a post. This is the first step to publish a media object (photo, video, etc.) to an Instagram Business account.", + "description": "Creates a media container for publishing content to Instagram Business account.", "tags": [ "instagram", "media", @@ -109,7 +109,7 @@ }, { "name": "INSTAGRAM__MEDIA_CONTAINER_PUBLISH", - "description": "Publishes a previously created media container to the Instagram Business account. This is the final step to make the media object (photo, video, etc.) visible on the user's Instagram feed.", + "description": "Publishes a media container to Instagram feed.", "tags": [ "instagram", "media", @@ -144,5 +144,940 @@ "visible": ["query"], "additionalProperties": false } + }, + { + "name": "INSTAGRAM__GET_USER_PROFILE", + "description": "Retrieves Instagram Business or Creator account profile information.", + "tags": [ + "instagram", + "profile", + "user", + "account" + ], + "visibility": "public", + "active": true, + "protocol": "rest", + "protocol_data": { + "method": "GET", + "path": "/v23.0/me", + "server_url": "https://graph.instagram.com" + }, + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "object", + "description": "Query parameters for retrieving user profile information", + "properties": { + "fields": { + "type": "string", + "description": "Comma-separated list of fields to retrieve from the Instagram user profile", + "default": "id,username,biography,followers_count,media_count" + } + }, + "required": [], + "visible": ["fields"], + "additionalProperties": false + } + }, + "required": [], + "visible": ["query"], + "additionalProperties": false + } + }, + { + "name": "INSTAGRAM__GET_USER_MEDIA", + "description": "Retrieves media objects from Instagram Business or Creator account.", + "tags": [ + "instagram", + "media", + "user", + "content" + ], + "visibility": "public", + "active": true, + "protocol": "rest", + "protocol_data": { + "method": "GET", + "path": "/v23.0/{ig_id}/media", + "server_url": "https://graph.instagram.com" + }, + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "object", + "description": "Path parameters for the request", + "properties": { + "ig_id": { + "type": "string", + "description": "Instagram User ID to retrieve media objects for" + } + }, + "required": ["ig_id"], + "visible": ["ig_id"], + "additionalProperties": false + }, + "query": { + "type": "object", + "description": "Query parameters for retrieving user media objects", + "properties": { + "fields": { + "type": "string", + "description": "Comma-separated list of fields to retrieve for each media object", + "default": "id" + }, + "limit": { + "type": "number", + "description": "Number of media objects to return (max 25)", + "minimum": 1, + "maximum": 25 + }, + "after": { + "type": "string", + "description": "Pagination cursor to get media objects after this cursor" + }, + "before": { + "type": "string", + "description": "Pagination cursor to get media objects before this cursor" + } + }, + "required": [], + "visible": ["fields", "limit", "after", "before"], + "additionalProperties": false + } + }, + "required": ["path"], + "visible": ["path", "query"], + "additionalProperties": false + } + }, + { + "name": "INSTAGRAM__GET_CONVERSATIONS", + "description": "Retrieves conversations for Instagram professional account.", + "tags": [ + "instagram", + "conversations", + "messages", + "business" + ], + "visibility": "public", + "active": true, + "protocol": "rest", + "protocol_data": { + "method": "GET", + "path": "/v23.0/{ig_id}/conversations", + "server_url": "https://graph.instagram.com" + }, + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "object", + "description": "Path parameters for the request", + "properties": { + "ig_id": { + "type": "string", + "description": "Instagram professional account ID to retrieve conversations for" + } + }, + "required": ["ig_id"], + "visible": ["ig_id"], + "additionalProperties": false + }, + "query": { + "type": "object", + "description": "Query parameters for retrieving conversations", + "properties": { + "platform": { + "type": "string", + "description": "Platform to filter conversations", + "default": "instagram", + "enum": ["instagram"] + }, + "fields": { + "type": "string", + "description": "Comma-separated list of fields to retrieve for each conversation", + "default": "id,updated_time" + }, + "limit": { + "type": "number", + "description": "Number of conversations to return", + "minimum": 1 + }, + "after": { + "type": "string", + "description": "Pagination cursor to get conversations after this cursor" + }, + "before": { + "type": "string", + "description": "Pagination cursor to get conversations before this cursor" + } + }, + "required": [], + "visible": ["platform", "fields", "limit", "after", "before"], + "additionalProperties": false + } + }, + "required": ["path"], + "visible": ["path", "query"], + "additionalProperties": false + } + }, + { + "name": "INSTAGRAM__GET_CONVERSATION_MESSAGES", + "description": "Retrieves messages from a specific Instagram conversation.", + "tags": [ + "instagram", + "messages", + "conversation", + "business" + ], + "visibility": "public", + "active": true, + "protocol": "rest", + "protocol_data": { + "method": "GET", + "path": "/v23.0/{conversation_id}", + "server_url": "https://graph.instagram.com" + }, + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "object", + "description": "Path parameters for the request", + "properties": { + "conversation_id": { + "type": "string", + "description": "Instagram conversation ID to retrieve messages from" + } + }, + "required": ["conversation_id"], + "visible": ["conversation_id"], + "additionalProperties": false + }, + "query": { + "type": "object", + "description": "Query parameters for retrieving conversation messages", + "properties": { + "fields": { + "type": "string", + "description": "Comma-separated list of fields to retrieve", + "default": "messages" + }, + "limit": { + "type": "number", + "description": "Number of messages to return", + "minimum": 1 + }, + "after": { + "type": "string", + "description": "Pagination cursor to get messages after this cursor" + }, + "before": { + "type": "string", + "description": "Pagination cursor to get messages before this cursor" + } + }, + "required": [], + "visible": ["fields", "limit", "after", "before"], + "additionalProperties": false + } + }, + "required": ["path"], + "visible": ["path", "query"], + "additionalProperties": false + } + }, + { + "name": "INSTAGRAM__GET_MESSAGE_INFO", + "description": "Retrieves detailed information about a specific Instagram message.", + "tags": [ + "instagram", + "message", + "details", + "business" + ], + "visibility": "public", + "active": true, + "protocol": "rest", + "protocol_data": { + "method": "GET", + "path": "/v23.0/{message_id}", + "server_url": "https://graph.instagram.com" + }, + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "object", + "description": "Path parameters for the request", + "properties": { + "message_id": { + "type": "string", + "description": "Instagram message ID to retrieve information for" + } + }, + "required": ["message_id"], + "visible": ["message_id"], + "additionalProperties": false + }, + "query": { + "type": "object", + "description": "Query parameters for retrieving message information", + "properties": { + "fields": { + "type": "string", + "description": "Comma-separated list of fields to retrieve for the message", + "default": "id,created_time,from,to,message" + } + }, + "required": [], + "visible": ["fields"], + "additionalProperties": false + } + }, + "required": ["path"], + "visible": ["path", "query"], + "additionalProperties": false + } + }, + { + "name": "INSTAGRAM__GET_CONTAINER_STATUS", + "description": "Retrieves Instagram media container status and details.", + "tags": [ + "instagram", + "container", + "status", + "media" + ], + "visibility": "public", + "active": true, + "protocol": "rest", + "protocol_data": { + "method": "GET", + "path": "/v23.0/{ig_container_id}", + "server_url": "https://graph.instagram.com" + }, + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "object", + "description": "Path parameters for the request", + "properties": { + "ig_container_id": { + "type": "string", + "description": "Instagram Container ID to retrieve information for" + } + }, + "required": ["ig_container_id"], + "visible": ["ig_container_id"], + "additionalProperties": false + }, + "query": { + "type": "object", + "description": "Query parameters for retrieving container information", + "properties": { + "fields": { + "type": "string", + "description": "Comma-separated list of fields to retrieve for the container.", + "default": "id,status_code,status" + } + }, + "required": [], + "visible": ["fields"], + "additionalProperties": false + } + }, + "required": ["path"], + "visible": ["path", "query"], + "additionalProperties": false + } + }, + { + "name": "INSTAGRAM__GET_MEDIA_COMMENTS", + "description": "Retrieves comments on a published Instagram media object.", + "tags": [ + "instagram", + "comments", + "media", + "engagement" + ], + "visibility": "public", + "active": true, + "protocol": "rest", + "protocol_data": { + "method": "GET", + "path": "/v23.0/{ig_media_id}/comments", + "server_url": "https://graph.instagram.com" + }, + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "object", + "description": "Path parameters for the request", + "properties": { + "ig_media_id": { + "type": "string", + "description": "Instagram Media ID to retrieve comments for" + } + }, + "required": ["ig_media_id"], + "visible": ["ig_media_id"], + "additionalProperties": false + }, + "query": { + "type": "object", + "description": "Query parameters for retrieving media comments", + "properties": { + "fields": { + "type": "string", + "description": "Comma-separated list of fields to retrieve for each comment", + "default": "id,text,timestamp" + }, + "limit": { + "type": "number", + "description": "Number of comments to return", + "minimum": 1 + }, + "after": { + "type": "string", + "description": "Pagination cursor to get comments after this cursor" + }, + "before": { + "type": "string", + "description": "Pagination cursor to get comments before this cursor" + } + }, + "required": [], + "visible": ["fields", "limit", "after", "before"], + "additionalProperties": false + } + }, + "required": ["path"], + "visible": ["path", "query"], + "additionalProperties": false + } + }, + { + "name": "INSTAGRAM__REPLY_TO_COMMENT", + "description": "Replies to a specific Instagram comment.", + "tags": [ + "instagram", + "comment", + "reply", + "engagement" + ], + "visibility": "public", + "active": true, + "protocol": "rest", + "protocol_data": { + "method": "POST", + "path": "/v23.0/{ig_comment_id}/replies", + "server_url": "https://graph.instagram.com" + }, + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "object", + "description": "Path parameters for the request", + "properties": { + "ig_comment_id": { + "type": "string", + "description": "Instagram Comment ID to reply to" + } + }, + "required": ["ig_comment_id"], + "visible": ["ig_comment_id"], + "additionalProperties": false + }, + "body": { + "type": "object", + "description": "Request body parameters for replying to a comment", + "properties": { + "message": { + "type": "string", + "description": "The reply message text to post" + } + }, + "required": ["message"], + "visible": ["message"], + "additionalProperties": false + } + }, + "required": ["path", "body"], + "visible": ["path", "body"], + "additionalProperties": false + } + }, + { + "name": "INSTAGRAM__SEND_PRIVATE_REPLY", + "description": "Sends a private reply message to a commenter on Instagram.", + "tags": [ + "instagram", + "message", + "private", + "reply" + ], + "visibility": "public", + "active": true, + "protocol": "rest", + "protocol_data": { + "method": "POST", + "path": "/v23.0/{ig_id}/messages", + "server_url": "https://graph.instagram.com" + }, + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "object", + "description": "Path parameters for the request", + "properties": { + "ig_id": { + "type": "string", + "description": "Instagram professional account ID to send the private reply from" + } + }, + "required": ["ig_id"], + "visible": ["ig_id"], + "additionalProperties": false + }, + "body": { + "type": "object", + "description": "Request body parameters for sending a private reply", + "properties": { + "recipient": { + "type": "object", + "description": "Recipient information for the private reply", + "properties": { + "comment_id": { + "type": "string", + "description": "The ID of the comment to reply to privately" + } + }, + "required": ["comment_id"], + "visible": ["comment_id"], + "additionalProperties": false + }, + "message": { + "type": "object", + "description": "Message content for the private reply", + "properties": { + "text": { + "type": "string", + "description": "The text content of the private reply message" + } + }, + "required": ["text"], + "visible": ["text"], + "additionalProperties": false + } + }, + "required": ["recipient", "message"], + "visible": ["recipient", "message"], + "additionalProperties": false + } + }, + "required": ["path", "body"], + "visible": ["path", "body"], + "additionalProperties": false + } + }, + { + "name": "INSTAGRAM__GET_MEDIA_INSIGHTS", + "description": "Retrieves analytics and performance metrics for Instagram media objects.", + "tags": [ + "instagram", + "insights", + "analytics", + "metrics" + ], + "visibility": "public", + "active": true, + "protocol": "rest", + "protocol_data": { + "method": "GET", + "path": "/v23.0/{instagram_media_id}/insights", + "server_url": "https://graph.instagram.com" + }, + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "object", + "description": "Path parameters for the request", + "properties": { + "instagram_media_id": { + "type": "string", + "description": "Instagram Media ID (not Container ID) of a published media object to retrieve insights for" + } + }, + "required": ["instagram_media_id"], + "visible": ["instagram_media_id"], + "additionalProperties": false + }, + "query": { + "type": "object", + "description": "Query parameters for retrieving media insights", + "properties": { + "metric": { + "type": "string", + "description": "Comma-separated list of metrics to retrieve.", + "default": "reach,views,likes,comments" + }, + "period": { + "type": "string", + "description": "Time period for the metrics", + "default": "lifetime", + "enum": ["day", "week", "days_28", "month", "lifetime", "total_over_range"] + } + }, + "required": [], + "visible": ["metric", "period"], + "additionalProperties": false + } + }, + "required": ["path"], + "visible": ["path", "query"], + "additionalProperties": false + } + }, + { + "name": "INSTAGRAM__GET_ACCOUNT_INSIGHTS", + "description": "Retrieves social interaction metrics for Instagram business or creator account.", + "tags": [ + "instagram", + "insights", + "analytics", + "account", + "metrics" + ], + "visibility": "public", + "active": true, + "protocol": "rest", + "protocol_data": { + "method": "GET", + "path": "/v23.0/{ig_account_id}/insights", + "server_url": "https://graph.instagram.com" + }, + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "object", + "description": "Path parameters for the request", + "properties": { + "ig_account_id": { + "type": "string", + "description": "Instagram professional account ID to retrieve insights for" + } + }, + "required": ["ig_account_id"], + "visible": ["ig_account_id"], + "additionalProperties": false + }, + "query": { + "type": "object", + "description": "Query parameters for retrieving account insights", + "properties": { + "metric": { + "type": "string", + "description": "Comma-separated list of metrics to retrieve." + }, + "period": { + "type": "string", + "description": "Period aggregation for interaction metrics. Required for interaction metrics, not applicable for demographic metrics.", + "enum": ["day", "week", "days_28", "month", "lifetime"], + "default": "day" + }, + "metric_type": { + "type": "string", + "description": "Designates response aggregation type: time_series for time period aggregation, total_value for simple totals with optional breakdowns", + "enum": ["time_series", "total_value"], + "default": "total_value" + }, + "breakdown": { + "type": "string", + "description": "Break down results into subsets (only with metric_type=total_value). contact_button_type: profile component breakdown, follow_type: followers vs non-followers, media_product_type: surface breakdown (AD, FEED, REELS, STORY)", + "enum": ["contact_button_type", "follow_type", "media_product_type"] + }, + "timeframe": { + "type": "string", + "description": "Required for demographic metrics. Designates how far back to look for data, overrides since/until parameters", + "enum": ["last_14_days", "last_30_days", "last_90_days", "prev_month", "this_month", "this_week"] + }, + "since": { + "type": "number", + "description": "Unix timestamp indicating start of range for interaction metrics (ignored for demographic metrics when timeframe is used)" + }, + "until": { + "type": "number", + "description": "Unix timestamp indicating end of range for interaction metrics (ignored for demographic metrics when timeframe is used)" + } + }, + "required": ["metric"], + "visible": ["metric", "period", "metric_type", "breakdown", "timeframe", "since", "until"], + "additionalProperties": false + } + }, + "required": ["path", "query"], + "visible": ["path", "query"], + "additionalProperties": false + } + }, + { + "name": "INSTAGRAM__GET_MEDIA_DETAILS", + "description": "Retrieves detailed information about a specific Instagram media object.", + "tags": [ + "instagram", + "media", + "details", + "information" + ], + "visibility": "public", + "active": true, + "protocol": "rest", + "protocol_data": { + "method": "GET", + "path": "/v23.0/{ig_media_id}", + "server_url": "https://graph.instagram.com" + }, + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "object", + "description": "Path parameters for the request", + "properties": { + "ig_media_id": { + "type": "string", + "description": "Instagram Media ID to retrieve detailed information for" + } + }, + "required": ["ig_media_id"], + "visible": ["ig_media_id"], + "additionalProperties": false + }, + "query": { + "type": "object", + "description": "Query parameters for retrieving media details", + "properties": { + "fields": { + "type": "string", + "description": "Comma-separated list of fields to retrieve for the media object.", + "default": "id,caption,media_type,media_url,permalink,timestamp,like_count,comments_count" + } + }, + "required": [], + "visible": ["fields"], + "additionalProperties": false + } + }, + "required": ["path"], + "visible": ["path", "query"], + "additionalProperties": false + } + }, + { + "name": "INSTAGRAM__HIDE_UNHIDE_COMMENT", + "description": "Hides or unhides a specific comment on Instagram media.", + "tags": [ + "instagram", + "comment", + "hide", + "moderation" + ], + "visibility": "public", + "active": true, + "protocol": "rest", + "protocol_data": { + "method": "POST", + "path": "/v23.0/{ig_comment_id}", + "server_url": "https://graph.instagram.com" + }, + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "object", + "description": "Path parameters for the request", + "properties": { + "ig_comment_id": { + "type": "string", + "description": "Instagram Comment ID to hide or unhide" + } + }, + "required": ["ig_comment_id"], + "visible": ["ig_comment_id"], + "additionalProperties": false + }, + "query": { + "type": "object", + "description": "Query parameters for hiding/unhiding the comment", + "properties": { + "hide": { + "type": "boolean", + "description": "Set to true to hide the comment, or false to show the comment" + } + }, + "required": ["hide"], + "visible": ["hide"], + "additionalProperties": false + } + }, + "required": ["path", "query"], + "visible": ["path", "query"], + "additionalProperties": false + } + }, + { + "name": "INSTAGRAM__DELETE_COMMENT", + "description": "Deletes a specific comment on Instagram media.", + "tags": [ + "instagram", + "comment", + "delete", + "moderation" + ], + "visibility": "public", + "active": true, + "protocol": "rest", + "protocol_data": { + "method": "DELETE", + "path": "/v23.0/{ig_comment_id}", + "server_url": "https://graph.instagram.com" + }, + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "object", + "description": "Path parameters for the request", + "properties": { + "ig_comment_id": { + "type": "string", + "description": "Instagram Comment ID to delete" + } + }, + "required": ["ig_comment_id"], + "visible": ["ig_comment_id"], + "additionalProperties": false + } + }, + "required": ["path"], + "visible": ["path"], + "additionalProperties": false + } + }, + { + "name": "INSTAGRAM__GET_COMMENT_DETAILS", + "description": "Retrieves detailed information about a specific Instagram comment.", + "tags": [ + "instagram", + "comment", + "details", + "information" + ], + "visibility": "public", + "active": true, + "protocol": "rest", + "protocol_data": { + "method": "GET", + "path": "/v23.0/{ig_comment_id}", + "server_url": "https://graph.instagram.com" + }, + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "object", + "description": "Path parameters for the request", + "properties": { + "ig_comment_id": { + "type": "string", + "description": "Instagram Comment ID to retrieve detailed information for" + } + }, + "required": ["ig_comment_id"], + "visible": ["ig_comment_id"], + "additionalProperties": false + }, + "query": { + "type": "object", + "description": "Query parameters for retrieving comment details", + "properties": { + "fields": { + "type": "string", + "description": "Comma-separated list of fields to retrieve for the comment", + "default": "id,text,timestamp,from,like_count" + } + }, + "required": [], + "visible": ["fields"], + "additionalProperties": false + } + }, + "required": ["path"], + "visible": ["path", "query"], + "additionalProperties": false + } + }, + { + "name": "INSTAGRAM__ENABLE_DISABLE_COMMENTS", + "description": "Enables or disables comments on Instagram media object.", + "tags": [ + "instagram", + "comments", + "enable", + "disable", + "moderation" + ], + "visibility": "public", + "active": true, + "protocol": "rest", + "protocol_data": { + "method": "POST", + "path": "/v23.0/{ig_media_id}", + "server_url": "https://graph.instagram.com" + }, + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "object", + "description": "Path parameters for the request", + "properties": { + "ig_media_id": { + "type": "string", + "description": "Instagram Media ID to enable or disable comments on" + } + }, + "required": ["ig_media_id"], + "visible": ["ig_media_id"], + "additionalProperties": false + }, + "query": { + "type": "object", + "description": "Query parameters for enabling/disabling comments", + "properties": { + "comment_enabled": { + "type": "boolean", + "description": "Set to true to enable comments or false to disable comments" + } + }, + "required": ["comment_enabled"], + "visible": ["comment_enabled"], + "additionalProperties": false + } + }, + "required": ["path", "query"], + "visible": ["path", "query"], + "additionalProperties": false + } } -] \ No newline at end of file +] From 7055ef7b1e1369d892c23e4585e2a014db0b8620 Mon Sep 17 00:00:00 2001 From: YueTwo <1364856580@qq.com> Date: Thu, 7 Aug 2025 23:12:35 +0800 Subject: [PATCH 07/13] minor changes --- backend/aci/server/oauth2_manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/aci/server/oauth2_manager.py b/backend/aci/server/oauth2_manager.py index 3cb56abd..df176ccc 100644 --- a/backend/aci/server/oauth2_manager.py +++ b/backend/aci/server/oauth2_manager.py @@ -189,6 +189,7 @@ async def exchange_short_lived_token(self, short_lived_token: str) -> dict[str, "client_secret": self.client_secret, "access_token": short_lived_token, }, + timeout=30.0, ) response.raise_for_status() From cdc2e80d761874e473b899378cee7089b99d5da9 Mon Sep 17 00:00:00 2001 From: YueTwo <1364856580@qq.com> Date: Sat, 9 Aug 2025 11:06:48 +0800 Subject: [PATCH 08/13] address review: use hardcoded Instagram exchange token URL --- backend/aci/common/schemas/security_scheme.py | 6 ------ backend/aci/server/oauth2_manager.py | 8 ++------ backend/aci/server/routes/linked_accounts.py | 2 -- backend/aci/server/security_credentials_manager.py | 1 - backend/apps/instagram/app.json | 1 - 5 files changed, 2 insertions(+), 16 deletions(-) diff --git a/backend/aci/common/schemas/security_scheme.py b/backend/aci/common/schemas/security_scheme.py index ee77d332..74130127 100644 --- a/backend/aci/common/schemas/security_scheme.py +++ b/backend/aci/common/schemas/security_scheme.py @@ -79,12 +79,6 @@ class OAuth2Scheme(BaseModel): redirect_url: str | None = Field( default=None, min_length=1, max_length=2048, description="Redirect URL for OAuth2 callback." ) - exchange_token_url: str | None = Field( - default=None, - min_length=1, - max_length=2048, - description="URL for exchanging short-lived tokens to long-lived tokens (Instagram specific)", - ) # NOTE: need to show these fields for custom oauth2 app feature. diff --git a/backend/aci/server/oauth2_manager.py b/backend/aci/server/oauth2_manager.py index df176ccc..bbadf45a 100644 --- a/backend/aci/server/oauth2_manager.py +++ b/backend/aci/server/oauth2_manager.py @@ -24,7 +24,6 @@ def __init__( access_token_url: str, refresh_token_url: str, token_endpoint_auth_method: str | None = None, - exchange_token_url: str | None = None, ): """ Initialize the OAuth2Manager @@ -40,7 +39,6 @@ def __init__( token_endpoint_auth_method: client_secret_basic (default) | client_secret_post | none Additional options can be achieved by registering a custom auth method - exchange_token_url: The URL for exchanging short-lived tokens to long-lived tokens (Instagram specific) """ self.app_name = app_name self.client_id = client_id @@ -50,7 +48,6 @@ def __init__( self.access_token_url = access_token_url self.refresh_token_url = refresh_token_url self.token_endpoint_auth_method = token_endpoint_auth_method - self.exchange_token_url = exchange_token_url # TODO: need to close the client after use # Add an aclose() helper (or implement __aenter__/__aexit__) and make callers invoke it during shutdown. @@ -178,12 +175,11 @@ async def exchange_short_lived_token(self, short_lived_token: str) -> dict[str, if self.app_name != "INSTAGRAM": raise OAuth2Error("Token exchange is only supported for Instagram") - if not self.exchange_token_url: - raise OAuth2Error("Exchange token URL is not configured for Instagram") + exchange_token_url = "https://graph.instagram.com/access_token" try: response = await self.oauth2_client.get( - self.exchange_token_url, + exchange_token_url, params={ "grant_type": "ig_exchange_token", "client_secret": self.client_secret, diff --git a/backend/aci/server/routes/linked_accounts.py b/backend/aci/server/routes/linked_accounts.py index b059108c..8e66b015 100644 --- a/backend/aci/server/routes/linked_accounts.py +++ b/backend/aci/server/routes/linked_accounts.py @@ -360,7 +360,6 @@ async def link_oauth2_account( access_token_url=oauth2_scheme.access_token_url, refresh_token_url=oauth2_scheme.refresh_token_url, token_endpoint_auth_method=oauth2_scheme.token_endpoint_auth_method, - exchange_token_url=oauth2_scheme.exchange_token_url, ) path = request.url_for(LINKED_ACCOUNTS_OAUTH2_CALLBACK_ROUTE_NAME).path @@ -507,7 +506,6 @@ async def linked_accounts_oauth2_callback( access_token_url=oauth2_scheme.access_token_url, refresh_token_url=oauth2_scheme.refresh_token_url, token_endpoint_auth_method=oauth2_scheme.token_endpoint_auth_method, - exchange_token_url=oauth2_scheme.exchange_token_url, ) token_response = await oauth2_manager.fetch_token( diff --git a/backend/aci/server/security_credentials_manager.py b/backend/aci/server/security_credentials_manager.py index 2b2592ab..da0baf94 100644 --- a/backend/aci/server/security_credentials_manager.py +++ b/backend/aci/server/security_credentials_manager.py @@ -167,7 +167,6 @@ async def _refresh_oauth2_access_token( scope=oauth2_scheme_credentials.scope, authorize_url=oauth2_scheme.authorize_url, access_token_url=oauth2_scheme.access_token_url, - exchange_token_url=oauth2_scheme.exchange_token_url, refresh_token_url=oauth2_scheme.refresh_token_url, token_endpoint_auth_method=oauth2_scheme.token_endpoint_auth_method, ) diff --git a/backend/apps/instagram/app.json b/backend/apps/instagram/app.json index 4b89df83..562becb2 100644 --- a/backend/apps/instagram/app.json +++ b/backend/apps/instagram/app.json @@ -15,7 +15,6 @@ "scope": "instagram_business_basic instagram_business_content_publish instagram_business_manage_messages instagram_business_manage_comments instagram_business_manage_insights", "authorize_url": "https://www.instagram.com/oauth/authorize", "access_token_url": "https://api.instagram.com/oauth/access_token", - "exchange_token_url": "https://graph.instagram.com/access_token", "refresh_token_url": "https://graph.instagram.com/refresh_access_token", "token_endpoint_auth_method": "client_secret_post" } From 63502f97ecca9cfba33018e49954ccccf020a425 Mon Sep 17 00:00:00 2001 From: YueTwo <1364856580@qq.com> Date: Tue, 12 Aug 2025 23:49:27 +0800 Subject: [PATCH 09/13] feat: add field to OAuth2 scheme for provider-specific configurations --- backend/aci/common/schemas/security_scheme.py | 4 ++++ backend/aci/server/oauth2_manager.py | 7 ++++++- backend/aci/server/routes/linked_accounts.py | 2 ++ backend/aci/server/security_credentials_manager.py | 1 + backend/apps/instagram/app.json | 5 ++++- 5 files changed, 17 insertions(+), 2 deletions(-) diff --git a/backend/aci/common/schemas/security_scheme.py b/backend/aci/common/schemas/security_scheme.py index 74130127..34f80739 100644 --- a/backend/aci/common/schemas/security_scheme.py +++ b/backend/aci/common/schemas/security_scheme.py @@ -73,6 +73,10 @@ class OAuth2Scheme(BaseModel): description="The authentication method for the OAuth2 token endpoint, e.g., 'client_secret_post' " "for some providers that require client_id/client_secret to be sent in the body of the token request, like Hubspot", ) + custom_data: dict | None = Field( + default=None, + description="Custom data for OAuth2 scheme, e.g., additional URLs or configuration parameters specific to the provider", + ) # NOTE: For now this field should not be provided when creating a new OAuth2 App (because the current server redirect URL should be used, # which is constructed dynamically). # It only makes sense for user to provide it in OAuth2SchemeOverride if they want whitelabeling. diff --git a/backend/aci/server/oauth2_manager.py b/backend/aci/server/oauth2_manager.py index bbadf45a..c6abf614 100644 --- a/backend/aci/server/oauth2_manager.py +++ b/backend/aci/server/oauth2_manager.py @@ -24,6 +24,7 @@ def __init__( access_token_url: str, refresh_token_url: str, token_endpoint_auth_method: str | None = None, + custom_data: dict | None = None, ): """ Initialize the OAuth2Manager @@ -39,6 +40,7 @@ def __init__( token_endpoint_auth_method: client_secret_basic (default) | client_secret_post | none Additional options can be achieved by registering a custom auth method + custom_data: Custom data for OAuth2 scheme, e.g., additional URLs or configuration """ self.app_name = app_name self.client_id = client_id @@ -48,6 +50,7 @@ def __init__( self.access_token_url = access_token_url self.refresh_token_url = refresh_token_url self.token_endpoint_auth_method = token_endpoint_auth_method + self.custom_data = custom_data or {} # TODO: need to close the client after use # Add an aclose() helper (or implement __aenter__/__aexit__) and make callers invoke it during shutdown. @@ -175,7 +178,9 @@ async def exchange_short_lived_token(self, short_lived_token: str) -> dict[str, if self.app_name != "INSTAGRAM": raise OAuth2Error("Token exchange is only supported for Instagram") - exchange_token_url = "https://graph.instagram.com/access_token" + exchange_token_url = self.custom_data.get( + "exchange_token_url", "https://graph.instagram.com/access_token" + ) try: response = await self.oauth2_client.get( diff --git a/backend/aci/server/routes/linked_accounts.py b/backend/aci/server/routes/linked_accounts.py index 8e66b015..334f40f6 100644 --- a/backend/aci/server/routes/linked_accounts.py +++ b/backend/aci/server/routes/linked_accounts.py @@ -360,6 +360,7 @@ async def link_oauth2_account( access_token_url=oauth2_scheme.access_token_url, refresh_token_url=oauth2_scheme.refresh_token_url, token_endpoint_auth_method=oauth2_scheme.token_endpoint_auth_method, + custom_data=oauth2_scheme.custom_data, ) path = request.url_for(LINKED_ACCOUNTS_OAUTH2_CALLBACK_ROUTE_NAME).path @@ -506,6 +507,7 @@ async def linked_accounts_oauth2_callback( access_token_url=oauth2_scheme.access_token_url, refresh_token_url=oauth2_scheme.refresh_token_url, token_endpoint_auth_method=oauth2_scheme.token_endpoint_auth_method, + custom_data=oauth2_scheme.custom_data, ) token_response = await oauth2_manager.fetch_token( diff --git a/backend/aci/server/security_credentials_manager.py b/backend/aci/server/security_credentials_manager.py index da0baf94..6631d09b 100644 --- a/backend/aci/server/security_credentials_manager.py +++ b/backend/aci/server/security_credentials_manager.py @@ -169,6 +169,7 @@ async def _refresh_oauth2_access_token( access_token_url=oauth2_scheme.access_token_url, refresh_token_url=oauth2_scheme.refresh_token_url, token_endpoint_auth_method=oauth2_scheme.token_endpoint_auth_method, + custom_data=oauth2_scheme.custom_data, ) return await oauth2_manager.refresh_token(refresh_token) diff --git a/backend/apps/instagram/app.json b/backend/apps/instagram/app.json index 562becb2..bf41b976 100644 --- a/backend/apps/instagram/app.json +++ b/backend/apps/instagram/app.json @@ -16,11 +16,14 @@ "authorize_url": "https://www.instagram.com/oauth/authorize", "access_token_url": "https://api.instagram.com/oauth/access_token", "refresh_token_url": "https://graph.instagram.com/refresh_access_token", + "custom_data": { + "exchange_token_url": "https://graph.instagram.com/access_token" + }, "token_endpoint_auth_method": "client_secret_post" } }, "default_security_credentials_by_scheme": {}, - "categories": ["Social Media", "User Data"], + "categories": ["Social Media"], "visibility": "public", "active": true } From 7ae8c43816a3b7f694c978c893ba475a4a97086b Mon Sep 17 00:00:00 2001 From: YueTwo <1364856580@qq.com> Date: Tue, 12 Aug 2025 23:52:58 +0800 Subject: [PATCH 10/13] address review: remove the special handling of instgram when parsing --- backend/aci/server/routes/linked_accounts.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/backend/aci/server/routes/linked_accounts.py b/backend/aci/server/routes/linked_accounts.py index 334f40f6..b3f7eb01 100644 --- a/backend/aci/server/routes/linked_accounts.py +++ b/backend/aci/server/routes/linked_accounts.py @@ -434,11 +434,6 @@ async def linked_accounts_oauth2_callback( # check for state state_jwt = request.query_params.get("state") - # Special handling for Instagram: remove #_ suffix if present - if state_jwt and state_jwt.endswith("#_"): - state_jwt = state_jwt[:-2] # Remove the last 2 characters (#_) - logger.info("Removed Instagram #_ suffix from state") - if not state_jwt: logger.error( "OAuth2 account linking callback received, missing state", From 9efd4392ca03ddf3a5de10d945f3d4815aa99aa3 Mon Sep 17 00:00:00 2001 From: YueTwo <1364856580@qq.com> Date: Wed, 13 Aug 2025 00:03:50 +0800 Subject: [PATCH 11/13] address review: move Instagram token exchange logic to fetch_token method --- backend/aci/server/oauth2_manager.py | 118 ++++++++++++++------------- 1 file changed, 60 insertions(+), 58 deletions(-) diff --git a/backend/aci/server/oauth2_manager.py b/backend/aci/server/oauth2_manager.py index c6abf614..495b698d 100644 --- a/backend/aci/server/oauth2_manager.py +++ b/backend/aci/server/oauth2_manager.py @@ -114,6 +114,48 @@ async def create_authorization_url( return str(authorization_url) + async def exchange_short_lived_token(self, short_lived_token: str) -> dict[str, Any]: + """ + Exchange short-lived access token for long-lived access token. + This is specific to Instagram's API requirements. + + Args: + short_lived_token: The short-lived access token from the initial OAuth flow + + Returns: + Token response dictionary with long-lived access token + """ + if self.app_name != "INSTAGRAM": + raise OAuth2Error("Token exchange is only supported for Instagram") + + exchange_token_url = self.custom_data.get( + "exchange_token_url", "https://graph.instagram.com/access_token" + ) + + try: + response = await self.oauth2_client.get( + exchange_token_url, + params={ + "grant_type": "ig_exchange_token", + "client_secret": self.client_secret, + "access_token": short_lived_token, + }, + timeout=30.0, + ) + response.raise_for_status() + + token_data = cast(dict[str, Any], response.json()) + logger.info( + f"Successfully exchanged short-lived token for long-lived token, app_name={self.app_name}" + ) + return token_data + + except Exception as e: + logger.error( + f"Failed to exchange short-lived token, app_name={self.app_name}, error={e}" + ) + raise OAuth2Error("Failed to exchange short-lived token for long-lived token") from e + # TODO: some app may not support "code_verifier"? async def fetch_token( self, @@ -143,6 +185,24 @@ async def fetch_token( scope=self.scope, ), ) + # handle Instagram's special case - exchange short-lived token for long-lived token + if self.app_name == "INSTAGRAM": + if "access_token" in token: + short_lived_token = token["access_token"] + logger.info( + f"Exchanging short-lived token for long-lived token, app_name={self.app_name}" + ) + long_lived_token_response = await self.exchange_short_lived_token( + short_lived_token + ) + # Update data with long-lived token response: add expires_in and token_type, update access_token + token.update(long_lived_token_response) + else: + logger.error( + f"Missing access_token in Instagram OAuth response, app={self.app_name}" + ) + raise OAuth2Error("Missing access_token in Instagram OAuth response") + # return the token response with long-lived access token return token except Exception as e: logger.error(f"Failed to fetch access token, app_name={self.app_name}, error={e}") @@ -164,48 +224,6 @@ async def refresh_token( logger.error(f"Failed to refresh access token, app_name={self.app_name}, error={e}") raise OAuth2Error("Failed to refresh access token") from e - async def exchange_short_lived_token(self, short_lived_token: str) -> dict[str, Any]: - """ - Exchange short-lived access token for long-lived access token. - This is specific to Instagram's API requirements. - - Args: - short_lived_token: The short-lived access token from the initial OAuth flow - - Returns: - Token response dictionary with long-lived access token - """ - if self.app_name != "INSTAGRAM": - raise OAuth2Error("Token exchange is only supported for Instagram") - - exchange_token_url = self.custom_data.get( - "exchange_token_url", "https://graph.instagram.com/access_token" - ) - - try: - response = await self.oauth2_client.get( - exchange_token_url, - params={ - "grant_type": "ig_exchange_token", - "client_secret": self.client_secret, - "access_token": short_lived_token, - }, - timeout=30.0, - ) - response.raise_for_status() - - token_data = cast(dict[str, Any], response.json()) - logger.info( - f"Successfully exchanged short-lived token for long-lived token, app_name={self.app_name}" - ) - return token_data - - except Exception as e: - logger.error( - f"Failed to exchange short-lived token, app_name={self.app_name}, error={e}" - ) - raise OAuth2Error("Failed to exchange short-lived token for long-lived token") from e - async def parse_fetch_token_response(self, token: dict) -> OAuth2SchemeCredentials: """ Parse OAuth2SchemeCredentials from token response with app-specific handling. @@ -226,22 +244,6 @@ async def parse_fetch_token_response(self, token: dict) -> OAuth2SchemeCredentia logger.error(f"Missing authed_user in Slack OAuth response, app={self.app_name}") raise OAuth2Error("Missing access_token in Slack OAuth response") - # handle Instagram's special case - exchange short-lived token for long-lived token - if self.app_name == "INSTAGRAM": - if "access_token" in data: - short_lived_token = data["access_token"] - logger.info( - f"Exchanging short-lived token for long-lived token, app_name={self.app_name}" - ) - long_lived_token_response = await self.exchange_short_lived_token(short_lived_token) - # Update data with long-lived token response: add expires_in and token_type, update access_token - data.update(long_lived_token_response) - else: - logger.error( - f"Missing access_token in Instagram OAuth response, app={self.app_name}" - ) - raise OAuth2Error("Missing access_token in Instagram OAuth response") - if "access_token" not in data: logger.error(f"Missing access_token in OAuth response, app={self.app_name}") raise OAuth2Error("Missing access_token in OAuth response") From 8b14aaa990990cb22fbd86da22c53a2bf16b50c4 Mon Sep 17 00:00:00 2001 From: YueTwo <1364856580@qq.com> Date: Wed, 13 Aug 2025 00:56:17 +0800 Subject: [PATCH 12/13] feat: implement Instagram-specific OAuth2 refresh token logic --- backend/aci/server/oauth2_manager.py | 32 +++++++++++++++---- .../server/security_credentials_manager.py | 11 +++++-- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/backend/aci/server/oauth2_manager.py b/backend/aci/server/oauth2_manager.py index 495b698d..7d9719ba 100644 --- a/backend/aci/server/oauth2_manager.py +++ b/backend/aci/server/oauth2_manager.py @@ -202,6 +202,7 @@ async def fetch_token( f"Missing access_token in Instagram OAuth response, app={self.app_name}" ) raise OAuth2Error("Missing access_token in Instagram OAuth response") + # return the token response with long-lived access token return token except Exception as e: @@ -210,15 +211,28 @@ async def fetch_token( async def refresh_token( self, + access_token: str, refresh_token: str, ) -> dict[str, Any]: try: - token = cast( - dict[str, Any], - await self.oauth2_client.refresh_token( - self.refresh_token_url, refresh_token=refresh_token - ), - ) + if self.app_name == "INSTAGRAM": + response = await self.oauth2_client.get( + self.refresh_token_url, + params={ + "grant_type": "ig_refresh_token", + "access_token": access_token, + }, + timeout=30.0, + ) + response.raise_for_status() + token = cast(dict[str, Any], response.json()) + else: + token = cast( + dict[str, Any], + await self.oauth2_client.refresh_token( + self.refresh_token_url, refresh_token=refresh_token + ), + ) return token except Exception as e: logger.error(f"Failed to refresh access token, app_name={self.app_name}, error={e}") @@ -253,7 +267,11 @@ async def parse_fetch_token_response(self, token: dict) -> OAuth2SchemeCredentia if "expires_at" in data: expires_at = int(data["expires_at"]) elif "expires_in" in data: - expires_at = int(time.time()) + int(data["expires_in"]) + if self.app_name == "INSTAGRAM": + # Reduce expiration time by 1 day (86400 seconds) for safety margin + expires_at = int(time.time()) + max(0, int(data["expires_in"]) - 86400) + else: + expires_at = int(time.time()) + int(data["expires_in"]) # TODO: if scope is present, check if it matches the scope in the App Configuration diff --git a/backend/aci/server/security_credentials_manager.py b/backend/aci/server/security_credentials_manager.py index 6631d09b..0301a8b8 100644 --- a/backend/aci/server/security_credentials_manager.py +++ b/backend/aci/server/security_credentials_manager.py @@ -97,6 +97,7 @@ async def _get_oauth2_credentials( linked_account.security_credentials ) if _access_token_is_expired(oauth2_scheme_credentials): + # Instagram's access token only could be refreshed with a valid access token, so we need to re-authorize if app.name == "INSTAGRAM": logger.error( f"Access token expired, please re-authorize, linked_account_id={linked_account.id}, " @@ -120,7 +121,11 @@ async def _get_oauth2_credentials( if "expires_at" in token_response: expires_at = int(token_response["expires_at"]) elif "expires_in" in token_response: - expires_at = int(time.time()) + int(token_response["expires_in"]) + if app.name == "INSTAGRAM": + # Reduce expiration time by 1 day (86400 seconds) for safety margin + expires_at = int(time.time()) + max(0, int(token_response["expires_in"]) - 86400) + else: + expires_at = int(time.time()) + int(token_response["expires_in"]) if not token_response.get("access_token") or not expires_at: logger.error( @@ -155,6 +160,8 @@ async def _refresh_oauth2_access_token( app_name: str, oauth2_scheme: OAuth2Scheme, oauth2_scheme_credentials: OAuth2SchemeCredentials ) -> dict: refresh_token = oauth2_scheme_credentials.refresh_token + access_token = oauth2_scheme_credentials.access_token + if not refresh_token: raise OAuth2Error("no refresh token found") @@ -172,7 +179,7 @@ async def _refresh_oauth2_access_token( custom_data=oauth2_scheme.custom_data, ) - return await oauth2_manager.refresh_token(refresh_token) + return await oauth2_manager.refresh_token(refresh_token, access_token) def _get_api_key_credentials( From 856eea6cee779e664801988376104e4d79d59cf8 Mon Sep 17 00:00:00 2001 From: YueTwo <1364856580@qq.com> Date: Wed, 13 Aug 2025 11:18:26 +0800 Subject: [PATCH 13/13] fix: improve Instagram OAuth2 token refresh flow --- backend/aci/server/oauth2_manager.py | 25 ++++++++++++++++++- .../server/security_credentials_manager.py | 24 ++++++++++-------- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/backend/aci/server/oauth2_manager.py b/backend/aci/server/oauth2_manager.py index 7d9719ba..bd9300b8 100644 --- a/backend/aci/server/oauth2_manager.py +++ b/backend/aci/server/oauth2_manager.py @@ -3,11 +3,13 @@ import time from typing import Any, cast +import httpx from authlib.integrations.httpx_client import AsyncOAuth2Client from aci.common.exceptions import OAuth2Error from aci.common.logging_setup import get_logger from aci.common.schemas.security_scheme import OAuth2SchemeCredentials +from aci.server import config UNICODE_ASCII_CHARACTER_SET = string.ascii_letters + string.digits logger = get_logger(__name__) @@ -214,6 +216,16 @@ async def refresh_token( access_token: str, refresh_token: str, ) -> dict[str, Any]: + """ + Refresh OAuth2 access token + + Args: + access_token: The current access token used for Instagram refresh + refresh_token: The refresh token used for standard OAuth2 refresh + + Returns: + Token response dictionary + """ try: if self.app_name == "INSTAGRAM": response = await self.oauth2_client.get( @@ -233,11 +245,22 @@ async def refresh_token( self.refresh_token_url, refresh_token=refresh_token ), ) - return token + + except httpx.HTTPStatusError as e: + logger.error(f"Failed to refresh access token, app_name={self.app_name}, error={e}") + if self.app_name == "INSTAGRAM" and e.response.status_code == 400: + raise OAuth2Error( + f"Access token expired. Please re-authorize at: " + f"{config.DEV_PORTAL_URL}/appconfigs/{self.app_name}" + ) from e + raise OAuth2Error("Failed to refresh access token") from e + except Exception as e: logger.error(f"Failed to refresh access token, app_name={self.app_name}, error={e}") raise OAuth2Error("Failed to refresh access token") from e + return token + async def parse_fetch_token_response(self, token: dict) -> OAuth2SchemeCredentials: """ Parse OAuth2SchemeCredentials from token response with app-specific handling. diff --git a/backend/aci/server/security_credentials_manager.py b/backend/aci/server/security_credentials_manager.py index 0301a8b8..c4320ca0 100644 --- a/backend/aci/server/security_credentials_manager.py +++ b/backend/aci/server/security_credentials_manager.py @@ -97,17 +97,20 @@ async def _get_oauth2_credentials( linked_account.security_credentials ) if _access_token_is_expired(oauth2_scheme_credentials): - # Instagram's access token only could be refreshed with a valid access token, so we need to re-authorize + # Instagram's access token only could be refreshed with a valid access token, so we need to re-authorize if invalid if app.name == "INSTAGRAM": - logger.error( - f"Access token expired, please re-authorize, linked_account_id={linked_account.id}, " - f"security_scheme={linked_account.security_scheme}, app={app.name}" - ) - # NOTE: this error message could be used by the frontend to guide the user to re-authorize - raise OAuth2Error( - f"Access token expired. Please re-authorize at: " - f"{config.DEV_PORTAL_URL}/appconfigs/{app.name}" - ) + # Since _access_token_is_expired returned True, expires_at is guaranteed to be not None + actual_expires_at = oauth2_scheme_credentials.expires_at + 86400 # type: ignore[operator] + if int(time.time()) > actual_expires_at: + logger.error( + f"Access token expired, please re-authorize, linked_account_id={linked_account.id}, " + f"security_scheme={linked_account.security_scheme}, app={app.name}" + ) + # NOTE: this error message could be used by the frontend to guide the user to re-authorize + raise OAuth2Error( + f"Access token expired. Please re-authorize at: " + f"{config.DEV_PORTAL_URL}/appconfigs/{app.name}" + ) logger.warning( f"Access token expired, trying to refresh linked_account_id={linked_account.id}, " @@ -116,6 +119,7 @@ async def _get_oauth2_credentials( token_response = await _refresh_oauth2_access_token( app.name, oauth2_scheme, oauth2_scheme_credentials ) + # TODO: refactor parsing to _refresh_oauth2_access_token expires_at: int | None = None if "expires_at" in token_response: