Compare commits

...

6 Commits

Author SHA1 Message Date
Nicholas Tindle
6ee5002bff fix(backend): block managed credentials from scope upgrade, guard None metadata
- Reject managed/system credentials in both _prepare_scope_upgrade and
  _upgrade_existing_credential with 400 (coderabbitai, sentry)
- Guard metadata merge against None values (coderabbitai)
- Fix test helper _make_state_with_credential_id scopes pattern (cursor)
- Add 4 new tests confirming each fix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:55:14 -05:00
Nicholas Tindle
7f2317d20b Merge branch 'dev' of https://github.com/Significant-Gravitas/AutoGPT into feat/incremental-oauth 2026-04-14 08:55:04 -05:00
Nicholas Tindle
f7c1f70a9d fix(backend): address review comments on incremental OAuth
- Use provider_matches() for Python 3.13 StrEnum compat (sentry, coderabbitai)
- Filter out managed/system credentials from implicit merge (cursor)
- Skip implicit merge when credentials.username is None (sentry)
- Preserve existing metadata on credential upgrade (cursor)
- Fix test factories to use `is not None` instead of `or` (coderabbitai)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:39:15 -05:00
Nicholas Tindle
d78a919c45 style(platform): format platform_cost_test and update openapi.json
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:23:07 -05:00
Nicholas Tindle
916b42385d Merge branch 'dev' of https://github.com/Significant-Gravitas/AutoGPT into feat/incremental-oauth 2026-04-11 11:04:36 -05:00
Nicholas Tindle
3ad03d64b8 feat(platform): add incremental OAuth authorization for scope upgrades
Allow users to upgrade existing OAuth credentials with additional scopes
instead of creating duplicate credentials. Google uses native incremental
auth (include_granted_scopes), GitHub uses scope union in login URL.

Backend: OAuthState gains credential_id field, login endpoint accepts
credential_id param, callback merges scopes into existing credentials or
auto-detects same provider+username for implicit merge.

Frontend: API client passes credential_id, credentials provider upserts
on callback, useCredentials splits into saved vs upgradeable lists,
useCredentialsInput exposes handleScopeUpgrade.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:04:19 -05:00
10 changed files with 907 additions and 39 deletions

View File

@@ -59,6 +59,8 @@ class OAuthState(BaseModel):
code_verifier: Optional[str] = None
scopes: list[str]
"""Unix timestamp (seconds) indicating when this OAuth state expires"""
credential_id: Optional[str] = None
"""If set, this OAuth flow upgrades an existing credential's scopes."""
class UserMetadata(BaseModel):

View File

@@ -0,0 +1,681 @@
"""Tests for incremental OAuth authorization (scope upgrade)."""
from unittest.mock import AsyncMock, MagicMock, patch
import fastapi
import fastapi.testclient
import pytest
from pydantic import SecretStr
from backend.api.features.integrations.router import router
from backend.data.model import APIKeyCredentials, OAuth2Credentials, OAuthState
app = fastapi.FastAPI()
app.include_router(router)
client = fastapi.testclient.TestClient(app)
TEST_USER_ID = "test-user-id"
def _make_google_oauth2_cred(
cred_id: str = "google-cred-1",
scopes: list[str] | None = None,
username: str = "alice@gmail.com",
title: str = "My Google",
) -> OAuth2Credentials:
return OAuth2Credentials(
id=cred_id,
provider="google",
title=title,
access_token=SecretStr("ya29.access-token"),
refresh_token=SecretStr("1//refresh-token"),
scopes=(
scopes
if scopes is not None
else ["https://www.googleapis.com/auth/gmail.readonly"]
),
username=username,
access_token_expires_at=9999999999,
)
def _make_github_oauth2_cred(
cred_id: str = "github-cred-1",
scopes: list[str] | None = None,
username: str = "alice",
title: str = "My GitHub",
) -> OAuth2Credentials:
return OAuth2Credentials(
id=cred_id,
provider="github",
title=title,
access_token=SecretStr("ghp_access_token"),
refresh_token=SecretStr("ghp_refresh_token"),
scopes=scopes if scopes is not None else ["repo"],
username=username,
)
@pytest.fixture(autouse=True)
def setup_auth(mock_jwt_user):
from autogpt_libs.auth.jwt_utils import get_jwt_payload
app.dependency_overrides[get_jwt_payload] = mock_jwt_user["get_jwt_payload"]
yield
app.dependency_overrides.clear()
# ==================== OAuthState model tests ==================== #
class TestOAuthStateCredentialId:
"""OAuthState model should support a credential_id field for upgrades."""
def test_oauth_state_accepts_credential_id(self):
state = OAuthState(
token="abc",
provider="google",
expires_at=9999999999,
scopes=["openid"],
credential_id="existing-cred-id",
)
assert state.credential_id == "existing-cred-id"
def test_oauth_state_defaults_credential_id_none(self):
state = OAuthState(
token="abc",
provider="google",
expires_at=9999999999,
scopes=["openid"],
)
assert state.credential_id is None
# ==================== Login endpoint tests ==================== #
class TestIncrementalOAuthLogin:
"""Tests for the login endpoint with credential_id parameter."""
def test_login_with_credential_id_stores_in_state(self):
"""Login with credential_id should pass it through to store_state_token."""
existing = _make_google_oauth2_cred()
handler = MagicMock()
handler.get_login_url.return_value = "https://accounts.google.com/auth"
with (
patch(
"backend.api.features.integrations.router._get_provider_oauth_handler",
return_value=handler,
),
patch("backend.api.features.integrations.router.creds_manager") as mock_mgr,
):
mock_mgr.store.get_creds_by_id = AsyncMock(return_value=existing)
mock_mgr.store.store_state_token = AsyncMock(
return_value=("state-token", "code-challenge")
)
resp = client.get(
"/google/login",
params={
"scopes": "https://www.googleapis.com/auth/calendar.readonly",
"credential_id": "google-cred-1",
},
)
assert resp.status_code == 200
# Verify store_state_token was called with credential_id
call_kwargs = mock_mgr.store.store_state_token.call_args
assert call_kwargs.kwargs.get("credential_id") == "google-cred-1" or (
len(call_kwargs.args) > 3 and call_kwargs.args[3] == "google-cred-1"
)
def test_login_github_unions_scopes_for_upgrade(self):
"""For GitHub, login should request union of existing + new scopes."""
existing = _make_github_oauth2_cred(scopes=["repo"])
handler = MagicMock()
handler.get_login_url.return_value = "https://github.com/login/oauth/authorize"
with (
patch(
"backend.api.features.integrations.router._get_provider_oauth_handler",
return_value=handler,
),
patch("backend.api.features.integrations.router.creds_manager") as mock_mgr,
):
mock_mgr.store.get_creds_by_id = AsyncMock(return_value=existing)
mock_mgr.store.store_state_token = AsyncMock(
return_value=("state-token", "code-challenge")
)
resp = client.get(
"/github/login",
params={
"scopes": "read:org",
"credential_id": "github-cred-1",
},
)
assert resp.status_code == 200
# The scopes passed to get_login_url should be the union
login_scopes = handler.get_login_url.call_args[0][0]
assert set(login_scopes) == {"repo", "read:org"}
def test_login_google_keeps_requested_scopes_only(self):
"""For Google, login should use only the new scopes (include_granted_scopes handles merging)."""
existing = _make_google_oauth2_cred(
scopes=["https://www.googleapis.com/auth/gmail.readonly"]
)
handler = MagicMock()
handler.get_login_url.return_value = "https://accounts.google.com/auth"
with (
patch(
"backend.api.features.integrations.router._get_provider_oauth_handler",
return_value=handler,
),
patch("backend.api.features.integrations.router.creds_manager") as mock_mgr,
):
mock_mgr.store.get_creds_by_id = AsyncMock(return_value=existing)
mock_mgr.store.store_state_token = AsyncMock(
return_value=("state-token", "code-challenge")
)
resp = client.get(
"/google/login",
params={
"scopes": "https://www.googleapis.com/auth/calendar.readonly",
"credential_id": "google-cred-1",
},
)
assert resp.status_code == 200
login_scopes = handler.get_login_url.call_args[0][0]
# Google should NOT union scopes in the login URL
assert "https://www.googleapis.com/auth/calendar.readonly" in login_scopes
assert "https://www.googleapis.com/auth/gmail.readonly" not in login_scopes
# Verify credential_id was passed through to store_state_token
call_kwargs = mock_mgr.store.store_state_token.call_args
assert call_kwargs.kwargs.get("credential_id") == "google-cred-1"
def test_login_credential_not_found_returns_404(self):
handler = MagicMock()
with (
patch(
"backend.api.features.integrations.router._get_provider_oauth_handler",
return_value=handler,
),
patch("backend.api.features.integrations.router.creds_manager") as mock_mgr,
):
mock_mgr.store.get_creds_by_id = AsyncMock(return_value=None)
resp = client.get(
"/google/login",
params={
"scopes": "openid",
"credential_id": "nonexistent",
},
)
assert resp.status_code == 404
def test_login_credential_provider_mismatch_returns_400(self):
"""credential_id pointing to a Google cred when URL says github -> 400."""
google_cred = _make_google_oauth2_cred()
handler = MagicMock()
with (
patch(
"backend.api.features.integrations.router._get_provider_oauth_handler",
return_value=handler,
),
patch("backend.api.features.integrations.router.creds_manager") as mock_mgr,
):
mock_mgr.store.get_creds_by_id = AsyncMock(return_value=google_cred)
resp = client.get(
"/github/login",
params={
"scopes": "repo",
"credential_id": "google-cred-1",
},
)
assert resp.status_code == 400
def test_login_non_oauth2_credential_returns_400(self):
"""credential_id pointing to an API key credential -> 400."""
api_key_cred = APIKeyCredentials(
id="apikey-1",
provider="github",
title="API Key",
api_key=SecretStr("ghp_key"),
)
handler = MagicMock()
with (
patch(
"backend.api.features.integrations.router._get_provider_oauth_handler",
return_value=handler,
),
patch("backend.api.features.integrations.router.creds_manager") as mock_mgr,
):
mock_mgr.store.get_creds_by_id = AsyncMock(return_value=api_key_cred)
resp = client.get(
"/github/login",
params={
"scopes": "repo",
"credential_id": "apikey-1",
},
)
assert resp.status_code == 400
# ==================== Callback endpoint tests ==================== #
class TestIncrementalOAuthCallback:
"""Tests for the callback endpoint when upgrading credentials."""
def _make_state_with_credential_id(
self,
credential_id: str,
scopes: list[str] | None = None,
provider: str = "google",
) -> OAuthState:
return OAuthState(
token="state-token",
provider=provider,
expires_at=9999999999,
scopes=(
scopes
if scopes is not None
else ["https://www.googleapis.com/auth/calendar.readonly"]
),
credential_id=credential_id,
)
def test_callback_upgrades_existing_credential(self):
"""When state has credential_id, should update existing credential."""
existing = _make_google_oauth2_cred(
scopes=["https://www.googleapis.com/auth/gmail.readonly"]
)
new_cred = _make_google_oauth2_cred(
scopes=[
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
]
)
state = self._make_state_with_credential_id("google-cred-1")
handler = MagicMock()
handler.exchange_code_for_tokens = AsyncMock(return_value=new_cred)
handler.handle_default_scopes.return_value = state.scopes
with (
patch(
"backend.api.features.integrations.router._get_provider_oauth_handler",
return_value=handler,
),
patch("backend.api.features.integrations.router.creds_manager") as mock_mgr,
):
mock_mgr.store.verify_state_token = AsyncMock(return_value=state)
mock_mgr.store.get_creds_by_id = AsyncMock(return_value=existing)
mock_mgr.update = AsyncMock()
mock_mgr.create = AsyncMock()
resp = client.post(
"/google/callback",
json={"code": "auth-code", "state_token": "state-token"},
)
assert resp.status_code == 200
# Should call update, not create
mock_mgr.update.assert_called_once()
mock_mgr.create.assert_not_called()
def test_callback_upgrade_merges_scopes(self):
"""Upgraded credential should have union of old + new scopes."""
existing = _make_google_oauth2_cred(
scopes=["https://www.googleapis.com/auth/gmail.readonly"]
)
new_cred = _make_google_oauth2_cred(
scopes=[
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
]
)
state = self._make_state_with_credential_id("google-cred-1")
handler = MagicMock()
handler.exchange_code_for_tokens = AsyncMock(return_value=new_cred)
handler.handle_default_scopes.return_value = state.scopes
with (
patch(
"backend.api.features.integrations.router._get_provider_oauth_handler",
return_value=handler,
),
patch("backend.api.features.integrations.router.creds_manager") as mock_mgr,
):
mock_mgr.store.verify_state_token = AsyncMock(return_value=state)
mock_mgr.store.get_creds_by_id = AsyncMock(return_value=existing)
mock_mgr.update = AsyncMock()
resp = client.post(
"/google/callback",
json={"code": "auth-code", "state_token": "state-token"},
)
assert resp.status_code == 200
data = resp.json()
assert set(data["scopes"]) == {
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
}
def test_callback_upgrade_preserves_id_and_title(self):
"""Upgraded credential should keep its original ID and title."""
existing = _make_google_oauth2_cred(
cred_id="original-id", title="My Work Google"
)
new_cred = _make_google_oauth2_cred(cred_id="new-id-from-exchange")
state = self._make_state_with_credential_id("original-id")
handler = MagicMock()
handler.exchange_code_for_tokens = AsyncMock(return_value=new_cred)
handler.handle_default_scopes.return_value = state.scopes
with (
patch(
"backend.api.features.integrations.router._get_provider_oauth_handler",
return_value=handler,
),
patch("backend.api.features.integrations.router.creds_manager") as mock_mgr,
):
mock_mgr.store.verify_state_token = AsyncMock(return_value=state)
mock_mgr.store.get_creds_by_id = AsyncMock(return_value=existing)
mock_mgr.update = AsyncMock()
resp = client.post(
"/google/callback",
json={"code": "auth-code", "state_token": "state-token"},
)
assert resp.status_code == 200
data = resp.json()
assert data["id"] == "original-id"
assert data["title"] == "My Work Google"
def test_callback_upgrade_rejects_username_mismatch(self):
"""Should reject if the new auth returns a different username."""
existing = _make_google_oauth2_cred(username="alice@gmail.com")
new_cred = _make_google_oauth2_cred(username="bob@gmail.com")
state = self._make_state_with_credential_id("google-cred-1")
handler = MagicMock()
handler.exchange_code_for_tokens = AsyncMock(return_value=new_cred)
handler.handle_default_scopes.return_value = state.scopes
with (
patch(
"backend.api.features.integrations.router._get_provider_oauth_handler",
return_value=handler,
),
patch("backend.api.features.integrations.router.creds_manager") as mock_mgr,
):
mock_mgr.store.verify_state_token = AsyncMock(return_value=state)
mock_mgr.store.get_creds_by_id = AsyncMock(return_value=existing)
resp = client.post(
"/google/callback",
json={"code": "auth-code", "state_token": "state-token"},
)
assert resp.status_code == 400
assert "username" in resp.json()["detail"].lower()
def test_callback_implicit_merge_same_provider_username(self):
"""Without credential_id, should auto-merge when same provider+username exists."""
existing = _make_google_oauth2_cred(
scopes=["https://www.googleapis.com/auth/gmail.readonly"]
)
new_cred = _make_google_oauth2_cred(
cred_id="new-cred-id",
scopes=[
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
],
username="alice@gmail.com",
)
# State WITHOUT credential_id
state = OAuthState(
token="state-token",
provider="google",
expires_at=9999999999,
scopes=["https://www.googleapis.com/auth/calendar.readonly"],
)
handler = MagicMock()
handler.exchange_code_for_tokens = AsyncMock(return_value=new_cred)
handler.handle_default_scopes.return_value = state.scopes
with (
patch(
"backend.api.features.integrations.router._get_provider_oauth_handler",
return_value=handler,
),
patch("backend.api.features.integrations.router.creds_manager") as mock_mgr,
):
mock_mgr.store.verify_state_token = AsyncMock(return_value=state)
mock_mgr.store.get_creds_by_provider = AsyncMock(return_value=[existing])
mock_mgr.store.get_creds_by_id = AsyncMock(return_value=existing)
mock_mgr.update = AsyncMock()
mock_mgr.create = AsyncMock()
resp = client.post(
"/google/callback",
json={"code": "auth-code", "state_token": "state-token"},
)
assert resp.status_code == 200
# Should update the existing credential, not create a new one
mock_mgr.update.assert_called_once()
mock_mgr.create.assert_not_called()
# The returned ID should be the existing credential's ID
data = resp.json()
assert data["id"] == "google-cred-1"
def test_callback_no_implicit_merge_different_username(self):
"""Without credential_id, different username should create new credential."""
existing = _make_google_oauth2_cred(username="alice@gmail.com")
new_cred = _make_google_oauth2_cred(
cred_id="new-cred-id",
username="bob@gmail.com",
)
state = OAuthState(
token="state-token",
provider="google",
expires_at=9999999999,
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
)
handler = MagicMock()
handler.exchange_code_for_tokens = AsyncMock(return_value=new_cred)
handler.handle_default_scopes.return_value = state.scopes
with (
patch(
"backend.api.features.integrations.router._get_provider_oauth_handler",
return_value=handler,
),
patch("backend.api.features.integrations.router.creds_manager") as mock_mgr,
):
mock_mgr.store.verify_state_token = AsyncMock(return_value=state)
mock_mgr.store.get_creds_by_provider = AsyncMock(return_value=[existing])
mock_mgr.create = AsyncMock()
mock_mgr.update = AsyncMock()
resp = client.post(
"/google/callback",
json={"code": "auth-code", "state_token": "state-token"},
)
assert resp.status_code == 200
mock_mgr.create.assert_called_once()
mock_mgr.update.assert_not_called()
# Verify the implicit merge lookup was attempted
mock_mgr.store.get_creds_by_provider.assert_called_once()
def test_callback_creates_new_when_no_existing(self):
"""Without credential_id and no matching credential, creates new."""
new_cred = _make_google_oauth2_cred()
state = OAuthState(
token="state-token",
provider="google",
expires_at=9999999999,
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
)
handler = MagicMock()
handler.exchange_code_for_tokens = AsyncMock(return_value=new_cred)
handler.handle_default_scopes.return_value = state.scopes
with (
patch(
"backend.api.features.integrations.router._get_provider_oauth_handler",
return_value=handler,
),
patch("backend.api.features.integrations.router.creds_manager") as mock_mgr,
):
mock_mgr.store.verify_state_token = AsyncMock(return_value=state)
mock_mgr.store.get_creds_by_provider = AsyncMock(return_value=[])
mock_mgr.create = AsyncMock()
mock_mgr.update = AsyncMock()
resp = client.post(
"/google/callback",
json={"code": "auth-code", "state_token": "state-token"},
)
assert resp.status_code == 200
mock_mgr.create.assert_called_once()
mock_mgr.update.assert_not_called()
# Verify the implicit merge lookup was attempted
mock_mgr.store.get_creds_by_provider.assert_called_once()
# ==================== Round 2: Review feedback tests ==================== #
class TestManagedCredentialProtection:
"""Managed/system credentials must not be upgradeable."""
def test_login_rejects_managed_credential_id(self):
"""Explicit credential_id pointing to a managed credential -> 400."""
managed = _make_google_oauth2_cred(cred_id="managed-1")
managed.is_managed = True
handler = MagicMock()
with (
patch(
"backend.api.features.integrations.router._get_provider_oauth_handler",
return_value=handler,
),
patch("backend.api.features.integrations.router.creds_manager") as mock_mgr,
):
mock_mgr.store.get_creds_by_id = AsyncMock(return_value=managed)
resp = client.get(
"/google/login",
params={
"scopes": "https://www.googleapis.com/auth/calendar.readonly",
"credential_id": "managed-1",
},
)
assert resp.status_code == 400
def test_callback_rejects_upgrade_of_managed_credential(self):
"""Callback with credential_id for a managed credential -> 400."""
managed = _make_google_oauth2_cred(cred_id="managed-1")
managed.is_managed = True
new_cred = _make_google_oauth2_cred()
state = OAuthState(
token="state-token",
provider="google",
expires_at=9999999999,
scopes=["https://www.googleapis.com/auth/calendar.readonly"],
credential_id="managed-1",
)
handler = MagicMock()
handler.exchange_code_for_tokens = AsyncMock(return_value=new_cred)
handler.handle_default_scopes.return_value = state.scopes
with (
patch(
"backend.api.features.integrations.router._get_provider_oauth_handler",
return_value=handler,
),
patch("backend.api.features.integrations.router.creds_manager") as mock_mgr,
):
mock_mgr.store.verify_state_token = AsyncMock(return_value=state)
mock_mgr.store.get_creds_by_id = AsyncMock(return_value=managed)
resp = client.post(
"/google/callback",
json={"code": "auth-code", "state_token": "state-token"},
)
assert resp.status_code == 400
class TestMetadataNoneGuard:
"""Metadata merge must handle None values."""
def test_callback_upgrade_handles_none_metadata(self):
"""Upgrading credential with metadata=None should not crash."""
existing = _make_google_oauth2_cred(
scopes=["https://www.googleapis.com/auth/gmail.readonly"]
)
existing.metadata = None # type: ignore[assignment]
new_cred = _make_google_oauth2_cred(
scopes=[
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
]
)
new_cred.metadata = None # type: ignore[assignment]
state = OAuthState(
token="state-token",
provider="google",
expires_at=9999999999,
scopes=["https://www.googleapis.com/auth/calendar.readonly"],
credential_id="google-cred-1",
)
handler = MagicMock()
handler.exchange_code_for_tokens = AsyncMock(return_value=new_cred)
handler.handle_default_scopes.return_value = state.scopes
with (
patch(
"backend.api.features.integrations.router._get_provider_oauth_handler",
return_value=handler,
),
patch("backend.api.features.integrations.router.creds_manager") as mock_mgr,
):
mock_mgr.store.verify_state_token = AsyncMock(return_value=state)
mock_mgr.store.get_creds_by_id = AsyncMock(return_value=existing)
mock_mgr.update = AsyncMock()
resp = client.post(
"/google/callback",
json={"code": "auth-code", "state_token": "state-token"},
)
assert resp.status_code == 200
class TestStateHelperScopesPattern:
"""Test helper should handle empty scopes correctly."""
def test_make_state_preserves_empty_scopes(self):
"""_make_state_with_credential_id([]) should keep empty list."""
state_maker = TestIncrementalOAuthCallback()
state = state_maker._make_state_with_credential_id("cred-1", scopes=[])
assert state.scopes == []

View File

@@ -87,14 +87,23 @@ async def login(
scopes: Annotated[
str, Query(title="Comma-separated list of authorization scopes")
] = "",
credential_id: Annotated[
str | None,
Query(title="ID of existing credential to upgrade scopes for"),
] = None,
) -> LoginResponse:
handler = _get_provider_oauth_handler(request, provider)
requested_scopes = scopes.split(",") if scopes else []
if credential_id:
requested_scopes = await _prepare_scope_upgrade(
user_id, provider, credential_id, requested_scopes
)
# Generate and store a secure random state token along with the scopes
state_token, code_challenge = await creds_manager.store.store_state_token(
user_id, provider, requested_scopes
user_id, provider, requested_scopes, credential_id=credential_id
)
login_url = handler.get_login_url(
requested_scopes, state_token, code_challenge=code_challenge
@@ -216,7 +225,9 @@ async def callback(
)
# TODO: Allow specifying `title` to set on `credentials`
await creds_manager.create(user_id, credentials)
credentials = await _merge_or_create_credential(
user_id, provider, credentials, valid_state.credential_id
)
logger.debug(
f"Successfully processed OAuth callback for user {user_id} "
@@ -574,6 +585,135 @@ async def _execute_webhook_preset_trigger(
# Continue processing - webhook should be resilient to individual failures
# -------------------- INCREMENTAL AUTH HELPERS -------------------- #
async def _prepare_scope_upgrade(
user_id: str,
provider: ProviderName,
credential_id: str,
requested_scopes: list[str],
) -> list[str]:
"""Validate an existing credential for scope upgrade and compute scopes.
For providers without native incremental auth (e.g. GitHub), returns the
union of existing + requested scopes. For providers that handle merging
server-side (e.g. Google with ``include_granted_scopes``), returns the
requested scopes unchanged.
Raises HTTPException on validation failure.
"""
existing = await creds_manager.store.get_creds_by_id(user_id, credential_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Credential to upgrade not found",
)
if not isinstance(existing, OAuth2Credentials):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only OAuth2 credentials can be upgraded",
)
if not provider_matches(existing.provider, provider.value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Credential provider does not match the requested provider",
)
if existing.is_managed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Managed credentials cannot be upgraded",
)
# Google handles scope merging via include_granted_scopes; others need
# the union of existing + new scopes in the login URL.
if provider != ProviderName.GOOGLE:
requested_scopes = list(set(requested_scopes) | set(existing.scopes))
return requested_scopes
async def _merge_or_create_credential(
user_id: str,
provider: ProviderName,
credentials: OAuth2Credentials,
credential_id: str | None,
) -> OAuth2Credentials:
"""Either upgrade an existing credential or create a new one.
When *credential_id* is set (explicit upgrade), merges scopes and updates
the existing credential. Otherwise, checks for an implicit merge (same
provider + username) before falling back to creating a new credential.
"""
if credential_id:
return await _upgrade_existing_credential(user_id, credential_id, credentials)
# Implicit merge: check for existing credential with same provider+username.
# Skip managed/system credentials and require a non-None username on both
# sides so we never accidentally merge unrelated credentials.
if credentials.username is None:
await creds_manager.create(user_id, credentials)
return credentials
existing_creds = await creds_manager.store.get_creds_by_provider(user_id, provider)
matching = next(
(
c
for c in existing_creds
if isinstance(c, OAuth2Credentials)
and not c.is_managed
and c.username is not None
and c.username == credentials.username
),
None,
)
if matching:
return await _upgrade_existing_credential(user_id, matching.id, credentials)
await creds_manager.create(user_id, credentials)
return credentials
async def _upgrade_existing_credential(
user_id: str,
existing_cred_id: str,
new_credentials: OAuth2Credentials,
) -> OAuth2Credentials:
"""Merge scopes from *new_credentials* into an existing credential."""
existing = await creds_manager.store.get_creds_by_id(user_id, existing_cred_id)
if not existing or not isinstance(existing, OAuth2Credentials):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Credential to upgrade not found",
)
if existing.is_managed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Managed credentials cannot be upgraded",
)
if (
existing.username
and new_credentials.username
and existing.username != new_credentials.username
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username mismatch: authenticated as a different user",
)
merged_scopes = list(set(existing.scopes) | set(new_credentials.scopes))
new_credentials.id = existing.id
new_credentials.title = existing.title
new_credentials.scopes = merged_scopes
new_credentials.metadata = {
**(existing.metadata or {}),
**(new_credentials.metadata or {}),
}
await creds_manager.update(user_id, new_credentials)
return new_credentials
# --------------------------- UTILITIES ---------------------------- #

View File

@@ -456,6 +456,8 @@ class OAuthState(BaseModel):
code_verifier: Optional[str] = None
"""Unix timestamp (seconds) indicating when this OAuth state expires"""
scopes: list[str]
credential_id: Optional[str] = None
"""If set, this OAuth flow upgrades an existing credential's scopes."""
# Fields for external API OAuth flows
callback_url: Optional[str] = None
"""External app's callback URL for OAuth redirect"""

View File

@@ -551,6 +551,7 @@ class IntegrationCredentialsStore:
callback_url: Optional[str] = None,
state_metadata: Optional[dict] = None,
initiated_by_api_key_id: Optional[str] = None,
credential_id: Optional[str] = None,
) -> tuple[str, str]:
token = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=10)
@@ -563,6 +564,7 @@ class IntegrationCredentialsStore:
code_verifier=code_verifier,
expires_at=int(expires_at.timestamp()),
scopes=scopes,
credential_id=credential_id,
# External API OAuth flow fields
callback_url=callback_url,
state_metadata=state_metadata or {},

View File

@@ -4125,6 +4125,15 @@
"title": "Comma-separated list of authorization scopes",
"default": ""
}
},
{
"name": "credential_id",
"in": "query",
"required": false,
"schema": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "ID of existing credential to upgrade scopes for"
}
}
],
"responses": {

View File

@@ -154,6 +154,7 @@ export function useCredentialsInput({
supportsUserPassword,
supportsHostScoped,
savedCredentials,
upgradeableCredentials,
oAuthCallback,
mcpOAuthCallback,
isSystemProvider,
@@ -163,8 +164,11 @@ export function useCredentialsInput({
// Split credentials into user and system
const userCredentials = filterSystemCredentials(savedCredentials);
const systemCredentials = getSystemCredentials(savedCredentials);
const userUpgradeableCredentials = filterSystemCredentials(
upgradeableCredentials,
);
async function handleOAuthLogin() {
async function executeOAuthFlow(credentialID?: string) {
setOAuthError(null);
// Abort any previous OAuth flow
@@ -187,6 +191,7 @@ export function useCredentialsInput({
({ login_url, state_token } = await api.oAuthLogin(
provider,
schema.credentials_scopes,
credentialID,
));
}
@@ -253,6 +258,14 @@ export function useCredentialsInput({
}
}
async function handleOAuthLogin() {
return executeOAuthFlow();
}
async function handleScopeUpgrade(credentialID: string) {
return executeOAuthFlow(credentialID);
}
const hasMultipleCredentialTypes =
countSupportedTypes(
supportsOAuth2,
@@ -393,6 +406,8 @@ export function useCredentialsInput({
handleDeleteCredential,
handleDeleteConfirm,
handleOAuthLogin,
handleScopeUpgrade,
userUpgradeableCredentials,
onSelectCredential,
schema,
siblingInputs,

View File

@@ -7,6 +7,7 @@ import {
} from "@/providers/agent-credentials/credentials-provider";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaResponse,
CredentialsProviderName,
} from "@/lib/autogpt-server-api";
import { getHostFromUrl } from "@/lib/utils/url";
@@ -30,6 +31,7 @@ export type CredentialsData =
supportsHostScoped: boolean;
isLoading: false;
discriminatorValue?: string;
upgradeableCredentials: CredentialsMetaResponse[];
});
export default function useCredentials(
@@ -83,45 +85,46 @@ export default function useCredentials(
// No provider means maybe it's still loading
if (!provider) {
// return {
// provider: credsInputSchema.credentials_provider,
// schema: credsInputSchema,
// supportsApiKey,
// supportsOAuth2,
// isLoading: true,
// };
return null;
}
const savedCredentials = provider.savedCredentials.filter((c) => {
// First, check if the credential type is supported by this block
const savedCredentials: CredentialsMetaResponse[] = [];
const upgradeableCredentials: CredentialsMetaResponse[] = [];
for (const c of provider.savedCredentials) {
const supportedTypes = credsInputSchema.credentials_types;
if (!supportedTypes.includes(c.type)) {
return false;
}
if (!supportedTypes.includes(c.type)) continue;
// Filter MCP OAuth2 credentials by server URL matching
// MCP OAuth2 credentials filter by server URL — not upgradeable
if (c.type === "oauth2" && c.provider === "mcp") {
return discriminatorValue != null && c.host === discriminatorValue;
if (discriminatorValue != null && c.host === discriminatorValue) {
savedCredentials.push(c);
}
continue;
}
// Filter by OAuth credentials that have sufficient scopes for this block
if (c.type === "oauth2") {
const requiredScopes = credsInputSchema.credentials_scopes;
return (
if (
!requiredScopes ||
new Set(c.scopes).isSupersetOf(new Set(requiredScopes))
);
) {
savedCredentials.push(c);
} else {
upgradeableCredentials.push(c);
}
continue;
}
// Filter host_scoped credentials by host matching
if (c.type === "host_scoped") {
return discriminatorValue && getHostFromUrl(discriminatorValue) == c.host;
if (discriminatorValue && getHostFromUrl(discriminatorValue) == c.host) {
savedCredentials.push(c);
}
continue;
}
// Include all other credential types that passed the type check
return true;
});
savedCredentials.push(c);
}
return {
...provider,
@@ -132,6 +135,7 @@ export default function useCredentials(
supportsUserPassword,
supportsHostScoped,
savedCredentials,
upgradeableCredentials,
discriminatorValue,
isLoading: false,
};

View File

@@ -325,9 +325,15 @@ export default class BackendAPI {
oAuthLogin(
provider: string,
scopes?: string[],
credentialID?: string,
): Promise<{ login_url: string; state_token: string }> {
const query = scopes ? { scopes: scopes.join(",") } : undefined;
return this._get(`/integrations/${provider}/login`, query);
const query: Record<string, string> = {};
if (scopes) query.scopes = scopes.join(",");
if (credentialID) query.credential_id = credentialID;
return this._get(
`/integrations/${provider}/login`,
Object.keys(query).length > 0 ? query : undefined,
);
}
oAuthCallback(

View File

@@ -83,7 +83,7 @@ export default function CredentialsProvider({
const api = useBackendAPI();
const onFailToast = useToastOnFail();
const addCredentials = useCallback(
const upsertCredentials = useCallback(
(
provider: CredentialsProviderName,
credentials: CredentialsMetaResponse,
@@ -91,11 +91,18 @@ export default function CredentialsProvider({
setProviders((prev) => {
if (!prev || !prev[provider]) return prev;
const existing = prev[provider].savedCredentials;
const idx = existing.findIndex((c) => c.id === credentials.id);
const updated =
idx >= 0
? existing.map((c, i) => (i === idx ? credentials : c))
: [...existing, credentials];
return {
...prev,
[provider]: {
...prev[provider],
savedCredentials: [...prev[provider].savedCredentials, credentials],
savedCredentials: updated,
},
};
});
@@ -116,14 +123,14 @@ export default function CredentialsProvider({
code,
state_token,
);
addCredentials(provider as string, credsMeta);
upsertCredentials(provider as string, credsMeta);
return credsMeta;
} catch (error) {
onFailToast("complete OAuth authentication")(error);
throw error;
}
},
[api, addCredentials, onFailToast],
[api, upsertCredentials, onFailToast],
);
/** Exchanges an MCP OAuth code for tokens and adds the result to the internal credentials store. */
@@ -145,14 +152,14 @@ export default function CredentialsProvider({
username: response.data.username ?? undefined,
host: response.data.host ?? undefined,
};
addCredentials("mcp", credsMeta);
upsertCredentials("mcp", credsMeta);
return credsMeta;
} catch (error) {
onFailToast("complete MCP OAuth authentication")(error);
throw error;
}
},
[addCredentials, onFailToast],
[upsertCredentials, onFailToast],
);
/** Wraps `BackendAPI.createAPIKeyCredentials`, and adds the result to the internal credentials store. */
@@ -166,14 +173,14 @@ export default function CredentialsProvider({
provider,
...credentials,
});
addCredentials(provider, credsMeta);
upsertCredentials(provider, credsMeta);
return credsMeta;
} catch (error) {
onFailToast("create API key credentials")(error);
throw error;
}
},
[api, addCredentials, onFailToast],
[api, upsertCredentials, onFailToast],
);
/** Wraps `BackendAPI.createUserPasswordCredentials`, and adds the result to the internal credentials store. */
@@ -187,14 +194,14 @@ export default function CredentialsProvider({
provider,
...credentials,
});
addCredentials(provider, credsMeta);
upsertCredentials(provider, credsMeta);
return credsMeta;
} catch (error) {
onFailToast("create user/password credentials")(error);
throw error;
}
},
[api, addCredentials, onFailToast],
[api, upsertCredentials, onFailToast],
);
/** Wraps `BackendAPI.createHostScopedCredentials`, and adds the result to the internal credentials store. */
@@ -208,14 +215,14 @@ export default function CredentialsProvider({
provider,
...credentials,
});
addCredentials(provider, credsMeta);
upsertCredentials(provider, credsMeta);
return credsMeta;
} catch (error) {
onFailToast("create host-scoped credentials")(error);
throw error;
}
},
[api, addCredentials, onFailToast],
[api, upsertCredentials, onFailToast],
);
/** Wraps `BackendAPI.deleteCredentials`, and removes the credentials from the internal store. */