Description
ToolHive's embedded authorization server today issues JWTs that identify
only the user. In an agentic deployment — where an LLM-based agent
calls an MCP server on a user's behalf — this leaves us with two bad
options at the MCP server / backend:
-
Pretend the agent is the user. The agent forwards the user's JWT
verbatim. The MCP server, audit log, and rate limiter see only "Alice".
An audit entry that reads "Alice deleted the repo" cannot tell whether
Alice typed the command herself or whether her agent did it after a
prompt injection.
-
Treat the agent as the only identity. The agent uses
client_credentials. Now the user is invisible: per-user policy and
audit are lost.
We need a token format that carries both identities at once, with a
clear distinction between principal (whose data) and actor (who is
acting on their behalf).
The standard answer
RFC 8693 OAuth 2.0 Token Exchange
solves exactly this. The agent calls the AS's token endpoint with
grant_type=urn:ietf:params:oauth:grant-type:token-exchange, presents
the user's JWT as subject_token, and gets back a delegated JWT:
{
"iss": "https://auth.toolhive.example.com",
"sub": "[email protected]",
"act": { "sub": "coding-agent" },
"aud": "https://mcp.example.com",
"exp": 1714000899
}
Backends authorise on sub (the user); audit and per-agent policy use
act.sub (the agent).
Why the existing tokenexchange middleware (RFC-0007) doesn't solve it
pkg/oauth/tokenexchange is outbound middleware: it exchanges the
incoming token at an external token endpoint before forwarding to a
backend. It cannot mint a token that carries act because the external
AS has no notion of our agent. This issue is about extending our own
AS to do inbound token exchange.
Proposed Solution (TL;DR)
-
Add the RFC 8693 token-exchange grant to the embedded AS's fosite
provider. Confidential clients only; lifetime capped at
min(subject_remaining, configured_max) (default 15m, max 24h).
-
Support two trust models for the subject token:
- Same domain — the subject token is an AS-issued JWT (validated
against the AS's own JWKS).
- Federated — the subject token is issued by an external OIDC
provider trusted via a new oidc-trust upstream type. The AS
validates against that provider's JWKS via OIDC discovery.
Alice ──► (login) ──► ToolHive AS ──► JWT-A
Agent ──► (POST /oauth/token, subject_token=JWT-A) ──► ToolHive AS ──► JWT-B
(sub=alice,
act=coding-agent)
Agent ──► (Bearer JWT-B) ──► MCP Server
-
Resolve the actor identity in this order:
- If
actor_token is supplied: validate it against the AS's own JWKS
and enforce actor_token.sub == authenticated_client_id (binding
check — prevents replay of a leaked actor token by a different
client).
- Otherwise, the actor is the authenticated OAuth client.
-
Extend the Cedar authorizer to read the act claim, so policies can
write rules like "Alice can write, but coding-agent acting on Alice's
behalf can only read".
-
Add operational plumbing for in-cluster IDPs:
caBundleConfigMapRef (private CA) and allowPrivateIP (cluster
service IPs) on oidc-trust upstreams.
Scope
MCPExternalAuthConfig.spec.embeddedAuthServer.upstreamProviders gains
a new oidc-trust type.
OIDCUpstreamConfig gains optional caBundleConfigMapRef and
allowPrivateIP fields.
EmbeddedAuthServerConfig gains optional delegationTokenLifespan.
- New package
pkg/authserver/server/tokenexchange (handler + multi-issuer validator).
- Cedar
act claim support in pkg/authz/authorizers/cedar.
Strictly additive: deployments that don't configure delegation are
unaffected, and the existing authorization_code, refresh_token, and
client_credentials grants are unchanged.
Out of scope (deferred)
- Multi-actor chaining (
act.act.…).
- mTLS / SPIFFE-based actor identity (kept as a follow-up; we explored it
in spiffee-authserver and extracted the OAuth-only path first).
- CLI-mode (
thv proxy) integration.
- Step-up auth signaling.
Impact
- Without this, agentic workloads on ToolHive cannot get per-agent
audit or per-agent authorization on top of per-user identity.
- Required for the platform-authorization design to express
agent-scoped Cedar policies.
- Unblocks the Keycloak + sidecar-agent demo currently held back by
the same-IDP-only single-identity JWT model.
Related
- RFC: TBD (link forthcoming) — full design, threat model, and phased
implementation plan.
- Builds on: RFC-0019 (auth server), RFC-0031 (embedded AS in proxy
runner), RFC-0052 (multi-upstream IDP).
- Counterpart of: RFC-0007 (outbound token exchange middleware).
Description
ToolHive's embedded authorization server today issues JWTs that identify
only the user. In an agentic deployment — where an LLM-based agent
calls an MCP server on a user's behalf — this leaves us with two bad
options at the MCP server / backend:
Pretend the agent is the user. The agent forwards the user's JWT
verbatim. The MCP server, audit log, and rate limiter see only "Alice".
An audit entry that reads "Alice deleted the repo" cannot tell whether
Alice typed the command herself or whether her agent did it after a
prompt injection.
Treat the agent as the only identity. The agent uses
client_credentials. Now the user is invisible: per-user policy andaudit are lost.
We need a token format that carries both identities at once, with a
clear distinction between principal (whose data) and actor (who is
acting on their behalf).
The standard answer
RFC 8693 OAuth 2.0 Token Exchange
solves exactly this. The agent calls the AS's token endpoint with
grant_type=urn:ietf:params:oauth:grant-type:token-exchange, presentsthe user's JWT as
subject_token, and gets back a delegated JWT:{ "iss": "https://auth.toolhive.example.com", "sub": "[email protected]", "act": { "sub": "coding-agent" }, "aud": "https://mcp.example.com", "exp": 1714000899 }Backends authorise on
sub(the user); audit and per-agent policy useact.sub(the agent).Why the existing
tokenexchangemiddleware (RFC-0007) doesn't solve itpkg/oauth/tokenexchangeis outbound middleware: it exchanges theincoming token at an external token endpoint before forwarding to a
backend. It cannot mint a token that carries
actbecause the externalAS has no notion of our agent. This issue is about extending our own
AS to do inbound token exchange.
Proposed Solution (TL;DR)
Add the RFC 8693 token-exchange grant to the embedded AS's fosite
provider. Confidential clients only; lifetime capped at
min(subject_remaining, configured_max)(default 15m, max 24h).Support two trust models for the subject token:
against the AS's own JWKS).
provider trusted via a new
oidc-trustupstream type. The ASvalidates against that provider's JWKS via OIDC discovery.
Resolve the actor identity in this order:
actor_tokenis supplied: validate it against the AS's own JWKSand enforce
actor_token.sub == authenticated_client_id(bindingcheck — prevents replay of a leaked actor token by a different
client).
Extend the Cedar authorizer to read the
actclaim, so policies canwrite rules like "Alice can write, but coding-agent acting on Alice's
behalf can only read".
Add operational plumbing for in-cluster IDPs:
caBundleConfigMapRef(private CA) andallowPrivateIP(clusterservice IPs) on
oidc-trustupstreams.Scope
MCPExternalAuthConfig.spec.embeddedAuthServer.upstreamProvidersgainsa new
oidc-trusttype.OIDCUpstreamConfiggains optionalcaBundleConfigMapRefandallowPrivateIPfields.EmbeddedAuthServerConfiggains optionaldelegationTokenLifespan.pkg/authserver/server/tokenexchange(handler + multi-issuer validator).actclaim support inpkg/authz/authorizers/cedar.Strictly additive: deployments that don't configure delegation are
unaffected, and the existing
authorization_code,refresh_token, andclient_credentialsgrants are unchanged.Out of scope (deferred)
act.act.…).in
spiffee-authserverand extracted the OAuth-only path first).thv proxy) integration.Impact
audit or per-agent authorization on top of per-user identity.
agent-scoped Cedar policies.
the same-IDP-only single-identity JWT model.
Related
implementation plan.
runner), RFC-0052 (multi-upstream IDP).