mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-05 22:14:11 +00:00
225 lines
8.2 KiB
Python
225 lines
8.2 KiB
Python
"""Outbound (backend) identity (EP-07-T01/T02, README.md §20)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from emcp_bus.downstream.identity import (
|
|
BackendCredentialProvider,
|
|
BackendCredentialUnavailableError,
|
|
TokenExchangeClient,
|
|
UnknownBackendError,
|
|
)
|
|
from emcp_bus.downstream.models import BackendConfig
|
|
|
|
|
|
def test_backend_config_rejects_a_literal_secret_in_the_env_var_name_field() -> None:
|
|
"""README.md §35.5: credentials must never live inline in config."""
|
|
with pytest.raises(ValidationError):
|
|
BackendConfig(
|
|
backend="sales-domain",
|
|
url="http://127.0.0.1:8100/mcp",
|
|
audience="sales-domain-service",
|
|
service_account_token_env_var="eyJhbGciOiJSUzI1NiJ9.not-an-env-var-name",
|
|
)
|
|
|
|
|
|
def test_backend_config_requires_env_var_for_service_account_mode() -> None:
|
|
with pytest.raises(ValidationError):
|
|
BackendConfig(
|
|
backend="sales-domain", url="http://127.0.0.1:8100/mcp", audience="sales-domain-service"
|
|
)
|
|
|
|
|
|
def test_backend_config_requires_endpoint_for_token_exchange_mode() -> None:
|
|
with pytest.raises(ValidationError):
|
|
BackendConfig(
|
|
backend="sales-domain",
|
|
url="http://127.0.0.1:8100/mcp",
|
|
audience="sales-domain-service",
|
|
credential_mode="token_exchange",
|
|
token_exchange_scope="aud-sales-domain",
|
|
)
|
|
|
|
|
|
def test_backend_config_requires_scope_for_token_exchange_mode() -> None:
|
|
with pytest.raises(ValidationError):
|
|
BackendConfig(
|
|
backend="sales-domain",
|
|
url="http://127.0.0.1:8100/mcp",
|
|
audience="sales-domain-service",
|
|
credential_mode="token_exchange",
|
|
token_exchange_endpoint="https://keycloak.example/realms/emcp/protocol/openid-connect/token",
|
|
)
|
|
|
|
|
|
async def test_credential_for_configured_backend_resolves_from_env(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "backend-secret-token")
|
|
provider = BackendCredentialProvider(
|
|
[
|
|
BackendConfig(
|
|
backend="sales-domain",
|
|
url="http://127.0.0.1:8100/mcp",
|
|
audience="sales-domain-service",
|
|
service_account_token_env_var="SALES_DOMAIN_SERVICE_TOKEN",
|
|
)
|
|
]
|
|
)
|
|
|
|
credential = await provider.credential_for("sales-domain")
|
|
|
|
assert credential.token == "backend-secret-token"
|
|
assert credential.audience == "sales-domain-service"
|
|
|
|
|
|
async def test_credential_differs_from_a_hypothetical_inbound_client_token(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The property README.md §20/ADR-006 actually cares about: whatever the
|
|
inbound MCP Client token was, the outbound credential is never it."""
|
|
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "backend-secret-token")
|
|
provider = BackendCredentialProvider(
|
|
[
|
|
BackendConfig(
|
|
backend="sales-domain",
|
|
url="http://127.0.0.1:8100/mcp",
|
|
audience="sales-domain-service",
|
|
service_account_token_env_var="SALES_DOMAIN_SERVICE_TOKEN",
|
|
)
|
|
]
|
|
)
|
|
inbound_client_token = "inbound-oidc-access-token" # nosec -- test fixture, not a real secret
|
|
|
|
credential = await provider.credential_for("sales-domain")
|
|
|
|
assert credential.token != inbound_client_token
|
|
assert credential.audience != "emcp-gateway" # the Gateway's own inbound audience
|
|
|
|
|
|
async def test_unknown_backend_raises() -> None:
|
|
provider = BackendCredentialProvider([])
|
|
|
|
with pytest.raises(UnknownBackendError):
|
|
await provider.credential_for("no-such-backend")
|
|
|
|
|
|
async def test_missing_env_var_at_call_time_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("SALES_DOMAIN_SERVICE_TOKEN", raising=False)
|
|
provider = BackendCredentialProvider(
|
|
[
|
|
BackendConfig(
|
|
backend="sales-domain",
|
|
url="http://127.0.0.1:8100/mcp",
|
|
audience="sales-domain-service",
|
|
service_account_token_env_var="SALES_DOMAIN_SERVICE_TOKEN",
|
|
)
|
|
]
|
|
)
|
|
|
|
with pytest.raises(BackendCredentialUnavailableError):
|
|
await provider.credential_for("sales-domain")
|
|
|
|
|
|
# --- token_exchange mode (EP-07, RFC 8693) ---------------------------------
|
|
|
|
_TOKEN_EXCHANGE_CONFIG = BackendConfig(
|
|
backend="finance-domain",
|
|
url="http://127.0.0.1:8101/mcp",
|
|
audience="finance-domain-service",
|
|
credential_mode="token_exchange",
|
|
token_exchange_endpoint="https://keycloak.example/realms/emcp/protocol/openid-connect/token",
|
|
token_exchange_scope="aud-finance-domain-service",
|
|
)
|
|
|
|
|
|
async def test_token_exchange_mode_without_a_configured_client_fails_closed() -> None:
|
|
"""A backend declared as `token_exchange` but with no
|
|
GATEWAY_TOKEN_EXCHANGE_CLIENT_ID/_SECRET configured (no `TokenExchangeClient`
|
|
passed to the provider) must DENY, not silently fall back to something else."""
|
|
provider = BackendCredentialProvider([_TOKEN_EXCHANGE_CONFIG])
|
|
|
|
with pytest.raises(BackendCredentialUnavailableError):
|
|
await provider.credential_for("finance-domain")
|
|
|
|
|
|
def _mock_keycloak_transport(
|
|
*, exchanged_token: str = "exchanged-access-token", expires_in: int = 60
|
|
) -> httpx.MockTransport:
|
|
calls: list[dict[str, str]] = []
|
|
|
|
def handle(request: httpx.Request) -> httpx.Response:
|
|
from urllib.parse import parse_qsl
|
|
|
|
body = dict(parse_qsl(request.content.decode()))
|
|
calls.append(body)
|
|
if body["grant_type"] == "client_credentials":
|
|
return httpx.Response(200, json={"access_token": "subject-token", "expires_in": 300})
|
|
assert body["grant_type"] == "urn:ietf:params:oauth:grant-type:token-exchange"
|
|
assert body["subject_token"] == "subject-token"
|
|
assert body["audience"] == "finance-domain-service"
|
|
assert body["scope"] == "aud-finance-domain-service"
|
|
return httpx.Response(200, json={"access_token": exchanged_token, "expires_in": expires_in})
|
|
|
|
transport = httpx.MockTransport(handle)
|
|
transport.calls = calls # type: ignore[attr-defined]
|
|
return transport
|
|
|
|
|
|
async def test_token_exchange_produces_a_token_scoped_to_the_target_backend() -> None:
|
|
transport = _mock_keycloak_transport()
|
|
exchange_client = TokenExchangeClient(
|
|
"gateway-exchange-client",
|
|
"gateway-exchange-secret",
|
|
http_client=httpx.AsyncClient(transport=transport),
|
|
)
|
|
provider = BackendCredentialProvider(
|
|
[_TOKEN_EXCHANGE_CONFIG], token_exchange_client=exchange_client
|
|
)
|
|
|
|
credential = await provider.credential_for("finance-domain")
|
|
|
|
assert credential.token == "exchanged-access-token"
|
|
assert credential.audience == "finance-domain-service"
|
|
await exchange_client.aclose()
|
|
|
|
|
|
async def test_token_exchange_result_is_cached_until_near_expiry() -> None:
|
|
transport = _mock_keycloak_transport(expires_in=300)
|
|
exchange_client = TokenExchangeClient(
|
|
"gateway-exchange-client",
|
|
"gateway-exchange-secret",
|
|
http_client=httpx.AsyncClient(transport=transport),
|
|
)
|
|
provider = BackendCredentialProvider(
|
|
[_TOKEN_EXCHANGE_CONFIG], token_exchange_client=exchange_client
|
|
)
|
|
|
|
first = await provider.credential_for("finance-domain")
|
|
second = await provider.credential_for("finance-domain")
|
|
|
|
assert first.token == second.token == "exchanged-access-token"
|
|
assert len(transport.calls) == 2 # type: ignore[attr-defined] # client_credentials + exchange, once
|
|
await exchange_client.aclose()
|
|
|
|
|
|
async def test_token_exchange_failure_fails_closed() -> None:
|
|
def handle(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(400, json={"error": "invalid_request"})
|
|
|
|
exchange_client = TokenExchangeClient(
|
|
"gateway-exchange-client",
|
|
"gateway-exchange-secret",
|
|
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handle)),
|
|
)
|
|
provider = BackendCredentialProvider(
|
|
[_TOKEN_EXCHANGE_CONFIG], token_exchange_client=exchange_client
|
|
)
|
|
|
|
with pytest.raises(BackendCredentialUnavailableError):
|
|
await provider.credential_for("finance-domain")
|
|
await exchange_client.aclose()
|