You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@knox.apache.org by "Harrison Sheinblatt (Jira)" <ji...@apache.org> on 2026/10/21 07:17:00 UTC

[jira] [Comment Edited] (KNOX-3520) Token exchange: support multi-hop RFC 8693 delegation by binding re-exchanged subject tokens to the actor (delegation policy subjectAudiences)

    [ https://issues.apache.org/jira/browse/KNOX-3520?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18129469#comment-18129469 ] 

Harrison Sheinblatt edited comment on KNOX-3520 at 10/21/26 7:16 AM:
---------------------------------------------------------------------

Thanks Sandor. My initial thoughts are
 # The ‘first-hop’ user token seems impractical. Can you explain more what you meant here?
 ## If we presume that knox owns the tokens used for token exchange, then any user token would necessarily be minted by knox and we could prescribe what audiences it should have.
 ## We already can restrict the allowed knox issuers so that only knox tokens minted by allowed knox instances can be verified. For example, the knoxidf minted tokens for federated identities would be validated, and potentially no others, or we could trust other knox instances across other clusters as well.
 ## Initial interactive user token generation paths are external. For example, an SSO login federated with a customer IdP would result in a cookie. The JWT therein could be used to call API services where knox would validate the token. Then those services would need to use that token for token exchange to call other services on behalf of the user. Since the token is external, the aud claims should be about the endpoints the user can call externally, not a cluster-internal path or string for token exchange. 
 ### If same-subject token exchange is exposed externally through a gateway, then it does make sense to expose that capability in an aud claim, but we’d likely want to do it generically as RFC 8693 token exchange rather than `knoxidf`. So we’d call it something configurable but more like `rfc-8693-token-exhange`.
 ### We don’t have a compelling use case for same-subject token exchange now. We currently restrict requested audiences to a subset of what’s already in the user token. So the main use case for external same-subject token exchange would be audience narrowing and would depend on the solution having enforcement for audiences hitting external endpoints. Knox doesn’t have this capability generically now. One could add a new RequestAudienceValidator where specific audience values in user tokens are mapped to externally available service endpoints, serving APIs the user is allowed to call, and the validator can check that the requested service matches one allowed by the aud values. One could also configure the cluster services in a way that different knox topologies are used for different API endpoint authentication so that the hardcoded values allowed in the topology are sufficient.
 ## If you mean that a token like the above SSO JWT would be exchanged automatically for an ‘internal’ only user token meant for token exchange, then how is that authorized?
 ## It introduces a new threat: if the ‘first-hop’ token is stolen, then it can necessarily be used by any service for token exchange. This is not much better than any valid user token being usable if stolen. This is mitigated by shorter lived tokens.
 # We should require that the service account token for token exchange has the appropriate knoxidf audience (rule 1). That prevents service account tokens meant for unrelated purposes from being used for token exchange. But we need to be able to turn this off in case it's a problem without affecting other use cases.
 ## In order to generate a service account token, especially one that already has a delegation policy, one must have platform permissions. If one had platform permissions, one likely could do worse things. I believe it’s possible that kubelets can be authorized with a request-serviceaccounts-token-audience control that restricts the audiences they can request generating a service account token, so there are at least some configurations where a kubelet might have permissions to generate a service account token but not one with the knoxidf audience.
 ## If one otherwise stole a service account token meant for a different audience, this would prevent the misuse.
 # It is a good security feature to validate that user tokens are meant for token exchange by the actor initiating the exchange.
 ## The user token may not have an act claim, in which case we’d check if there was a valid token exchange audience. This could be any allowed string configured when minting the token. But this means that interactive login tokens that might be used to call APIs such as SSO cookies along with interactive API use tokens would always need this audience added. Otherwise, any API authenticated by them wouldn’t be able to act on behalf of them even if the service account identity was authorized to and it was necessary to service the API request.
 ## If the user token has an act claim, then we want to enforce that it was used to call a service with the identity of the service account initiating the token exchange. Here we want an inverse map of the service account to all services for which it is the identity (well, transitively the service account is the identity of the pods that back the service). That is, a destination SPIFFE ID for the service request would be the service account.
 ## Ideally this is available within the platform so the delegation policy author can look it up, but may be dynamic. In the case where a service account is shared as the identity across several services, it may be somewhat ambiguous what belongs there. This is not something that’s efficient enough for knox to query dynamically per request. If we require a static map in the delegation policy as proposed, it may need frequent updates, and may get stale at least for some interval, which would be the responsibility of the service.
 ## We should be able to turn this off for configurations where it’s impractical to know this statically at delegation policy authoring time.
 ## The threat is mitigated by audit: the token exchange and access usage logs would show that a token was stolen and used inappropriately, as well as which service account used it inappropriately, and alerts could be generated.
 ## It’s better to use sub metadata fields without format, which will be simpler to extend later and simpler to allow different format matching. That is, instead of a list of subjectAudiences strings, we could have a dict like `subjectAudiences: [\\{service-name: serviceA, namespace: ns-A}, \\{...}]`. Then we'd have different rules about what aud formats to allow that can be extended. We'd match the actual audience in the same way we do in the audience validator: we'd read the policy metadata and match it to the actual audience after detecting the audience format.
 ## We should have a setting where it's required and one where an empty value in the policy means that we just don't check it – with a note about the threat.


was (Author: JIRAUSER313664):
Thanks Sandor. My initial thoughts are
 # The ‘first-hop’ user token seems impractical. Can you explain more what you meant here?
 ## If we presume that knox owns the tokens used for token exchange, then any user token would necessarily be minted by knox and we could prescribe what audiences it should have.
 ## We already can restrict the allowed knox issuers so that only knox tokens minted by allowed knox instances can be verified. For example, the knoxidf minted tokens for federated identities would be validated, and potentially no others, or we could trust other knox instances across other clusters as well.
 ## Initial interactive user token generation paths are external. For example, an SSO login federated with a customer IdP would result in a cookie. The JWT therein could be used to call API services where knox would validate the token. Then those services would need to use that token for token exchange to call other services on behalf of the user. Since the token is external, the aud claims should be about the endpoints the user can call externally, not a cluster-internal path or string for token exchange. 
 ### If same-subject token exchange is exposed externally through a gateway, then it does make sense to expose that capability in an aud claim, but we’d likely want to do it generically as RFC 8693 token exchange rather than `knoxidf`. So we’d call it something configurable but more like `rfc-8693-token-exhange`.
 ### We don’t have a compelling use case for same-subject token exchange now. We currently restrict requested audiences to a subset of what’s already in the user token. So the main use case for external same-subject token exchange would be audience narrowing and would depend on the solution having enforcement for audiences hitting external endpoints. Knox doesn’t have this capability generically now. One could add a new RequestAudienceValidator where specific audience values in user tokens are mapped to externally available service endpoints, serving APIs the user is allowed to call, and the validator can check that the requested service matches one allowed by the aud values. One could also configure the cluster services in a way that different knox topologies are used for different API endpoint authentication so that the hardcoded values allowed in the topology are sufficient.
 ## If you mean that a token like the above SSO JWT would be exchanged automatically for an ‘internal’ only user token meant for token exchange, then how is that authorized?
 ## It introduces a new threat: if the ‘first-hop’ token is stolen, then it can necessarily be used by any service for token exchange. This is not much better than any valid user token being usable if stolen. This is mitigated by shorter lived tokens.
 # We should require that the service account token for token exchange has the appropriate knoxidf audience (rule 1). That prevents service account tokens meant for unrelated purposes from being used for token exchange. But we need to be able to turn this off in case it's a problem without affecting other use cases.
 ## In order to generate a service account token, especially one that already has a delegation policy, one must have platform permissions. If one had platform permissions, one likely could do worse things. I believe it’s possible that kubelets can be authorized with a request-serviceaccounts-token-audience control that restricts the audiences they can request generating a service account token, so there are at least some configurations where a kubelet might have permissions to generate a service account token but not one with the knoxidf audience.
 ## If one otherwise stole a service account token meant for a different audience, this would prevent the misuse.
 # It is a good security feature to validate that user tokens are meant for token exchange by the actor initiating the exchange.
 ## The user token may not have an act claim, in which case we’d check if there was a valid token exchange audience. This could be any allowed string configured when minting the token. But this means that interactive login tokens that might be used to call APIs such as SSO cookies along with interactive API use tokens would always need this audience added. Otherwise, any API authenticated by them wouldn’t be able to act on behalf of them even if the service account identity was authorized to and it was necessary to service the API request.
 ## If the user token has an act claim, then we want to enforce that it was used to call a service with the identity of the service account initiating the token exchange. Here we want an inverse map of the service account to all services for which it is the identity (well, transitively the service account is the identity of the pods that back the service). That is, a destination SPIFFE ID for the service request would be the service account.
 ## Ideally this is available within the platform so the delegation policy author can look it up, but may be dynamic. In the case where a service account is shared as the identity across several services, it may be somewhat ambiguous what belongs there. This is not something that’s efficient enough for knox to query dynamically per request. If we require a static map in the delegation policy as proposed, it may need frequent updates, and may get stale at least for some interval, which would be the responsibility of the service.
 ## We should be able to turn this off for configurations where it’s impractical to know this statically at delegation policy authoring time.
 ## The threat is mitigated by audit: the token exchange and access usage logs would show that a token was stolen and used inappropriately, as well as which service account used it inappropriately, and alerts could be generated.
 ## It’s better to use sub metadata fields without format, which will be simpler to extend later and simpler to allow different format matching. That is, instead of a list of subjectAudiences strings, we could have a dict like `subjectAudiences: [\{service-name: serviceA, namespace: ns-A}, \{...}]`. Then we'd have different rules about what aud formats to allow that can be extended. We'd match the actual audience in the same way we do in the audience validator: we'd read the policy metadata and match it to the actual audience after detecting the audience format.

>  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
>            Priority: Critical
>             Fix For: 3.1.0
>
>          Time Spent: 20m
>  Remaining Estimate: 0h
>
> 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}}|-|
> h3. Prior art
> Other authorization servers that support chained token exchange restrict re-exchanged subject tokens in the same two ways this proposal does:
> # the subject token must be *issued by the authorization server itself* (or a trusted sibling);
> # the subject token must be *addressed to the party presenting it*.
> ||Server||Subject token issuer||Subject token audience||Requester authenticated by||Chaining / {{act}}||
> |Keycloak, standard token exchange V2|Same realm only. External tokens are not accepted; a JWT Authorization Grant is recommended instead|"The {{subject_token}} sent to the token exchange endpoint must have the requester client set as an audience in the {{aud}} claim."|Client authentication ({{client_id}} + secret or certificate)|Chaining works; no {{actor_token}}/{{act}} in V2 (delegation and impersonation were only preview features in legacy V1)|
> |Microsoft Entra On-Behalf-Of|Entra itself|"This token must have an audience ({{aud}}) claim of the app making this OBO request ... Applications can't redeem a token for a different app."|Client credentials (secret or certificate)|Chaining works; no {{act}} claim|
> |Okta On-Behalf-Of token exchange|Same authorization server, or a trusted authorization server in the same Okta org|Authorization server audience|Service app client credentials|Nested {{act.act}}, at most 5 actors|
> How this maps to Knox:
> * *Issuer restriction:* {{token.exchange.subject.token.policy.audiences.issuers}} limits the relaxed path (rule 3) to tokens minted by Knox itself, as the servers above do. External and first-hop tokens still enter only through rule 2 ({{aud}} contains {{knox.token.audiences}}), which matches Keycloak's separate grant for external tokens and Okta's trusted servers.
> * *Recipient binding:* In Keycloak and Entra the requester is an OAuth client, and its {{client_id}} is the audience, so "{{aud}} must be the requester" needs no extra configuration. In Knox the requester is identified by an {{actor_token}} (e.g. {{system:serviceaccount:tools:mcp-sa}}), and that identity is not the audience it serves (e.g. {{https://svc.cluster.local/tools/mcp-server}}). {{subjectAudiences}} is the explicit actor-identity-to-audience mapping that a client registration provides implicitly in Keycloak and Entra.
> * *Actor credential (rule 1):* The servers above authenticate the requester with a client secret or certificate. A {{private_key_jwt}} client assertion, which must be addressed to the token endpoint, is the closest equivalent of requiring {{aud=knoxidf}} on the {{actor_token}}.
> * *Delegation chain:* Like Okta, Knox records the chain as nested {{act}} and caps its length ({{delegation.max.actor.chain.depth}}).
> RFC 8693 leaves which subject and actor tokens are acceptable to authorization-server policy (Section 2.1), so this proposal follows common practice; the spec doesn't mandate it.
> References:
> * [Keycloak: Configuring and using token exchange|https://www.keycloak.org/securing-apps/token-exchange]
> * [Microsoft Entra: OAuth 2.0 On-Behalf-Of flow|https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow]
> * [Okta: Set up On-Behalf-Of token exchange|https://developer.okta.com/docs/guides/set-up-token-exchange/main/]
> * [RFC 8693: OAuth 2.0 Token Exchange|https://www.rfc-editor.org/rfc/rfc8693]



--
This message was sent by Atlassian Jira
(v8.20.10#820010)