mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
fix(mcp): invalidate a browser-authorized upstream token when a mint-relevant field changes
An admin who ran Authorize & Fetch and then changed a field that determines which upstream OAuth token gets minted kept using the stale token for tool preview, sessionStorage, and (on the backend) the stored per-user credential and its cache. Grounded in RFC 8707/8693 and the MCP auth spec, a token is bound to one tuple: resource/audience (url), OAuth mode/grant (auth_type, oauth_flow_type), the authorization-server endpoints, and the OAuth client + scopes. A shared getOAuthAuthorizationIdentity captures exactly those fields; transport (http/sse on the same url is the same audience) and delegate_auth_to_upstream (a downstream-usage toggle never sent to the authorize request) are excluded. UI: both the create and edit forms now discard the held token (React state / sessionStorage / hook, plus the fetched token + DCR client in form.credentials) whenever the identity diverges from the one it was authorized against, re-applying the admin's in-flight edit so it is never wiped. The check lives in one shared helper so the two forms cannot drift. Backend: editing an MCP server now compares the pre/post identity and, on a mint-relevant change, purges every stored per-user OAuth credential for the server (DB row + per-user token cache) so no user forwards a token minted for a resource/AS/client that no longer matches. Best-effort; a purge failure never fails the update.
This commit is contained in:
parent
68a4ca7247
commit
05f39bf942
7 changed files with 312 additions and 35 deletions
|
|
@ -1070,6 +1070,50 @@ async def list_user_oauth_credentials(
|
|||
return results
|
||||
|
||||
|
||||
def mcp_oauth_token_identity(server: Any) -> tuple[Any, ...]:
|
||||
"""The upstream-OAuth-token-determining fields of an MCP server: the resource/audience (url), the
|
||||
OAuth mode/grant (auth_type, oauth2_flow), the authorization-server endpoints, and the OAuth client +
|
||||
scopes. Mirrors the dashboard's getOAuthAuthorizationIdentity. When any of these change on a server
|
||||
update, previously stored per-user tokens were minted for the old identity and are stale. Excludes
|
||||
transport and delegate_auth_to_upstream, which do not affect what token is minted (RFC 8707/8693)."""
|
||||
creds = getattr(server, "credentials", None)
|
||||
creds_dict: Dict[str, Any] = creds if isinstance(creds, dict) else {}
|
||||
return (
|
||||
getattr(server, "url", None),
|
||||
getattr(server, "auth_type", None),
|
||||
getattr(server, "oauth2_flow", None),
|
||||
getattr(server, "authorization_url", None),
|
||||
getattr(server, "token_url", None),
|
||||
getattr(server, "registration_url", None),
|
||||
creds_dict.get("client_id"),
|
||||
creds_dict.get("client_secret"),
|
||||
creds_dict.get("scopes"),
|
||||
)
|
||||
|
||||
|
||||
async def purge_user_oauth_credentials_for_server(prisma_client: PrismaClient, server_id: str) -> int:
|
||||
"""Delete every stored per-user OAuth credential for a server and drop each from the per-user token
|
||||
cache, so no user keeps a token minted for a superseded configuration. Called when a server update
|
||||
changes a mint-relevant field (see mcp_oauth_token_identity). Returns the number of rows removed."""
|
||||
repo = MCPUserCredentialsRepository(prisma_client)
|
||||
rows = await repo.table.find_many(where={"server_id": server_id})
|
||||
if not rows:
|
||||
return 0
|
||||
await repo.table.delete_many(where={"server_id": server_id})
|
||||
from litellm.proxy._experimental.mcp_server.oauth2_token_cache import (
|
||||
mcp_per_user_token_cache,
|
||||
)
|
||||
|
||||
for row in rows:
|
||||
try:
|
||||
await mcp_per_user_token_cache.delete(row.user_id, server_id)
|
||||
except Exception as exc: # noqa: BLE001 - cache drop is best-effort; the DB delete is authoritative
|
||||
verbose_proxy_logger.warning(
|
||||
"Failed to drop cached MCP OAuth token for user=%s server=%s: %s", row.user_id, server_id, exc
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
|
||||
async def refresh_user_oauth_token(
|
||||
prisma_client: PrismaClient,
|
||||
user_id: str,
|
||||
|
|
|
|||
|
|
@ -125,7 +125,9 @@ if MCP_AVAILABLE:
|
|||
get_user_env_vars_bulk,
|
||||
get_user_oauth_credential,
|
||||
list_user_oauth_credentials,
|
||||
mcp_oauth_token_identity,
|
||||
merge_user_env_vars,
|
||||
purge_user_oauth_credentials_for_server,
|
||||
reject_mcp_server,
|
||||
store_user_credential,
|
||||
store_user_oauth_credential,
|
||||
|
|
@ -2318,6 +2320,9 @@ if MCP_AVAILABLE:
|
|||
},
|
||||
)
|
||||
|
||||
# Snapshot the pre-update identity so we can detect a mint-relevant change below.
|
||||
old_server_record = await get_mcp_server(prisma_client, payload.server_id)
|
||||
|
||||
# try to update the mcp server
|
||||
mcp_server_record_updated = await update_mcp_server(
|
||||
prisma_client,
|
||||
|
|
@ -2336,6 +2341,30 @@ if MCP_AVAILABLE:
|
|||
# Ensure registry is up to date by reloading from database
|
||||
await global_mcp_server_manager.reload_servers_from_database()
|
||||
|
||||
# If a field that determines which upstream OAuth token gets minted changed (url/audience, OAuth
|
||||
# mode/grant, authorization-server endpoints, or the OAuth client + scopes), every stored per-user
|
||||
# token was minted for the old configuration and is stale. Purge them (DB + cache) so the next
|
||||
# tool call re-authorizes instead of forwarding a token for a resource/AS/client that no longer
|
||||
# matches. Best-effort: a purge failure must not fail the update, whose primary job already
|
||||
# succeeded.
|
||||
if old_server_record is not None and mcp_oauth_token_identity(old_server_record) != mcp_oauth_token_identity(
|
||||
mcp_server_record_updated
|
||||
):
|
||||
try:
|
||||
purged = await purge_user_oauth_credentials_for_server(prisma_client, payload.server_id)
|
||||
if purged:
|
||||
verbose_logger.info(
|
||||
"MCP server %s: purged %d stale per-user OAuth token(s) after a mint-relevant config change",
|
||||
payload.server_id,
|
||||
purged,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - purge is best-effort; the server update already succeeded
|
||||
verbose_logger.warning(
|
||||
"MCP server %s: failed to purge stale per-user OAuth tokens after config change: %s",
|
||||
payload.server_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
# TODO: Enterprise: Finish audit log trail
|
||||
if litellm.store_audit_logs:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ keeps a plain-base64 fallback on read so existing rows continue to work.
|
|||
import base64
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
|
@ -63,6 +64,94 @@ def _legacy_row(payload: str):
|
|||
return row
|
||||
|
||||
|
||||
def _identity_server(**overrides):
|
||||
base = dict(
|
||||
url="https://up.example.com/mcp",
|
||||
auth_type="oauth2",
|
||||
oauth2_flow="authorization_code",
|
||||
authorization_url="https://idp.example.com/authorize",
|
||||
token_url="https://idp.example.com/token",
|
||||
registration_url="https://idp.example.com/register",
|
||||
credentials={"client_id": "cid", "client_secret": "csec", "scopes": ["a"]},
|
||||
server_name="srv",
|
||||
description="d",
|
||||
)
|
||||
base.update(overrides)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides",
|
||||
[
|
||||
{"url": "https://other.example.com/mcp"},
|
||||
{"auth_type": "oauth_delegate"},
|
||||
{"oauth2_flow": "client_credentials"},
|
||||
{"authorization_url": "https://other.example.com/authorize"},
|
||||
{"token_url": "https://other.example.com/token"},
|
||||
{"registration_url": "https://other.example.com/register"},
|
||||
{"credentials": {"client_id": "new", "client_secret": "csec", "scopes": ["a"]}},
|
||||
{"credentials": {"client_id": "cid", "client_secret": "rotated", "scopes": ["a"]}},
|
||||
{"credentials": {"client_id": "cid", "client_secret": "csec", "scopes": ["b"]}},
|
||||
],
|
||||
)
|
||||
def test_mcp_oauth_token_identity_changes_on_mint_relevant_fields(overrides):
|
||||
from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity
|
||||
|
||||
assert mcp_oauth_token_identity(_identity_server()) != mcp_oauth_token_identity(_identity_server(**overrides))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides",
|
||||
[
|
||||
{"server_name": "renamed"},
|
||||
{"description": "changed"},
|
||||
],
|
||||
)
|
||||
def test_mcp_oauth_token_identity_stable_on_non_mint_fields(overrides):
|
||||
from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity
|
||||
|
||||
assert mcp_oauth_token_identity(_identity_server()) == mcp_oauth_token_identity(_identity_server(**overrides))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purge_user_oauth_credentials_for_server_deletes_rows_and_cache(monkeypatch):
|
||||
from litellm.proxy._experimental.mcp_server import oauth2_token_cache
|
||||
from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server
|
||||
|
||||
r1 = MagicMock(user_id="alice", server_id="srv-1")
|
||||
r2 = MagicMock(user_id="bob", server_id="srv-1")
|
||||
prisma = MagicMock()
|
||||
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[r1, r2])
|
||||
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock()
|
||||
|
||||
cache_deletes = []
|
||||
monkeypatch.setattr(
|
||||
oauth2_token_cache.mcp_per_user_token_cache,
|
||||
"delete",
|
||||
AsyncMock(side_effect=lambda uid, sid: cache_deletes.append((uid, sid))),
|
||||
)
|
||||
|
||||
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1")
|
||||
|
||||
assert purged == 2
|
||||
prisma.db.litellm_mcpusercredentials.delete_many.assert_awaited_once()
|
||||
assert set(cache_deletes) == {("alice", "srv-1"), ("bob", "srv-1")}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purge_user_oauth_credentials_for_server_noop_when_empty():
|
||||
from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server
|
||||
|
||||
prisma = MagicMock()
|
||||
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[])
|
||||
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock()
|
||||
|
||||
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1")
|
||||
|
||||
assert purged == 0
|
||||
prisma.db.litellm_mcpusercredentials.delete_many.assert_not_awaited()
|
||||
|
||||
|
||||
def _stored_value(prisma) -> str:
|
||||
"""Pull the credential_b64 value passed to the most recent upsert call."""
|
||||
call = prisma.db.litellm_mcpusercredentials.upsert.call_args
|
||||
|
|
|
|||
|
|
@ -681,6 +681,44 @@ describe("CreateMCPServer", () => {
|
|||
// Asserted in setupOAuthInteractive
|
||||
});
|
||||
|
||||
it("invalidates the held token when the auth mode changes after Authorize & Fetch", async () => {
|
||||
await setupOAuthInteractive();
|
||||
const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
|
||||
await act(async () => {
|
||||
fireEvent.change(urlInput, { target: { value: "https://a.example.com/mcp" } });
|
||||
});
|
||||
act(() => {
|
||||
oauthHook.onTokenReceived?.({ access_token: "tok-a" }, { clientId: "client-a", clientSecret: "secret-a" });
|
||||
});
|
||||
oauthHook.reset.mockClear();
|
||||
|
||||
// Switching the Authentication mode changes the OAuth identity, so the held token is discarded.
|
||||
await selectAntOption("Authentication", "True Passthrough (no LiteLLM auth)");
|
||||
|
||||
await waitFor(() => expect(oauthHook.reset).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("does NOT invalidate the held token when a non-mint field (server name) changes", async () => {
|
||||
await setupOAuthInteractive();
|
||||
const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
|
||||
await act(async () => {
|
||||
fireEvent.change(urlInput, { target: { value: "https://a.example.com/mcp" } });
|
||||
});
|
||||
act(() => {
|
||||
oauthHook.onTokenReceived?.({ access_token: "tok-a" }, { clientId: "client-a", clientSecret: "secret-a" });
|
||||
});
|
||||
oauthHook.reset.mockClear();
|
||||
|
||||
const nameInput = document.getElementById("server_name") as HTMLInputElement;
|
||||
await act(async () => {
|
||||
fireEvent.change(nameInput, { target: { value: "Renamed_Server" } });
|
||||
});
|
||||
|
||||
// server_name is not part of the OAuth identity, so the held token must survive the edit.
|
||||
await waitFor(() => expect(screen.getAllByRole("button", { name: "Add MCP Server" }).length).toBeGreaterThan(0));
|
||||
expect(oauthHook.reset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes token_validation in payload when token_validation_json is filled with valid JSON", async () => {
|
||||
vi.mocked(networking.createMCPServer).mockResolvedValue({
|
||||
server_id: "new-server-oauth",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
MCP_OAUTH2_FLOW_M2M,
|
||||
MCP_OAUTH2_FLOW_INTERACTIVE,
|
||||
isClientForwardedTokenMode,
|
||||
getOAuthAuthorizationIdentity,
|
||||
} from "./types";
|
||||
import OAuthFormFields from "./OAuthFormFields";
|
||||
import TruePassthroughWarning from "./TruePassthroughWarning";
|
||||
|
|
@ -99,7 +100,10 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
const [oauthAccessToken, setOauthAccessToken] = useState<string | null>(null);
|
||||
const [logoUrl, setLogoUrl] = useState<string | undefined>(undefined);
|
||||
const [oauthDocsUrl, setOauthDocsUrl] = useState<string | null>(null);
|
||||
const [authorizedUrl, setAuthorizedUrl] = useState<string | undefined>(undefined);
|
||||
// The OAuth authorization identity (see getOAuthAuthorizationIdentity) captured at the moment a token
|
||||
// was fetched; undefined when no valid token is held. If any mint-relevant field diverges from this,
|
||||
// the held token is stale and is discarded so the admin must re-authorize.
|
||||
const [authorizedIdentity, setAuthorizedIdentity] = useState<string | undefined>(undefined);
|
||||
|
||||
// Single hook call shared by MCPConnectionStatus and MCPToolConfiguration to avoid duplicate requests.
|
||||
const {
|
||||
|
|
@ -125,12 +129,6 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4;
|
||||
const isM2MFlow = isOAuthAuthType && formValues.oauth_flow_type === OAUTH_FLOW.M2M;
|
||||
|
||||
const getOAuthAuthorizationTarget = (values: Record<string, unknown>): string | undefined => {
|
||||
const transport = values.transport || transportType;
|
||||
const target = transport === TRANSPORT.OPENAPI ? values.spec_path : values.url;
|
||||
return typeof target === "string" ? target : undefined;
|
||||
};
|
||||
|
||||
const persistCreateUiState = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
|
|
@ -207,6 +205,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
// and committed to sessionStorage on submit; it must never be written into form.credentials,
|
||||
// which would persist it as server-level credentials on the created server row. Mirrors the
|
||||
// edit form's onTokenReceived early return.
|
||||
setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getFieldsValue(true)));
|
||||
NotificationsManager.success(
|
||||
"Token held for this browser session. Tools can now be previewed and configured; nothing will be saved to LiteLLM.",
|
||||
);
|
||||
|
|
@ -223,7 +222,9 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
};
|
||||
|
||||
form.setFieldsValue({ credentials });
|
||||
setAuthorizedUrl(getOAuthAuthorizationTarget(form.getFieldsValue(true)));
|
||||
// Capture the identity AFTER writing the DCR'd credentials so the held token is not spuriously
|
||||
// invalidated by its own credential write.
|
||||
setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getFieldsValue(true)));
|
||||
|
||||
NotificationsManager.success(
|
||||
"OAuth authorization successful! Please click 'Create MCP Server' to save the configuration.",
|
||||
|
|
@ -233,13 +234,24 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
flowSource: "create",
|
||||
});
|
||||
|
||||
const clearAuthorizedOAuthState = (values: Record<string, unknown>) => {
|
||||
form.resetFields(["credentials", "authorization_url", "token_url", "registration_url"]);
|
||||
form.setFieldsValue(values);
|
||||
// Discard the held browser-authorized token and its tool preview when the authorization identity
|
||||
// changes (or the modal closes). For oauth2 the fetched token + DCR client also live in
|
||||
// form.credentials, and the discovered endpoints in authorization_url/token_url/registration_url, so
|
||||
// those form fields are reset too; whatever the admin just changed (passed via changedValues) is
|
||||
// re-applied so the invalidation never wipes their in-flight edit.
|
||||
const CLEARED_ON_INVALIDATION = ["credentials", "authorization_url", "token_url", "registration_url"] as const;
|
||||
const clearHeldOAuthToken = (changedValues: Record<string, unknown> = {}) => {
|
||||
setOauthAccessToken(null);
|
||||
clearTools();
|
||||
resetOAuthFlow();
|
||||
setAuthorizedUrl(undefined);
|
||||
setAuthorizedIdentity(undefined);
|
||||
form.resetFields([...CLEARED_ON_INVALIDATION]);
|
||||
const preserved = Object.fromEntries(
|
||||
CLEARED_ON_INVALIDATION.filter((key) => key in changedValues).map((key) => [key, changedValues[key]]),
|
||||
);
|
||||
if (Object.keys(preserved).length > 0) {
|
||||
form.setFieldsValue(preserved);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
@ -577,7 +589,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
: { spec_path: undefined, command: undefined, args: undefined, env: undefined };
|
||||
|
||||
const nextValues =
|
||||
authorizedUrl === undefined
|
||||
authorizedIdentity === undefined
|
||||
? transportValues
|
||||
: {
|
||||
...transportValues,
|
||||
|
|
@ -587,10 +599,9 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
registration_url: undefined,
|
||||
};
|
||||
|
||||
if (authorizedUrl !== undefined) {
|
||||
clearAuthorizedOAuthState(nextValues);
|
||||
} else {
|
||||
form.setFieldsValue(nextValues);
|
||||
form.setFieldsValue(nextValues);
|
||||
if (authorizedIdentity !== undefined) {
|
||||
clearHeldOAuthToken();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -652,28 +663,18 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
setOauthAccessToken(null);
|
||||
clearTools();
|
||||
resetOAuthFlow();
|
||||
setAuthorizedUrl(undefined);
|
||||
setAuthorizedIdentity(undefined);
|
||||
}
|
||||
}, [isModalVisible, form, clearTools, resetOAuthFlow]);
|
||||
|
||||
const isAdmin = isAdminRole(userRole);
|
||||
|
||||
const handleFormValuesChange = (changedValues: Record<string, unknown>, allValues: Record<string, unknown>) => {
|
||||
const changedAuthorizationTarget = "url" in changedValues || "spec_path" in changedValues;
|
||||
if (
|
||||
changedAuthorizationTarget &&
|
||||
authorizedUrl !== undefined &&
|
||||
getOAuthAuthorizationTarget(allValues) !== authorizedUrl
|
||||
) {
|
||||
const invalidated = {
|
||||
credentials: undefined,
|
||||
authorization_url: changedValues.authorization_url,
|
||||
token_url: changedValues.token_url,
|
||||
registration_url: changedValues.registration_url,
|
||||
};
|
||||
clearAuthorizedOAuthState(invalidated);
|
||||
setFormValues({ ...allValues, ...invalidated });
|
||||
return;
|
||||
// Any change to a mint-relevant field (url, auth_type, oauth_flow_type, client creds/scopes, or the
|
||||
// authorization/token/registration endpoints — see getOAuthAuthorizationIdentity) makes a held token
|
||||
// stale, so discard it and force a fresh authorize.
|
||||
if (authorizedIdentity !== undefined && getOAuthAuthorizationIdentity(allValues) !== authorizedIdentity) {
|
||||
clearHeldOAuthToken(changedValues);
|
||||
}
|
||||
setFormValues(allValues);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/rea
|
|||
import {
|
||||
AUTH_TYPE,
|
||||
isClientForwardedTokenMode,
|
||||
getOAuthAuthorizationIdentity,
|
||||
OAUTH_FLOW,
|
||||
MCP_OAUTH2_FLOW_M2M,
|
||||
MCP_OAUTH2_FLOW_INTERACTIVE,
|
||||
|
|
@ -15,7 +16,7 @@ import {
|
|||
oauth2FlowToFormValue,
|
||||
} from "./types";
|
||||
import { updateMCPServer, listMCPTools, storeMCPOAuthUserCredential } from "../networking";
|
||||
import { getToken, isTokenValid, setToken } from "@/utils/mcpTokenStore";
|
||||
import { getToken, isTokenValid, removeToken, setToken } from "@/utils/mcpTokenStore";
|
||||
import { buildMcpPassthroughAuthHeader } from "@/utils/mcpHeaderUtils";
|
||||
import MCPServerCostConfig from "./mcp_server_cost_config";
|
||||
import MCPPermissionManagement from "./MCPPermissionManagement";
|
||||
|
|
@ -136,11 +137,17 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
// that read only mcpServer.auth_type go stale the moment the admin switches modes in the form.
|
||||
const getEffectiveAuthType = () => form.getFieldValue("auth_type") ?? mcpServer.auth_type;
|
||||
|
||||
// The OAuth authorization identity (see getOAuthAuthorizationIdentity) captured when a token is fetched
|
||||
// in this edit session; undefined when none is held. If a mint-relevant field later diverges from it,
|
||||
// the held token (hook response + sessionStorage) is discarded so the admin must re-authorize.
|
||||
const authorizedIdentityRef = React.useRef<string | undefined>(undefined);
|
||||
|
||||
const {
|
||||
startOAuthFlow,
|
||||
status: oauthStatus,
|
||||
error: oauthError,
|
||||
tokenResponse: oauthTokenResponse,
|
||||
reset: resetOAuthFlow,
|
||||
} = useMcpOAuthFlow({
|
||||
accessToken,
|
||||
getCredentials: () => form.getFieldValue("credentials"),
|
||||
|
|
@ -183,6 +190,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
authorizedIdentityRef.current = getOAuthAuthorizationIdentity(form.getFieldsValue(true));
|
||||
if (isClientForwardedTokenMode(getEffectiveAuthType())) {
|
||||
const browserHeldToken = {
|
||||
access_token: token.access_token,
|
||||
|
|
@ -205,6 +213,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
};
|
||||
|
||||
form.setFieldsValue({ credentials });
|
||||
// Re-capture after writing credentials so the token is not invalidated by its own credential write.
|
||||
authorizedIdentityRef.current = getOAuthAuthorizationIdentity(form.getFieldsValue(true));
|
||||
|
||||
NotificationsManager.success(
|
||||
"OAuth authorization successful! Please click 'Update MCP Server' to save the credentials.",
|
||||
|
|
@ -378,6 +388,39 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mcpServer, accessToken, userID, oauthTokenResponse?.access_token]);
|
||||
|
||||
// Invalidate a token authorized in this edit session once any mint-relevant field diverges from the
|
||||
// identity it was minted against (url, auth_type, oauth_flow_type, client creds/scopes, or the
|
||||
// authorization/token/registration endpoints — see getOAuthAuthorizationIdentity). Discards the hook
|
||||
// token (resetOAuthFlow, which re-runs fetchTools to prompt a fresh authorize), the sessionStorage
|
||||
// token (removeToken, browser-held modes), and the fetched token/DCR client in form.credentials + the
|
||||
// discovered endpoint fields; the admin's in-flight edit is re-applied so it is never wiped. Only fires
|
||||
// when a token was actually authorized here (ref set), so a token already valid for the saved server on
|
||||
// mount is left untouched. Driven from onValuesChange (user input only), never programmatic resets.
|
||||
const CLEARED_ON_INVALIDATION = ["credentials", "authorization_url", "token_url", "registration_url"] as const;
|
||||
const clearHeldOAuthToken = (changedValues: Record<string, unknown> = {}) => {
|
||||
authorizedIdentityRef.current = undefined;
|
||||
if (mcpServer.server_id) {
|
||||
removeToken(mcpServer.server_id, userID);
|
||||
}
|
||||
resetOAuthFlow();
|
||||
form.resetFields([...CLEARED_ON_INVALIDATION]);
|
||||
const preserved = Object.fromEntries(
|
||||
CLEARED_ON_INVALIDATION.filter((key) => key in changedValues).map((key) => [key, changedValues[key]]),
|
||||
);
|
||||
if (Object.keys(preserved).length > 0) {
|
||||
form.setFieldsValue(preserved);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormValuesChange = (changedValues: Record<string, unknown>) => {
|
||||
if (
|
||||
authorizedIdentityRef.current !== undefined &&
|
||||
getOAuthAuthorizationIdentity(form.getFieldsValue(true)) !== authorizedIdentityRef.current
|
||||
) {
|
||||
clearHeldOAuthToken(changedValues);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTools = async () => {
|
||||
if (!accessToken || !mcpServer.server_id) return;
|
||||
|
||||
|
|
@ -805,7 +848,13 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
</TabList>
|
||||
<TabPanels className="mt-6">
|
||||
<TabPanel>
|
||||
<Form form={form} onFinish={handleSave} initialValues={initialValues} layout="vertical">
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleSave}
|
||||
onValuesChange={handleFormValuesChange}
|
||||
initialValues={initialValues}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
label="MCP Server Name"
|
||||
name="server_name"
|
||||
|
|
|
|||
|
|
@ -57,6 +57,33 @@ export const OAUTH_FLOW = {
|
|||
M2M: "m2m",
|
||||
};
|
||||
|
||||
// The fields that determine which upstream OAuth token "Authorize & Fetch" mints: the resource/audience
|
||||
// (url), the OAuth mode/grant (auth_type, oauth_flow_type), the OAuth client and requested scope
|
||||
// (credentials.client_id / client_secret / scopes), and the authorization-server endpoints
|
||||
// (authorization_url / token_url / registration_url). Grounded in RFC 8707 / RFC 8693 and the MCP auth
|
||||
// spec: an access token is bound to exactly this tuple (resource/audience + scope + client + issuer), so
|
||||
// a previously authorized token is stale if and only if this identity changes and must be re-minted.
|
||||
// Deliberately EXCLUDES: transport (http<->sse on the same url is the same audience; a transport switch
|
||||
// only matters when it changes the target url, which `target` already captures), delegate_auth_to_upstream
|
||||
// (a downstream-usage toggle that is never sent to the authorize request), and all metadata/RBAC/routing
|
||||
// fields. Shared by the create and edit forms so their invalidation logic cannot drift.
|
||||
export const getOAuthAuthorizationIdentity = (values: Record<string, unknown>): string => {
|
||||
const credentials = (values.credentials ?? {}) as Record<string, unknown>;
|
||||
const target = values.transport === TRANSPORT.OPENAPI ? values.spec_path : values.url;
|
||||
const identity = {
|
||||
target: typeof target === "string" ? target : null,
|
||||
auth_type: values.auth_type ?? null,
|
||||
oauth_flow_type: values.oauth_flow_type ?? null,
|
||||
client_id: credentials.client_id ?? null,
|
||||
client_secret: credentials.client_secret ?? null,
|
||||
scopes: credentials.scopes ?? null,
|
||||
authorization_url: values.authorization_url ?? null,
|
||||
token_url: values.token_url ?? null,
|
||||
registration_url: values.registration_url ?? null,
|
||||
};
|
||||
return JSON.stringify(identity);
|
||||
};
|
||||
|
||||
// Backend value of `oauth2_flow` that marks a machine-to-machine server. Distinct
|
||||
// from the UI-local OAUTH_FLOW.M2M ("m2m"); this is what the API actually returns.
|
||||
export const MCP_OAUTH2_FLOW_M2M = "client_credentials";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue