# ADR-019: MCP Client identity -- production extension point (mTLS / workload identity) **Status:** Documented (spike), not implemented in the RI (EP-01-T04, `could`). **Related:** this ADR is the canonical record of this decision. See `RI/docs/AS-BUILT.md` and `RI/docs/interfaces/identity-oidc.md` for implementation and validation context. Also relevant: README.md §19, RF-02, Axiom 2. ## Context The RI's identity boundary (`src/emcp_bus/identity/authn.py`, EP-01-T03) is OAuth 2.1 `client_credentials` against Keycloak (EP-01-T01): every MCP Client authenticates with a client_id + secret, gets a short-lived JWT, and the Gateway validates that JWT's signature/issuer/audience/expiry against the IdP's JWKS before trusting the `azp` claim as the caller's identity. This is a deliberate, confirmed decision for the RI (not a placeholder): OAuth 2.1 satisfies every property README.md §19 actually requires of an identity mechanism -- short-lived, revocable, audience-bound, verifiable without a shared long-term secret in the request path. Real enterprise deployments commonly go further, using mTLS (mutual TLS client certificates) or a cloud provider's workload identity federation (e.g. SPIFFE/SPIRE, AWS IAM roles for service accounts, GCP Workload Identity, Azure Managed Identity) instead of -- or in addition to -- a bearer JWT. This ADR documents how that extension plugs into the RI's existing seam, without requiring one, so a production fork isn't blocked on a redesign. ## Decision Do **not** implement mTLS/workload identity in the RI. Document the extension point instead, so `EP-01-T03`'s contract is provably stable under it. ## The extension point `TokenValidator.validate(token: str) -> AuthenticatedClient` is the *only* function in the RI that produces client identity for authorization purposes (`emcp_bus.gateway.server.on_list_tools`/`on_call_tool` both call `get_access_token()`, which is populated by the SDK's `TokenVerifier` protocol wrapping this same validator -- see `emcp_bus.identity.token_verifier.MCPTokenVerifier`). Everything downstream of it -- `EntitlementManager.resolve`, the PDP, the audit/correlation layer (EP-08) -- consumes only the resulting `AuthenticatedClient.client_id`. None of that code cares *how* the identity was established. A production deployment adding mTLS or workload identity would: 1. Implement a second identity source honoring the same output contract: ```python class MTLSClientValidator: def validate(self, request: ...) -> AuthenticatedClient: ... ``` fed from the terminating proxy/load balancer's verified client certificate (a `X-Client-Cert-CN` style header from a trusted mTLS terminator, or the ASGI `scope["extensions"]["tls"]` a compliant server exposes) instead of a JWT. `AuthenticatedClient.client_id` would be the certificate's Subject/SAN, mapped 1:1 to a `ClientRegistration.client_id` exactly the way a JWT's `azp` is today -- **no other model in the RI changes**: `ClientRegistration`, `ClientProfile`, `EntitlementManager`, the PDP contract, and the audit schema are all already identity-mechanism agnostic. 2. Provide an alternate `build_auth()`-equivalent in `emcp_bus/gateway/server.py` that wires the new validator in instead of `MCPTokenVerifier`, gated by deployment config -- the same shape as today's `build_auth()` returning `(None, None)` for the "no OIDC configured" mode. A hybrid deployment (mTLS for one class of backend service, OAuth for interactive agents) is possible by making the `TokenVerifier`/equivalent selection per-listener rather than per-process, which the SDK's `streamable_http_app(auth=..., token_verifier=...)` parameters already support per app instance. 3. Cloud workload identity (SPIFFE/SPIRE, IRSA, Workload Identity, Managed Identity) is a variant of the same shape: the "verify a credential the caller cannot forge, and extract a stable identifier from it" contract is identical, only the credential format and verification library differ. None of it requires touching `EntitlementManager`, the PDP, the Fabric router (EP-06-T01), or the audit/correlation layer (EP-08-T02/T03). ## What must not change - **`AuthenticatedClient` shape** (`client_id`, `issuer`, `expires_at`): every downstream consumer types against this, not against a JWT-specific claim set. - **Fail-closed contract**: any identity mechanism's failure path must raise (an `AuthenticationError`-shaped exception) rather than fall back to an unauthenticated or agent-declared identity (README.md §5, Axiom 2) -- this is the actual security property EP-01-T03's acceptance criteria test, and it is mechanism-independent. - **No new privileged path for `clientInfo`**: whatever the identity mechanism, only a value verified independently of the MCP request body may ever populate `client_id`. ## Consequences - Confirmed for the RI: reduces EP-01's implementation effort without weakening any of README.md §19's desirable identity properties (all are satisfied by OAuth 2.1 `client_credentials` against Keycloak). - A production fork adding mTLS/workload identity changes exactly one module (`identity/`) and one wiring function (`build_auth`-equivalent); every other component in the RI (Entitlement Manager, PDP, Fabric, Registry, audit/correlation, approval) requires zero changes.