[
https://issues.apache.org/jira/browse/KNOX-3520?focusedWorklogId=1048424&page=com.atlassian.jira.plugin.system.issuetabpanels:worklog-tabpanel#worklog-1048424
]
ASF GitHub Bot logged work on KNOX-3520:
----------------------------------------
Author: ASF GitHub Bot
Created on: 20/Oct/26 10:11
Start Date: 20/Oct/26 10:11
Worklog Time Spent: 10m
Work Description: smolnar82 opened a new pull request, #1460:
URL: https://github.com/apache/knox/pull/1460
[KNOX-3520](https://issues.apache.org/jira/browse/KNOX-3520) - Support
multi-hop RFC 8693 delegation via policy-bound subject audiences
## What changes were proposed in this pull request?
A token-exchange topology validates the `subject_token` and the
`actor_token` against the same `knox.token.audiences` list. A token minted by a
delegated exchange carries only the requested target audience, so it can never
be presented again as a `subject_token`, and on-behalf-of chains stop after the
first hop, even though `delegation.max.actor.chain.depth` allows deeper chains.
The workarounds either break recipient binding (adding the token-service
audience to every downstream token) or open the gate (dropping
`knox.token.audiences`, which lets any stolen SA token, minted for any
audience, act as a delegation actor).
This PR adds one narrow relaxation that is bound to the actor: a
re-exchanged `subject_token` is accepted only by the service it was addressed
to.
**New delegation policy field: `subjectAudiences`**
- The audiences that identify the actor *as a recipient*. This is the
inbound counterpart of `resourcePolicy`, which lists the services the actor may
mint tokens for.
- Optional, defaults to empty. An empty set never matches, so existing
policies behave exactly as before.
- Stored in a new `DELEGATION_POLICY_SUBJECT_AUDIENCES` child table, added
to both the default and the Oracle DDL scripts, with `ON DELETE CASCADE`.
`createTablesIfNotExist` creates missing tables at startup, so existing
databases pick up the new table.
- Exposed through the KNOXIDF_ADMIN delegation-policy API (request and
response). Blank values are rejected with `400 invalid_request`.
- `JdbcDelegationPolicyService#evaluate` denies with the new audit reason
`subject_audience_not_allowed` when the check is required and the subject
token's `aud` does not intersect `subjectAudiences`. The client still gets the
generic `400 invalid_request` "rejected by policy".
**New JWTProvider parameters (both off by default)**
| Parameter | Effect |
|---|---|
| `token.exchange.subject.token.policy.audiences.issuers` | Comma-separated
issuers. On a delegated exchange (`actor_token` present), a `subject_token`
from one of these issuers that fails the `knox.token.audiences` check is
accepted only if its `aud` intersects the actor policy's `subjectAudiences`.
Never applies to actor tokens, headless or same-subject exchanges. Unset:
today's behavior. |
| `delegation.headless.reject.subject.with.act` | When `true`, a headless
`subject_token` (the acting party) that carries an `act` claim is rejected with
`400 invalid_request` (audit reason `headless_subject_has_act`). |
**Resulting audience rules**
| # | Token | Rule | Status |
|---|---|---|---|
| 1 | `actor_token`, and the headless `subject_token` | `aud` must contain a
`knox.token.audiences` value | Unchanged |
| 2 | First-hop delegated `subject_token` | `aud` must contain a
`knox.token.audiences` value | Unchanged |
| 3 | Re-exchanged delegated `subject_token` | Without a
`knox.token.audiences` value, accepted only if an `actor_token` is present,
`iss` is listed, and `aud` ∩ `subjectAudiences` ≠ ∅ | **New** |
| 4 | Same-subject `subject_token` | `aud` must contain a
`knox.token.audiences` value | Unchanged |
| 5 | Headless `subject_token` | Must not carry `act` (when enabled) |
**New** |
**Implementation**
- `PolicyBoundSubjectAudienceValidator` (new) wraps the topology's
`RequestAudienceValidator`. When the `knox.token.audiences` check fails for a
token from a listed issuer, it defers the decision to the policy instead of
rejecting. Signature, issuer, expiry and not-before checks are unchanged and
still enforced.
- `TokenExchangeHandler` uses it only for delegated exchanges
(`createSubjectAudienceValidator`), and passes the subject token's audiences,
plus whether the policy match is required, to the policy check through
`PolicyCheckRequest`.
- `JWTFederationFilter` reads the two new parameters.
**Docs**
`knox-site/docs/knoxidf/`:
- `token_exchange.md`: the new `subjectAudiences` field, evaluation order, a
new "Multi-hop delegation" section, and the configuration table.
- `configuration.md`: the 2 new parameters.
- `endpoints.md`: the admin API field and validation.
- `security.md`: 2 new bullets.
- `operations.md`: the new deny reason.
## How was this patch tested?
- `mvn -pl
gateway-spi,gateway-server,gateway-provider-security-jwt,gateway-service-knoxidf
verify`. All modules pass, including checkstyle, PMD, SpotBugs and
forbiddenapis.
- New and updated unit and integration tests:
- `PolicyBoundSubjectAudienceValidatorTest` (new): a delegate pass returns
no policy match; a delegate failure is accepted provisionally only for a listed
issuer (not for unlisted or null issuers); the configured audiences are passed
to the delegate; the flag is reset on each validation.
- `TokenExchangeHandlerTest`:
- rule 3: a policy match is required only for a listed issuer whose
token fails the configured audience check; a mismatch is rejected with
`invalid_request` and audited;
- rule 3 never applies without an actor token, to headless exchanges,
when no issuers are configured, or to the actor token;
- rule 5: enabled and disabled, with and without `act`, and not applied
to delegated exchanges.
- `JWTFederationFilterDelegationConfigTest`: parsing of the new parameters.
- `JWTFederationFilterTokenExchangeTest`, full filter path:
- a re-exchanged token from a listed issuer requires the policy audience
match;
- a bad signature is still rejected (`401`) even when the issuer is
listed;
- with no issuers configured, the token is rejected with "missing
required token audience";
- actor tokens and same-subject exchanges are not relaxed.
- `JdbcDelegationPolicyServiceTest`:
- `subject_audience_not_allowed` cases: match, mismatch, empty policy
set, token without `aud`, ignored when not required;
- checked before the subject and resource checks;
- register, update, registerOrUpdate and delete of `subjectAudiences`.
- `DelegationPolicySchemaTest` and `KnoxDatabaseTest`: the new table is
queryable, enforces its FK and cascades on delete; a pre-KNOX-3520 schema gets
the table on upgrade.
- `DelegationPolicyResourceTest`: field omitted, persisted, null elements
filtered; empty or whitespace values rejected on register, update and
registerOrUpdate.
- `DelegationPolicyDtoImmutabilityTest`: defensive copies in the request
and response DTOs.
- `DelegationPolicyTest` and `PolicyCheckRequestTest`: new fields,
defaults, immutability.
- Docker Compose integration suite run locally: 149 passed.
## Integration Tests
- New topology
`.github/workflows/build/conf/topologies/knoxidf-token-multi-hop.xml`, added to
the Dockerfile. It sets `knox.token.audiences=https://recipient1`, its own
issuer (listed in both `jwt.expected.issuer` and
`token.exchange.subject.token.policy.audiences.issuers`),
`delegation.headless.reject.subject.with.act=true`,
`delegation.max.actor.chain.depth=3`, nested `act` claims, and the
`passthrough` audience validator.
- New suite `.github/workflows/tests/test_multi_hop_delegation.py` (11
tests). Policies are seeded through the KNOXIDF_ADMIN API. tom is the subject;
recursiveUser and sam alternate as actors.
- 2-hop and 3-hop chains succeed. Each minted token has `sub=tom`, the
multi-hop issuer, only the requested `aud`, and the full nested `act` chain.
- A 4th hop is rejected by `delegation.max.actor.chain.depth`.
- A re-exchange is rejected by policy when the actor's `subjectAudiences`
do not match, or are empty.
- A delegated token from an unlisted issuer is rejected for the missing
audience, even though it is addressed to the actor.
- A re-exchanged token is rejected in a same-subject exchange and as a
headless subject.
- A delegated token that does carry the token-service audience is still
rejected as a headless subject (rule 5).
- Actor tokens and first-hop subject tokens without the token-service
audience are rejected.
- `delegation_helpers.py`:
- `DelegationPolicyAdmin.register()`/`update()` accept `subject_audiences`.
- New shared `assert_issued_jwt()` helper.
```
tests-1 | ------------------------------------
tests-1 | Your code has been rated at 10.00/10
tests-1 |
tests-1 | Waiting for knox...
tests-1 | ============================= test session starts
==============================
tests-1 | platform linux
Issue Time Tracking
-------------------
Worklog Id: (was: 1048424)
Remaining Estimate: 0h
Time Spent: 10m
> 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: 10m
> 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)