Sandor Molnar created KNOX-3520:
-----------------------------------
Summary: Token exchange: support multi-hop RFC 8693 delegation by
binding re-exchanged subject tokens to the actor (delegation policy
subjectAudiences)
Key: KNOX-3520
URL: https://issues.apache.org/jira/browse/KNOX-3520
Project: Apache Knox
Issue Type: Bug
Components: JWT, Server
Affects Versions: 3.1.0
Reporter: Sandor Molnar
Assignee: Sandor Molnar
Fix For: 3.1.0
h2. Summary
The RFC 8693 token exchange topology validates every presented JWT (both
{{subject_token}} and {{{}actor_token{}}}) against a single
{{knox.token.audiences}} list. Tokens minted by a delegated exchange carry only
the requested target audience, so they can never be presented again as a
{{{}subject_token{}}}. As a result, multi-hop on-behalf-of chains (A -> B -> C
-> D) stop after the first delegated hop, even though
{{delegation.max.actor.chain.depth}} suggests deeper chains are supported.
The two available workarounds either do not scale (adding the token-service
audience to every downstream token) or remove an important protection (dropping
{{{}knox.token.audiences{}}}, which lets any stolen service-account token,
minted for any audience, act as a delegation actor).
This JIRA proposes per-role audience rules: actor tokens stay strictly bound to
the token-service audience, while a re-exchanged {{subject_token}} is accepted
only when it is addressed to the actor presenting it. The actor's own audiences
are declared in a new delegation policy field, {{{}subjectAudiences{}}}.
h2. Current configuration
{code:xml}
<topology>
<gateway>
<provider>
<role>federation</role>
<name>JWTProvider</name>
<enabled>true</enabled>
<param>
<name>jwt.expected.issuer</name>
<value>http://knox.knox.svc.cluster.local:8443/gateway/knox-knoxidf-tokenexchange</value>
</param>
<param>
<name>knox.token.audiences</name>
<value>knoxidf</value>
</param>
<param>
<name>jwt.instance.key.fallback</name>
<value>true</value>
</param>
<param>
<name>delegation.server.enabled</name>
<value>true</value>
</param>
<param>
<name>delegation.requested.subject.enabled</name>
<value>true</value>
</param>
<param>
<name>delegation.enforce.requested.audience.required</name>
<value>true</value>
</param>
<param>
<name>delegation.enforce.requested.audience.max.one</name>
<value>true</value>
</param>
<param>
<name>token.exchange.same.subject.requested.audience.enabled</name>
<value>true</value>
</param>
</provider>
<provider>
<role>identity-assertion</role>
<name>Default</name>
<enabled>true</enabled>
</provider>
</gateway>
<service>
<role>KNOXIDF</role>
<param>
<name>knoxidf.knox.token.ttl</name>
<value>3600000</value>
</param>
<param>
<name>delegation.max.actor.chain.depth</name>
<value>6</value>
</param>
<param>
<name>knoxidf.knox.token.limit.per.user</name>
<value>500</value>
</param>
<param>
<name>knoxidf.knox.token.issuer</name>
<value>http://knox.knox.svc.cluster.local:8443/gateway/knox-knoxidf-tokenexchange</value>
</param>
<param>
<name>knoxidf.knox.token.audiences</name>
<value>knoxidf</value>
</param>
<param>
<name>knoxidf.knox.token.enable.delegated.auth</name>
<value>true</value>
</param>
<param>
<name>knoxidf.knox.token.audience.validator</name>
<value>passthrough</value>
</param>
</service>
</topology>
{code}
* First-hop subject tokens (user access tokens) are minted by another Knox
topology with {{{}aud=knoxidf{}}}.
* Actor tokens are Kubernetes projected service-account (SA) tokens with
{{{}audience: knoxidf{}}}, from an issuer registered in the Trusted OIDC Issuer
registry.
* The {{passthrough}} audience validator makes the minted token's {{aud}}
exactly the requested {{{}resource{}}}/{{{}audience{}}} values.
h2. Problem: multi-hop delegation does not work
Example chain: user {{alice}} -> {{dataservice}} (SA {{{}ds-sa{}}}) ->
{{mcp-server}} (SA {{{}mcp-sa{}}}) -> {{search-api}} (SA {{{}search-sa{}}}) ->
{{{}vector-db{}}}.
# *Hop 1* ({{{}dataservice{}}} exchanges {{{}alice{}}}'s token,
{{{}resource=[https://svc.cluster.local/tools/mcp-server]{}}}): succeeds. The
minted token T1 has {{{}sub=alice{}}},
{{{}act.sub=system:serviceaccount:dataservice:ds-sa{}}}, and {{aud}} contains
only {{{}[https://svc.cluster.local/tools/mcp-server]{}}}.
# *Hop 2* ({{{}mcp-server{}}} presents T1 as {{subject_token}} and its own SA
token as {{{}actor_token{}}}): rejected with {{{}401{}}}.
{{TokenExchangeHandler#handle}} validates the {{subject_token}} through
{{{}JWTFederationFilter#parseAndValidateJWT{}}}, which applies
{{AbstractJWTFilter#matchesConfiguredAudiences}} against
{{{}knox.token.audiences=knoxidf{}}}. T1 does not carry {{{}knoxidf{}}}.
The same check runs on the {{actor_token}} in
{{{}TokenExchangeHandler#handleDelegationExchange{}}}, so one list governs two
tokens with very different provenance.
h3. Workaround 1: add {{knoxidf}} to every downstream token (does not scale,
weakens binding)
The caller requests {{resource=<target>}} plus {{audience=knoxidf}} on every
hop whose token may be exchanged again. This requires:
* {{{}delegation.enforce.requested.audience.max.one=false{}}};
* {{knoxidf}} as a key in every actor's {{resourcePolicy}} (otherwise
{{evaluate()}} denies with {{{}resource_not_allowed{}}});
* every caller to know in advance whether its callee will re-exchange;
* every downstream service, including third-party tools and MCP servers, to
accept a multi-valued {{aud}} that names the token service.
It also removes recipient binding. Every downstream token becomes a valid
{{subject_token}} for *any* actor whose policy allows the user and the next
resource, not only for the service it was addressed to. A token leaked from
logs, a proxy or a misbehaving client can be exchanged by a different,
authorized-for-that-user workload.
h3. Workaround 2: remove {{knox.token.audiences}} (opens the gate)
With the parameter unset, {{configuredAudiences == null}} and
{{matchesConfiguredAudiences}} accepts *any* audience, for both subject and
actor tokens. Hop 2 then succeeds, but:
* *Any SA token from a trusted issuer, minted for any audience, becomes a
valid {{{}actor_token{}}}.* This includes the automounted token
({{{}aud=[https://kubernetes.default.svc|https://kubernetes.default.svc/]...{}}}),
and tokens a workload sends to Vault or to any other service that accepts SA
tokens. Whoever obtains such a token (the receiving service, its logs, an
intermediary) can exchange it for tokens for every user the SA's policy allows.
* *The same applies to headless exchanges* ({{{}requested_subject{}}}). There
the {{subject_token}} is the actor, and no user token is needed at all. With
{{{}allowHeadlessExchange=true{}}}, a single stolen SA token is enough to mint
tokens for any user in {{{}canActForUsers{}}}, to any resource in
{{{}resourcePolicy{}}}.
* *Same-subject exchanges* accept any SA token and convert it into a
Knox-issued token.
* *Re-exchanged subject tokens are still not bound to their recipient* (same
issue as workaround 1).
Delegation policies cannot compensate. A policy is keyed by actor identity
({{{}ActorIdentity{}}}: {{iss:namespace:serviceaccount}} for {{{}K8S_SA{}}}),
so an SA token minted for Vault and one minted for {{knoxidf}} resolve to the
same policy. Neither {{DelegationPolicy}} nor {{TrustedOidcIssuer}} has any
notion of the presented token's audience.
Stealing the actor's own {{{}knoxidf{}}}-audience token remains a full
compromise of that actor. That case is unavoidable, and the mitigation is
short-lived projected tokens. The goal is to keep every other token useless at
the token endpoint.
h2. Proposal
h3. Audience rules per token role
||#||Token||Rule||Status||Why||
|1|{{{}actor_token{}}}, and the {{subject_token}} of a headless exchange (it is
the actor)|{{aud}} must contain a {{knox.token.audiences}}
value|Existing|Blocks replay of SA tokens minted for other audiences
(kube-apiserver, Vault, ...). Only tokens deliberately minted for the token
service can act.|
|2|{{subject_token}} of a delegated exchange, first hop (e.g. a user access
token)|{{aud}} must contain a {{knox.token.audiences}} value|Existing|Entry
into the delegation system is limited to tokens deliberately minted for the
token service.|
|3|{{subject_token}} of a delegated exchange, later hops (a token minted by
this topology for the actor's service)|Accepted without a
{{knox.token.audiences}} value only if: an {{actor_token}} is present, the
token's {{iss}} is in
{{{}token.exchange.subject.token.policy.audiences.issuers{}}}, and its {{aud}}
intersects the actor policy's {{subjectAudiences}}|*New*|The only relaxation;
it enables multi-hop. A service can re-exchange only a token addressed to
itself, and only while proving its identity with its own rule-1 actor token.
Other actors, even ones authorized for the same user, cannot use it.|
|4|{{subject_token}} of a same-subject exchange|{{aud}} must contain a
{{knox.token.audiences}} value (no exception)|Existing|No policy is consulted
in same-subject exchanges, so there is no recipient binding to fall back on.|
|5|{{subject_token}} of a headless exchange|Must not carry an {{act}} claim
({{{}delegation.headless.reject.subject.with.act{}}})|*New*|Defense in depth: a
delegated token, however it got a {{knoxidf}} audience, must never become a
headless actor, which would mean acting as anyone in the policy without a user
token.|
Applied per exchange type:
* *Delegated* ({{{}actor_token{}}} present): rule 1 on the actor, rule 2 or
rule 3 on the subject.
* *Headless* ({{{}requested_subject{}}}, no actor): rules 1 and 5 on the
{{{}subject_token{}}}. Rule 3 never applies.
* {*}Same-subject{*}: rule 4.
h3. New delegation policy field: {{subjectAudiences}}
An optional set of audiences that identify the actor {*}as a recipient{*}, i.e.
the audiences under which other actors mint tokens for it. Defaults to empty,
which means rule 3 never matches for that actor (fail-safe, backward
compatible).
{code:yaml}
{
"actorAuthority": "K8S_SA",
"actorId": "https://kubernetes.default.svc.cluster.local:tools:mcp-sa",
"name": "mcp-sa-delegation",
"status": "active",
"allowHeadlessExchange": false,
"canActForUsers": ["alice"],
"canActForGroups": [],
"subjectAudiences": ["https://svc.cluster.local/tools/mcp-server"],
"resourcePolicy": {
"https://svc.cluster.local/search/search-api": []
}
}
{code}
h3. Why {{resourcePolicy}} cannot be reused for this check
{{resourcePolicy}} and {{subjectAudiences}} answer opposite questions:
||Field||Direction||Question||Example for {{mcp-sa}}||
|{{resourcePolicy}}|Outbound|Which services may this actor mint tokens
{_}for{_}?|{{search-api}} (the services it calls)|
|{{subjectAudiences}}|Inbound|Which tokens addressed _to_ this actor may it
present as {{{}subject_token{}}}?|{{mcp-server}} (the service it is)|
# *The legitimate flow fails.* T1's {{aud}} is {{{}mcp-server{}}}, but
{{{}mcp-sa{}}}'s {{resourcePolicy}} lists {{{}search-api{}}}. A service rarely
lists itself as a target.
# *It binds the token to the wrong parties.* The actors that list
{{mcp-server}} in {{resourcePolicy}} are its {_}callers{_}. An "aud in
resourcePolicy" check would let any caller of {{mcp-server}} that obtains T1
exchange it. That is a confused-deputy gap: it proves the presenter may _send
to_ the service, not that it _is_ the service.
# *Making #1 pass widens outbound permissions.* Adding {{mcp-server}} to
{{{}mcp-sa{}}}'s own {{resourcePolicy}} also authorizes {{mcp-sa}} to mint
tokens addressed to itself for any user in its policy, including headless
exchanges when {{{}allowHeadlessExchange=true{}}}.
# *Different administrative constraints.* {{resourcePolicy}} keys must be able
to name services in other namespaces or tenants, because services call across
them. {{subjectAudiences}} can be restricted by admission control or the admin
API to the actor's own namespace, which prevents team A from claiming team B's
audience. One shared field would allow no such restriction.
# {{resourcePolicy}} values are scopes for the outbound target; they have no
meaning for an inbound check.
Nothing in the current policy model links the actor identity to the audience it
serves. {{subjectAudiences}} is that link.
h3. Proposed topology
Unchanged except for the two new JWTProvider parameters (both default to unset
or {{{}false{}}}, which keeps today's behavior):
{code:xml}
<topology>
<gateway>
<provider>
<role>federation</role>
<name>JWTProvider</name>
<enabled>true</enabled>
<param>
<name>jwt.expected.issuer</name>
<value>http://knox.knox.svc.cluster.local:8443/gateway/knox-knoxidf-tokenexchange</value>
</param>
<!-- Rules 1, 2, 4: unchanged, still applies to every actor token and to
non re-exchanged subject tokens. -->
<param>
<name>knox.token.audiences</name>
<value>knoxidf</value>
</param>
<param>
<name>jwt.instance.key.fallback</name>
<value>true</value>
</param>
<!-- NEW, rule 3: on a delegated exchange (actor_token present), a
subject_token issued by one of these
issuers that lacks knox.token.audiences is accepted if its aud
intersects the actor policy's
subjectAudiences. Unset: rule 3 is disabled (today's behavior).
Never applies to actor tokens,
headless or same-subject exchanges. -->
<param>
<name>token.exchange.subject.token.policy.audiences.issuers</name>
<value>http://knox.knox.svc.cluster.local:8443/gateway/knox-knoxidf-tokenexchange</value>
</param>
<!-- NEW, rule 5: reject a headless subject_token that carries an act
claim. -->
<param>
<name>delegation.headless.reject.subject.with.act</name>
<value>true</value>
</param>
<param>
<name>delegation.server.enabled</name>
<value>true</value>
</param>
<param>
<name>delegation.requested.subject.enabled</name>
<value>true</value>
</param>
<param>
<name>delegation.enforce.requested.audience.required</name>
<value>true</value>
</param>
<!-- Can stay true: minted tokens no longer need the token-service
audience to be re-exchanged. -->
<param>
<name>delegation.enforce.requested.audience.max.one</name>
<value>true</value>
</param>
<param>
<name>token.exchange.same.subject.requested.audience.enabled</name>
<value>true</value>
</param>
</provider>
<provider>
<role>identity-assertion</role>
<name>Default</name>
<enabled>true</enabled>
</provider>
</gateway>
<service>
<role>KNOXIDF</role>
<param>
<name>knoxidf.knox.token.ttl</name>
<value>3600000</value>
</param>
<param>
<name>delegation.max.actor.chain.depth</name>
<value>6</value>
</param>
<param>
<name>knoxidf.knox.token.limit.per.user</name>
<value>500</value>
</param>
<param>
<name>knoxidf.knox.token.issuer</name>
<value>http://knox.knox.svc.cluster.local:8443/gateway/knox-knoxidf-tokenexchange</value>
</param>
<param>
<name>knoxidf.knox.token.audiences</name>
<value>knoxidf</value>
</param>
<param>
<name>knoxidf.knox.token.enable.delegated.auth</name>
<value>true</value>
</param>
<param>
<name>knoxidf.knox.token.audience.validator</name>
<value>passthrough</value>
</param>
</service>
</topology>
{code}
h3. Implementation notes
* {{{}TokenExchangeHandler#handle{}}}: the {{subject_token}} audience can no
longer be decided when it is parsed, because the actor and its policy are only
known later. Validate signature, issuer and expiry with the
{{JWTFederationFilter#parseAndValidateJWT}} overload that takes a
{{{}RequestAudienceValidator{}}}, and record whether the
{{knox.token.audiences}} check passed.
** Same-subject or headless and the check failed: reject as today
({{{}401{}}}).
** Delegated and the check failed: only continue if the token's {{iss}} is in
{{{}token.exchange.subject.token.policy.audiences.issuers{}}}. Otherwise reject
as today.
* {{actor_token}} validation is unchanged (rule 1).
* {{PolicyCheckRequest}} carries the subject token's audiences and whether
rule 3 is needed. {{JdbcDelegationPolicyService#evaluate}} denies with a new
reason {{subject_audience_not_allowed}} when they do not intersect
{{{}subjectAudiences{}}}. The client still gets the generic {{{}400
invalid_request{}}}; the reason is audited.
* {{{}DelegationPolicy{}}}: new {{subjectAudiences}} field (DB schema
migration, admin API request/response, validation of non-blank values).
* Headless: when {{{}delegation.headless.reject.subject.with.act=true{}}}, a
{{subject_token}} with an {{act}} claim is rejected (new audit reason
{{{}headless_subject_has_act{}}}).
* Fail-safe defaults: no new parameter set and empty {{subjectAudiences}}
keeps today's behavior.
h3. Related (optional, could be a separate JIRA)
With the {{passthrough}} validator, a same-subject exchange that requests no
audience mints a token with *no* {{aud}} claim ({{{}TokenResource#getJWT{}}}
only sets audiences when the list is non-empty). Such a token cannot be
exchanged again and is rejected by most receivers. Suggestion: {{passthrough}}
should fall back to the configured {{knox.token.audiences}} when the request
names none.
h2. 3-hop example once implemented
Chain: {{alice}} -> {{dataservice}} ({{{}ds-sa{}}}) -> {{mcp-server}}
({{{}mcp-sa{}}}) -> {{search-api}} ({{{}search-sa{}}}) -> {{{}vector-db{}}}.
Delegation policies (only the relevant fields):
{code:yaml}
// ds-sa: the first hop needs no subjectAudiences (the subject token carries
knoxidf, rule 2)
{ "actorId": "https://kubernetes.default.svc.cluster.local:dataservice:ds-sa",
"canActForUsers": ["alice"],
"subjectAudiences": [],
"resourcePolicy": { "https://svc.cluster.local/tools/mcp-server": [] } }
// mcp-sa
{ "actorId": "https://kubernetes.default.svc.cluster.local:tools:mcp-sa",
"canActForUsers": ["alice"],
"subjectAudiences": ["https://svc.cluster.local/tools/mcp-server"],
"resourcePolicy": { "https://svc.cluster.local/search/search-api": [] } }
// search-sa
{ "actorId": "https://kubernetes.default.svc.cluster.local:search:search-sa",
"canActForUsers": ["alice"],
"subjectAudiences": ["https://svc.cluster.local/search/search-api"],
"resourcePolicy": { "https://svc.cluster.local/data/vector-db": [] } }
{code}
h3. Hop 1: {{dataservice}} -> token for {{mcp-server}}
{code:bash}
POST /gateway/knox-knoxidf-tokenexchange/knoxidf/api/v1/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<T0> subject_token_type=urn:ietf:params:oauth:token-type:jwt
actor_token=<ds-sa> actor_token_type=urn:ietf:params:oauth:token-type:jwt
resource=https://svc.cluster.local/tools/mcp-server
// subject_token T0 (user access token) -> rule 2: aud contains knoxidf
{ "iss": "http://knox.knox.svc.cluster.local:8443/gateway/knox-tokenexchange",
"sub": "alice", "aud": ["knoxidf"] }
// actor_token (ds-sa projected SA token) -> rule 1: aud contains knoxidf
{ "iss": "https://kubernetes.default.svc.cluster.local",
"sub": "system:serviceaccount:dataservice:ds-sa", "aud": ["knoxidf"] }
// policy ds-sa: alice allowed, mcp-server in resourcePolicy -> minted T1
{ "iss":
"http://knox.knox.svc.cluster.local:8443/gateway/knox-knoxidf-tokenexchange",
"sub": "alice",
"aud": ["https://svc.cluster.local/tools/mcp-server"],
"act": { "sub": "system:serviceaccount:dataservice:ds-sa" } }
{code}
{{dataservice}} calls {{mcp-server}} with T1. {{mcp-server}} performs an
ordinary audience check: its own audience is in {{{}aud{}}}. It does not need
{{knoxidf}} in it.
h3. Hop 2: {{mcp-server}} -> token for {{search-api}}
{code:yaml}
subject_token=<T1> actor_token=<mcp-sa>
resource=https://svc.cluster.local/search/search-api
// subject_token T1: aud lacks knoxidf -> rule 3:
// actor_token present, iss = knox-knoxidf-tokenexchange (listed), aud
intersects mcp-sa subjectAudiences
// actor_token (mcp-sa projected SA token) -> rule 1
{ "iss": "https://kubernetes.default.svc.cluster.local",
"sub": "system:serviceaccount:tools:mcp-sa", "aud": ["knoxidf"] }
// policy mcp-sa: alice allowed, search-api in resourcePolicy, chain depth 2 <=
6 -> minted T2
{ "iss":
"http://knox.knox.svc.cluster.local:8443/gateway/knox-knoxidf-tokenexchange",
"sub": "alice",
"aud": ["https://svc.cluster.local/search/search-api"],
"act": { "sub": "system:serviceaccount:tools:mcp-sa",
"act": { "sub": "system:serviceaccount:dataservice:ds-sa" } } }
{code}
h3. Hop 3: {{search-api}} -> token for {{vector-db}}
{code:yaml}
subject_token=<T2> actor_token=<search-sa>
resource=https://svc.cluster.local/data/vector-db
// subject_token T2 -> rule 3: aud intersects search-sa subjectAudiences
// actor_token (search-sa projected SA token, aud knoxidf) -> rule 1
// policy search-sa: alice allowed, vector-db in resourcePolicy, chain depth 3
<= 6 -> minted T3
{ "iss":
"http://knox.knox.svc.cluster.local:8443/gateway/knox-knoxidf-tokenexchange",
"sub": "alice",
"aud": ["https://svc.cluster.local/data/vector-db"],
"act": { "sub": "system:serviceaccount:search:search-sa",
"act": { "sub": "system:serviceaccount:tools:mcp-sa",
"act": { "sub": "system:serviceaccount:dataservice:ds-sa" }
} } }
{code}
h3. Attack cases
||Attacker has||Result||Rule||
|{{ds-sa}} automounted SA token
({{{}aud=[https://kubernetes.default.svc|https://kubernetes.default.svc/]...{}}})
or one minted for Vault|Rejected as {{actor_token}} and as headless
{{subject_token}}|1|
|T1, presented with another workload's {{knoxidf}} actor token (even one
allowed to act for {{{}alice{}}})|Rejected: {{mcp-server}} is not in that
actor's {{subjectAudiences}}|3|
|T1 as a headless {{subject_token}}|Rejected: no {{knoxidf}} audience, and it
carries {{act}}|1, 5|
|T1 in a same-subject exchange|Rejected: no {{knoxidf}} audience|4|
|An SA token with {{aud=[https://svc.cluster.local/tools/mcp-server]}} sent
directly to {{{}mcp-server{}}}, used as {{subject_token}}|Rejected: its issuer
is not in {{token.exchange.subject.token.policy.audiences.issuers}}|3|
|{{{}mcp-sa{}}}'s own {{knoxidf}} projected SA token|Attacker acts as
{{{}mcp-sa{}}}, within its policy. Unavoidable; mitigate with short
{{expirationSeconds}}|-|
--
This message was sent by Atlassian Jira
(v8.20.10#820010)