From 7870359df5f504311dd107ac23b10464080c679b Mon Sep 17 00:00:00 2001 From: Markus Schwer Date: Mon, 24 Jan 2022 20:11:22 +0100 Subject: [PATCH] Support for multiple custom claim paths to find roles in oidc token --- .../asciidoc/security-openid-connect.adoc | 2 +- .../io/quarkus/oidc/OidcTenantConfig.java | 21 ++++++++------- .../io/quarkus/oidc/runtime/OidcUtils.java | 8 ++++-- .../quarkus/oidc/runtime/OidcUtilsTest.java | 26 +++++++++++++++---- .../src/test/resources/tokenCustomPath.json | 26 ++++++++++++------- 5 files changed, 55 insertions(+), 28 deletions(-) diff --git a/docs/src/main/asciidoc/security-openid-connect.adoc b/docs/src/main/asciidoc/security-openid-connect.adoc index 5bb1e637e70c7..f7414da5548e0 100644 --- a/docs/src/main/asciidoc/security-openid-connect.adoc +++ b/docs/src/main/asciidoc/security-openid-connect.adoc @@ -370,7 +370,7 @@ The default tenant's `OidcConfigurationMetadata` is injected if the endpoint is SecurityIdentity roles can be mapped from the verified JWT access tokens as follows: -* If `quarkus.oidc.roles.role-claim-path` property is set and a matching array or string claim is found then the roles are extracted from this claim. +* If `quarkus.oidc.roles.role-claim-path` property is set and matching array or string claims are found then the roles are extracted from these claims. For example, `customroles`, `customroles/array`, `scope`, `"http://namespace-qualified-custom-claim"/roles`, `"http://namespace-qualified-roles"`, etc. * If `groups` claim is available then its value is used * If `realm_access/roles` or `resource_access/client_id/roles` (where `client_id` is the value of the `quarkus.oidc.client-id` property) claim is available then its value is used. diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java index 30991114cb605..023314f20896d 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java @@ -349,11 +349,11 @@ public Logout getLogout() { @ConfigGroup public static class Roles { - public static Roles fromClaimPath(String path) { + public static Roles fromClaimPath(List path) { return fromClaimPathAndSeparator(path, null); } - public static Roles fromClaimPathAndSeparator(String path, String sep) { + public static Roles fromClaimPathAndSeparator(List path, String sep) { Roles roles = new Roles(); roles.roleClaimPath = Optional.ofNullable(path); roles.roleClaimSeparator = Optional.ofNullable(sep); @@ -361,16 +361,17 @@ public static Roles fromClaimPathAndSeparator(String path, String sep) { } /** - * Path to the claim containing an array of groups. It starts from the top level JWT JSON object and - * can contain multiple segments where each segment represents a JSON object name only, example: "realm/groups". - * Use double quotes with the namespace qualified claim names. - * This property can be used if a token has no 'groups' claim but has the groups set in a different claim. + * List of paths to claims containing an array of groups. Each path starts from the top level JWT JSON object + * and can contain multiple segments where each segment represents a JSON object name only, + * example: "realm/groups". Use double quotes with the namespace qualified claim names. + * This property can be used if a token has no 'groups' claim but has the groups set in one or more different + * claims. */ @ConfigItem - public Optional roleClaimPath = Optional.empty(); + public Optional> roleClaimPath = Optional.empty(); /** * Separator for splitting a string which may contain multiple group values. - * It will only be used if the "role-claim-path" property points to a custom claim whose value is a string. + * It will only be used if the "role-claim-path" property points to one or more custom claims whose values are strings. * A single space will be used by default because the standard 'scope' claim may contain a space separated sequence. */ @ConfigItem @@ -382,11 +383,11 @@ public static Roles fromClaimPathAndSeparator(String path, String sep) { @ConfigItem public Optional source = Optional.empty(); - public Optional getRoleClaimPath() { + public Optional> getRoleClaimPath() { return roleClaimPath; } - public void setRoleClaimPath(String roleClaimPath) { + public void setRoleClaimPath(List roleClaimPath) { this.roleClaimPath = Optional.of(roleClaimPath); } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java index 8a10965651da8..2c9b9b7e4f120 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java @@ -97,9 +97,13 @@ public static JsonObject decodeJwtHeaders(String jwt) { } public static List findRoles(String clientId, OidcTenantConfig.Roles rolesConfig, JsonObject json) { - // If the user configured a specific path - check and enforce a claim at this path exists + // If the user configured specific paths - check and enforce the claims at these paths exist if (rolesConfig.getRoleClaimPath().isPresent()) { - return findClaimWithRoles(rolesConfig, rolesConfig.getRoleClaimPath().get(), json); + List roles = new LinkedList<>(); + for (String roleClaimPath : rolesConfig.getRoleClaimPath().get()) { + roles.addAll(findClaimWithRoles(rolesConfig, roleClaimPath, json)); + } + return roles; } // Check 'groups' next diff --git a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java index a2ced385f2c20..6458364cbeb86 100644 --- a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java +++ b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java @@ -11,6 +11,7 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; +import java.util.Collections; import java.util.List; import java.util.stream.Collectors; @@ -317,17 +318,30 @@ public void testTokenWithGroups() throws Exception { @Test public void testTokenWithCustomRoles() throws Exception { - OidcTenantConfig.Roles rolesCfg = OidcTenantConfig.Roles.fromClaimPath("application_card/embedded/roles"); + OidcTenantConfig.Roles rolesCfg = OidcTenantConfig.Roles + .fromClaimPath(Collections.singletonList("application_card/embedded/roles")); List roles = OidcUtils.findRoles(null, rolesCfg, read(getClass().getResourceAsStream("/tokenCustomPath.json"))); assertEquals(2, roles.size()); assertTrue(roles.contains("r1")); assertTrue(roles.contains("r2")); } + @Test + public void testTokenWithMultipleCustomRolePaths() throws Exception { + OidcTenantConfig.Roles rolesCfg = OidcTenantConfig.Roles + .fromClaimPath(List.of("application_card/embedded/roles", "application_card/embedded2/roles")); + List roles = OidcUtils.findRoles(null, rolesCfg, read(getClass().getResourceAsStream("/tokenCustomPath.json"))); + assertEquals(4, roles.size()); + assertTrue(roles.contains("r1")); + assertTrue(roles.contains("r2")); + assertTrue(roles.contains("r5")); + assertTrue(roles.contains("r6")); + } + @Test public void testTokenWithCustomNamespacedRoles() throws Exception { OidcTenantConfig.Roles rolesCfg = OidcTenantConfig.Roles - .fromClaimPath("application_card/embedded/\"https://custom/roles\""); + .fromClaimPath(Collections.singletonList("application_card/embedded/\"https://custom/roles\"")); List roles = OidcUtils.findRoles(null, rolesCfg, read(getClass().getResourceAsStream("/tokenCustomPath.json"))); assertEquals(2, roles.size()); assertTrue(roles.contains("r3")); @@ -336,7 +350,7 @@ public void testTokenWithCustomNamespacedRoles() throws Exception { @Test public void testTokenWithScope() throws Exception { - OidcTenantConfig.Roles rolesCfg = OidcTenantConfig.Roles.fromClaimPath("scope"); + OidcTenantConfig.Roles rolesCfg = OidcTenantConfig.Roles.fromClaimPath(Collections.singletonList("scope")); List roles = OidcUtils.findRoles(null, rolesCfg, read(getClass().getResourceAsStream("/tokenScope.json"))); assertEquals(2, roles.size()); assertTrue(roles.contains("s1")); @@ -345,7 +359,8 @@ public void testTokenWithScope() throws Exception { @Test public void testTokenWithCustomScope() throws Exception { - OidcTenantConfig.Roles rolesCfg = OidcTenantConfig.Roles.fromClaimPathAndSeparator("customScope", ","); + OidcTenantConfig.Roles rolesCfg = OidcTenantConfig.Roles + .fromClaimPathAndSeparator(Collections.singletonList("customScope"), ","); List roles = OidcUtils.findRoles(null, rolesCfg, read(getClass().getResourceAsStream("/tokenCustomScope.json"))); assertEquals(2, roles.size()); @@ -355,7 +370,8 @@ public void testTokenWithCustomScope() throws Exception { @Test public void testTokenWithCustomRolesWrongPath() throws Exception { - OidcTenantConfig.Roles rolesCfg = OidcTenantConfig.Roles.fromClaimPath("application-card/embedded/roles"); + OidcTenantConfig.Roles rolesCfg = OidcTenantConfig.Roles + .fromClaimPath(Collections.singletonList("application-card/embedded/roles")); InputStream is = getClass().getResourceAsStream("/tokenCustomPath.json"); List roles = OidcUtils.findRoles(null, rolesCfg, read(is)); assertEquals(0, roles.size()); diff --git a/extensions/oidc/runtime/src/test/resources/tokenCustomPath.json b/extensions/oidc/runtime/src/test/resources/tokenCustomPath.json index 6a7cdf6596a54..5c9e08ba22c34 100644 --- a/extensions/oidc/runtime/src/test/resources/tokenCustomPath.json +++ b/extensions/oidc/runtime/src/test/resources/tokenCustomPath.json @@ -9,15 +9,21 @@ "iat": 1311280970, "auth_time": 1311280969, "application_card": { - "embedded": { - "roles": [ - "r1", - "r2" - ], - "https://custom/roles": [ - "r3", - "r4" - ] - } + "embedded": { + "roles": [ + "r1", + "r2" + ], + "https://custom/roles": [ + "r3", + "r4" + ] + }, + "embedded2": { + "roles": [ + "r5", + "r6" + ] + } } }