Repository: aipotheosis-labs/aci
Pull request #350: Feat: custom oauth2 client
Author: login: hanyixxx
Created: 2025-05-12T12:06:12Z | Merged: 2025-05-12T17:02:55Z

### 🏷️ Ticket

https://www.notion.so/Custom-OAuth2-Client-1ee8378d6a478062bdf4cbd54acfd9fa

### 📝 Description

- Allow user to provide custom oauth2 app
- bind client id, client secret, scope to linked account 

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Added support for overriding OAuth2 client credentials (client_id, client_secret) in app configurations.
  - App listing and details now display supported security schemes and user-overridable fields.
  - Security-sensitive fields (e.g., client_secret) are masked in API responses.
  - Added client_id field to OAuth2 linking state for improved OAuth2 flow integrity.

- **Improvements**
  - Enhanced OAuth2 credential encryption and decryption for improved security.
  - Enforced stricter validation for OAuth2 flows, including client_id consistency checks.
  - Centralized retrieval of OAuth2 scheme configuration with overrides applied.
  - Extra fields are now forbidden in several models for stricter data validation.
  - OAuth2 token parsing and management updated to include client credentials and scope.
  - OAuth2 token refresh logic updated to use linked account credentials exclusively.
  - Updated OAuth2 manager to handle token parsing with app-specific logic and scope management.

- **Bug Fixes**
  - Fixed issues with updating and displaying security scheme overrides in app configurations.
  - Removed support for updating certain security scheme fields to prevent inconsistent states.

- **Tests**
  - Added tests for creating app configurations with OAuth2 overrides.
  - Updated and removed tests to align with new OAuth2 credential handling logic.
  - Enhanced OAuth2-related tests to cover client credential overrides and flow consistency.
  - Parameterized OAuth2 linking and execution tests to cover custom OAuth2 app configurations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/backend/aci/common/db/crud/app_configurations.py b/backend/aci/common/db/crud/app_configurations.py
index 21265c6a5..2cac206b9 100644
--- a/backend/aci/common/db/crud/app_configurations.py
+++ b/backend/aci/common/db/crud/app_configurations.py
@@ -28,7 +28,9 @@ def create_app_configuration(
         project_id=project_id,
         app_id=app_id,
         security_scheme=app_configuration_create.security_scheme,
-        security_scheme_overrides=app_configuration_create.security_scheme_overrides,
+        security_scheme_overrides=app_configuration_create.security_scheme_overrides.model_dump(
+            exclude_none=True
+        ),  # type: ignore
         enabled=True,
         all_functions_enabled=app_configuration_create.all_functions_enabled,
         enabled_functions=app_configuration_create.enabled_functions,
@@ -50,10 +52,6 @@ def update_app_configuration(
     If a field is None, it will not be changed.
     """
     # TODO: a better way to do update?
-    if update.security_scheme is not None:
-        app_configuration.security_scheme = update.security_scheme
-    if update.security_scheme_overrides is not None:
-        app_configuration.security_scheme_overrides = update.security_scheme_overrides
     if update.enabled is not None:
         app_configuration.enabled = update.enabled
     if update.all_functions_enabled is not None:
diff --git a/backend/aci/common/db/custom_sql_types.py b/backend/aci/common/db/custom_sql_types.py
index 8f03f2c5d..ee662de20 100644
--- a/backend/aci/common/db/custom_sql_types.py
+++ b/backend/aci/common/db/custom_sql_types.py
@@ -100,6 +100,10 @@ def process_bind_param(self, value: dict | None, dialect: Dialect) -> dict | Non
 
             # OAuth2SchemeCredentials
             elif "access_token" in encrypted_value:
+                client_secret = encrypted_value.get("client_secret")
+                if isinstance(client_secret, str):
+                    encrypted_value["client_secret"] = _encrypt_value(client_secret)
+
                 access_token = encrypted_value.get("access_token")
                 if isinstance(access_token, str):
                     encrypted_value["access_token"] = _encrypt_value(access_token)
@@ -130,6 +134,10 @@ def process_result_value(self, value: dict | None, dialect: Dialect) -> dict | N
 
             # OAuth2SchemeCredentials
             elif "access_token" in decrypted_value:
+                client_secret_b64 = decrypted_value.get("client_secret")
+                if isinstance(client_secret_b64, str):
+                    decrypted_value["client_secret"] = _decrypt_value(client_secret_b64)
+
                 access_token_b64 = decrypted_value.get("access_token")
                 if isinstance(access_token_b64, str):
                     decrypted_value["access_token"] = _decrypt_value(access_token_b64)
diff --git a/backend/aci/common/schemas/app.py b/backend/aci/common/schemas/app.py
index 90ad66a98..3ca75f1c9 100644
--- a/backend/aci/common/schemas/app.py
+++ b/backend/aci/common/schemas/app.py
@@ -1,6 +1,5 @@
 import re
 from datetime import datetime
-from typing import Any
 from uuid import UUID
 
 from pydantic import BaseModel, ConfigDict, Field, field_validator
@@ -14,10 +13,11 @@
     NoAuthSchemeCredentials,
     OAuth2Scheme,
     OAuth2SchemeCredentials,
+    SecuritySchemesPublic,
 )
 
 
-class AppUpsert(BaseModel):
+class AppUpsert(BaseModel, extra="forbid"):
     name: str
     display_name: str
     provider: str
@@ -154,16 +154,11 @@ class AppDetails(BaseModel):
     # the security_schemes field in the db model is a dict of supported security schemes and their config,
     # which contains sensitive information like OAuth2 client secret.
     security_schemes: list[SecurityScheme]
+    # TODO: added supported_security_schemes instead of chaning security_schemes for backward compatibility
+    # consider merging the two fields in the future
+    supported_security_schemes: SecuritySchemesPublic
+
     functions: list[FunctionDetails]
 
     created_at: datetime
     updated_at: datetime
-
-    model_config = ConfigDict(from_attributes=True)
-
-    @field_validator("security_schemes", mode="before")
-    @classmethod
-    def extract_supported_security_schemes(cls, v: Any) -> Any:
-        if isinstance(v, dict):
-            return [SecurityScheme(k) for k in v.keys()]
-        return v
diff --git a/backend/aci/common/schemas/app_configurations.py b/backend/aci/common/schemas/app_configurations.py
index 4fa5f7cf0..73377c1da 100644
--- a/backend/aci/common/schemas/app_configurations.py
+++ b/backend/aci/common/schemas/app_configurations.py
@@ -4,6 +4,7 @@
 from pydantic import BaseModel, ConfigDict, Field, model_validator
 
 from aci.common.enums import SecurityScheme
+from aci.common.schemas.security_scheme import SecuritySchemeOverrides
 
 
 class AppConfigurationPublic(BaseModel):
@@ -11,7 +12,7 @@ class AppConfigurationPublic(BaseModel):
     project_id: UUID
     app_name: str
     security_scheme: SecurityScheme
-    security_scheme_overrides: dict
+    security_scheme_overrides: SecuritySchemeOverrides
     enabled: bool
     all_functions_enabled: bool
     enabled_functions: list[str]
@@ -20,6 +21,13 @@ class AppConfigurationPublic(BaseModel):
 
     model_config = ConfigDict(from_attributes=True)
 
+    # scrub the client_secret in SecuritySchemeOverrides if present
+    @model_validator(mode="after")
+    def scrub_client_secret(self) -> "AppConfigurationPublic":
+        if self.security_scheme_overrides.oauth2:
+            self.security_scheme_overrides.oauth2.client_secret = "******"
+        return self
+
 
 class AppConfigurationCreate(BaseModel):
     """Create a new app configuration
@@ -30,8 +38,10 @@ class AppConfigurationCreate(BaseModel):
 
     app_name: str
     security_scheme: SecurityScheme
-    # TODO: add typing/class to security_scheme_overrides
-    security_scheme_overrides: dict = Field(default_factory=dict)
+    # NOTE: default_factory needs to be SecuritySchemeOverrides instead of dict
+    security_scheme_overrides: SecuritySchemeOverrides = Field(
+        default_factory=SecuritySchemeOverrides
+    )
     all_functions_enabled: bool = Field(default=True)
     enabled_functions: list[str] = Field(default_factory=list)
 
@@ -45,10 +55,18 @@ def check_all_functions_enabled(self) -> "AppConfigurationCreate":
             )
         return self
 
+    @model_validator(mode="after")
+    def check_security_scheme_matches_override(self) -> "AppConfigurationCreate":
+        if self.security_scheme_overrides.oauth2:
+            if self.security_scheme != SecurityScheme.OAUTH2:
+                raise ValueError(
+                    f"unsupported security_scheme_overrides provided for the security scheme {self.security_scheme}"
+                )
+        return self
+
 
 class AppConfigurationUpdate(BaseModel):
-    security_scheme: SecurityScheme | None = None
-    security_scheme_overrides: dict | None = None
+    # TODO: we currently don't support changing security_scheme and security_scheme_overrides (e.g., client_id, client_secret)
     enabled: bool | None = None
     all_functions_enabled: bool | None = None
     enabled_functions: list[str] | None = None
diff --git a/backend/aci/common/schemas/linked_accounts.py b/backend/aci/common/schemas/linked_accounts.py
index b96819b49..8e8f66b02 100644
--- a/backend/aci/common/schemas/linked_accounts.py
+++ b/backend/aci/common/schemas/linked_accounts.py
@@ -35,6 +35,10 @@ class LinkedAccountOAuth2CreateState(BaseModel):
     project_id: UUID
     app_name: str
     linked_account_owner_id: str = Field(..., max_length=MAX_STRING_LENGTH)
+    # The OAuth2 client ID used at the start of the OAuth2 flow. We need to store this and verify in the callback
+    # because we support custom client ID, and user may have changed the client ID after the flow starts.
+    # (e.g., delete and recreate app configuration. Even though this is rare.)
+    client_id: str
     redirect_uri: str
     code_verifier: str
     after_oauth2_link_redirect_url: str | None = None
diff --git a/backend/aci/common/schemas/plans.py b/backend/aci/common/schemas/plans.py
index 302252f27..2f27517a4 100644
--- a/backend/aci/common/schemas/plans.py
+++ b/backend/aci/common/schemas/plans.py
@@ -10,10 +10,9 @@ class PlanFeatures(BaseModel):
     log_retention_days: int
 
 
-class PlanUpdate(BaseModel):
+class PlanUpdate(BaseModel, extra="forbid"):
     stripe_product_id: str | None = Field(None)
     stripe_monthly_price_id: str | None = Field(None)
     stripe_yearly_price_id: str | None = Field(None)
     features: PlanFeatures | None = Field(None)
     is_public: bool | None = Field(None)
-    model_config = {"extra": "forbid"}
diff --git a/backend/aci/common/schemas/security_scheme.py b/backend/aci/common/schemas/security_scheme.py
index 4fb5cb69d..1136d1c27 100644
--- a/backend/aci/common/schemas/security_scheme.py
+++ b/backend/aci/common/schemas/security_scheme.py
@@ -20,6 +20,11 @@ class APIKeyScheme(BaseModel):
     )
 
 
+# NOTE: not necessary but for the sake of consistency and future use
+class APIKeySchemePublic(BaseModel):
+    pass
+
+
 class OAuth2Scheme(BaseModel):
     # TODO: consider providing a default value for in_, name, prefix as they are usually the same for most apps
     location: HttpLocation = Field(
@@ -36,10 +41,12 @@ class OAuth2Scheme(BaseModel):
     )
     client_id: str = Field(
         ...,
+        min_length=1,
         description="The client ID of the OAuth2 client (provided by ACI) used for the app",
     )
     client_secret: str = Field(
         ...,
+        min_length=1,
         description="The client secret of the OAuth2 client (provided by ACI) used for the app",
     )
     scope: str = Field(
@@ -66,6 +73,34 @@ class OAuth2Scheme(BaseModel):
     )
 
 
+# NOTE: need to show these fields for custom oauth2 app feature.
+class OAuth2SchemePublic(BaseModel):
+    # user needs to know the scope to set in their own oauth2 app.
+    scope: str = Field(
+        ...,
+        description="Space separated scopes of the OAuth2 client used for the app, "
+        "e.g., 'openid email profile https://www.googleapis.com/auth/calendar'",
+    )
+
+
+class OAuth2SchemeOverride(BaseModel):
+    """
+    Fields that are allowed to be overridden by the user.
+    """
+
+    client_id: str = Field(
+        ...,
+        min_length=1,
+        description="The client ID of the OAuth2 client used for the app",
+    )
+    client_secret: str = Field(
+        ...,
+        min_length=1,
+        description="The client secret of the OAuth2 client used for the app",
+    )
+    # TODO: will support "scope" and "redirect_uri" in the future, both will be optional
+
+
 class NoAuthScheme(BaseModel, extra="forbid"):
     """
     model for security scheme that has no authentication.
@@ -76,6 +111,11 @@ class NoAuthScheme(BaseModel, extra="forbid"):
     pass
 
 
+# NOTE: not necessary but for the sake of consistency and future use
+class NoAuthSchemePublic(BaseModel):
+    pass
+
+
 class APIKeySchemeCredentials(BaseModel):
     """
     Credentials for API key scheme
@@ -92,6 +132,14 @@ class APIKeySchemeCredentials(BaseModel):
 class OAuth2SchemeCredentials(BaseModel):
     """Credentials for OAuth2 scheme"""
 
+    # We need to store client_id and client_secret as part of the credentials because oauth2 client can
+    # change any time for a particular App Configuration. (e.g., user provided custom oauth2 app, or we changed
+    # our default oauth2 app). In which case, we still want the existing linked accounts to work. (refresh token)
+    client_id: str
+    client_secret: str
+    # Technically we don't need to store scope, but can be useful if we know which accounts are not up to date
+    # with current App/ App Configuration's oauth2 scopes and to let user know.
+    scope: str
     access_token: str
     token_type: str | None = None
     expires_at: int | None = None
@@ -110,5 +158,24 @@ class NoAuthSchemeCredentials(BaseModel, extra="forbid"):
     pass
 
 
+class SecuritySchemesPublic(BaseModel):
+    """
+    scheme_type -> scheme with sensitive information removed
+    """
+
+    api_key: APIKeySchemePublic | None = None
+    oauth2: OAuth2SchemePublic | None = None
+    no_auth: NoAuthSchemePublic | None = None
+
+
+class SecuritySchemeOverrides(BaseModel, extra="forbid"):
+    """
+    Allowed security scheme overrides
+    NOTE: for now we only support oauth2 overrides (because nothing is overridable for api_key and no_auth)
+    """
+
+    oauth2: OAuth2SchemeOverride | None = None
+
+
 TScheme = TypeVar("TScheme", APIKeyScheme, OAuth2Scheme, NoAuthScheme)
 TCred = TypeVar("TCred", APIKeySchemeCredentials, OAuth2SchemeCredentials, NoAuthSchemeCredentials)
diff --git a/backend/aci/common/tests/crud/test_linked_accounts.py b/backend/aci/common/tests/crud/test_linked_accounts.py
index 3923947bf..7341c470c 100644
--- a/backend/aci/common/tests/crud/test_linked_accounts.py
+++ b/backend/aci/common/tests/crud/test_linked_accounts.py
@@ -17,7 +17,13 @@
     [
         (
             SecurityScheme.OAUTH2,
-            OAuth2SchemeCredentials(access_token="test", refresh_token="test"),
+            OAuth2SchemeCredentials(
+                client_id="test",
+                client_secret="test",
+                scope="test_scope_1 test_scope_2",
+                access_token="test",
+                refresh_token="test",
+            ),
             False,
         ),
         (
@@ -27,16 +33,8 @@
         ),
         (SecurityScheme.NO_AUTH, NoAuthSchemeCredentials(), False),
         (SecurityScheme.OAUTH2, APIKeySchemeCredentials(secret_key="test"), True),
-        (
-            SecurityScheme.API_KEY,
-            OAuth2SchemeCredentials(access_token="test", refresh_token="test"),
-            True,
-        ),
-        (
-            SecurityScheme.NO_AUTH,
-            OAuth2SchemeCredentials(access_token="test", refresh_token="test"),
-            True,
-        ),
+        (SecurityScheme.API_KEY, NoAuthSchemeCredentials(), True),
+        (SecurityScheme.NO_AUTH, APIKeySchemeCredentials(secret_key="test"), True),
     ],
 )
 def test_update_linked_account_credentials(
diff --git a/backend/aci/server/oauth2_manager.py b/backend/aci/server/oauth2_manager.py
index d07b4a694..12551425d 100644
--- a/backend/aci/server/oauth2_manager.py
+++ b/backend/aci/server/oauth2_manager.py
@@ -41,17 +41,21 @@ def __init__(
                 Additional options can be achieved by registering a custom auth method
         """
         self.app_name = app_name
+        self.client_id = client_id
+        self.client_secret = client_secret
+        self.scope = scope
         self.authorize_url = authorize_url
         self.access_token_url = access_token_url
         self.refresh_token_url = refresh_token_url
+        self.token_endpoint_auth_method = token_endpoint_auth_method
 
         # TODO: need to close the client after use
         # Add an aclose() helper (or implement __aenter__/__aexit__) and make callers invoke it during shutdown.
+        # NOTE: don't pass in scope here, otherwise it will be sent during refresh token request which is not needed
         self.oauth2_client = AsyncOAuth2Client(
             client_id=client_id,
             client_secret=client_secret,
             token_endpoint_auth_method=token_endpoint_auth_method,
-            scope=scope,
             code_challenge_method="S256",  # only S256 is supported
             # TODO: use update_token callback to save tokens to the database
             update_token=None,
@@ -101,6 +105,7 @@ async def create_authorization_url(
             code_verifier=code_verifier,
             access_type=access_type,
             prompt=prompt,
+            scope=self.scope,
             **app_specific_params,
         )
 
@@ -132,6 +137,7 @@ async def fetch_token(
                     redirect_uri=redirect_uri,
                     code=code,
                     code_verifier=code_verifier,
+                    scope=self.scope,
                 ),
             )
             return token
@@ -161,6 +167,56 @@ async def refresh_token(
             )
             raise OAuth2Error("failed to refresh access token") from e
 
+    def parse_fetch_token_response(self, token: dict) -> OAuth2SchemeCredentials:
+        """
+        Parse OAuth2SchemeCredentials from token response with app-specific handling.
+
+        Args:
+            token: OAuth2 token response from provider
+
+        Returns:
+            OAuth2SchemeCredentials with appropriate fields set
+        """
+        data = token
+
+        # handle Slack's special case
+        if self.app_name == "SLACK":
+            if "authed_user" in data:
+                data = cast(dict, data["authed_user"])
+            else:
+                logger.error(
+                    "Missing authed_user in Slack OAuth response",
+                    extra={"token": token, "app": self.app_name},
+                )
+                raise OAuth2Error("Missing access_token in Slack OAuth response")
+
+        if "access_token" not in data:
+            logger.error(
+                "Missing access_token in OAuth response",
+                extra={"token": token, "app": self.app_name},
+            )
+            raise OAuth2Error("Missing access_token in OAuth response")
+
+        # some apps have long live access token so expiration time may not be present
+        expires_at: int | None = None
+        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"])
+
+        # TODO: if scope is present, check if it matches the scope in the App Configuration
+
+        return OAuth2SchemeCredentials(
+            client_id=self.client_id,
+            client_secret=self.client_secret,
+            scope=self.scope,
+            access_token=data["access_token"],
+            token_type=data.get("token_type"),
+            expires_at=expires_at,
+            refresh_token=data.get("refresh_token"),
+            raw_token_response=token,
+        )
+
     @staticmethod
     def generate_code_verifier(length: int = 48) -> str:
         """
@@ -203,52 +259,3 @@ def rewrite_oauth2_authorization_url(app_name: str, authorization_url: str) -> s
                 return new_url
 
         return authorization_url
-
-    @staticmethod
-    def parse_oauth2_security_credentials(
-        app_name: str, token_response: dict
-    ) -> OAuth2SchemeCredentials:
-        """
-        Parse OAuth2SchemeCredentials from token response with app-specific handling.
-
-        Args:
-            app_name: Name of the app/provider (e.g., "SLACK", "GOOGLE")
-            token_response: OAuth2 token response from provider
-
-        Returns:
-            OAuth2SchemeCredentials with appropriate fields set
-        """
-        data = token_response
-
-        # handle Slack's special case
-        if app_name == "SLACK":
-            if "authed_user" in data:
-                data = cast(dict, data["authed_user"])
-            else:
-                logger.error(
-                    "Missing authed_user in Slack OAuth response",
-                    extra={"token_response": token_response, "app": app_name},
-                )
-                raise OAuth2Error("Missing access_token in Slack OAuth response")
-
-        if "access_token" not in data:
-            logger.error(
-                "Missing access_token in OAuth response",
-                extra={"token_response": token_response, "app": app_name},
-            )
-            raise OAuth2Error("Missing access_token in OAuth response")
-
-        # some apps have long live access token so expiration time may not be present
-        expires_at: int | None = None
-        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"])
-
-        return OAuth2SchemeCredentials(
-            access_token=data["access_token"],
-            token_type=data.get("token_type"),
-            expires_at=expires_at,
-            refresh_token=data.get("refresh_token"),
-            raw_token_response=token_response,
-        )
diff --git a/backend/aci/server/routes/apps.py b/backend/aci/server/routes/apps.py
index 4239b3c1e..a7981a620 100644
--- a/backend/aci/server/routes/apps.py
+++ b/backend/aci/server/routes/apps.py
@@ -4,7 +4,6 @@
 from openai import OpenAI
 
 from aci.common.db import crud
-from aci.common.db.sql_models import App
 from aci.common.embeddings import generate_embedding
 from aci.common.enums import Visibility
 from aci.common.exceptions import AppNotFound
@@ -16,6 +15,7 @@
     AppsSearch,
 )
 from aci.common.schemas.function import BasicFunctionDefinition, FunctionDetails
+from aci.common.schemas.security_scheme import SecuritySchemesPublic
 from aci.server import config
 from aci.server import dependencies as deps
 
@@ -25,11 +25,11 @@
 openai_client = OpenAI(api_key=config.OPENAI_API_KEY)
 
 
-@router.get("", response_model=list[AppDetails])
+@router.get("", response_model_exclude_none=True)
 async def list_apps(
     context: Annotated[deps.RequestContext, Depends(deps.get_request_context)],
     query_params: Annotated[AppsList, Query()],
-) -> list[App]:
+) -> list[AppDetails]:
     """
     Get a list of Apps and their details. Sorted by App name.
     """
@@ -40,7 +40,7 @@ async def list_apps(
         },
     )
 
-    return crud.apps.get_apps(
+    apps = crud.apps.get_apps(
         context.db_session,
         context.project.visibility_access == Visibility.PUBLIC,
         True,
@@ -49,6 +49,30 @@ async def list_apps(
         query_params.offset,
     )
 
+    response: list[AppDetails] = []
+    for app in apps:
+        app_details = AppDetails(
+            id=app.id,
+            name=app.name,
+            display_name=app.display_name,
+            provider=app.provider,
+            version=app.version,
+            description=app.description,
+            logo=app.logo,
+            categories=app.categories,
+            visibility=app.visibility,
+            active=app.active,
+            security_schemes=list(app.security_schemes.keys()),
+            # TODO: check validation latency
+            supported_security_schemes=SecuritySchemesPublic.model_validate(app.security_schemes),
+            functions=[FunctionDetails.model_validate(function) for function in app.functions],
+            created_at=app.created_at,
+            updated_at=app.updated_at,
+        )
+        response.append(app_details)
+
+    return response
+
 
 @router.get("/search", response_model_exclude_none=True)
 async def search_apps(
@@ -113,7 +137,7 @@ async def search_apps(
     return apps
 
 
-@router.get("/{app_name}", response_model=AppDetails)
+@router.get("/{app_name}", response_model_exclude_none=True)
 async def get_app_details(
     context: Annotated[deps.RequestContext, Depends(deps.get_request_context)],
     app_name: str,
@@ -159,6 +183,7 @@ async def get_app_details(
         visibility=app.visibility,
         active=app.active,
         security_schemes=list(app.security_schemes.keys()),
+        supported_security_schemes=SecuritySchemesPublic.model_validate(app.security_schemes),
         functions=[FunctionDetails.model_validate(function) for function in functions],
         created_at=app.created_at,
         updated_at=app.updated_at,
diff --git a/backend/aci/server/routes/functions.py b/backend/aci/server/routes/functions.py
index 18f4124d8..c6ea3a999 100644
--- a/backend/aci/server/routes/functions.py
+++ b/backend/aci/server/routes/functions.py
@@ -396,7 +396,7 @@ async def execute_function(
         )
 
     security_credentials_response: SecurityCredentialsResponse = await scm.get_security_credentials(
-        function.app, linked_account
+        app_configuration.app, app_configuration, linked_account
     )
 
     logger.info(
@@ -427,14 +427,12 @@ async def execute_function(
             )
         db_session.commit()
 
-    # Check for custom instruction violations if OpenAI client is provided
-    if openai_client:
-        custom_instructions.check_for_violation(
-            openai_client,
-            function,
-            function_input,
-            agent.custom_instructions,
-        )
+    custom_instructions.check_for_violation(
+        openai_client,
+        function,
+        function_input,
+        agent.custom_instructions,
+    )
 
     function_executor = get_executor(function.protocol, linked_account)
     logger.info(
diff --git a/backend/aci/server/routes/linked_accounts.py b/backend/aci/server/routes/linked_accounts.py
index 0c8723d99..5a279c645 100644
--- a/backend/aci/server/routes/linked_accounts.py
+++ b/backend/aci/server/routes/linked_accounts.py
@@ -32,11 +32,10 @@
 from aci.common.schemas.security_scheme import (
     APIKeySchemeCredentials,
     NoAuthSchemeCredentials,
-    OAuth2Scheme,
-    OAuth2SchemeCredentials,
 )
 from aci.server import config
 from aci.server import dependencies as deps
+from aci.server import security_credentials_manager as scm
 from aci.server.oauth2_manager import OAuth2Manager
 
 router = APIRouter()
@@ -364,21 +363,19 @@ async def link_oauth2_account(
             f"{app_configuration.security_scheme}, not OAuth2"
         )
 
-    # TODO: load client's overrides if they specify any, for example, client_id, client_secret, scope, etc.
-    # security_scheme of the app configuration must be one of the App's security_schemes, so we can safely validate it
-    app_default_oauth2_config = OAuth2Scheme.model_validate(
-        app_configuration.app.security_schemes[SecurityScheme.OAUTH2]
+    oauth2_scheme = scm.get_app_configuration_oauth2_scheme(
+        app_configuration.app, app_configuration
     )
 
     oauth2_manager = OAuth2Manager(
         app_name=query_params.app_name,
-        client_id=app_default_oauth2_config.client_id,
-        client_secret=app_default_oauth2_config.client_secret,
-        scope=app_default_oauth2_config.scope,
-        authorize_url=app_default_oauth2_config.authorize_url,
-        access_token_url=app_default_oauth2_config.access_token_url,
-        refresh_token_url=app_default_oauth2_config.refresh_token_url,
-        token_endpoint_auth_method=app_default_oauth2_config.token_endpoint_auth_method,
+        client_id=oauth2_scheme.client_id,
+        client_secret=oauth2_scheme.client_secret,
+        scope=oauth2_scheme.scope,
+        authorize_url=oauth2_scheme.authorize_url,
+        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,
     )
 
     path = request.url_for(LINKED_ACCOUNTS_OAUTH2_CALLBACK_ROUTE_NAME).path
@@ -391,6 +388,7 @@ async def link_oauth2_account(
         app_name=query_params.app_name,
         project_id=context.project.id,
         linked_account_owner_id=query_params.linked_account_owner_id,
+        client_id=oauth2_scheme.client_id,
         redirect_uri=redirect_uri,
         code_verifier=OAuth2Manager.generate_code_verifier(),
         after_oauth2_link_redirect_url=query_params.after_oauth2_link_redirect_url,
@@ -491,7 +489,10 @@ async def linked_accounts_oauth2_callback(
         )
         raise AppNotFound(f"app={state.app_name} not found")
 
-    # check if app configuration exists and configuration is OAuth2
+    # check app configuration
+    # - exists
+    # - configuration is OAuth2
+    # - client_id matches the one used at the start of the OAuth2 flow
     app_configuration = crud.app_configurations.get_app_configuration(
         db_session, state.project_id, state.app_name
     )
@@ -509,19 +510,29 @@ async def linked_accounts_oauth2_callback(
         raise NoImplementationFound(f"app configuration for app={state.app_name} is not OAuth2")
 
     # create oauth2 manager
-    app_default_oauth2_config = OAuth2Scheme.model_validate(
-        app.security_schemes[SecurityScheme.OAUTH2]
+    oauth2_scheme = scm.get_app_configuration_oauth2_scheme(
+        app_configuration.app, app_configuration
     )
+    if oauth2_scheme.client_id != state.client_id:
+        logger.error(
+            "unable to continue with account linking, client_id of state doesn't match client_id of app configuration",
+            extra={
+                "app_name": state.app_name,
+                "client_id": oauth2_scheme.client_id,
+                "state_client_id": state.client_id,
+            },
+        )
+        raise OAuth2Error("client_id mismatch during account linking")
 
     oauth2_manager = OAuth2Manager(
         app_name=state.app_name,
-        client_id=app_default_oauth2_config.client_id,
-        client_secret=app_default_oauth2_config.client_secret,
-        scope=app_default_oauth2_config.scope,
-        authorize_url=app_default_oauth2_config.authorize_url,
-        access_token_url=app_default_oauth2_config.access_token_url,
-        refresh_token_url=app_default_oauth2_config.refresh_token_url,
-        token_endpoint_auth_method=app_default_oauth2_config.token_endpoint_auth_method,
+        client_id=oauth2_scheme.client_id,
+        client_secret=oauth2_scheme.client_secret,
+        scope=oauth2_scheme.scope,
+        authorize_url=oauth2_scheme.authorize_url,
+        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,
     )
 
     token_response = await oauth2_manager.fetch_token(
@@ -529,12 +540,7 @@ async def linked_accounts_oauth2_callback(
         code=code,
         code_verifier=state.code_verifier,
     )
-
-    # TODO: we might want to verify scope authorized by end user (token_response["scope"]) is what we asked
-    # parse the token_response into the security_credentials, handling provider-specific edge cases
-    security_credentials: OAuth2SchemeCredentials = OAuth2Manager.parse_oauth2_security_credentials(
-        app.name, token_response
-    )
+    security_credentials = 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
diff --git a/backend/aci/server/security_credentials_manager.py b/backend/aci/server/security_credentials_manager.py
index fb60f809b..999064198 100644
--- a/backend/aci/server/security_credentials_manager.py
+++ b/backend/aci/server/security_credentials_manager.py
@@ -2,7 +2,7 @@
 
 from pydantic import BaseModel
 
-from aci.common.db.sql_models import App, LinkedAccount
+from aci.common.db.sql_models import App, AppConfiguration, LinkedAccount
 from aci.common.enums import SecurityScheme
 from aci.common.exceptions import NoImplementationFound, OAuth2Error
 from aci.common.logging_setup import get_logger
@@ -13,6 +13,7 @@
     NoAuthSchemeCredentials,
     OAuth2Scheme,
     OAuth2SchemeCredentials,
+    SecuritySchemeOverrides,
 )
 from aci.server.oauth2_manager import OAuth2Manager
 
@@ -28,12 +29,12 @@ class SecurityCredentialsResponse(BaseModel):
 
 
 async def get_security_credentials(
-    app: App, linked_account: LinkedAccount
+    app: App, app_configuration: AppConfiguration, linked_account: LinkedAccount
 ) -> SecurityCredentialsResponse:
     if linked_account.security_scheme == SecurityScheme.API_KEY:
         return _get_api_key_credentials(app, linked_account)
     elif linked_account.security_scheme == SecurityScheme.OAUTH2:
-        return await _get_oauth2_credentials(app, linked_account)
+        return await _get_oauth2_credentials(app, app_configuration, linked_account)
     elif linked_account.security_scheme == SecurityScheme.NO_AUTH:
         return _get_no_auth_credentials(app, linked_account)
     else:
@@ -51,59 +52,23 @@ async def get_security_credentials(
 
 
 async def _get_oauth2_credentials(
-    app: App, linked_account: LinkedAccount
+    app: App, app_configuration: AppConfiguration, linked_account: LinkedAccount
 ) -> SecurityCredentialsResponse:
     """Get OAuth2 credentials from linked account or app's default credentials.
     If the access token is expired, it will be refreshed.
     """
-    is_app_default_credentials = False
     is_updated = False
-    oauth2_scheme = OAuth2Scheme.model_validate(app.security_schemes[SecurityScheme.OAUTH2])
-
-    if linked_account.security_credentials:
-        logger.debug(
-            "using linked account's security credentials",
-            extra={
-                "linked_account": linked_account.id,
-                "security_scheme": linked_account.security_scheme,
-            },
-        )
-        oauth2_credentials = linked_account.security_credentials
-
-    elif app.default_security_credentials_by_scheme.get(linked_account.security_scheme):
-        is_app_default_credentials = True
-        logger.info(
-            "using app's default security credentials",
-            extra={
-                "app": app.name,
-                "security_scheme": linked_account.security_scheme,
-                "linked_account": linked_account.id,
-            },
-        )
-        oauth2_credentials = app.default_security_credentials_by_scheme[
-            linked_account.security_scheme
-        ]
-    else:
-        logger.error(
-            "no security credentials usable",
-            extra={
-                "linked_account": linked_account.id,
-                "security_scheme": linked_account.security_scheme,
-                "app": app.name,
-            },
-        )
-        # TODO: check all 'NoImplementationFound' exceptions see if a more suitable exception can be used
-        raise NoImplementationFound(
-            f"no security credentials usable for app={app.name}, "
-            f"security_scheme={linked_account.security_scheme}, "
-            f"linked_account_owner_id={linked_account.linked_account_owner_id}"
-        )
-
-    oauth2_scheme_credentials = OAuth2SchemeCredentials.model_validate(oauth2_credentials)
-    if (
-        _access_token_is_expired(oauth2_scheme_credentials)
-        and oauth2_scheme_credentials.refresh_token
-    ):
+    oauth2_scheme = get_app_configuration_oauth2_scheme(app_configuration.app, app_configuration)
+    # TODO: for backward compatibility, remove later
+    oauth2_scheme_credentials = OAuth2SchemeCredentials.model_validate(
+        {
+            **linked_account.security_credentials,
+            "client_id": oauth2_scheme.client_id,
+            "client_secret": oauth2_scheme.client_secret,
+            "scope": oauth2_scheme.scope,
+        }
+    )
+    if _access_token_is_expired(oauth2_scheme_credentials):
         logger.warning(
             "access token expired, trying to refresh",
             extra={
@@ -113,9 +78,9 @@ async def _get_oauth2_credentials(
             },
         )
         token_response = await _refresh_oauth2_access_token(
-            app, oauth2_scheme_credentials.refresh_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:
             expires_at = int(token_response["expires_at"])
@@ -146,46 +111,36 @@ async def _get_oauth2_credentials(
             update=fields_to_update,
         )
         is_updated = True
-    else:
-        logger.debug(
-            "access token is valid",
-            extra={
-                "linked_account": linked_account.id,
-                "security_scheme": linked_account.security_scheme,
-                "app": app.name,
-            },
-        )
 
     return SecurityCredentialsResponse(
         scheme=oauth2_scheme,
         credentials=oauth2_scheme_credentials,
-        is_app_default_credentials=is_app_default_credentials,
+        is_app_default_credentials=False,  # Should never support default credentials for oauth2
         is_updated=is_updated,
     )
 
 
-async def _refresh_oauth2_access_token(app: App, refresh_token: str) -> dict:
-    app_default_oauth2_config = OAuth2Scheme.model_validate(
-        app.security_schemes[SecurityScheme.OAUTH2]
-    )
+async def _refresh_oauth2_access_token(
+    app_name: str, oauth2_scheme: OAuth2Scheme, oauth2_scheme_credentials: OAuth2SchemeCredentials
+) -> dict:
+    refresh_token = oauth2_scheme_credentials.refresh_token
+    if not refresh_token:
+        raise OAuth2Error("no refresh token found")
+
+    # NOTE: it's important to use oauth2_scheme_credentials's client_id, client_secret, scope because
+    # these fields might have changed for the app configuration after the linked account was created
     oauth2_manager = OAuth2Manager(
-        app_name=app.name,
-        client_id=app_default_oauth2_config.client_id,
-        client_secret=app_default_oauth2_config.client_secret,
-        scope=app_default_oauth2_config.scope,
-        authorize_url=app_default_oauth2_config.authorize_url,
-        access_token_url=app_default_oauth2_config.access_token_url,
-        refresh_token_url=app_default_oauth2_config.refresh_token_url,
-        token_endpoint_auth_method=app_default_oauth2_config.token_endpoint_auth_method,
+        app_name=app_name,
+        client_id=oauth2_scheme_credentials.client_id,
+        client_secret=oauth2_scheme_credentials.client_secret,
+        scope=oauth2_scheme_credentials.scope,
+        authorize_url=oauth2_scheme.authorize_url,
+        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,
     )
 
-    token_response = await oauth2_manager.refresh_token(refresh_token)
-
-    # TODO: seems the token_response contains both "expires_at" and "expires_in", in which case
-    # the "expires_at" should be used. Need to double check if it's the same for "authorize_access_token" method
-    # and other providers.
-
-    return token_response
+    return await oauth2_manager.refresh_token(refresh_token)
 
 
 def _get_api_key_credentials(
@@ -243,3 +198,25 @@ def _access_token_is_expired(oauth2_credentials: OAuth2SchemeCredentials) -> boo
     if oauth2_credentials.expires_at is None:
         return False
     return oauth2_credentials.expires_at < int(time.time())
+
+
+def get_app_configuration_oauth2_scheme(
+    app: App, app_configuration: AppConfiguration
+) -> OAuth2Scheme:
+    """
+    Get the OAuth2 scheme for an app configuration, taking into account potential overrides.
+    """
+    oauth2_scheme = OAuth2Scheme.model_validate(app.security_schemes[SecurityScheme.OAUTH2])
+
+    # Parse the security scheme overrides
+    security_scheme_overrides = SecuritySchemeOverrides.model_validate(
+        app_configuration.security_scheme_overrides
+    )
+
+    # Apply oauth2 overrides if they exist
+    if security_scheme_overrides.oauth2:
+        oauth2_scheme = oauth2_scheme.model_copy(
+            update=security_scheme_overrides.oauth2.model_dump(exclude_none=True)
+        )
+
+    return oauth2_scheme
diff --git a/backend/aci/server/tests/conftest.py b/backend/aci/server/tests/conftest.py
index 5dd4d3fb6..7e89441d8 100644
--- a/backend/aci/server/tests/conftest.py
+++ b/backend/aci/server/tests/conftest.py
@@ -552,6 +552,9 @@ def dummy_linked_account_api_key_credentials() -> APIKeySchemeCredentials:
 @pytest.fixture(scope="function")
 def dummy_linked_account_oauth2_credentials() -> OAuth2SchemeCredentials:
     return OAuth2SchemeCredentials(
+        client_id="dummy_linked_account_oauth2_credentials_client_id",
+        client_secret="dummy_linked_account_oauth2_credentials_client_secret",
+        scope="dummy_scope_1 dummy_scope_2",
         access_token="dummy_linked_account_oauth2_credentials_access_token",
         token_type="Bearer",
         expires_at=int(time.time()) + 3600,
diff --git a/backend/aci/server/tests/crud/custom_sql_types/test_encrypted_security_credentials.py b/backend/aci/server/tests/crud/custom_sql_types/test_encrypted_security_credentials.py
index e3bc843d3..24480052a 100644
--- a/backend/aci/server/tests/crud/custom_sql_types/test_encrypted_security_credentials.py
+++ b/backend/aci/server/tests/crud/custom_sql_types/test_encrypted_security_credentials.py
@@ -75,10 +75,15 @@ def test_linked_account_table_security_credentials_column_oauth2_encryption(
     for OAuth2 type security credentials.
     """
     # Given - Create test data
+    expected_client_id = "test_client_id"
+    expected_client_secret = "test_client_secret"
     expected_access_token = "test_access_token"
     expected_refresh_token = "test_refresh_token"
     expected_raw_token_response = {"key": "value"}
     expected_default_security_credential = OAuth2SchemeCredentials(
+        client_id=expected_client_id,
+        client_secret=expected_client_secret,
+        scope="test",
         access_token=expected_access_token,
         token_type="Bearer",
         expires_at=1234567890,
@@ -110,6 +115,8 @@ def test_linked_account_table_security_credentials_column_oauth2_encryption(
     retrieved_oauth2_scheme_credentials = OAuth2SchemeCredentials.model_validate(
         retrieved_linked_account.security_credentials
     )
+    assert retrieved_oauth2_scheme_credentials.client_id == expected_client_id
+    assert retrieved_oauth2_scheme_credentials.client_secret == expected_client_secret
     assert retrieved_oauth2_scheme_credentials.access_token == expected_access_token
     assert retrieved_oauth2_scheme_credentials.refresh_token == expected_refresh_token
     assert retrieved_oauth2_scheme_credentials.raw_token_response == expected_raw_token_response
@@ -121,6 +128,16 @@ def test_linked_account_table_security_credentials_column_oauth2_encryption(
     assert raw_security_credentials is not None
     assert isinstance(raw_security_credentials, dict)
 
+    # Then - Verify client_secret is encrypted and can be decrypted to the original value
+    raw_client_secret = raw_security_credentials["client_secret"]
+    assert raw_client_secret is not None
+    assert raw_client_secret != expected_client_secret
+
+    decrypted_client_secret = encryption.decrypt(base64.b64decode(raw_client_secret)).decode(
+        "utf-8"
+    )
+    assert decrypted_client_secret == expected_client_secret
+
     # Then - Verify access_token is encrypted and can be decrypted to the original value
     raw_access_token = raw_security_credentials["access_token"]
     assert raw_access_token is not None
@@ -162,6 +179,9 @@ def test_linked_account_table_security_credentials_column_mutable_dict_detection
     # Given - Create test data
     expected_token_type = "Bearer"
     expected_default_security_credential = OAuth2SchemeCredentials(
+        client_id="test",
+        client_secret="test",
+        scope="test",
         access_token="test_access_token",
         token_type=expected_token_type,
         expires_at=1234567890,
diff --git a/backend/aci/server/tests/dummy_apps/aci_test/app.json b/backend/aci/server/tests/dummy_apps/aci_test/app.json
index a2c21e3f4..ed387951c 100644
--- a/backend/aci/server/tests/dummy_apps/aci_test/app.json
+++ b/backend/aci/server/tests/dummy_apps/aci_test/app.json
@@ -25,12 +25,6 @@
     "default_security_credentials_by_scheme": {
         "api_key": {
             "secret_key": "default-shared-api-key"
-        },
-        "oauth2": {
-            "access_token": "dummy_app_default_oauth2_access_token",
-            "token_type": "Bearer",
-            "expires_at": 9999999999,
-            "refresh_token": "dummy_app_default_oauth2_refresh_token"
         }
     },
     "categories": ["testcategory", "utility"],
diff --git a/backend/aci/server/tests/routes/app_configurations/test_app_configurations_add.py b/backend/aci/server/tests/routes/app_configurations/test_app_configurations_add.py
index ea4a4f364..36df0327d 100644
--- a/backend/aci/server/tests/routes/app_configurations/test_app_configurations_add.py
+++ b/backend/aci/server/tests/routes/app_configurations/test_app_configurations_add.py
@@ -41,6 +41,35 @@ def test_create_app_configuration(
     assert str(response.json()["error"]).startswith("App configuration already exists")
 
 
+def test_create_app_configuration_with_security_scheme_override(
+    test_client: TestClient,
+    dummy_api_key_1: str,
+    dummy_apps: list[App],
+) -> None:
+    dummy_app = dummy_apps[0]
+    body = AppConfigurationCreate.model_validate(
+        {
+            "app_name": dummy_app.name,
+            "security_scheme": SecurityScheme.OAUTH2,
+            "security_scheme_overrides": {
+                "oauth2": {"client_id": "123456", "client_secret": "abcdef"}
+            },
+        }
+    )
+    response = test_client.post(
+        f"{config.ROUTER_PREFIX_APP_CONFIGURATIONS}",
+        json=body.model_dump(mode="json"),
+        headers={"x-api-key": dummy_api_key_1},
+    )
+    assert response.status_code == status.HTTP_200_OK
+    app_configuration = AppConfigurationPublic.model_validate(response.json())
+    assert app_configuration.security_scheme_overrides.oauth2 is not None
+    assert app_configuration.security_scheme_overrides.oauth2.client_id == "123456"
+    assert app_configuration.security_scheme_overrides.oauth2.client_secret == "******", (
+        "client_secret should be redacted"
+    )
+
+
 def test_create_app_configuration_security_scheme_not_supported(
     test_client: TestClient,
     dummy_api_key_1: str,
diff --git a/backend/aci/server/tests/routes/functions/test_functions_execute_with_oauth2_account.py b/backend/aci/server/tests/routes/functions/test_functions_execute_with_oauth2_account.py
index dd49f9b95..dbbcc336e 100644
--- a/backend/aci/server/tests/routes/functions/test_functions_execute_with_oauth2_account.py
+++ b/backend/aci/server/tests/routes/functions/test_functions_execute_with_oauth2_account.py
@@ -1,4 +1,5 @@
 import httpx
+import pytest
 import respx
 from fastapi import status
 from fastapi.testclient import TestClient
@@ -13,12 +14,32 @@
 
 
 @respx.mock
+@pytest.mark.parametrize("use_custom_oauth2_app", [True, False])
 def test_execute_oauth2_based_function_with_linked_account_credentials(
+    db_session: Session,
     test_client: TestClient,
     dummy_agent_1_with_all_apps_allowed: Agent,
     dummy_function_aci_test__hello_world_with_args: Function,
     dummy_linked_account_oauth2_aci_test_project_1: LinkedAccount,
+    use_custom_oauth2_app: bool,
 ) -> None:
+    # update the linked account's credentials if use_custom_oauth2_app is True
+    if use_custom_oauth2_app:
+        dummy_linked_account_oauth2_aci_test_project_1.security_credentials["client_id"] = (
+            "custom_client_id"
+        )
+        dummy_linked_account_oauth2_aci_test_project_1.security_credentials["client_secret"] = (
+            "custom_client_secret"
+        )
+        crud.linked_accounts.update_linked_account_credentials(
+            db_session,
+            dummy_linked_account_oauth2_aci_test_project_1,
+            security_credentials=OAuth2SchemeCredentials.model_validate(
+                dummy_linked_account_oauth2_aci_test_project_1.security_credentials
+            ),
+        )
+        db_session.commit()
+
     # Mock the HTTP endpoint and response
     mock_response_data = {
         "message": "Hello, test_execute_oauth2_based_function_with_linked_account_credentials!"
@@ -76,13 +97,32 @@ def test_execute_oauth2_based_function_with_linked_account_credentials(
 
 
 @respx.mock
+@pytest.mark.parametrize("use_custom_oauth2_app", [True, False])
 def test_execute_oauth2_based_function_with_expired_linked_account_access_token(
     db_session: Session,
     test_client: TestClient,
     dummy_agent_1_with_all_apps_allowed: Agent,
     dummy_function_aci_test__hello_world_with_args: Function,
     dummy_linked_account_oauth2_aci_test_project_1: LinkedAccount,
+    use_custom_oauth2_app: bool,
 ) -> None:
+    # update the linked account's credentials if use_custom_oauth2_app is True
+    if use_custom_oauth2_app:
+        dummy_linked_account_oauth2_aci_test_project_1.security_credentials["client_id"] = (
+            "custom_client_id"
+        )
+        dummy_linked_account_oauth2_aci_test_project_1.security_credentials["client_secret"] = (
+            "custom_client_secret"
+        )
+        crud.linked_accounts.update_linked_account_credentials(
+            db_session,
+            dummy_linked_account_oauth2_aci_test_project_1,
+            security_credentials=OAuth2SchemeCredentials.model_validate(
+                dummy_linked_account_oauth2_aci_test_project_1.security_credentials
+            ),
+        )
+        db_session.commit()
+
     # Mock the function's HTTP endpoint and response
     mock_response_data = {
         "message": "Hello, test_execute_oauth2_based_function_with_expired_linked_account_access_token!"
@@ -164,157 +204,3 @@ def test_execute_oauth2_based_function_with_expired_linked_account_access_token(
         dummy_linked_account_oauth2_aci_test_project_1.security_credentials["access_token"]
         == mock_refresh_token_response["access_token"]
     )
-
-
-@respx.mock
-def test_execute_oauth2_based_function_with_app_default_credentials(
-    test_client: TestClient,
-    dummy_agent_1_with_all_apps_allowed: Agent,
-    dummy_function_aci_test__hello_world_with_args: Function,
-    dummy_linked_account_default_aci_test_project_1: LinkedAccount,
-) -> None:
-    # Mock the HTTP endpoint and response
-    mock_response_data = {
-        "message": "Hello, test_execute_oauth2_based_function_with_app_default_credentials!"
-    }
-    request = respx.post("https://api.mock.aci.com/v1/greet/John").mock(
-        return_value=httpx.Response(
-            200,
-            json=mock_response_data,
-        )
-    )
-
-    # execute the function
-    function_execute = FunctionExecute(
-        linked_account_owner_id=dummy_linked_account_default_aci_test_project_1.linked_account_owner_id,
-        function_input={
-            "path": {"userId": "John"},
-            "query": {"lang": "en"},
-            "body": {"name": "John"},  # greeting is not visible so no input here
-            "header": {"X-CUSTOM-HEADER": "header123"},
-            # "cookie" property is not visible in our test schema so no input here
-        },
-    )
-    response = test_client.post(
-        f"{config.ROUTER_PREFIX_FUNCTIONS}/{dummy_function_aci_test__hello_world_with_args.name}/execute",
-        json=function_execute.model_dump(mode="json"),
-        headers={"x-api-key": dummy_agent_1_with_all_apps_allowed.api_keys[0].key},
-    )
-
-    # verify response is successful
-    assert response.status_code == status.HTTP_200_OK
-    assert "error" not in response.json()
-    function_execution_response = FunctionExecutionResult.model_validate(response.json())
-    assert function_execution_response.success
-    assert function_execution_response.data == mock_response_data
-
-    # Verify the request was made with correct inputs
-    assert request.called
-    assert request.calls.last.request.url == "https://api.mock.aci.com/v1/greet/John?lang=en"
-    assert request.calls.last.request.headers["X-CUSTOM-HEADER"] == "header123"
-    assert request.calls.last.request.content == b'{"name": "John", "greeting": "default-greeting"}'
-
-    # verify request was made with the correct credentials
-    app = dummy_function_aci_test__hello_world_with_args.app
-    oauth2_scheme = OAuth2Scheme.model_validate(app.security_schemes[SecurityScheme.OAUTH2])
-    app_default_oauth2_credentials = OAuth2SchemeCredentials.model_validate(
-        app.default_security_credentials_by_scheme[SecurityScheme.OAUTH2]
-    )
-    assert (
-        request.calls.last.request.headers["Authorization"]
-        == f"{oauth2_scheme.prefix} {app_default_oauth2_credentials.access_token}"
-    )
-
-
-@respx.mock
-def test_execute_oauth2_based_function_with_expired_app_default_access_token(
-    db_session: Session,
-    test_client: TestClient,
-    dummy_agent_1_with_all_apps_allowed: Agent,
-    dummy_function_aci_test__hello_world_with_args: Function,
-    dummy_linked_account_default_aci_test_project_1: LinkedAccount,
-) -> None:
-    # Mock the HTTP endpoint and response
-    mock_response_data = {
-        "message": (
-            "Hello, test_execute_oauth2_based_function_with_expired_app_default_access_token!"
-        )
-    }
-    request = respx.post("https://api.mock.aci.com/v1/greet/John").mock(
-        return_value=httpx.Response(
-            200,
-            json=mock_response_data,
-        )
-    )
-
-    # mock the refresh token endpoint
-    mock_refresh_token_response = {
-        "access_token": "new_dummy_app_default_oauth2_access_token",
-        "expires_in": 3600,
-    }
-    respx.post("https://api.mock.aci.com/v1/oauth2/refresh").mock(
-        return_value=httpx.Response(
-            200,
-            json=mock_refresh_token_response,
-        )
-    )
-
-    # set the app's default credentials to expired
-    app = dummy_function_aci_test__hello_world_with_args.app
-    oauth2_scheme = OAuth2Scheme.model_validate(app.security_schemes[SecurityScheme.OAUTH2])
-    app_default_oauth2_credentials = OAuth2SchemeCredentials.model_validate(
-        app.default_security_credentials_by_scheme[SecurityScheme.OAUTH2]
-    )
-    old_access_token = app_default_oauth2_credentials.access_token
-    app_default_oauth2_credentials.expires_at = 0
-    crud.apps.update_app_default_security_credentials(
-        db_session,
-        app,
-        security_scheme=SecurityScheme.OAUTH2,
-        security_credentials=app_default_oauth2_credentials.model_dump(),
-    )
-    db_session.commit()
-
-    # execute the function
-    function_execute = FunctionExecute(
-        linked_account_owner_id=dummy_linked_account_default_aci_test_project_1.linked_account_owner_id,
-        function_input={
-            "path": {"userId": "John"},
-            "query": {"lang": "en"},
-            "body": {"name": "John"},  # greeting is not visible so no input here
-            "header": {"X-CUSTOM-HEADER": "header123"},
-            # "cookie" property is not visible in our test schema so no input here
-        },
-    )
-    response = test_client.post(
-        f"{config.ROUTER_PREFIX_FUNCTIONS}/{dummy_function_aci_test__hello_world_with_args.name}/execute",
-        json=function_execute.model_dump(mode="json"),
-        headers={"x-api-key": dummy_agent_1_with_all_apps_allowed.api_keys[0].key},
-    )
-
-    # verify response is successful
-    assert response.status_code == status.HTTP_200_OK
-    assert "error" not in response.json()
-    function_execution_response = FunctionExecutionResult.model_validate(response.json())
-    assert function_execution_response.success
-    assert function_execution_response.data == mock_response_data
-
-    # Verify the request was made with correct inputs
-    assert request.called
-    assert request.calls.last.request.url == "https://api.mock.aci.com/v1/greet/John?lang=en"
-    assert request.calls.last.request.headers["X-CUSTOM-HEADER"] == "header123"
-    assert request.calls.last.request.content == b'{"name": "John", "greeting": "default-greeting"}'
-
-    # verify request was made with the new access token
-    assert (
-        request.calls.last.request.headers["Authorization"]
-        == f"{oauth2_scheme.prefix} {mock_refresh_token_response['access_token']}"
-    )
-    assert old_access_token != mock_refresh_token_response["access_token"]
-
-    # verify the app's default credentials were updated
-    db_session.refresh(app)
-    assert (
-        app.default_security_credentials_by_scheme[SecurityScheme.OAUTH2]["access_token"]
-        == mock_refresh_token_response["access_token"]
-    )
diff --git a/backend/aci/server/tests/routes/linked_acounts/test_accounts_link_oauth2.py b/backend/aci/server/tests/routes/linked_acounts/test_accounts_link_oauth2.py
index c98fbcf26..e411e3431 100644
--- a/backend/aci/server/tests/routes/linked_acounts/test_accounts_link_oauth2.py
+++ b/backend/aci/server/tests/routes/linked_acounts/test_accounts_link_oauth2.py
@@ -12,13 +12,20 @@
 from aci.common.db import crud
 from aci.common.db.sql_models import App
 from aci.common.enums import SecurityScheme
-from aci.common.schemas.app_configurations import AppConfigurationPublic
+from aci.common.schemas.app_configurations import (
+    AppConfigurationCreate,
+    AppConfigurationPublic,
+)
 from aci.common.schemas.linked_accounts import (
     LinkedAccountOAuth2Create,
     LinkedAccountOAuth2CreateState,
     LinkedAccountPublic,
 )
-from aci.common.schemas.security_scheme import OAuth2SchemeCredentials
+from aci.common.schemas.security_scheme import (
+    OAuth2SchemeCredentials,
+    OAuth2SchemeOverride,
+    SecuritySchemeOverrides,
+)
 from aci.server import config
 
 MOCK_GOOGLE_AUTH_REDIRECT_URI_PREFIX = (
@@ -27,23 +34,51 @@
 
 
 @pytest.mark.parametrize(
-    "after_oauth2_link_redirect_url,callback_response_code",
+    "use_custom_oauth2_app,after_oauth2_link_redirect_url,callback_response_code",
     [
-        (None, status.HTTP_200_OK),
-        ("https://platform.aci.dev", status.HTTP_302_FOUND),
+        (False, None, status.HTTP_200_OK),
+        (False, "https://platform.aci.dev", status.HTTP_302_FOUND),
+        (True, None, status.HTTP_200_OK),
+        (True, "https://platform.aci.dev", status.HTTP_302_FOUND),
     ],
 )
 def test_link_oauth2_account_success(
     test_client: TestClient,
     dummy_api_key_1: str,
-    dummy_app_configuration_oauth2_google_project_1: AppConfigurationPublic,
+    dummy_app_google: App,
     db_session: Session,
+    use_custom_oauth2_app: bool,
     after_oauth2_link_redirect_url: str | None,
     callback_response_code: int,
 ) -> None:
+    # create a new app configuration
+    if use_custom_oauth2_app:
+        app_configuration_create = AppConfigurationCreate(
+            app_name=dummy_app_google.name,
+            security_scheme=SecurityScheme.OAUTH2,
+            security_scheme_overrides=SecuritySchemeOverrides(
+                oauth2=OAuth2SchemeOverride(
+                    client_id="custom_client_id",
+                    client_secret="custom_client_secret",
+                )
+            ),
+        )
+    else:
+        app_configuration_create = AppConfigurationCreate(
+            app_name=dummy_app_google.name,
+            security_scheme=SecurityScheme.OAUTH2,
+        )
+    response = test_client.post(
+        f"{config.ROUTER_PREFIX_APP_CONFIGURATIONS}",
+        json=app_configuration_create.model_dump(mode="json"),
+        headers={"x-api-key": dummy_api_key_1},
+    )
+    assert response.status_code == status.HTTP_200_OK
+    google_app_configuration = AppConfigurationPublic.model_validate(response.json())
+
     # init account linking proces
     body = LinkedAccountOAuth2Create(
-        app_name=dummy_app_configuration_oauth2_google_project_1.app_name,
+        app_name=dummy_app_google.name,
         linked_account_owner_id="test_link_oauth2_account_success",
         after_oauth2_link_redirect_url=after_oauth2_link_redirect_url,
     )
@@ -60,13 +95,19 @@ def test_link_oauth2_account_success(
     state_jwt = qs_params.get("state", [None])[0]
     assert state_jwt is not None
     state = LinkedAccountOAuth2CreateState.model_validate(jwt.decode(state_jwt, config.SIGNING_KEY))
-    assert state.project_id == dummy_app_configuration_oauth2_google_project_1.project_id
-    assert state.app_name == dummy_app_configuration_oauth2_google_project_1.app_name
+    assert state.project_id == google_app_configuration.project_id
+    assert state.app_name == google_app_configuration.app_name
     assert state.linked_account_owner_id == "test_link_oauth2_account_success"
     assert state.after_oauth2_link_redirect_url == after_oauth2_link_redirect_url
     assert state.redirect_uri == (
         f"{config.REDIRECT_URI_BASE}{config.ROUTER_PREFIX_LINKED_ACCOUNTS}/oauth2/callback"
     )
+    if use_custom_oauth2_app:
+        assert state.client_id == "custom_client_id"
+    else:
+        assert (
+            state.client_id == dummy_app_google.security_schemes[SecurityScheme.OAUTH2]["client_id"]
+        )
 
     # mock the oauth2 manager's fetch_token response
     mock_oauth2_token_response = {
@@ -109,18 +150,35 @@ def test_link_oauth2_account_success(
             linked_account.security_credentials
         )
         assert linked_account.security_scheme == SecurityScheme.OAUTH2
+        assert (
+            oauth2_credentials.scope
+            == dummy_app_google.security_schemes[SecurityScheme.OAUTH2]["scope"]
+        )
         assert oauth2_credentials.access_token == mock_oauth2_token_response["access_token"]
         assert oauth2_credentials.token_type == mock_oauth2_token_response["token_type"]
         assert oauth2_credentials.expires_at == mock_oauth2_token_retrieval_time + cast(
             int, mock_oauth2_token_response["expires_in"]
         )
         assert oauth2_credentials.refresh_token == mock_oauth2_token_response["refresh_token"]
+        assert oauth2_credentials.raw_token_response == mock_oauth2_token_response
         assert linked_account.enabled is True
-        assert linked_account.app.name == dummy_app_configuration_oauth2_google_project_1.app_name
-        assert linked_account.app.name == state.app_name
-        assert linked_account.project_id == state.project_id
+        assert linked_account.app.name == google_app_configuration.app_name
+        assert linked_account.project_id == google_app_configuration.project_id
         assert linked_account.linked_account_owner_id == state.linked_account_owner_id
 
+        if use_custom_oauth2_app:
+            assert oauth2_credentials.client_id == "custom_client_id"
+            assert oauth2_credentials.client_secret == "custom_client_secret"
+        else:
+            assert (
+                oauth2_credentials.client_id
+                == dummy_app_google.security_schemes[SecurityScheme.OAUTH2]["client_id"]
+            )
+            assert (
+                oauth2_credentials.client_secret
+                == dummy_app_google.security_schemes[SecurityScheme.OAUTH2]["client_secret"]
+            )
+
         if not after_oauth2_link_redirect_url:
             assert LinkedAccountPublic.model_validate(response.json()), (
                 "should return linked account in response if after_oauth2_link_redirect_url is not provided"


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 5**

<details>
<summary>🧹 Nitpick comments (4)</summary><blockquote>

<details>
<summary>backend/aci/common/schemas/app.py (1)</summary><blockquote>

`157-159`: **Provides safer exposure of security scheme data**

The addition of `supported_security_schemes` as a `SecuritySchemesPublic` type allows for better separation of public and sensitive security scheme data, while maintaining backward compatibility with the existing `security_schemes` list.


Consider creating a ticket to track the future refactoring mentioned in the TODO comment to eventually merge these two fields.

</blockquote></details>
<details>
<summary>backend/aci/server/oauth2_manager.py (1)</summary><blockquote>

`170-218`: **Well-implemented token response parsing method**

The new `parse_fetch_token_response` instance method provides a clean and structured approach to handling OAuth2 token responses, including:
1. Special handling for Slack's nested token structure
2. Validation for required fields
3. Proper calculation of token expiration
4. Returning a fully populated `OAuth2SchemeCredentials` object with client credentials



Consider implementing the TODO on line 207 to check if the returned scope matches the expected scope, as mismatches could indicate potential issues with the OAuth2 provider or user permissions.


```diff
- # TODO: if scope is present, check if it matches the scope in the App Configuration
+ # Check if returned scope matches requested scope
+ if "scope" in data and data["scope"] != self.scope:
+     logger.warning(
+         "OAuth2 returned scope doesn't match requested scope",
+         extra={
+             "app_name": self.app_name,
+             "requested_scope": self.scope,
+             "returned_scope": data["scope"],
+         },
+     )
+     # We still proceed with the returned scope as some providers might modify it
```

</blockquote></details>
<details>
<summary>backend/aci/server/routes/linked_accounts.py (1)</summary><blockquote>

`366-379`: **Avoid leaking OAuth2 client credentials in application logs**

`OAuth2Manager` is initialised with both `client_id` and `client_secret`, and subsequently a full authorisation URL is logged (lines 408-422).  
Because `client_id` and *sometimes* the PKCE `code_challenge` are query parameters of that URL, they will end up in plaintext logs. While `client_secret` is not present, `client_id` is still considered sensitive in certain compliance regimes and gives attackers a head-start in phishing scenarios.

```diff
-logger.info(
-    "authorization url",
-    extra={"authorization_url": authorization_url},
-)
+logger.debug(           # downgrade to debug to keep prod logs clean
+    "generated OAuth2 authorisation URL",
+    extra={"app_name": query_params.app_name},
+)
```

Consider logging only the app name (or a redacted URL) and moving the full URL to DEBUG level.

</blockquote></details>
<details>
<summary>backend/aci/common/schemas/security_scheme.py (1)</summary><blockquote>

`86-103`: **`OAuth2SchemeOverride` is too strict – prevents partial overrides**

Both `client_id` *and* `client_secret` are required. That blocks legitimate scenarios where a customer can supply a public `client_id` while letting ACI keep the managed `client_secret`, or vice-versa when using a public-client flow. Mark the fields `Optional` but keep `min_length` to enable partial overrides:

```diff
-class OAuth2SchemeOverride(BaseModel):
+class OAuth2SchemeOverride(BaseModel):
@@
-    client_id: str = Field(..., min_length=1, description="…")
-    client_secret: str = Field(..., min_length=1, description="…")
+    client_id: str | None = Field(
+        default=None,
+        min_length=1,
+        description="The client ID of the OAuth2 client used for the app",
+    )
+    client_secret: str | None = Field(
+        default=None,
+        min_length=1,
+        description="The client secret of the OAuth2 client used for the app",
+    )
```

The helper that merges overrides already uses `model_dump(exclude_none=True)` so optional values are handled gracefully.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between d3549fe7e8734ab916208c831ffb2a6924006a9b and 1474c5f56e49aceab72e16fcfbc8e6b033cd01b4.

</details>

<details>
<summary>📒 Files selected for processing (18)</summary>

* `backend/aci/common/db/crud/app_configurations.py` (1 hunks)
* `backend/aci/common/db/custom_sql_types.py` (2 hunks)
* `backend/aci/common/schemas/app.py` (2 hunks)
* `backend/aci/common/schemas/app_configurations.py` (4 hunks)
* `backend/aci/common/schemas/linked_accounts.py` (1 hunks)
* `backend/aci/common/schemas/plans.py` (1 hunks)
* `backend/aci/common/schemas/security_scheme.py` (6 hunks)
* `backend/aci/common/tests/crud/test_linked_accounts.py` (2 hunks)
* `backend/aci/server/oauth2_manager.py` (4 hunks)
* `backend/aci/server/routes/apps.py` (6 hunks)
* `backend/aci/server/routes/functions.py` (2 hunks)
* `backend/aci/server/routes/linked_accounts.py` (5 hunks)
* `backend/aci/server/security_credentials_manager.py` (7 hunks)
* `backend/aci/server/tests/conftest.py` (1 hunks)
* `backend/aci/server/tests/crud/custom_sql_types/test_encrypted_security_credentials.py` (4 hunks)
* `backend/aci/server/tests/dummy_apps/aci_test/app.json` (0 hunks)
* `backend/aci/server/tests/routes/app_configurations/test_app_configurations_add.py` (1 hunks)
* `backend/aci/server/tests/routes/functions/test_functions_execute_with_oauth2_account.py` (0 hunks)

</details>

<details>
<summary>💤 Files with no reviewable changes (2)</summary>

* backend/aci/server/tests/dummy_apps/aci_test/app.json
* backend/aci/server/tests/routes/functions/test_functions_execute_with_oauth2_account.py

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (10)</summary>

<details>
<summary>backend/aci/server/tests/routes/app_configurations/test_app_configurations_add.py (3)</summary><blockquote>

<details>
<summary>backend/aci/server/tests/conftest.py (3)</summary>

* `test_client` (112-117)
* `dummy_api_key_1` (173-174)
* `dummy_apps` (234-249)

</details>
<details>
<summary>backend/aci/common/schemas/app_configurations.py (2)</summary>

* `AppConfigurationCreate` (32-65)
* `AppConfigurationPublic` (10-29)

</details>
<details>
<summary>backend/aci/common/enums.py (1)</summary>

* `SecurityScheme` (12-21)

</details>

</blockquote></details>
<details>
<summary>backend/aci/server/routes/functions.py (1)</summary><blockquote>

<details>
<summary>backend/aci/server/custom_instructions.py (1)</summary>

* `check_for_violation` (19-114)

</details>

</blockquote></details>
<details>
<summary>backend/aci/server/tests/crud/custom_sql_types/test_encrypted_security_credentials.py (2)</summary><blockquote>

<details>
<summary>backend/aci/common/schemas/security_scheme.py (1)</summary>

* `OAuth2SchemeCredentials` (132-147)

</details>
<details>
<summary>backend/aci/common/encryption.py (1)</summary>

* `decrypt` (46-49)

</details>

</blockquote></details>
<details>
<summary>backend/aci/server/routes/apps.py (5)</summary><blockquote>

<details>
<summary>backend/aci/common/schemas/security_scheme.py (1)</summary>

* `SecuritySchemesPublic` (161-168)

</details>
<details>
<summary>backend/aci/server/dependencies.py (2)</summary>

* `RequestContext` (32-37)
* `get_request_context` (125-144)

</details>
<details>
<summary>backend/aci/common/schemas/app.py (2)</summary>

* `AppsList` (122-131)
* `AppDetails` (142-164)

</details>
<details>
<summary>backend/aci/common/db/crud/apps.py (1)</summary>

* `get_apps` (80-99)

</details>
<details>
<summary>backend/aci/common/schemas/function.py (1)</summary>

* `FunctionDetails` (158-174)

</details>

</blockquote></details>
<details>
<summary>backend/aci/common/tests/crud/test_linked_accounts.py (2)</summary><blockquote>

<details>
<summary>backend/aci/common/schemas/security_scheme.py (3)</summary>

* `OAuth2SchemeCredentials` (132-147)
* `NoAuthSchemeCredentials` (150-158)
* `APIKeySchemeCredentials` (119-129)

</details>
<details>
<summary>backend/aci/common/enums.py (1)</summary>

* `SecurityScheme` (12-21)

</details>

</blockquote></details>
<details>
<summary>backend/aci/server/security_credentials_manager.py (5)</summary><blockquote>

<details>
<summary>backend/aci/common/db/sql_models.py (1)</summary>

* `AppConfiguration` (309-370)

</details>
<details>
<summary>backend/aci/common/schemas/security_scheme.py (3)</summary>

* `SecuritySchemeOverrides` (171-177)
* `OAuth2SchemeCredentials` (132-147)
* `OAuth2Scheme` (28-73)

</details>
<details>
<summary>backend/aci/common/enums.py (1)</summary>

* `SecurityScheme` (12-21)

</details>
<details>
<summary>backend/aci/server/oauth2_manager.py (2)</summary>

* `refresh_token` (151-168)
* `OAuth2Manager` (16-261)

</details>
<details>
<summary>backend/aci/common/exceptions.py (1)</summary>

* `OAuth2Error` (424-434)

</details>

</blockquote></details>
<details>
<summary>backend/aci/common/schemas/app.py (1)</summary><blockquote>

<details>
<summary>backend/aci/common/schemas/security_scheme.py (1)</summary>

* `SecuritySchemesPublic` (161-168)

</details>

</blockquote></details>
<details>
<summary>backend/aci/server/oauth2_manager.py (2)</summary><blockquote>

<details>
<summary>backend/aci/common/schemas/security_scheme.py (1)</summary>

* `OAuth2SchemeCredentials` (132-147)

</details>
<details>
<summary>backend/aci/common/exceptions.py (1)</summary>

* `OAuth2Error` (424-434)

</details>

</blockquote></details>
<details>
<summary>backend/aci/server/routes/linked_accounts.py (3)</summary><blockquote>

<details>
<summary>backend/aci/server/security_credentials_manager.py (1)</summary>

* `get_app_configuration_oauth2_scheme` (197-214)

</details>
<details>
<summary>backend/aci/server/oauth2_manager.py (3)</summary>

* `OAuth2Manager` (16-261)
* `fetch_token` (115-149)
* `parse_fetch_token_response` (170-218)

</details>
<details>
<summary>backend/aci/common/exceptions.py (1)</summary>

* `OAuth2Error` (424-434)

</details>

</blockquote></details>
<details>
<summary>backend/aci/common/schemas/app_configurations.py (2)</summary><blockquote>

<details>
<summary>backend/aci/common/schemas/security_scheme.py (1)</summary>

* `SecuritySchemeOverrides` (171-177)

</details>
<details>
<summary>backend/aci/common/enums.py (1)</summary>

* `SecurityScheme` (12-21)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (2)</summary>

* GitHub Check: Format & Lint
* GitHub Check: Compose Tests

</details>

<details>
<summary>🔇 Additional comments (23)</summary><blockquote>

<details>
<summary>backend/aci/server/routes/functions.py (2)</summary>

`398-399`: **Updated parameters for security credentials retrieval**

The call to `get_security_credentials` now correctly includes the `app_configuration` parameter, allowing proper handling of OAuth2 credentials with custom client configuration overrides. This change aligns with the new feature to support custom OAuth2 applications.

---

`430-435`: **Code improvement: Simplified custom instruction violation check logic**

Removing the conditional check around `custom_instructions.check_for_violation` makes the code more straightforward. The function already handles failures gracefully by wrapping the inference call in a try-except block.

</details>
<details>
<summary>backend/aci/common/schemas/plans.py (1)</summary>

`13-13`: **Improved Pydantic configuration syntax**

Updated the model configuration to use the more concise inheritance-based approach for setting `extra="forbid"` rather than a separate `model_config` attribute. This makes the code more maintainable and follows modern Pydantic practices.

</details>
<details>
<summary>backend/aci/server/tests/conftest.py (1)</summary>

`555-557`: **Updated OAuth2 test fixture with required new fields**

Added necessary fields (`client_id`, `client_secret`, and `scope`) to support the custom OAuth2 client feature. This ensures tests properly reflect the new credential structure used in production code.

</details>
<details>
<summary>backend/aci/common/schemas/linked_accounts.py (1)</summary>

`38-41`: **Added client_id tracking for OAuth2 flow security**

The new `client_id` field in `LinkedAccountOAuth2CreateState` with clear documentation is an excellent security enhancement. By storing and verifying the client ID used at the start of the OAuth2 flow, you prevent potential issues if users change their client ID configuration during an ongoing OAuth2 authorization flow.

</details>
<details>
<summary>backend/aci/common/db/crud/app_configurations.py (1)</summary>

`31-33`: **Good use of exclude_none=True to clean overrides data.**

The addition of `exclude_none=True` when dumping the security_scheme_overrides model ensures that null values don't unnecessarily populate the database record, leading to cleaner data storage.

Note that the `# type: ignore` comment suggests potential type system complexity with the returned value. While this works, you might want to consider addressing the underlying typing issue in a future refactoring.

</details>
<details>
<summary>backend/aci/server/tests/crud/custom_sql_types/test_encrypted_security_credentials.py (4)</summary>

`78-86`: **LGTM - Good test data setup for OAuth2 credentials.**

The test correctly sets up the expected values for client_id, client_secret, and scope which aligns with the updated OAuth2SchemeCredentials model. This provides good test coverage for the new fields added to support custom OAuth2 applications.

---

`118-119`: **LGTM - Proper validation of OAuth2 client fields.**

These assertions correctly verify that the client_id and client_secret are retrieved from the database with their expected values.

---

`131-139`: **LGTM - Thorough encryption testing for client_secret.**

Good verification of both the encryption and decryption of the client_secret field. This ensures that sensitive OAuth2 credentials are properly secured in the database and can be successfully retrieved when needed.

---

`182-184`: **LGTM - Updated mutable dict test with new fields.**

The test case correctly includes the new required fields (client_id, client_secret, and scope) in the OAuth2SchemeCredentials for the mutable dict detection test.

</details>
<details>
<summary>backend/aci/server/tests/routes/app_configurations/test_app_configurations_add.py (1)</summary>

`44-71`: **Excellent test for OAuth2 security scheme overrides.**

This test comprehensively verifies the core functionality of this PR - allowing custom OAuth2 credentials. The test:

1. Creates an app configuration with OAuth2 security scheme and custom client_id/client_secret
2. Verifies the client_id is preserved in the response
3. Confirms that the client_secret is properly masked in the response for security

This test provides good coverage of both the functionality and security aspects of the feature.

</details>
<details>
<summary>backend/aci/common/tests/crud/test_linked_accounts.py (2)</summary>

`20-26`: **LGTM - Updated test params for OAuth2 credentials.**

The test parameters have been correctly updated to include the new required fields (client_id, client_secret, and scope) in the OAuth2SchemeCredentials model.

---

`36-37`: **LGTM - Improved test cases for credential validation.**

The test cases for mismatched security schemes have been appropriately updated to use NoAuthSchemeCredentials, providing better test coverage for credential validation.

</details>
<details>
<summary>backend/aci/common/db/custom_sql_types.py (2)</summary>

`103-106`: **Good addition of client_secret encryption**

The encryption handling for the `client_secret` field has been correctly implemented in the OAuth2SchemeCredentials processing logic, following the same pattern as other sensitive fields like `access_token` and `refresh_token`.

---

`137-140`: **LGTM on client_secret decryption implementation**

The decryption handling for the `client_secret` field matches the encryption implementation, maintaining consistency with the existing pattern for other sensitive fields.

</details>
<details>
<summary>backend/aci/common/schemas/app.py (2)</summary>

`16-16`: **Appropriate import for new security scheme model**

The import of `SecuritySchemesPublic` is necessary for the new field added to the `AppDetails` model.

---

`20-20`: **Good security practice with extra="forbid"**

Adding `extra="forbid"` to the `AppUpsert` model enforces strict validation by rejecting any additional fields not defined in the model, preventing potential security issues from unexpected input data.

</details>
<details>
<summary>backend/aci/server/routes/apps.py (3)</summary>

`18-18`: **Required import for security scheme validation**

The import of `SecuritySchemesPublic` is needed for the security scheme validation in both endpoints.

---

`28-28`: **Improves API response efficiency**

Adding `response_model_exclude_none=True` ensures that fields with `None` values are omitted from the JSON responses, reducing response size and improving readability.



Also applies to: 140-140

---

`186-186`: **Enhances security scheme data safety**

Adding the `supported_security_schemes` field with proper validation ensures consistent handling of security scheme data across endpoints and prevents exposure of sensitive credentials.

</details>
<details>
<summary>backend/aci/server/oauth2_manager.py (3)</summary>

`44-50`: **Good refactoring to store OAuth2 credentials as instance attributes**

Storing the client credentials and token endpoint auth method as instance attributes makes them available throughout the class, enabling a more coherent approach to OAuth2 authorization and token handling.

---

`54-62`: **Important clarification on scope usage**

The comment explains why scope isn't passed during client initialization (to avoid sending it during refresh token requests), which is a good practice with many OAuth2 providers.

---

`108-108`: **Ensures consistent scope application**

Explicitly including `scope=self.scope` in both the authorization URL creation and token fetching ensures that the correct scope is consistently used across the OAuth2 flow.



Also applies to: 140-140

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T12:11:35Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (5)</summary><blockquote>

<details>
<summary>backend/aci/server/tests/routes/linked_acounts/test_accounts_link_oauth2.py (2)</summary><blockquote>

`54-71`: **Minor duplication when building `AppConfigurationCreate`**  
The only difference between the two branches is the optional `security_scheme_overrides`.  Building the shared kwargs first and adding the override conditionally would keep this block DRY and easier to extend (e.g. if scope override is added later).

```diff
-if use_custom_oauth2_app:
-    app_configuration_create = AppConfigurationCreate(
-        app_name=dummy_app_google.name,
-        security_scheme=SecurityScheme.OAUTH2,
-        security_scheme_overrides=SecuritySchemeOverrides(
-            oauth2=OAuth2SchemeOverride(
-                client_id="custom_client_id",
-                client_secret="custom_client_secret",
-            )
-        ),
-    )
-else:
-    app_configuration_create = AppConfigurationCreate(
-        app_name=dummy_app_google.name,
-        security_scheme=SecurityScheme.OAUTH2,
-    )
+base_kwargs = dict(
+    app_name=dummy_app_google.name,
+    security_scheme=SecurityScheme.OAUTH2,
+)
+if use_custom_oauth2_app:
+    base_kwargs["security_scheme_overrides"] = SecuritySchemeOverrides(
+        oauth2=OAuth2SchemeOverride(
+            client_id="custom_client_id",
+            client_secret="custom_client_secret",
+        )
+    )
+app_configuration_create = AppConfigurationCreate(**base_kwargs)
```

---

`153-156`: **Scope assertion may miss future override scenarios**  
If a future ticket allows overriding `scope`, this assertion will start failing. Consider asserting against `google_app_configuration.security_scheme_overrides.oauth2.scope` if present, otherwise fall back to the default, to make the test resilient.

</blockquote></details>
<details>
<summary>backend/aci/server/tests/routes/functions/test_functions_execute_with_oauth2_account.py (3)</summary><blockquote>

`26-42`: **Credential patch logic duplicated across tests**  
The block that mutates `client_id`/`client_secret` and persists via `crud.linked_accounts.update_linked_account_credentials` appears twice. Extracting a small helper (or autouse fixture taking `use_custom_oauth2_app`) would remove duplication and reduce maintenance.

---

`109-125`: **Same duplication note as earlier block**  
Applies the same mutation logic; centralising will make future field additions (e.g., token_endpoint overrides) easier.

---

`150-161`: **Setting `expires_at = 0` works but is semantically unclear**  
Using `time.time() - 1` better conveys “already expired” while still exercising the code path.

```diff
-linked_account_oauth2_credentials.expires_at = 0
+linked_account_oauth2_credentials.expires_at = int(time.time()) - 1
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 1474c5f56e49aceab72e16fcfbc8e6b033cd01b4 and 6cd0f7e9dd9e78e54da4c012c025f262813d9ced.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `backend/aci/server/tests/routes/functions/test_functions_execute_with_oauth2_account.py` (3 hunks)
* `backend/aci/server/tests/routes/linked_acounts/test_accounts_link_oauth2.py` (4 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>backend/aci/server/tests/routes/functions/test_functions_execute_with_oauth2_account.py (3)</summary><blockquote>

<details>
<summary>backend/aci/server/tests/conftest.py (5)</summary>

* `db_session` (121-123)
* `test_client` (112-117)
* `dummy_agent_1_with_all_apps_allowed` (220-225)
* `dummy_function_aci_test__hello_world_with_args` (340-347)
* `dummy_linked_account_oauth2_aci_test_project_1` (668-683)

</details>
<details>
<summary>backend/aci/common/db/crud/linked_accounts.py (1)</summary>

* `update_linked_account_credentials` (113-132)

</details>
<details>
<summary>backend/aci/common/schemas/security_scheme.py (1)</summary>

* `OAuth2SchemeCredentials` (132-147)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (3)</summary>

* GitHub Check: Compose Tests
* GitHub Check: Format, Lint, and Test
* GitHub Check: Format & Lint

</details>

<details>
<summary>🔇 Additional comments (7)</summary><blockquote>

<details>
<summary>backend/aci/server/tests/routes/linked_acounts/test_accounts_link_oauth2.py (4)</summary>

`15-18`: **Imports look good and keep tests self-contained**  
The additional schema imports are correctly scoped to the test module and avoid circular dependencies.

---

`36-43`: **Nice use of `pytest.mark.parametrize` for matrix coverage**  
Covering both redirect / non-redirect and custom / default client flows in a single test dramatically reduces duplication while increasing branch coverage. 👍

---

`97-111`: **Assertion on `state.client_id` is clear and forward-compatible**  
Good to assert both branches explicitly; this will catch regressions if default credentials ever disappear or custom ones fail to propagate.

---

`169-180`: **Plain-text client secret round-trip assumes transparent encryption**  
The test checks that the stored `client_secret` equals the raw secret. This succeeds only if the DB custom SQL type decrypts transparently on read. If encryption becomes write-only (e.g., hash comparison), this assertion will break. Flagging so it’s not mis-diagnosed later.


Would you like a helper that inspects the SQL type behaviour and adjusts the expectation automatically?

</details>
<details>
<summary>backend/aci/server/tests/routes/functions/test_functions_execute_with_oauth2_account.py (3)</summary>

`17-18`: **Good parameterisation for custom vs default client**  
Keeps both tests aligned with account-link coverage.

---

`84-97`: **Assertion correctly couples Authorization header to scheme prefix**  
Nice touch validating both the prefix and token; prevents silent regressions if a different auth style sneaks in.

---

`192-206`: **Post-refresh credential persistence validated correctly**  
Refresh flow is fully exercised, confirming both outbound HTTP and DB persistence. 👌

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T14:15:29Z
user: login: hanyixxx
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T14:16:36Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T14:18:08Z
user: login: hanyixxx
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T14:22:51Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T14:27:52Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>backend/aci/server/tests/routes/functions/test_functions_execute_with_oauth2_account.py (2)</summary><blockquote>

`17-42`: **Consider extracting duplicated credential update logic.**

The credential update logic appears in both test functions with nearly identical implementation. Consider extracting this into a helper method to reduce duplication and improve maintainability.


```diff
+ def _update_linked_account_with_custom_oauth2_credentials(
+     db_session: Session,
+     linked_account: LinkedAccount,
+ ) -> None:
+     """Update linked account with custom OAuth2 credentials for testing."""
+     linked_account.security_credentials["client_id"] = "custom_client_id"
+     linked_account.security_credentials["client_secret"] = "custom_client_secret"
+     crud.linked_accounts.update_linked_account_credentials(
+         db_session,
+         linked_account,
+         security_credentials=OAuth2SchemeCredentials.model_validate(
+             linked_account.security_credentials
+         ),
+     )
+     db_session.commit()

 @respx.mock
 @pytest.mark.parametrize("use_custom_oauth2_app", [True, False])
 def test_execute_oauth2_based_function_with_linked_account_credentials(
     db_session: Session,
     test_client: TestClient,
     dummy_agent_1_with_all_apps_allowed: Agent,
     dummy_function_aci_test__hello_world_with_args: Function,
     dummy_linked_account_oauth2_aci_test_project_1: LinkedAccount,
     use_custom_oauth2_app: bool,
 ) -> None:
     # update the linked account's credentials if use_custom_oauth2_app is True
     if use_custom_oauth2_app:
-        dummy_linked_account_oauth2_aci_test_project_1.security_credentials["client_id"] = (
-            "custom_client_id"
-        )
-        dummy_linked_account_oauth2_aci_test_project_1.security_credentials["client_secret"] = (
-            "custom_client_secret"
-        )
-        crud.linked_accounts.update_linked_account_credentials(
-            db_session,
-            dummy_linked_account_oauth2_aci_test_project_1,
-            security_credentials=OAuth2SchemeCredentials.model_validate(
-                dummy_linked_account_oauth2_aci_test_project_1.security_credentials
-            ),
-        )
-        db_session.commit()
+        _update_linked_account_with_custom_oauth2_credentials(
+            db_session, 
+            dummy_linked_account_oauth2_aci_test_project_1
+        )
```

---

`110-125`: **Use the extracted helper method here as well.**

This is the second occurrence of the same credential update logic. If you implement the helper method suggested above, you can replace this block with a single call to that method.


```diff
     # update the linked account's credentials if use_custom_oauth2_app is True
     if use_custom_oauth2_app:
-        dummy_linked_account_oauth2_aci_test_project_1.security_credentials["client_id"] = (
-            "custom_client_id"
-        )
-        dummy_linked_account_oauth2_aci_test_project_1.security_credentials["client_secret"] = (
-            "custom_client_secret"
-        )
-        crud.linked_accounts.update_linked_account_credentials(
-            db_session,
-            dummy_linked_account_oauth2_aci_test_project_1,
-            security_credentials=OAuth2SchemeCredentials.model_validate(
-                dummy_linked_account_oauth2_aci_test_project_1.security_credentials
-            ),
-        )
-        db_session.commit()
+        _update_linked_account_with_custom_oauth2_credentials(
+            db_session, 
+            dummy_linked_account_oauth2_aci_test_project_1
+        )
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 6cd0f7e9dd9e78e54da4c012c025f262813d9ced and dd4329581ded4fb9095cd5756dd15b35aa869fc3.

</details>

<details>
<summary>📒 Files selected for processing (19)</summary>

* `backend/aci/common/db/crud/app_configurations.py` (1 hunks)
* `backend/aci/common/db/custom_sql_types.py` (2 hunks)
* `backend/aci/common/schemas/app.py` (2 hunks)
* `backend/aci/common/schemas/app_configurations.py` (4 hunks)
* `backend/aci/common/schemas/linked_accounts.py` (1 hunks)
* `backend/aci/common/schemas/plans.py` (1 hunks)
* `backend/aci/common/schemas/security_scheme.py` (6 hunks)
* `backend/aci/common/tests/crud/test_linked_accounts.py` (2 hunks)
* `backend/aci/server/oauth2_manager.py` (4 hunks)
* `backend/aci/server/routes/apps.py` (6 hunks)
* `backend/aci/server/routes/functions.py` (2 hunks)
* `backend/aci/server/routes/linked_accounts.py` (5 hunks)
* `backend/aci/server/security_credentials_manager.py` (7 hunks)
* `backend/aci/server/tests/conftest.py` (1 hunks)
* `backend/aci/server/tests/crud/custom_sql_types/test_encrypted_security_credentials.py` (4 hunks)
* `backend/aci/server/tests/dummy_apps/aci_test/app.json` (0 hunks)
* `backend/aci/server/tests/routes/app_configurations/test_app_configurations_add.py` (1 hunks)
* `backend/aci/server/tests/routes/functions/test_functions_execute_with_oauth2_account.py` (3 hunks)
* `backend/aci/server/tests/routes/linked_acounts/test_accounts_link_oauth2.py` (4 hunks)

</details>

<details>
<summary>💤 Files with no reviewable changes (1)</summary>

* backend/aci/server/tests/dummy_apps/aci_test/app.json

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (16)</summary>

* backend/aci/server/routes/functions.py
* backend/aci/server/tests/conftest.py
* backend/aci/common/schemas/linked_accounts.py
* backend/aci/common/db/crud/app_configurations.py
* backend/aci/common/schemas/plans.py
* backend/aci/server/tests/crud/custom_sql_types/test_encrypted_security_credentials.py
* backend/aci/common/db/custom_sql_types.py
* backend/aci/server/tests/routes/app_configurations/test_app_configurations_add.py
* backend/aci/common/schemas/app.py
* backend/aci/common/tests/crud/test_linked_accounts.py
* backend/aci/server/routes/linked_accounts.py
* backend/aci/server/oauth2_manager.py
* backend/aci/server/routes/apps.py
* backend/aci/server/security_credentials_manager.py
* backend/aci/common/schemas/app_configurations.py
* backend/aci/common/schemas/security_scheme.py

</details>

<details>
<summary>🔇 Additional comments (8)</summary><blockquote>

<details>
<summary>backend/aci/server/tests/routes/linked_acounts/test_accounts_link_oauth2.py (6)</summary>

`15-28`: **Good job with structured imports for new schema types.**

The imports are well-organized, clearly separating the app configuration schemas and security scheme-related schemas. This makes the dependencies of this test file clear and maintainable.

---

`36-43`: **Well-designed test parameterization.**

The parameterized test cases thoroughly cover both default and custom OAuth2 client configurations, with additional variations for redirect behavior. This comprehensive approach ensures all combinations are tested.

---

`54-77`: **Clean implementation of dynamic app configuration setup.**

The test now dynamically creates app configurations with or without security scheme overrides based on the `use_custom_oauth2_app` parameter. This approach clearly isolates the testing of custom OAuth2 client functionality.

---

`105-110`: **Thorough validation of OAuth2 state parameters.**

The test correctly validates that the client ID in the OAuth2 state JWT matches the expected value based on whether custom credentials are used. This ensures the OAuth2 flow properly carries the right client identifier.

---

`153-156`: **Good assertion for OAuth2 scope preservation.**

The test correctly verifies that the OAuth2 scope from the original application is preserved in the linked account credentials, ensuring that proper authorization scopes are maintained regardless of client customization.

---

`169-180`: **Complete verification of OAuth2 credential binding.**

The test thoroughly verifies that the client ID and client secret are properly stored in the linked account credentials, ensuring they match either the custom values or default values depending on the test case.

</details>
<details>
<summary>backend/aci/server/tests/routes/functions/test_functions_execute_with_oauth2_account.py (2)</summary>

`85-96`: **Good validation of request credentials.**

The test properly verifies that the request is made with the correct OAuth2 credentials from the linked account. This ensures that the custom OAuth2 client configuration is correctly used during function execution.

---

`192-198`: **Thorough verification of token refresh flow.**

The test effectively validates that expired tokens are properly refreshed and that the new token is used in subsequent requests. This ensures the token refresh mechanism works correctly with both default and custom OAuth2 client configurations.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T14:37:10Z

Review comments:
diff_hunk: @@ -49,6 +49,30 @@ async def list_apps(
         query_params.offset,
     )
 
+    response: list[AppDetails] = []
+    for app in apps:
+        app_details = AppDetails(
+            id=app.id,
+            name=app.name,
+            display_name=app.display_name,
+            provider=app.provider,
+            version=app.version,
+            description=app.description,
+            logo=app.logo,
+            categories=app.categories,
+            visibility=app.visibility,
+            active=app.active,
+            security_schemes=list(app.security_schemes.keys()),
+            # TODO: check validation latency
+            supported_security_schemes=SecuritySchemesPublic.model_validate(app.security_schemes),
+            functions=[FunctionDetails.model_validate(function) for function in app.functions],
+            created_at=app.created_at,
+            updated_at=app.updated_at,
+        )
+        response.append(app_details)
+
+    return response
path: backend/aci/server/routes/apps.py
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Well-structured refactoring of response generation**

The refactored `list_apps` endpoint now explicitly constructs `AppDetails` objects with proper validation for security schemes, ensuring sensitive data isn't exposed in API responses.


Consider monitoring the performance impact of the additional validation step as mentioned in the TODO comment on line 67.

---

🏁 Script executed:

```shell
#!/bin/bash
# Check if there's any existing performance monitoring for validation latency
rg -i "validation latency|performance|model_validate" --type py --glob "backend/aci/server/**/*.py"
```

Length of output: 15363

---



</details>

**Approve list_apps endpoint refactoring**

The explicit construction of `AppDetails` and use of `model_validate` for security schemes and functions is correct and prevents leakage of sensitive fields.

However, there’s no existing performance monitoring around these validation calls. Please add instrumentation to measure validation latency, for example:

- In `backend/aci/server/routes/apps.py` (around lines 67–68):
  - Wrap `supported_security_schemes = SecuritySchemesPublic.model_validate(...)` in a timer/histogram.
  - Similarly instrument the list comprehension calling `FunctionDetails.model_validate`.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-12T12:11:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,6 +21,13 @@ class AppConfigurationPublic(BaseModel):
 
     model_config = ConfigDict(from_attributes=True)
 
+    # scrub the client_secret in SecuritySchemeOverrides if present
+    @model_validator(mode="after")
+    def scrub_client_secret(self) -> "AppConfigurationPublic":
+        if self.security_scheme_overrides.oauth2:
+            self.security_scheme_overrides.oauth2.client_secret = "******"
+        return self
+
path: backend/aci/common/schemas/app_configurations.py
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Scrubbing the secret mutates the stored model instance**

`scrub_client_secret()` overwrites `client_secret` in-place. Any caller that keeps a reference to the returned `AppConfigurationPublic` object (e.g. caches, audit trails) now holds a redacted secret, making later serialisation or comparison impossible.

Safer pattern:

```diff
@model_validator(mode="after")
def scrub_client_secret(self) -> "AppConfigurationPublic":
-    if self.security_scheme_overrides.oauth2:
-        self.security_scheme_overrides.oauth2.client_secret = "******"
-    return self
+    if self.security_scheme_overrides.oauth2:
+        object.__setattr__(
+            self.security_scheme_overrides.oauth2, "client_secret", "******"
+        )
+    return self
```

Using `object.__setattr__` prevents accidental propagation to the original value stored in SQL.  



> Committable suggestion skipped: line range outside the PR's diff.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-12T12:11:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -146,46 +105,36 @@ async def _get_oauth2_credentials(
             update=fields_to_update,
         )
         is_updated = True
-    else:
-        logger.debug(
-            "access token is valid",
-            extra={
-                "linked_account": linked_account.id,
-                "security_scheme": linked_account.security_scheme,
-                "app": app.name,
-            },
-        )
 
     return SecurityCredentialsResponse(
         scheme=oauth2_scheme,
         credentials=oauth2_scheme_credentials,
-        is_app_default_credentials=is_app_default_credentials,
+        is_app_default_credentials=False,  # Should never support default credentials for oauth2
         is_updated=is_updated,
     )
 
 
-async def _refresh_oauth2_access_token(app: App, refresh_token: str) -> dict:
-    app_default_oauth2_config = OAuth2Scheme.model_validate(
-        app.security_schemes[SecurityScheme.OAUTH2]
-    )
+async def _refresh_oauth2_access_token(
+    app_name: str, oauth2_scheme: OAuth2Scheme, oauth2_scheme_credentials: OAuth2SchemeCredentials
+) -> dict:
+    refresh_token = oauth2_scheme_credentials.refresh_token
+    if not refresh_token:
+        raise OAuth2Error("no refresh token found")
+
+    # NOTE: it's important to use oauth2_scheme_credentials's client_id, client_secret, scope because
+    # these fields might have changed for the app configuration after the linked account was created
     oauth2_manager = OAuth2Manager(
-        app_name=app.name,
-        client_id=app_default_oauth2_config.client_id,
-        client_secret=app_default_oauth2_config.client_secret,
-        scope=app_default_oauth2_config.scope,
-        authorize_url=app_default_oauth2_config.authorize_url,
-        access_token_url=app_default_oauth2_config.access_token_url,
-        refresh_token_url=app_default_oauth2_config.refresh_token_url,
-        token_endpoint_auth_method=app_default_oauth2_config.token_endpoint_auth_method,
+        app_name=app_name,
+        client_id=oauth2_scheme_credentials.client_id,
+        client_secret=oauth2_scheme_credentials.client_secret,
+        scope=oauth2_scheme_credentials.scope,
+        authorize_url=oauth2_scheme.authorize_url,
+        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,
path: backend/aci/server/security_credentials_manager.py
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Persist refreshed tokens immediately**

After refreshing the token, `_get_oauth2_credentials()` returns `is_updated=True`, relying on the caller to persist changes. If the caller forgets, refreshed tokens are lost and the next request triggers another refresh loop.

Consider persisting inside this module (transaction-safe) or providing a dedicated helper that callers must use to guarantee persistence.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-12T12:11:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -243,3 +192,23 @@ def _access_token_is_expired(oauth2_credentials: OAuth2SchemeCredentials) -> boo
     if oauth2_credentials.expires_at is None:
         return False
     return oauth2_credentials.expires_at < int(time.time())
+
+
+def get_app_configuration_oauth2_scheme(
+    app: App, app_configuration: AppConfiguration
+) -> OAuth2Scheme:
+    """
+    Get the OAuth2 scheme for an app configuration, taking into account potential overrides.
+    """
+    oauth2_scheme_data = app.security_schemes[SecurityScheme.OAUTH2]
+
+    # Parse the security scheme overrides
+    security_scheme_overrides = SecuritySchemeOverrides.model_validate(
+        app_configuration.security_scheme_overrides
+    )
+
+    # Apply oauth2 overrides if they exist
+    if security_scheme_overrides.oauth2:
+        oauth2_scheme_data.update(security_scheme_overrides.oauth2.model_dump(exclude_none=True))
+
+    return OAuth2Scheme.model_validate(oauth2_scheme_data)
path: backend/aci/server/security_credentials_manager.py
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**In-place mutation of shared app state causes cross-tenant leakage**

`oauth2_scheme_data = app.security_schemes[SecurityScheme.OAUTH2]`  
`oauth2_scheme_data.update(…)`

Because `dict.update()` mutates the *shared* `security_schemes` dict attached to `App`, overrides for **one** project bleed into all other projects using the same `App` instance in the current process.

```diff
-    oauth2_scheme_data = app.security_schemes[SecurityScheme.OAUTH2]
+    oauth2_scheme_data = dict(app.security_schemes[SecurityScheme.OAUTH2])  # shallow copy
```

Always copy before patching to keep tenant isolation intact.  



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
    oauth2_scheme_data = dict(app.security_schemes[SecurityScheme.OAUTH2])  # shallow copy

    # Parse the security scheme overrides
    security_scheme_overrides = SecuritySchemeOverrides.model_validate(
        app_configuration.security_scheme_overrides
    )

    # Apply oauth2 overrides if they exist
    if security_scheme_overrides.oauth2:
        oauth2_scheme_data.update(security_scheme_overrides.oauth2.model_dump(exclude_none=True))

    return OAuth2Scheme.model_validate(oauth2_scheme_data)
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-12T12:11:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,59 +52,17 @@ async def get_security_credentials(
 
 
 async def _get_oauth2_credentials(
-    app: App, linked_account: LinkedAccount
+    app: App, app_configuration: AppConfiguration, linked_account: LinkedAccount
 ) -> SecurityCredentialsResponse:
     """Get OAuth2 credentials from linked account or app's default credentials.
     If the access token is expired, it will be refreshed.
     """
-    is_app_default_credentials = False
     is_updated = False
-    oauth2_scheme = OAuth2Scheme.model_validate(app.security_schemes[SecurityScheme.OAUTH2])
-
-    if linked_account.security_credentials:
-        logger.debug(
-            "using linked account's security credentials",
-            extra={
-                "linked_account": linked_account.id,
-                "security_scheme": linked_account.security_scheme,
-            },
-        )
-        oauth2_credentials = linked_account.security_credentials
-
-    elif app.default_security_credentials_by_scheme.get(linked_account.security_scheme):
-        is_app_default_credentials = True
-        logger.info(
-            "using app's default security credentials",
-            extra={
-                "app": app.name,
-                "security_scheme": linked_account.security_scheme,
-                "linked_account": linked_account.id,
-            },
-        )
-        oauth2_credentials = app.default_security_credentials_by_scheme[
-            linked_account.security_scheme
-        ]
-    else:
-        logger.error(
-            "no security credentials usable",
-            extra={
-                "linked_account": linked_account.id,
-                "security_scheme": linked_account.security_scheme,
-                "app": app.name,
-            },
-        )
-        # TODO: check all 'NoImplementationFound' exceptions see if a more suitable exception can be used
-        raise NoImplementationFound(
-            f"no security credentials usable for app={app.name}, "
-            f"security_scheme={linked_account.security_scheme}, "
-            f"linked_account_owner_id={linked_account.linked_account_owner_id}"
-        )
-
-    oauth2_scheme_credentials = OAuth2SchemeCredentials.model_validate(oauth2_credentials)
-    if (
-        _access_token_is_expired(oauth2_scheme_credentials)
-        and oauth2_scheme_credentials.refresh_token
-    ):
+    oauth2_scheme = get_app_configuration_oauth2_scheme(app_configuration.app, app_configuration)
+    oauth2_scheme_credentials = OAuth2SchemeCredentials.model_validate(
+        linked_account.security_credentials
+    )
+    if _access_token_is_expired(oauth2_scheme_credentials):
         logger.warning(
path: backend/aci/server/security_credentials_manager.py
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Backwards-compat break: existing credentials will fail validation**

`OAuth2SchemeCredentials.model_validate(linked_account.security_credentials)` now expects `client_id`, `client_secret`, and `scope`.  
Linked-account rows created before this PR do not contain these keys, so validation raises a `ValidationError`, breaking every token refresh.

Mitigation ideas:
1. Provide default values during validation:
```python
oauth2_scheme_credentials = OAuth2SchemeCredentials.model_validate(
    {**linked_account.security_credentials, **{
        "client_id": oauth2_scheme.client_id,
        "client_secret": oauth2_scheme.client_secret,
        "scope": oauth2_scheme.scope,
    }}
)
```
2. Or run a DB migration to back-fill the three fields before deploying.

---

🏁 Script executed:

```shell
#!/usr/bin/env bash
# Locate the definition of OAuth2SchemeCredentials to inspect its required fields
rg -n "class OAuth2SchemeCredentials"
```

Length of output: 131

---

🏁 Script executed:

```shell
#!/usr/bin/env bash
# Show the OAuth2SchemeCredentials class definition and its fields with defaults
rg -n "class OAuth2SchemeCredentials" -A 50 backend/aci/common/schemas/security_scheme.py
```

Length of output: 2229

---

🏁 Script executed:

```shell
#!/usr/bin/env bash
# Find and show the definition of get_app_configuration_oauth2_scheme to inspect its return type
rg -n "def get_app_configuration_oauth2_scheme" -A 30 backend/aci
```

Length of output: 1761

---



</details>

**Backwards-compat break: OAuth2SchemeCredentials now requires new fields**  
Existing `linked_account.security_credentials` blobs don’t include `client_id`, `client_secret`, or `scope`, so  
```python
OAuth2SchemeCredentials.model_validate(linked_account.security_credentials)
```
will raise a `ValidationError` and break every token-refresh call.

Affected locations:  
- `backend/aci/server/security_credentials_manager.py` (around lines 61–66)  
- `backend/aci/common/schemas/security_scheme.py` (OAuth2SchemeCredentials declaration)

Mitigation options:  
1. Merge in defaults from the resolved OAuth2 scheme before validation:  
   ```diff
   - oauth2_scheme_credentials = OAuth2SchemeCredentials.model_validate(
   -     linked_account.security_credentials
   - )
   + oauth2_scheme_credentials = OAuth2SchemeCredentials.model_validate({
   +     **linked_account.security_credentials,
   +     "client_id":    oauth2_scheme.client_id,
   +     "client_secret":oauth2_scheme.client_secret,
   +     "scope":        oauth2_scheme.scope,
   + })
   ```
2. Or provide a DB migration to back-fill `client_id`, `client_secret`, and `scope` for all existing linked-account records.

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-12T12:11:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -243,3 +192,23 @@ def _access_token_is_expired(oauth2_credentials: OAuth2SchemeCredentials) -> boo
     if oauth2_credentials.expires_at is None:
         return False
     return oauth2_credentials.expires_at < int(time.time())
+
+
+def get_app_configuration_oauth2_scheme(
+    app: App, app_configuration: AppConfiguration
+) -> OAuth2Scheme:
+    """
+    Get the OAuth2 scheme for an app configuration, taking into account potential overrides.
+    """
+    oauth2_scheme_data = app.security_schemes[SecurityScheme.OAUTH2]
+
+    # Parse the security scheme overrides
+    security_scheme_overrides = SecuritySchemeOverrides.model_validate(
+        app_configuration.security_scheme_overrides
+    )
+
+    # Apply oauth2 overrides if they exist
+    if security_scheme_overrides.oauth2:
+        oauth2_scheme_data.update(security_scheme_overrides.oauth2.model_dump(exclude_none=True))
+
+    return OAuth2Scheme.model_validate(oauth2_scheme_data)
path: backend/aci/server/security_credentials_manager.py
user: login: hanyixxx
body: fixed
created_at: 2025-05-12T14:16:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -243,3 +192,23 @@ def _access_token_is_expired(oauth2_credentials: OAuth2SchemeCredentials) -> boo
     if oauth2_credentials.expires_at is None:
         return False
     return oauth2_credentials.expires_at < int(time.time())
+
+
+def get_app_configuration_oauth2_scheme(
+    app: App, app_configuration: AppConfiguration
+) -> OAuth2Scheme:
+    """
+    Get the OAuth2 scheme for an app configuration, taking into account potential overrides.
+    """
+    oauth2_scheme_data = app.security_schemes[SecurityScheme.OAUTH2]
+
+    # Parse the security scheme overrides
+    security_scheme_overrides = SecuritySchemeOverrides.model_validate(
+        app_configuration.security_scheme_overrides
+    )
+
+    # Apply oauth2 overrides if they exist
+    if security_scheme_overrides.oauth2:
+        oauth2_scheme_data.update(security_scheme_overrides.oauth2.model_dump(exclude_none=True))
+
+    return OAuth2Scheme.model_validate(oauth2_scheme_data)
path: backend/aci/server/security_credentials_manager.py
user: login: coderabbitai[bot]
body: @thisisfixer Thanks for implementing the fix! Creating a shallow copy of the dictionary before modifying it is a great way to prevent cross-tenant data leakage. This ensures that OAuth2 scheme overrides remain isolated to each request and don't affect other projects using the same App instance.



(\_/)
(•ᴥ•)
/ >📝

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: thisisfixer
PR: aipotheosis-labs/aci#350
File: backend/aci/server/security_credentials_manager.py:203-214
Timestamp: 2025-05-12T14:17:08.788Z
Learning: Always create a copy of dictionaries retrieved from shared application state before modifying them to prevent cross-tenant data leakage. For example, use `dict(some_dict)` for shallow copying or `copy.deepcopy(some_dict)` for deep copying when needed.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>




<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@thisisfixer Thanks for implementing the fix! Creating a shallow copy of the dictionary before modifying it is a great way to prevent cross-tenant data leakage. This ensures that OAuth2 scheme overrides remain isolated to each request and don't affect other projects using the same App instance.



(\_/)
(•ᴥ•)
/ >📝

---

<details>
<summary>✏️ Learnings added</summary>

```
Learnt from: thisisfixer
PR: aipotheosis-labs/aci#350
File: backend/aci/server/security_credentials_manager.py:203-214
Timestamp: 2025-05-12T14:17:08.788Z
Learning: Always create a copy of dictionaries retrieved from shared application state before modifying them to prevent cross-tenant data leakage. For example, use `dict(some_dict)` for shallow copying or `copy.deepcopy(some_dict)` for deep copying when needed.
```

> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).

</details>

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-12T14:18:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,59 +52,17 @@ async def get_security_credentials(
 
 
 async def _get_oauth2_credentials(
-    app: App, linked_account: LinkedAccount
+    app: App, app_configuration: AppConfiguration, linked_account: LinkedAccount
 ) -> SecurityCredentialsResponse:
     """Get OAuth2 credentials from linked account or app's default credentials.
     If the access token is expired, it will be refreshed.
     """
-    is_app_default_credentials = False
     is_updated = False
-    oauth2_scheme = OAuth2Scheme.model_validate(app.security_schemes[SecurityScheme.OAUTH2])
-
-    if linked_account.security_credentials:
-        logger.debug(
-            "using linked account's security credentials",
-            extra={
-                "linked_account": linked_account.id,
-                "security_scheme": linked_account.security_scheme,
-            },
-        )
-        oauth2_credentials = linked_account.security_credentials
-
-    elif app.default_security_credentials_by_scheme.get(linked_account.security_scheme):
-        is_app_default_credentials = True
-        logger.info(
-            "using app's default security credentials",
-            extra={
-                "app": app.name,
-                "security_scheme": linked_account.security_scheme,
-                "linked_account": linked_account.id,
-            },
-        )
-        oauth2_credentials = app.default_security_credentials_by_scheme[
-            linked_account.security_scheme
-        ]
-    else:
-        logger.error(
-            "no security credentials usable",
-            extra={
-                "linked_account": linked_account.id,
-                "security_scheme": linked_account.security_scheme,
-                "app": app.name,
-            },
-        )
-        # TODO: check all 'NoImplementationFound' exceptions see if a more suitable exception can be used
-        raise NoImplementationFound(
-            f"no security credentials usable for app={app.name}, "
-            f"security_scheme={linked_account.security_scheme}, "
-            f"linked_account_owner_id={linked_account.linked_account_owner_id}"
-        )
-
-    oauth2_scheme_credentials = OAuth2SchemeCredentials.model_validate(oauth2_credentials)
-    if (
-        _access_token_is_expired(oauth2_scheme_credentials)
-        and oauth2_scheme_credentials.refresh_token
-    ):
+    oauth2_scheme = get_app_configuration_oauth2_scheme(app_configuration.app, app_configuration)
+    oauth2_scheme_credentials = OAuth2SchemeCredentials.model_validate(
+        linked_account.security_credentials
+    )
+    if _access_token_is_expired(oauth2_scheme_credentials):
         logger.warning(
path: backend/aci/server/security_credentials_manager.py
user: login: hanyixxx
body: will back fill
created_at: 2025-05-12T14:22:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,59 +52,17 @@ async def get_security_credentials(
 
 
 async def _get_oauth2_credentials(
-    app: App, linked_account: LinkedAccount
+    app: App, app_configuration: AppConfiguration, linked_account: LinkedAccount
 ) -> SecurityCredentialsResponse:
     """Get OAuth2 credentials from linked account or app's default credentials.
     If the access token is expired, it will be refreshed.
     """
-    is_app_default_credentials = False
     is_updated = False
-    oauth2_scheme = OAuth2Scheme.model_validate(app.security_schemes[SecurityScheme.OAUTH2])
-
-    if linked_account.security_credentials:
-        logger.debug(
-            "using linked account's security credentials",
-            extra={
-                "linked_account": linked_account.id,
-                "security_scheme": linked_account.security_scheme,
-            },
-        )
-        oauth2_credentials = linked_account.security_credentials
-
-    elif app.default_security_credentials_by_scheme.get(linked_account.security_scheme):
-        is_app_default_credentials = True
-        logger.info(
-            "using app's default security credentials",
-            extra={
-                "app": app.name,
-                "security_scheme": linked_account.security_scheme,
-                "linked_account": linked_account.id,
-            },
-        )
-        oauth2_credentials = app.default_security_credentials_by_scheme[
-            linked_account.security_scheme
-        ]
-    else:
-        logger.error(
-            "no security credentials usable",
-            extra={
-                "linked_account": linked_account.id,
-                "security_scheme": linked_account.security_scheme,
-                "app": app.name,
-            },
-        )
-        # TODO: check all 'NoImplementationFound' exceptions see if a more suitable exception can be used
-        raise NoImplementationFound(
-            f"no security credentials usable for app={app.name}, "
-            f"security_scheme={linked_account.security_scheme}, "
-            f"linked_account_owner_id={linked_account.linked_account_owner_id}"
-        )
-
-    oauth2_scheme_credentials = OAuth2SchemeCredentials.model_validate(oauth2_credentials)
-    if (
-        _access_token_is_expired(oauth2_scheme_credentials)
-        and oauth2_scheme_credentials.refresh_token
-    ):
+    oauth2_scheme = get_app_configuration_oauth2_scheme(app_configuration.app, app_configuration)
+    oauth2_scheme_credentials = OAuth2SchemeCredentials.model_validate(
+        linked_account.security_credentials
+    )
+    if _access_token_is_expired(oauth2_scheme_credentials):
         logger.warning(
path: backend/aci/server/security_credentials_manager.py
user: login: coderabbitai[bot]
body: @thisisfixer Great! Back-filling the database with the required fields (`client_id`, `client_secret`, and `scope`) before deploying is the cleanest approach to maintain backward compatibility. It ensures all existing OAuth2 linked accounts will continue to work without validation errors during token refresh.


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@thisisfixer Great! Back-filling the database with the required fields (`client_id`, `client_secret`, and `scope`) before deploying is the cleanest approach to maintain backward compatibility. It ensures all existing OAuth2 linked accounts will continue to work without validation errors during token refresh.</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-12T14:27:52Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-05-12T12:06:16Z
body: [vc]: #x0LvQb1je9LYJV+THWOylXhUokW7yUAdXULO6uWM5IM=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJhY2ktZGV2LXBvcnRhbCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJhY2ktZGV2LXBvcnRhbC1naXQtZmVhdC1jdXN0b21vLTBiN2Y2MS1vYm5veGlvdXNwcm94eXMtcHJvamVjdHMudmVyY2VsLmFwcCJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vb2Jub3hpb3VzcHJveHlzLXByb2plY3RzL2FjaS1kZXYtcG9ydGFsL0RNaG1xNTJ1VXFwS3YzVGZMcVpSRTV6UEd4aXciLCJwcmV2aWV3VXJsIjoiYWNpLWRldi1wb3J0YWwtZ2l0LWZlYXQtY3VzdG9tby0wYjdmNjEtb2Jub3hpb3VzcHJveHlzLXByb2plY3RzLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJyb290RGlyZWN0b3J5IjoiZnJvbnRlbmQifV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **aci-dev-portal** | ✅ Ready ([Inspect](https://vercel.com/obnoxiousproxys-projects/aci-dev-portal/DMhmq52uUqpKv3TfLqZRE5zPGxiw)) | [Visit Preview](https://aci-dev-portal-git-feat-customo-0b7f61-obnoxiousproxys-projects.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/aci-dev-portal-git-feat-customo-0b7f61-obnoxiousproxys-projects.vercel.app?via=pr-comment-feedback-link) | May 12, 2025 5:00pm |


author_association: NONE
user: login: coderabbitai[bot]
created_at: 2025-05-12T12:06:19Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

"""
## Walkthrough

This update introduces a comprehensive overhaul of security scheme handling, especially for OAuth2, across the backend. It adds explicit models for public and overrideable security scheme fields, enforces stricter validation, removes support for app default OAuth2 credentials, and centralizes OAuth2 configuration merging. Related test fixtures and endpoints are updated to reflect these changes, with enhanced credential encryption and masking.

## Changes

| File(s)                                                                                           | Change Summary |
|-------------------------------------------------------------------------------------------------|----------------|
| `backend/aci/common/db/crud/app_configurations.py`                                              | Refined how `security_scheme_overrides` are stored (exclude `None` values); removed update logic for security scheme fields. |
| `backend/aci/common/db/custom_sql_types.py`                                                     | Extended encryption/decryption to include the `client_secret` field in OAuth2 credential storage. |
| `backend/aci/common/schemas/app.py`                                                             | Forbids extra fields in `AppUpsert`; adds `supported_security_schemes` to `AppDetails`; removes old validator and config. |
| `backend/aci/common/schemas/app_configurations.py`                                              | Changes `security_scheme_overrides` to a typed model, adds validators for masking and scheme validation, removes update fields for overrides. |
| `backend/aci/common/schemas/linked_accounts.py`                                                 | Adds required `client_id` field to `LinkedAccountOAuth2CreateState` for OAuth2 flow integrity. |
| `backend/aci/common/schemas/plans.py`                                                           | Moves `extra="forbid"` from config attribute to class inheritance in `PlanUpdate`. |
| `backend/aci/common/schemas/security_scheme.py`                                                 | Introduces public/override models for security schemes, adds min length to OAuth2 secrets, extends credentials with client info, and adds container models. |
| `backend/aci/common/tests/crud/test_linked_accounts.py`                                         | Updates OAuth2 credential test cases to include new fields and adjust negative test pairings. |
| `backend/aci/server/oauth2_manager.py`                                                          | Stores OAuth2 client and scope in manager, refines token parsing, and removes static parsing method. |
| `backend/aci/server/routes/apps.py`                                                             | Returns `AppDetails` schemas with validated security schemes and functions; updates response models for endpoints. |
| `backend/aci/server/routes/functions.py`                                                        | Updates credential retrieval to use app configuration; always checks custom instruction violations. |
| `backend/aci/server/routes/linked_accounts.py`                                                  | Centralizes OAuth2 scheme retrieval, adds client_id consistency check, updates token parsing, and refactors linking logic. |
| `backend/aci/server/security_credentials_manager.py`                                            | Requires `app_configuration` for credential retrieval, merges overrides, removes app default OAuth2 credential support, and updates refresh logic. |
| `backend/aci/server/tests/conftest.py`                                                          | Adds `client_id`, `client_secret`, and `scope` to OAuth2 credential test fixture. |
| `backend/aci/server/tests/crud/custom_sql_types/test_encrypted_security_credentials.py`         | Adds assertions for `client_id` and `client_secret` encryption/decryption in tests. |
| `backend/aci/server/tests/dummy_apps/aci_test/app.json`                                         | Removes default OAuth2 credentials from dummy app config. |
| `backend/aci/server/tests/routes/app_configurations/test_app_configurations_add.py`             | Adds test for creating app configurations with security scheme overrides and verifies masking. |
| `backend/aci/server/tests/routes/functions/test_functions_execute_with_oauth2_account.py`       | Removes tests for executing functions with app default OAuth2 credentials and expired token refresh. |
| `backend/aci/server/tests/routes/linked_acounts/test_accounts_link_oauth2.py`                   | Enhances OAuth2 linking test to create app configurations dynamically with/without overrides and verifies client ID consistency. |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant Client
    participant API
    participant DB
    participant OAuth2Manager
    participant SecurityManager

    Client->>API: POST /app_configurations (with security_scheme_overrides)
    API->>DB: Store app configuration (overrides, exclude None)
    DB-->>API: Confirmation
    API-->>Client: App configuration (client_secret masked)

    Client->>API: POST /linked_accounts/oauth2/start
    API->>DB: Fetch app + config
    API->>SecurityManager: get_app_configuration_oauth2_scheme(app, config)
    SecurityManager-->>API: OAuth2Scheme (with overrides)
    API->>OAuth2Manager: create_authorization_url (with client_id, scope)
    OAuth2Manager-->>API: Auth URL
    API-->>Client: Auth URL + state (includes client_id)

    Client->>API: GET /linked_accounts/oauth2/callback?state=...&code=...
    API->>DB: Fetch app configuration
    API->>SecurityManager: get_app_configuration_oauth2_scheme(app, config)
    SecurityManager-->>API: OAuth2Scheme (with overrides)
    API->>API: Verify state.client_id == scheme.client_id
    API->>OAuth2Manager: fetch_token (with client_id, secret, scope)
    OAuth2Manager-->>API: Token response
    API->>OAuth2Manager: parse_fetch_token_response(token)
    OAuth2Manager-->>API: OAuth2SchemeCredentials (with client_id, secret, scope)
    API->>DB: Store linked account with credentials
    API-->>Client: Linked account created
```

## Possibly related PRs

- **aipotheosis-labs/aci#339**: Refactors app configuration handling of security scheme overrides and OAuth2 credentials, removing update support for these fields and adding client_secret masking. Related through OAuth2 credential processing but focuses on different layers than this PR.

## Poem

> In the warren of code, security grows,  
> With secrets now masked, and stricter repose.  
> OAuth2’s credentials, encrypted and neat,  
> No more app defaults—just user’s own seat!  
> Schemes now are public, or overridden with care,  
> A rabbit’s delight—safe tokens everywhere! 🐇🔐
"""

<!-- walkthrough_end -->

<!-- announcements_start -->

> [!TIP]
> <details>
> <summary>⚡️ Faster reviews with caching</summary>
> 
> - CodeRabbit now supports caching for code and dependencies, helping speed up reviews. This means quicker feedback, reduced wait times, and a smoother review experience overall. Cached data is encrypted and stored securely. This feature will be automatically enabled for all accounts on May 16th. To opt out, configure `Review - Disable Cache` at either the organization or repository level. If you prefer to disable all data retention across your organization, simply turn off the `Data Retention` setting under your Organization Settings.
> 
> Enjoy the performance boost—your workflow just got faster.
> 
> </details>

<!-- announcements_end -->

---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between dd4329581ded4fb9095cd5756dd15b35aa869fc3 and dff4d1c3b8f6e57acb3ed60d1ada1fe79086c96d.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `backend/aci/server/security_credentials_manager.py` (7 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* backend/aci/server/security_credentials_manager.py

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (3)</summary>

* GitHub Check: Compose Tests
* GitHub Check: Format, Lint, and Test
* GitHub Check: Format & Lint

</details>

</details>
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=aipotheosis-labs/aci&utm_content=350):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: CONTRIBUTOR
user: login: hanyixxx
created_at: 2025-05-12T17:03:25Z
body: @dev-aipolabs @jiwei-aipolabs @alex-aipolabs  fyi
author_association: CONTRIBUTOR