mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-30 03:00:41 -04:00
Compare commits
6 Commits
harness
...
feat/incre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ee5002bff | ||
|
|
7f2317d20b | ||
|
|
f7c1f70a9d | ||
|
|
d78a919c45 | ||
|
|
916b42385d | ||
|
|
3ad03d64b8 |
@@ -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):
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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 ---------------------------- #
|
||||
|
||||
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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 {},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user