mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-06 06:24:12 +00:00
M3 implementada e backlog atualizado
This commit is contained in:
@@ -6,16 +6,23 @@
|
||||
The Gateway authenticates the *caller*; this module answers a completely
|
||||
separate question -- which credential the Fabric presents *to the backend*
|
||||
-- and never answers it by handing the inbound token through unmodified
|
||||
(README.md §35.6, "Token passthrough indiscriminado"). `service_account`
|
||||
mode resolves a static, backend-specific credential from an env var at call
|
||||
time; `token_exchange` (RFC 8693) is a documented, unimplemented extension
|
||||
(see `BackendConfig`).
|
||||
(README.md §35.6, "Token passthrough indiscriminado"). Two modes:
|
||||
`service_account` resolves a static, backend-specific credential from an env
|
||||
var at call time; `token_exchange` (RFC 8693 via Keycloak's Standard Token
|
||||
Exchange) is implemented by `TokenExchangeClient` below -- verified
|
||||
end-to-end against a real Keycloak 26.7.3 instance, 2026-09-14 (see
|
||||
`downstream/models.py`'s module docstring for the exact IdP-side setup this
|
||||
requires: the Gateway's own exchange client, a per-backend audience-mapper
|
||||
client scope).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from emcp_bus.downstream.models import BackendConfig
|
||||
@@ -26,14 +33,23 @@ class UnknownBackendError(Exception):
|
||||
|
||||
|
||||
class BackendCredentialUnavailableError(Exception):
|
||||
"""The configured env var for a backend's service-account token is unset.
|
||||
"""The configured credential source for a backend could not produce a token
|
||||
at call time -- a missing env var (`service_account` mode) or a failed
|
||||
RFC 8693 exchange (`token_exchange` mode).
|
||||
|
||||
Distinct from `UnknownBackendError`: the backend *is* configured, but
|
||||
its credential is missing at runtime -- callers (the Gateway) must
|
||||
treat this as DENY, not as "fall back to no credential" (README.md §38).
|
||||
Distinct from `UnknownBackendError`: the backend *is* configured, but its
|
||||
credential is unavailable right now -- callers (the Gateway) must treat
|
||||
this as DENY, not as "fall back to no credential" (README.md §38).
|
||||
"""
|
||||
|
||||
|
||||
class TokenExchangeError(Exception):
|
||||
"""The IdP rejected a `client_credentials` or RFC 8693 exchange call.
|
||||
Caught and re-raised as `BackendCredentialUnavailableError` by
|
||||
`BackendCredentialProvider` -- this type exists so `TokenExchangeClient`
|
||||
itself stays IdP-shaped, not Gateway-failure-policy-shaped."""
|
||||
|
||||
|
||||
class BackendCredential(BaseModel):
|
||||
backend: str
|
||||
url: str
|
||||
@@ -41,21 +57,125 @@ class BackendCredential(BaseModel):
|
||||
audience: str
|
||||
|
||||
|
||||
class BackendCredentialProvider:
|
||||
def __init__(self, backend_configs: list[BackendConfig]) -> None:
|
||||
self._configs = {config.backend: config for config in backend_configs}
|
||||
@dataclass
|
||||
class _CachedExchange:
|
||||
token: str
|
||||
expires_at: float
|
||||
|
||||
def credential_for(self, backend: str) -> BackendCredential:
|
||||
|
||||
class TokenExchangeClient:
|
||||
"""RFC 8693 token exchange against an OIDC IdP's token endpoint (Keycloak
|
||||
26.2+'s Standard Token Exchange, GA -- no preview feature flag needed).
|
||||
|
||||
Two HTTP round trips per fresh exchange: (1) `client_credentials` as the
|
||||
Gateway's own exchange client, producing a subject_token; (2) exchanging
|
||||
that subject_token for one scoped to the target backend's `audience`.
|
||||
Verified empirically against a real Keycloak instance that step 2 fails
|
||||
with "Requested audience not available" unless the request also carries
|
||||
a `scope` that actually adds that audience to the token (the `audience`
|
||||
parameter alone only *restricts*, never *adds*) -- hence
|
||||
`BackendConfig.token_exchange_scope`.
|
||||
|
||||
Exchanged tokens are cached per backend until shortly before their own
|
||||
`expires_in` (RFC 8693 exchanged tokens are commonly short-lived, and
|
||||
re-exchanging on every single `tools/call` would be both wasteful and
|
||||
slow); a cache hit costs zero HTTP round trips.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
*,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
expiry_margin_seconds: float = 5.0,
|
||||
) -> None:
|
||||
self._client_id = client_id
|
||||
self._client_secret = client_secret
|
||||
self._http = http_client or httpx.AsyncClient()
|
||||
self._expiry_margin_seconds = expiry_margin_seconds
|
||||
self._cache: dict[str, _CachedExchange] = {}
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._http.aclose()
|
||||
|
||||
async def exchange(
|
||||
self, *, token_endpoint: str, backend: str, audience: str, scope: str
|
||||
) -> str:
|
||||
now = time.monotonic()
|
||||
cached = self._cache.get(backend)
|
||||
if cached is not None and cached.expires_at > now:
|
||||
return cached.token
|
||||
|
||||
subject_token = await self._client_credentials(token_endpoint)
|
||||
token, expires_in = await self._exchange(token_endpoint, subject_token, audience, scope)
|
||||
self._cache[backend] = _CachedExchange(
|
||||
token=token, expires_at=now + max(expires_in - self._expiry_margin_seconds, 0.0)
|
||||
)
|
||||
return token
|
||||
|
||||
async def _client_credentials(self, token_endpoint: str) -> str:
|
||||
try:
|
||||
response = await self._http.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self._client_id,
|
||||
"client_secret": self._client_secret,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
raise TokenExchangeError(
|
||||
f"Failed to obtain the exchange client's own subject token from "
|
||||
f"{token_endpoint}: {exc}"
|
||||
) from exc
|
||||
access_token: str = response.json()["access_token"]
|
||||
return access_token
|
||||
|
||||
async def _exchange(
|
||||
self, token_endpoint: str, subject_token: str, audience: str, scope: str
|
||||
) -> tuple[str, int]:
|
||||
try:
|
||||
response = await self._http.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
"subject_token": subject_token,
|
||||
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||
"audience": audience,
|
||||
"scope": scope,
|
||||
"client_id": self._client_id,
|
||||
"client_secret": self._client_secret,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
raise TokenExchangeError(
|
||||
f"RFC 8693 token exchange for audience {audience!r} (scope {scope!r}) "
|
||||
f"at {token_endpoint} failed: {exc}"
|
||||
) from exc
|
||||
body = response.json()
|
||||
return body["access_token"], int(body.get("expires_in", 60))
|
||||
|
||||
|
||||
class BackendCredentialProvider:
|
||||
def __init__(
|
||||
self,
|
||||
backend_configs: list[BackendConfig],
|
||||
*,
|
||||
token_exchange_client: TokenExchangeClient | None = None,
|
||||
) -> None:
|
||||
self._configs = {config.backend: config for config in backend_configs}
|
||||
self._token_exchange_client = token_exchange_client
|
||||
|
||||
async def credential_for(self, backend: str) -> BackendCredential:
|
||||
config = self._configs.get(backend)
|
||||
if config is None:
|
||||
raise UnknownBackendError(backend)
|
||||
|
||||
if config.credential_mode == "token_exchange":
|
||||
raise NotImplementedError(
|
||||
f"credential_mode='token_exchange' for backend {backend!r} is a documented "
|
||||
"extension point (RFC 8693 via an OIDC IdP), not implemented in this RI -- "
|
||||
"see BackendConfig docstring."
|
||||
)
|
||||
return await self._credential_via_token_exchange(backend, config)
|
||||
|
||||
assert config.service_account_token_env_var is not None # enforced by BackendConfig
|
||||
token = os.environ.get(config.service_account_token_env_var)
|
||||
@@ -68,6 +188,30 @@ class BackendCredentialProvider:
|
||||
backend=backend, url=config.url, token=token, audience=config.audience
|
||||
)
|
||||
|
||||
async def _credential_via_token_exchange(
|
||||
self, backend: str, config: BackendConfig
|
||||
) -> BackendCredential:
|
||||
if self._token_exchange_client is None:
|
||||
raise BackendCredentialUnavailableError(
|
||||
f"Backend {backend!r} is configured for credential_mode='token_exchange' but "
|
||||
"no TokenExchangeClient is available (GATEWAY_TOKEN_EXCHANGE_CLIENT_ID/"
|
||||
"_SECRET unset) -- see emcp_bus.downstream.identity module docstring."
|
||||
)
|
||||
assert config.token_exchange_endpoint is not None # enforced by BackendConfig
|
||||
assert config.token_exchange_scope is not None # enforced by BackendConfig
|
||||
try:
|
||||
token = await self._token_exchange_client.exchange(
|
||||
token_endpoint=config.token_exchange_endpoint,
|
||||
backend=backend,
|
||||
audience=config.audience,
|
||||
scope=config.token_exchange_scope,
|
||||
)
|
||||
except TokenExchangeError as exc:
|
||||
raise BackendCredentialUnavailableError(str(exc)) from exc
|
||||
return BackendCredential(
|
||||
backend=backend, url=config.url, token=token, audience=config.audience
|
||||
)
|
||||
|
||||
def url_for(self, backend: str) -> str:
|
||||
config = self._configs.get(backend)
|
||||
if config is None:
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
"""Backend credential config schema (EP-07-T02, README.md §20).
|
||||
|
||||
One entry per backend the Fabric calls, declaring *how* the Gateway obtains
|
||||
an outbound credential for it -- never the credential's value. The RI
|
||||
implements `service_account` (a static credential resolved from an env var
|
||||
at call time); `token_exchange` (RFC 8693, e.g. via Keycloak) is modeled as
|
||||
a distinct, documented mode but not implemented in this milestone -- see
|
||||
`emcp_bus.downstream.identity.BackendCredentialProvider`.
|
||||
an outbound credential for it -- never the credential's value. Two modes:
|
||||
`service_account` (a static credential resolved from an env var at call
|
||||
time) and `token_exchange` (RFC 8693 via Keycloak's Standard Token Exchange,
|
||||
GA since Keycloak 26.2 -- verified against a real Keycloak 26.7.3 instance,
|
||||
2026-09-14: see `emcp_bus.downstream.identity.TokenExchangeClient`). Setting
|
||||
up `token_exchange` mode for a backend requires, on the real IdP: the
|
||||
Gateway's own exchange client has `attributes.standard.token.exchange.enabled
|
||||
= "true"` and is `serviceAccountsEnabled`; a client scope named
|
||||
`token_exchange_scope` exists with an `oidc-audience-mapper` targeting this
|
||||
backend's `audience` (verified empirically -- requesting `audience` alone,
|
||||
without also requesting a scope that actually adds it, fails with "Requested
|
||||
audience not available"); that scope is a default (or requested optional)
|
||||
scope of the Gateway's exchange client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -42,8 +50,13 @@ class BackendConfig(BaseModel):
|
||||
static credential. Required when `credential_mode == "service_account"`."""
|
||||
|
||||
token_exchange_endpoint: str | None = None
|
||||
"""RFC 8693 token endpoint URL. Documented extension point, not yet
|
||||
implemented -- see module docstring."""
|
||||
"""RFC 8693 token endpoint URL, e.g.
|
||||
`http://keycloak:8080/realms/emcp/protocol/openid-connect/token`."""
|
||||
|
||||
token_exchange_scope: str | None = None
|
||||
"""Name of the Keycloak client scope (carrying an `oidc-audience-mapper`
|
||||
for this backend's `audience`) to request alongside the RFC 8693
|
||||
`audience` parameter -- required, see module docstring."""
|
||||
|
||||
@field_validator("service_account_token_env_var")
|
||||
@classmethod
|
||||
@@ -66,4 +79,8 @@ class BackendConfig(BaseModel):
|
||||
raise ValueError(
|
||||
"token_exchange_endpoint is required when credential_mode == 'token_exchange'"
|
||||
)
|
||||
if self.credential_mode == "token_exchange" and not self.token_exchange_scope:
|
||||
raise ValueError(
|
||||
"token_exchange_scope is required when credential_mode == 'token_exchange'"
|
||||
)
|
||||
return self
|
||||
|
||||
Reference in New Issue
Block a user