Skip to content

CAMEL-24497: camel-undertow-spring-security-starter - validate the JWT issuer and audience - #1910

Merged
Croway merged 2 commits into
apache:mainfrom
oscerd:fix/CAMEL-24497
Aug 28, 2026
Merged

CAMEL-24497: camel-undertow-spring-security-starter - validate the JWT issuer and audience#1910
Croway merged 2 commits into
apache:mainfrom
oscerd:fix/CAMEL-24497

Conversation

@oscerd

@oscerd oscerd commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

UndertowSpringSecurityCustomizer.jwtDecoderByIssuerUri() built the decoder from the JWK set URI and
installed only a claim-set converter:

final NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
jwtDecoder.setClaimSetConverter(new KeycloakUsernameSubClaimAdapter(getProvider().getUserNameAttribute()));
return jwtDecoder;

Despite the bean name, no setJwtValidator call was made, so the default validator applied: signature and
timestamps were checked, the iss claim was not, and the configured clientId was used only to build the
ClientRegistration — never bound to the token audience. Spring Security's own withIssuerLocation path
installs an issuer validator; this construction path does not.

Why the audience check is the substantive part. Pinning the JWKS URI already ties tokens to the realm, so
issuer validation alone adds little — every client of that realm shares the same signing key. A token minted
for a different service of the same realm therefore validated here. Binding the token to the configured
resource audience is what closes that.

Change

final OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);
if (!getProvider().isValidateAudience()) {
    return withIssuer;
}
return new DelegatingOAuth2TokenValidator<>(withIssuer,
        new JwtAudienceValidator(getClientRegistration().getClientId()));

JwtAudienceValidator requires the token's aud claim to contain the configured client id. The azp claim
identifies the requesting client and does not substitute for the resource audience. Keycloak may require an
Audience protocol mapper to add the service client to access tokens.

The issuer is derived from the same url + realmId the client registration already resolves, via a shared
realmUri() helper, so the issuer is by construction the prefix of the JWK set URI (asserted in a test).

Behaviour change and opt-out

This is deliberate: a deployment presenting tokens minted for a different audience will now be rejected.
For anyone relying on that:

camel.security.undertow.keycloak.validate-audience = false

Documented in intro.adoc, and it surfaces in config metadata. Worth an upgrade-guide entry in apache/camel
when this lands.

Tests

This starter had no test module; this adds one along with spring-boot-starter-test (test scope only,
outside the generated dependency block).

  • JwtAudienceValidatorTest — accepted via aud, accepted when one of several audiences, rejected for another
    client of the same realm, rejected without an audience, and rejected when azp matches but aud targets
    another service.
  • KeycloakIssuerUriTest — issuer derivation, path on the configured URL ignored, issuer is the prefix of the
    generated JWK set URI, and audience validation defaults on.

Not addressed here

CAMEL-24497 also notes that the registration of the non-static inner @EnableWebSecurity SecurityConfiguration
is statically unconfirmed, and suggests a test asserting the filter chain actually installs. That is not added
here; it needs a Spring context with a reachable provider and is separate from the unit coverage above.

Codex on behalf of Federico Mariani.

…T issuer and audience

jwtDecoderByIssuerUri built the decoder with NimbusJwtDecoder.withJwkSetUri and
installed only a claim-set converter, so the default validator applied: signature
and timestamps were checked, the iss claim was not, and the configured clientId
was used only to build the ClientRegistration - never bound to the token.

Every client of a realm is served by the same signing key, so a token minted for
any other client of that realm satisfied the signature check and was accepted.

The decoder now installs JwtValidators.createDefaultWithIssuer for the realm the
client registration already points at, plus an audience validator binding the
token to the configured clientId through its aud or azp claim.

The audience check can be turned off with
camel.security.undertow.keycloak.validate-audience=false for deployments that
rely on tokens minted for a different client.

Adds the first tests to this starter, which had no test module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Andrea Cosentino <ancosen@gmail.com>

@davsclaus davsclaus 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.

Reviewed against this repo's rule files (.oss-ai-helper-rules/). Nice fix — closes a real gap: jwtDecoderByIssuerUri() previously checked signature and timestamps only, never the iss claim, and never bound the token to the configured client, so a token minted for a different client of the same realm would pass. Verified locally: checked out the branch and ran mvn verify in the module — build succeeds, all 9 new tests pass, and the checked-in generated files (undertow-spring-security.json, the starter doc page) exactly match what the build regenerates, so no drift there. CI is green and reviewers are already requested.

One question below, otherwise no blocking issues found from a conventions/build/test standpoint. This review does not replace CodeRabbit/Sourcery/SonarCloud or a substantive security review — please still get a human sign-off on the crypto/security-sensitive logic.

This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

/**
* The issuer the provider stamps into the {@code iss} claim of the tokens it mints.
*/
public abstract String getIssuerUri() throws URISyntaxException;

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.

Making getIssuerUri() abstract on this public class is source-breaking for any external subclass of AbstractProviderConfiguration — it won't compile against the new starter version until it implements this method. This class already has a precedent for avoiding exactly that: getJwtAuthenticationConverter() below (line 60) is deliberately non-abstract with a default throw new IllegalArgumentException("Not implemented"). Was making this one abstract intentional, or would following the existing default-throw pattern be safer for backwards compatibility? (Impact is likely low today since only KeycloakProviderConfiguration exists and UndertowSpringSecurityCustomizer only wires up keycloak, but flagging per this project's "maintain backwards compatibility for public APIs" standard.)

@davsclaus davsclaus 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.

Approving per maintainer instruction after review. Build verified locally (mvn verify, 9/9 tests pass) and generated docs/metadata match. See prior review comment for a non-blocking question about the new abstract getIssuerUri() method.

This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

@Croway
Croway merged commit 9044885 into apache:main Aug 28, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants