From 87eba545ad82c5dbc05f63d1eb2d13b2b08b6e74 Mon Sep 17 00:00:00 2001 From: Waldemar Reusch Date: Tue, 11 Jul 2023 21:35:13 +0200 Subject: [PATCH 1/5] feat(oidc-common): add scope option to oidc jwt In some cases, the jwt passed in a client-credentials grant flow requires a claim. This commit introduces the option --- .../oidc/common/runtime/OidcCommonConfig.java | 14 ++++++++++++++ .../oidc/common/runtime/OidcCommonUtils.java | 13 ++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java index d60c4f8f95579..46bbc1d539296 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java @@ -4,6 +4,7 @@ import java.time.Duration; import java.util.Optional; import java.util.OptionalInt; +import java.util.Set; import io.quarkus.runtime.annotations.ConfigGroup; import io.quarkus.runtime.annotations.ConfigItem; @@ -306,6 +307,12 @@ public static class Jwt { @ConfigItem public Optional signatureAlgorithm = Optional.empty(); + /** + * Additional `scope` added to JWT claims. + */ + @ConfigItem + public Optional> scope = Optional.empty(); + /** * JWT life-span in seconds. It will be added to the time it was issued at to calculate the expiration time. */ @@ -368,6 +375,13 @@ public void setKeyFile(String keyFile) { this.keyFile = Optional.of(keyFile); } + public Optional> getScope() { + return scope; + } + + public void setScope(Set scope) { + this.scope = Optional.of(scope); + } } /** 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 7ddcf22aee201..e1ed23c10d0cb 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 @@ -35,6 +35,7 @@ import io.quarkus.runtime.util.ClassPathUtils; import io.smallrye.jwt.algorithm.SignatureAlgorithm; import io.smallrye.jwt.build.Jwt; +import io.smallrye.jwt.build.JwtClaimsBuilder; import io.smallrye.jwt.build.JwtSignatureBuilder; import io.smallrye.jwt.util.KeyUtils; import io.smallrye.jwt.util.ResourceUtils; @@ -344,14 +345,20 @@ public static Key clientJwtKey(Credentials creds) { public static String signJwtWithKey(OidcCommonConfig oidcConfig, String tokenRequestUri, Key key) { // 'jti' and 'iat' claims are created by default, 'iat' - is set to the current time - JwtSignatureBuilder builder = Jwt + JwtClaimsBuilder claimsBuilder = Jwt .issuer(oidcConfig.credentials.jwt.issuer.orElse(oidcConfig.clientId.get())) .subject(oidcConfig.credentials.jwt.subject.orElse(oidcConfig.clientId.get())) .audience(oidcConfig.credentials.jwt.getAudience().isPresent() ? removeLastPathSeparator(oidcConfig.credentials.jwt.getAudience().get()) : tokenRequestUri) - .expiresIn(oidcConfig.credentials.jwt.lifespan) - .jws(); + .expiresIn(oidcConfig.credentials.jwt.lifespan); + + oidcConfig.credentials.jwt.scope.ifPresent((scope) -> { + claimsBuilder.claim("scope", String.join(",", scope)); + }); + + JwtSignatureBuilder builder = claimsBuilder.jws(); + if (oidcConfig.credentials.jwt.getTokenKeyId().isPresent()) { builder.keyId(oidcConfig.credentials.jwt.getTokenKeyId().get()); } From 77a769169001cd0ca01912830faabfd06fafd004 Mon Sep 17 00:00:00 2001 From: Waldemar Reusch Date: Tue, 11 Jul 2023 21:45:13 +0000 Subject: [PATCH 2/5] Implement review suggestions --- .../quarkus/oidc/common/runtime/OidcCommonConfig.java | 10 +++++----- .../quarkus/oidc/common/runtime/OidcCommonUtils.java | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java index 46bbc1d539296..3a67e82d84980 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java @@ -311,7 +311,7 @@ public static class Jwt { * Additional `scope` added to JWT claims. */ @ConfigItem - public Optional> scope = Optional.empty(); + public Optional> scopes = Optional.empty(); /** * JWT life-span in seconds. It will be added to the time it was issued at to calculate the expiration time. @@ -375,12 +375,12 @@ public void setKeyFile(String keyFile) { this.keyFile = Optional.of(keyFile); } - public Optional> getScope() { - return scope; + public Optional> getScopes() { + return scopes; } - public void setScope(Set scope) { - this.scope = Optional.of(scope); + public void setScopes(Set scopes) { + this.scopes = Optional.of(scopes); } } 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 e1ed23c10d0cb..f6fa3503d243d 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 @@ -352,10 +352,10 @@ public static String signJwtWithKey(OidcCommonConfig oidcConfig, String tokenReq ? removeLastPathSeparator(oidcConfig.credentials.jwt.getAudience().get()) : tokenRequestUri) .expiresIn(oidcConfig.credentials.jwt.lifespan); - - oidcConfig.credentials.jwt.scope.ifPresent((scope) -> { - claimsBuilder.claim("scope", String.join(",", scope)); - }); + + if (oidcConfig.credentials.jwt.scopes.isPresent()) { + claimsBuilder.claim(OidcConstants.TOKEN_SCOPE, String.join(",", oidcConfig.credentials.jwt.scopes.get())) + } JwtSignatureBuilder builder = claimsBuilder.jws(); From 66d25d51208ce8d0ccf8f8bd31f0572a5e2024bd Mon Sep 17 00:00:00 2001 From: Waldemar Reusch Date: Wed, 12 Jul 2023 09:58:12 +0200 Subject: [PATCH 3/5] fix: fix missing semicolon --- .../java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 f6fa3503d243d..f9a78e7d105cc 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 @@ -352,9 +352,9 @@ public static String signJwtWithKey(OidcCommonConfig oidcConfig, String tokenReq ? removeLastPathSeparator(oidcConfig.credentials.jwt.getAudience().get()) : tokenRequestUri) .expiresIn(oidcConfig.credentials.jwt.lifespan); - + if (oidcConfig.credentials.jwt.scopes.isPresent()) { - claimsBuilder.claim(OidcConstants.TOKEN_SCOPE, String.join(",", oidcConfig.credentials.jwt.scopes.get())) + claimsBuilder.claim(OidcConstants.TOKEN_SCOPE, String.join(",", oidcConfig.credentials.jwt.scopes.get())); } JwtSignatureBuilder builder = claimsBuilder.jws(); From bf479e9b7d4a7cfa98abad4de53360afb53d28d3 Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Thu, 13 Jul 2023 17:20:09 +0100 Subject: [PATCH 4/5] Add a test --- .../common/runtime/OidcCommonUtilsTest.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) 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 c4ccd112ba24f..9b9a5861a52ea 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 @@ -3,10 +3,17 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.util.Base64; import java.util.Optional; +import java.util.Set; +import java.util.StringTokenizer; import org.junit.jupiter.api.Test; +import io.vertx.core.json.JsonObject; import io.vertx.core.net.ProxyOptions; public class OidcCommonUtilsTest { @@ -42,4 +49,53 @@ public void testProxyOptionsWithHostWithScheme() throws Exception { assertEquals("user", options.getUsername()); assertEquals("password", options.getPassword()); } + + @Test + public void testJwtTokenWithScope() throws Exception { + OidcCommonConfig cfg = new OidcCommonConfig(); + cfg.setClientId("client"); + cfg.credentials.jwt.setScopes(Set.of("read", "write")); + PrivateKey key = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPrivate(); + String jwt = OidcCommonUtils.signJwtWithKey(cfg, "http://localhost", key); + JsonObject json = decodeJwtContent(jwt); + String scope = json.getString("scope"); + assertEquals("read,write", scope); + } + + public static JsonObject decodeJwtContent(String jwt) { + String encodedContent = getJwtContentPart(jwt); + if (encodedContent == null) { + return null; + } + return decodeAsJsonObject(encodedContent); + } + + public static String getJwtContentPart(String jwt) { + StringTokenizer tokens = new StringTokenizer(jwt, "."); + // part 1: skip the token headers + tokens.nextToken(); + if (!tokens.hasMoreTokens()) { + return null; + } + // part 2: token content + String encodedContent = tokens.nextToken(); + + // let's check only 1 more signature part is available + if (tokens.countTokens() != 1) { + return null; + } + return encodedContent; + } + + private static JsonObject decodeAsJsonObject(String encodedContent) { + try { + return new JsonObject(base64UrlDecode(encodedContent)); + } catch (IllegalArgumentException ex) { + return null; + } + } + + private static String base64UrlDecode(String encodedContent) { + return new String(Base64.getUrlDecoder().decode(encodedContent), StandardCharsets.UTF_8); + } } From b11741cceec931c2e95eec82b1e0e0f01edb7266 Mon Sep 17 00:00:00 2001 From: Waldemar Reusch Date: Sun, 16 Jul 2023 22:39:49 +0000 Subject: [PATCH 5/5] feat: add configurable scope separator which defaults to a space --- .../oidc/common/runtime/OidcCommonConfig.java | 14 ++++++++++++++ .../oidc/common/runtime/OidcCommonUtils.java | 4 +++- .../common/runtime/OidcCommonUtilsTest.java | 19 ++++++++++++++++++- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java index 3a67e82d84980..33ea19497d55a 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java @@ -313,6 +313,12 @@ public static class Jwt { @ConfigItem public Optional> scopes = Optional.empty(); + /** + * Separator used to join scopes. + */ + @ConfigItem + public Optional scopesSeparator = Optional.empty(); + /** * JWT life-span in seconds. It will be added to the time it was issued at to calculate the expiration time. */ @@ -382,6 +388,14 @@ public Optional> getScopes() { public void setScopes(Set scopes) { this.scopes = Optional.of(scopes); } + + public Optional getScopesSeparator() { + return scopesSeparator; + } + + public void setScopesSeparator(String scopesSeparator) { + this.scopesSeparator = Optional.of(scopesSeparator); + } } /** 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 f9a78e7d105cc..3604829a1c7f9 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 @@ -354,7 +354,9 @@ public static String signJwtWithKey(OidcCommonConfig oidcConfig, String tokenReq .expiresIn(oidcConfig.credentials.jwt.lifespan); if (oidcConfig.credentials.jwt.scopes.isPresent()) { - claimsBuilder.claim(OidcConstants.TOKEN_SCOPE, String.join(",", oidcConfig.credentials.jwt.scopes.get())); + String separator = oidcConfig.credentials.jwt.scopesSeparator.orElse(" "); + String scope = String.join(separator, oidcConfig.credentials.jwt.scopes.get()); + claimsBuilder.claim(OidcConstants.TOKEN_SCOPE, scope); } JwtSignatureBuilder builder = claimsBuilder.jws(); 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 9b9a5861a52ea..6ed9120143a16 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 @@ -59,7 +59,24 @@ public void testJwtTokenWithScope() throws Exception { String jwt = OidcCommonUtils.signJwtWithKey(cfg, "http://localhost", key); JsonObject json = decodeJwtContent(jwt); String scope = json.getString("scope"); - assertEquals("read,write", scope); + assertEquals( + Set.of("read", "write"), + Set.of(scope.split(" "))); + } + + @Test + public void testJwtTokenWithScopeAndCustomSeparator() throws Exception { + OidcCommonConfig cfg = new OidcCommonConfig(); + cfg.setClientId("client"); + cfg.credentials.jwt.setScopes(Set.of("read", "write")); + cfg.credentials.jwt.setScopesSeparator(","); + PrivateKey key = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPrivate(); + String jwt = OidcCommonUtils.signJwtWithKey(cfg, "http://localhost", key); + JsonObject json = decodeJwtContent(jwt); + String scope = json.getString("scope"); + assertEquals( + Set.of("read", "write"), + Set.of(scope.split(","))); } public static JsonObject decodeJwtContent(String jwt) {