From 91da575d51f4aa7d5d5b2b9905bffd5e1b2da314 Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Thu, 20 Nov 2025 19:18:18 +0000 Subject: [PATCH] Allow to keep JWT audience trailing slash --- .../security-oidc-expanded-configuration.adoc | 2 + .../oidc/client/OidcClientConfigImpl.java | 7 +++ .../runtime/OidcClientCommonConfig.java | 11 +++++ .../oidc/common/runtime/OidcCommonUtils.java | 9 ++-- .../config/OidcClientCommonConfig.java | 6 +++ .../config/OidcClientCommonConfigBuilder.java | 28 ++++++++++-- .../OidcClientCommonConfigBuilderTest.java | 1 + .../common/runtime/OidcCommonUtilsTest.java | 43 ++++++++++++++++++- .../oidc/runtime/OidcTenantConfigImpl.java | 7 +++ 9 files changed, 107 insertions(+), 7 deletions(-) diff --git a/docs/src/main/asciidoc/security-oidc-expanded-configuration.adoc b/docs/src/main/asciidoc/security-oidc-expanded-configuration.adoc index 9db04206afbbf..8c1a77ca4c3ed 100644 --- a/docs/src/main/asciidoc/security-oidc-expanded-configuration.adoc +++ b/docs/src/main/asciidoc/security-oidc-expanded-configuration.adoc @@ -261,6 +261,7 @@ https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication[Stand |quarkus.oidc.credentials.jwt.token-key-id ||The token key id (`kid`) header value |quarkus.oidc.credentials.jwt.audience |OIDC token endpoint address|Audience (`aud`) claim value +|quarkus.oidc.credentials.jwt.keep-audience-trailing-slash |false|Whether to keep a trailing slash in the audience value |quarkus.oidc.credentials.jwt.issuer |OIDC client id|Issuer (`iss`) claim value |quarkus.oidc.credentials.jwt.subject |OIDC client id|Subject (`sub`) cliam value |quarkus.oidc.credentials.jwt.lifespan |10 seconds|Lifespan added to the `issued at` (`iat`) claim value to calculate the expiry (`exp`) claim value @@ -270,6 +271,7 @@ https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication[Stand `quarkus.oidc.credentials.jwt.token-key-id` can be used to set a key identifier (`kid`) header value to help the OIDC provider with fidning a token verification key. All other properties listed in the table above can be used to customize JWT claim values. + ==== JWT Bearer By default, when a client JWT authentication token must be produced, it is generated by Quarkus OIDC. In some cases, the JWT bearer token may be provided and periodically updated by Kubernetes. diff --git a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/OidcClientConfigImpl.java b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/OidcClientConfigImpl.java index bfda0bcd29cce..a6d631a4b870b 100644 --- a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/OidcClientConfigImpl.java +++ b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/OidcClientConfigImpl.java @@ -91,6 +91,7 @@ enum ConfigMappingMethods { CREDENTIALS_JWT_LIFESPAN, CREDENTIALS_JWT_ASSERTION, CREDENTIALS_JWT_AUDIENCE, + CREDENTIALS_JWT_KEEP_AUDIENCE_TRAILING_SLASH, CREDENTIALS_JWT_TOKEN_ID, JWT_BEARER_TOKEN_PATH, REFRESH_INTERVAL @@ -265,6 +266,12 @@ public Optional audience() { return Optional.empty(); } + @Override + public boolean keepAudienceTrailingSlash() { + invocationsRecorder.put(ConfigMappingMethods.CREDENTIALS_JWT_KEEP_AUDIENCE_TRAILING_SLASH, true); + return false; + } + @Override public Optional tokenKeyId() { invocationsRecorder.put(ConfigMappingMethods.CREDENTIALS_JWT_TOKEN_ID, true); diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcClientCommonConfig.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcClientCommonConfig.java index 111188f865da9..ad1d2ba00c505 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcClientCommonConfig.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcClientCommonConfig.java @@ -330,6 +330,11 @@ public Optional audience() { return audience; } + @Override + public boolean keepAudienceTrailingSlash() { + return keepAudienceTrailingSlash; + } + @Override public Optional tokenKeyId() { return tokenKeyId; @@ -434,6 +439,11 @@ public static enum Source { */ public Optional audience = Optional.empty(); + /** + * Whether to keep a trailing slash `/` in the {@link #audience()} value. + */ + public boolean keepAudienceTrailingSlash = false; + /** * The key identifier of the signing key added as a JWT `kid` header. */ @@ -575,6 +585,7 @@ private void addConfigMappingValues( keyId = mapping.keyId(); keyPassword = mapping.keyPassword(); audience = mapping.audience(); + keepAudienceTrailingSlash = mapping.keepAudienceTrailingSlash(); tokenKeyId = mapping.tokenKeyId(); issuer = mapping.issuer(); subject = mapping.subject(); diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java index e71f234b7520f..d00c4fe0cbaf1 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java @@ -246,6 +246,10 @@ public static String getAuthServerUrl(OidcCommonConfig oidcConfig) { return removeLastPathSeparator(oidcConfig.authServerUrl().get()); } + private static String removeAudienceTrailingSlash(Credentials.Jwt jwtConfig, String value) { + return !jwtConfig.keepAudienceTrailingSlash() ? removeLastPathSeparator(value) : value; + } + private static String removeLastPathSeparator(String value) { return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; } @@ -435,9 +439,8 @@ public static String signJwtWithKey(OidcClientCommonConfig oidcConfig, String to .claims(additionalClaims(oidcConfig.credentials().jwt().claims())) .issuer(oidcConfig.credentials().jwt().issuer().orElse(oidcConfig.clientId().get())) .subject(oidcConfig.credentials().jwt().subject().orElse(oidcConfig.clientId().get())) - .audience(oidcConfig.credentials().jwt().audience().isPresent() - ? removeLastPathSeparator(oidcConfig.credentials().jwt().audience().get()) - : tokenRequestUri) + .audience(removeAudienceTrailingSlash(oidcConfig.credentials().jwt(), + oidcConfig.credentials().jwt().audience().orElse(tokenRequestUri))) .expiresIn(oidcConfig.credentials().jwt().lifespan()).jws(); if (oidcConfig.credentials().jwt().tokenKeyId().isPresent()) { jwtSignatureBuilder.keyId(oidcConfig.credentials().jwt().tokenKeyId().get()); diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/config/OidcClientCommonConfig.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/config/OidcClientCommonConfig.java index 58b02bdf81ec0..b08f05284ce3c 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/config/OidcClientCommonConfig.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/config/OidcClientCommonConfig.java @@ -209,6 +209,12 @@ enum Source { */ Optional audience(); + /** + * Whether to keep a trailing slash `/` in the {@link #audience()} value. + */ + @WithDefault("false") + boolean keepAudienceTrailingSlash(); + /** * The key identifier of the signing key added as a JWT `kid` header. */ diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/config/OidcClientCommonConfigBuilder.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/config/OidcClientCommonConfigBuilder.java index 7026891aae877..3d77233b1825b 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/config/OidcClientCommonConfigBuilder.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/config/OidcClientCommonConfigBuilder.java @@ -484,8 +484,9 @@ public static final class JwtBuilder { private record JwtImpl(Source source, Optional secret, Provider secretProvider, Optional key, Optional keyFile, Optional keyStoreFile, Optional keyStorePassword, - Optional keyId, Optional keyPassword, Optional audience, Optional tokenKeyId, - Optional issuer, Optional subject, Map claims, + Optional keyId, Optional keyPassword, Optional audience, + boolean keepAudienceTrailingSlash, + Optional tokenKeyId, Optional issuer, Optional subject, Map claims, Optional signatureAlgorithm, int lifespan, boolean assertion, Optional tokenPath) implements Jwt { @@ -503,6 +504,7 @@ private record JwtImpl(Source source, Optional secret, Provider secretPr private Optional keyId; private Optional keyPassword; private Optional audience; + private boolean keepAudienceTrailingSlash; private Optional tokenKeyId; private Optional issuer; private Optional subject; @@ -523,6 +525,7 @@ public JwtBuilder() { this.keyId = Optional.empty(); this.keyPassword = Optional.empty(); this.audience = Optional.empty(); + this.keepAudienceTrailingSlash = false; this.tokenKeyId = Optional.empty(); this.issuer = Optional.empty(); this.subject = Optional.empty(); @@ -548,6 +551,7 @@ private JwtBuilder(CredentialsBuilder builder, Jwt jwt) { this.keyId = jwt.keyId(); this.keyPassword = jwt.keyPassword(); this.audience = jwt.audience(); + this.keepAudienceTrailingSlash = jwt.keepAudienceTrailingSlash(); this.tokenKeyId = jwt.tokenKeyId(); this.issuer = jwt.issuer(); this.subject = jwt.subject(); @@ -666,6 +670,23 @@ public JwtBuilder audience(String audience) { return this; } + /** + * @param keepAudienceTrailingSlash {@link Jwt#keepAudienceTrailingSlash()} + * @return this builder + */ + public JwtBuilder keepAudienceTrailingSlash() { + return keepAudienceTrailingSlash(true); + } + + /** + * @param keepAudienceTrailingSlash {@link Jwt#keepAudienceTrailingSlash()} + * @return this builder + */ + public JwtBuilder keepAudienceTrailingSlash(boolean keepAudienceTrailingSlash) { + this.keepAudienceTrailingSlash = keepAudienceTrailingSlash; + return this; + } + /** * @param tokenKeyId {@link Jwt#tokenKeyId()} * @return this builder @@ -768,7 +789,8 @@ public T endCredentials() { */ public Jwt build() { return new JwtImpl(source, secret, secretProvider, key, keyFile, keyStoreFile, keyStorePassword, keyId, keyPassword, - audience, tokenKeyId, issuer, subject, Map.copyOf(claims), signatureAlgorithm, lifespan, assertion, + audience, keepAudienceTrailingSlash, tokenKeyId, issuer, subject, Map.copyOf(claims), signatureAlgorithm, + lifespan, assertion, tokenPath); } } diff --git a/extensions/oidc-common/runtime/src/test/java/io/quarkus/oidc/common/runtime/OidcClientCommonConfigBuilderTest.java b/extensions/oidc-common/runtime/src/test/java/io/quarkus/oidc/common/runtime/OidcClientCommonConfigBuilderTest.java index 5740558ffcc27..235524681f526 100644 --- a/extensions/oidc-common/runtime/src/test/java/io/quarkus/oidc/common/runtime/OidcClientCommonConfigBuilderTest.java +++ b/extensions/oidc-common/runtime/src/test/java/io/quarkus/oidc/common/runtime/OidcClientCommonConfigBuilderTest.java @@ -42,6 +42,7 @@ public void testCredentialsBuilderDefaultValues() { assertTrue(jwt.keyId().isEmpty()); assertTrue(jwt.keyPassword().isEmpty()); assertTrue(jwt.audience().isEmpty()); + assertFalse(jwt.keepAudienceTrailingSlash()); assertTrue(jwt.tokenKeyId().isEmpty()); assertTrue(jwt.issuer().isEmpty()); assertTrue(jwt.subject().isEmpty()); diff --git a/extensions/oidc-common/runtime/src/test/java/io/quarkus/oidc/common/runtime/OidcCommonUtilsTest.java b/extensions/oidc-common/runtime/src/test/java/io/quarkus/oidc/common/runtime/OidcCommonUtilsTest.java index 0125a7013e8f1..cbff21848b811 100644 --- a/extensions/oidc-common/runtime/src/test/java/io/quarkus/oidc/common/runtime/OidcCommonUtilsTest.java +++ b/extensions/oidc-common/runtime/src/test/java/io/quarkus/oidc/common/runtime/OidcCommonUtilsTest.java @@ -56,10 +56,51 @@ public void testJwtTokenWithScope() throws Exception { cfg.setClientId("client"); cfg.credentials.jwt.claims.put("scope", "read,write"); PrivateKey key = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPrivate(); - String jwt = OidcCommonUtils.signJwtWithKey(cfg, "http://localhost", key); + String jwt = OidcCommonUtils.signJwtWithKey(cfg, "http://some.service.com", key); JsonObject json = decodeJwtContent(jwt); String scope = json.getString("scope"); assertEquals("read,write", scope); + assertEquals("http://some.service.com", json.getString("aud")); + } + + @Test + public void testSignWithAudience() throws Exception { + OidcClientCommonConfig cfg = new OidcClientCommonConfig() { + }; + cfg.setClientId("client"); + cfg.credentials.jwt.audience = Optional.of("https://server.example.com"); + + PrivateKey key = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPrivate(); + String jwt = OidcCommonUtils.signJwtWithKey(cfg, "http://localhost", key); + JsonObject json = decodeJwtContent(jwt); + assertEquals("https://server.example.com", json.getString("aud")); + } + + @Test + public void testSignWithAudienceRemoveTrailingSlash() throws Exception { + OidcClientCommonConfig cfg = new OidcClientCommonConfig() { + }; + cfg.setClientId("client"); + cfg.credentials.jwt.audience = Optional.of("https://server.example.com/"); + + PrivateKey key = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPrivate(); + String jwt = OidcCommonUtils.signJwtWithKey(cfg, "http://localhost", key); + JsonObject json = decodeJwtContent(jwt); + assertEquals("https://server.example.com", json.getString("aud")); + } + + @Test + public void testSignWithAudienceKeepTrailingSlash() throws Exception { + OidcClientCommonConfig cfg = new OidcClientCommonConfig() { + }; + cfg.setClientId("client"); + cfg.credentials.jwt.audience = Optional.of("https://server.example.com/"); + cfg.credentials.jwt.keepAudienceTrailingSlash = true; + + PrivateKey key = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPrivate(); + String jwt = OidcCommonUtils.signJwtWithKey(cfg, "http://localhost", key); + JsonObject json = decodeJwtContent(jwt); + assertEquals("https://server.example.com/", json.getString("aud")); } public static JsonObject decodeJwtContent(String jwt) { diff --git a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcTenantConfigImpl.java b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcTenantConfigImpl.java index 5d5abcfcbdd00..f0c2fb9f228aa 100644 --- a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcTenantConfigImpl.java +++ b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcTenantConfigImpl.java @@ -86,6 +86,7 @@ enum ConfigMappingMethods { CREDENTIALS_JWT_LIFESPAN, CREDENTIALS_JWT_ASSERTION, CREDENTIALS_JWT_AUDIENCE, + CREDENTIALS_JWT_KEEP_AUDIENCE_TRAILING_SLASH, CREDENTIALS_JWT_TOKEN_ID, PROVIDER, JWKS, @@ -1121,6 +1122,12 @@ public Optional audience() { return Optional.empty(); } + @Override + public boolean keepAudienceTrailingSlash() { + invocationsRecorder.put(ConfigMappingMethods.CREDENTIALS_JWT_KEEP_AUDIENCE_TRAILING_SLASH, true); + return false; + } + @Override public Optional tokenKeyId() { invocationsRecorder.put(ConfigMappingMethods.CREDENTIALS_JWT_TOKEN_ID, true);