Skip to content

KNOX-3424: Dynamic audience handling in the KNOXTOKEN service - #1356

Open
hanicz wants to merge 4 commits into
apache:masterfrom
hanicz:KNOX-3424
Open

KNOX-3424: Dynamic audience handling in the KNOXTOKEN service#1356
hanicz wants to merge 4 commits into
apache:masterfrom
hanicz:KNOX-3424

Conversation

@hanicz

@hanicz hanicz commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

KNOX-3424 - Dynamic audience handling in the KNOXTOKEN service

What changes were proposed in this pull request?

Callers can now request a token's aud claim per request via an audience query parameter. Requested values are validated by a pluggable audience validator, selected with the new knox.token.audience.validator init param, so operators opt into dynamic audiences explicitly and untrusted callers can't spoof arbitrary audiences.

Validators

Selected per topology via knox.token.audience.validator:

  • static (default) — preserves the existing behavior. The audience query parameter is ignored and the statically configured knox.token.audiences are always used. No config change needed for existing deployments.
  • whitelist — honors the audience query parameter, validated against the knox.token.audiences whitelist.
  • passthrough — accepts whatever audience(s) the caller requests, with no local whitelist. Enforcement is deferred to the consumption-time JWTProvider on the topology fronting the target service.

Behavior (whitelist)

  • No audience param → the configured knox.token.audiences are used (unchanged).
  • audience param, all values in the whitelist → only the requested audience(s) land in aud.
  • audience param, any value not whitelisted → 400.
  • Multiple audiences allowed (comma-separated and/or repeated params); exact match only, whitespace trimmed.

Behavior (passthrough)

  • audience param present → the requested audience(s) are stamped into aud verbatim; no whitelist check, never rejected.
  • No audience param → the token is issued with no aud claim (no fallback to knox.token.audiences, unlike static/whitelist).
  • Multiple audiences allowed (comma-separated and/or repeated params); whitespace trimmed.
  • Use only where a downstream JWTProvider validates aud at consumption time.

Fail-fast

whitelist requires knox.token.audiences to be configured — a validator that needs a whitelist but has nothing to validate against fails deployment (ServiceLifecycleException) rather than silently accepting anything. An unknown validator name also fails deployment.

Extensibility

AudienceValidator.validateAndResolve(...) is a small strategy interface; new validators can be added and selected through knox.token.audience.validator without touching TokenResource.

How was this patch tested?

Unit tests, local tests

<param>
    <name>knox.token.audiences</name>
    <value>test1,test2</value>
</param>
<param>
    <name>knox.token.audience.validator</name>
    <value>whitelist</value>
</param>
curl -sku guest:guest-password -X GET \
  "https://localhost:8443/gateway/tokenissuer/knoxtoken/api/v2/token?lifespan=P0DT1H0M" \
| jq -r '.access_token' \
| cut -d. -f2 \
| { read p; pad=$(( (4 - ${#p} % 4) % 4 )); printf '%s%s' "$p" "$(printf '%*s' "$pad" '' | tr ' ' '=')" | tr '_-' '/+' | base64 -d; } \
| jq '{aud}'
{
  "aud": [
    "test1",
    "test2"
  ]
}
curl -sku guest:guest-password -X GET \
  "https://localhost:8443/gateway/tokenissuer/knoxtoken/api/v2/token?lifespan=P0DT1H0M&audience=test1,test2" \
| jq -r '.access_token' \
| cut -d. -f2 \
| { read p; pad=$(( (4 - ${#p} % 4) % 4 )); printf '%s%s' "$p" "$(printf '%*s' "$pad" '' | tr ' ' '=')" | tr '_-' '/+' | base64 -d; } \
| jq '{aud}'
{
  "aud": [
    "test1",
    "test2"
  ]
}

curl -sku guest:guest-password -X GET \
  "https://localhost:8443/gateway/tokenissuer/knoxtoken/api/v2/token?lifespan=P0DT1H0M&audience=test1" \
| jq -r '.access_token' \
| cut -d. -f2 \
| { read p; pad=$(( (4 - ${#p} % 4) % 4 )); printf '%s%s' "$p" "$(printf '%*s' "$pad" '' | tr ' ' '=')" | tr '_-' '/+' | base64 -d; } \
| jq '{aud}'
{
  "aud": "test1"
}
curl -sku guest:guest-password -X GET \
  "https://localhost:8443/gateway/tokenissuer/knoxtoken/api/v2/token?lifespan=P0DT1H0M&audience=test1,bad"
{
  "error": "The requested audience 'bad' is not allowed.",
  "code": 100
}
curl -sku guest:guest-password -X GET \
  "https://localhost:8443/gateway/tokenissuer/knoxtoken/api/v2/token?lifespan=P0DT1H0M&audience=bad"
{
  "error": "The requested audience 'bad' is not allowed.",
  "code": 100
}

Integration Tests

N/A

UI changes

N/A

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown

Test Results

 4 files   4 suites   10s ⏱️
56 tests 56 ✅ 0 💤 0 ❌
67 runs  67 ✅ 0 💤 0 ❌

Results for commit 01531a3.

♻️ This comment has been updated with latest results.

@hsheinblatt hsheinblatt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Tamás. See comment below, but I believe this logic needs to be moved to the filter. We'll need it there for knoxidf, and it would be confusing to have multiple audience request validation paths. We can discuss if that decision should be overruled and we are to put it in the token resource -- I had that plan too initially, but on retrospect, putting it in the filter is more consistent with the filter philosophy.

setupPublicCertPEM();
String jku = getJku();

final List<String> audiences;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense, but for the RFC 8693 extension, we'll have to validate the audience in the filter. We're going to need it for this release of knoxidf, and we'll have to validate it against policy in the filter stage. For same-subject exchanges, there are conventions (it's optional, but vendors use it in somewhat standard ways). Most will try to validate it against some kind of policy, like a stored allowed list in the client_id registration, per subject allow list, or the subject token aud list. But these are the kinds of things we're planning to put in the filter logic. I had initially thought to put it in knoxidf TokenResource that overrides this class, but that path was argued against: though this is kind of a 'token exchange request authorization' step rather than a 'token authorization' step, it was still required to put in the filter.

I had more in mind setting a request parameter for the resolved audience to use that the token resource would read instead of the hardcoded targetAudience -- or that would be replaced by the dynamic value derived in the filter. I hadn't designed this in detail yet as I was recently informed the logic was to be in the filter.

So, for consistency, it would be better to put this logic in the same place. Then there's only one flow that validates the requested audience, and we just branch off that flow based on how we want to authorize it based on some config parameter -- whether to use delegation authz, or same-subject exchange authz, or the whitelist.

for (String audience : requested) {
if (!targetAudiences.contains(audience)) {
throw new AudienceValidationException("The requested audience '" + audience + "' is not allowed.",
ErrorCode.INVALID_AUDIENCE);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For all these errors, they'll end up as invalid_request type errors for the RFC 8693 flows. So for consistency with #1354, we'll need to ensure the right error mapping happens in the response. See comment above, but if this logic moves to the filter, then that part will be easier.

@hsheinblatt hsheinblatt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Tamás. See more detailed comments below, but I'd switch from using a query parameter for the requested audience to a header. A header is generally more secure and how we normally pass values parsed in the filter to the delegated service. If we stick with a query parameter, then when we add RFC 8693 token exchange audience requests, we'll need separate logic for that, and we'll need a separate switch in knoxtoken to disable reading the request from the query string.

protected static final String TOKEN_TTL_PARAM = TOKEN_PARAM_PREFIX + "ttl";
public static final String TOKEN_TYPE_PARAM = TOKEN_PARAM_PREFIX + "type";
private static final String TOKEN_AUDIENCES_PARAM = TOKEN_PARAM_PREFIX + "audiences";
static final String AUDIENCE_QUERY_PARAM = "audience";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For RFC 8693 token exchange, the audience requested is in the post body. See section 2.1:

The client makes a token exchange request to the token endpoint with an extension grant type using the HTTP POST method. The following parameters are included in the HTTP request entity-body using the application/x-www-form-urlencoded format with a character encoding of UTF-8 as described in [Appendix B](https://www.rfc-editor.org/rfc/rfc6749#appendix-B) of [[RFC6749](https://datatracker.ietf.org/doc/html/rfc6749)].

then

audience
OPTIONAL. The logical name of the target service where the client intends to use the requested security token. This serves a purpose similar to the resource parameter but with the client providing a logical name for the target service. Interpretation of the name requires that the value be something that both the client and the authorization server understand. An OAuth client identifier, a SAML entity identifier [[OASIS.saml-core-2.0-os](http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf)], and an OpenID Connect Issuer Identifier [[OpenID.Core](https://openid.net/specs/openid-connect-core-1_0.html)] are examples of things that might be used as audience parameter values. However, audience values used with a given authorization server must be unique within that server to ensure that they are properly interpreted as the intended type of value. Multiple audience parameters may be used to indicate that the issued token is intended to be used at the multiple audiences listed. The audience and resource parameters may be used together to indicate multiple target services with a mix of logical names and resource URIs.

My presumption was that we'd parse out the requested audience from the body at the same time we parse out the subject_token and optionally the actor_token in the filter flow. We'd then have to stash that somewhere that the knoxtoken service could access for inclusion in the minted token, presuming the request passed filter authz. The commonly used spot seems to be a request header.

So, one possibility is that for RFC 8693 token exchange we require the audience to be specified in the body and reject requests with it in a header or query parameter rather than ignore it. We add it to the request headers if authz passes in the filter. Then for knoxtoken, we can read it from a header rather than a querystring.

If we want to support post body/header in one path and querystring in another, we probably want different feature flags for it. But i'm not aware of any reason to allow the querystring.

}

private JWT getJWT(UserContext userContext, long issueTime, long expires, String jku) throws TokenServiceException {
private List<String> parseRequestedAudiences() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where we could pull it from the header instead of the querystring, seems otherwise should just work.

EasyMock.expect(request.getParameter(TokenResource.QUERY_PARAMETER_DOAS)).andReturn(contextExpectations.get(TokenResource.QUERY_PARAMETER_DOAS)).anyTimes();
}
EasyMock.expect(request.getParameterNames()).andReturn(Collections.emptyEnumeration()).anyTimes();
final Map<String, String[]> parameterMap = new HashMap<>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly in the filter for the rfc token exchange we should check there is no audience querystring param and fail if there is, just to be clear. Also, if we're stashing a value in the header, we should fail if there already is one value there. If we switch knoxtoken to also use a header as suggested, then the former we'd maybe just want to do in general, but the latter we'd leave off if not rfc token exchange.

private final WhitelistAudienceValidator validator = new WhitelistAudienceValidator();

@Test
public void noRequestedAudienceReturnsConfigured() throws Exception {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the backwards compatible case is 'static' and the 'whitelist' is new, then why not have this return an empty set? That is, the way to get the current behavior is to use the 'static' validator, it just returns what's configured, ignores whatever is requested. If you switch to a whitelist, it means you want to request audiences. It seems like a security hole that if you don't request an audience you get all of them.

I could see it for an incremental adoption use case: some services start requesting audiences, but not all are updated yet. The ones that haven't updated get the same value as the static validator, so remain backwards compatible until they switch.

It's just that after migration is done, anyone that wants to backslide, or new services, can start off without requesting any audiences and get them all, so their token will just work everywhere. So the end state is less secure.

Just an observation, but I'd make sure to document this behavior: you have to check your audit logs for exchanges that don't request audiences after a migration to the 'whitelist' validator is done to ensure folk are requesting appropriate audiences. Or, put in some controls to restrict the number of audiences allowed in a token for validation.


With the `whitelist` validator its behavior is:

* the request does not contain an `audience` parameter -> the statically configured `knox.token.audiences` are used, exactly as before (unchanged default behavior)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, you did already document it, nice.


Only exact matches against the whitelist are honored.

The `passthrough` validator instead stamps the requested audience(s) into the token's `aud` claim verbatim, without any whitelist check, and rejects nothing. If the request contains no `audience` parameter the token is issued with no `aud` claim. Because it performs no local authorization, `passthrough` relies on a downstream JWTProvider to reject tokens whose `aud` does not match the consumer topology's expected audiences, deferring enforcement to the point of consumption. Use it only when such consumption-time validation is in place.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't follow this. Isn't the provider upstream from the knoxtoken? That is, if configured in the topology, the filter would run before all this code, and if it failed the audience validation there, no token would be issued, you'd get a failure response. There's no special requirement on consumption -- wherever you send the newly minted token.

What I thought was, something more like:
The passthrough validator is meant for use when a JWTProvider is configured in the topology that performs validation on the requested audience already. Then, any requested audience in KNOXTOKEN will be set in the aud claim of the minted token verbatim without further validation required.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants