diff --git a/extensions/oidc/runtime/pom.xml b/extensions/oidc/runtime/pom.xml index 70794c616d932..1a51c39011e3c 100644 --- a/extensions/oidc/runtime/pom.xml +++ b/extensions/oidc/runtime/pom.xml @@ -42,11 +42,11 @@ io.vertx vertx-auth-oauth2 - - junit - junit - test - + + io.quarkus + quarkus-junit5-internal + test + diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcConfig.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcConfig.java index e4930e24e3292..e5dc662e22772 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcConfig.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcConfig.java @@ -49,6 +49,12 @@ public class OidcConfig { @ConfigItem Optional clientId; + /** + * Configuration to find and parse a custom claim containing the roles information. + */ + @ConfigItem + Roles roles; + /** * Credentials which the OIDC adapter will use to authenticate to the OIDC server. */ @@ -67,6 +73,10 @@ public Credentials getCredentials() { return credentials; } + public Roles getRoles() { + return roles; + } + @ConfigGroup public static class Credentials { @@ -81,4 +91,43 @@ public Optional getSecret() { } } + @ConfigGroup + public static class Roles { + + /** + * 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". + * This property can be used if a token has no 'groups' claim but has the groups set in a different claim. + */ + @ConfigItem + Optional roleClaimPath; + + /** + * 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. + * A single space will be used by default because the standard 'scope' claim may contain a space separated sequence. + */ + @ConfigItem + Optional roleClaimSeparator; + + public Optional getRoleClaimPath() { + return roleClaimPath; + } + + public Optional getRoleClaimSeparator() { + return roleClaimSeparator; + } + + public static Roles fromClaimPath(String path) { + return fromClaimPathAndSeparator(path, null); + } + + public static Roles fromClaimPathAndSeparator(String path, String sep) { + Roles roles = new Roles(); + roles.roleClaimPath = Optional.ofNullable(path); + roles.roleClaimSeparator = Optional.ofNullable(sep); + return roles; + } + } + } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxJwtCallerPrincipal.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxJwtCallerPrincipal.java index 3718fe446c3a8..067b8a31d7866 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxJwtCallerPrincipal.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxJwtCallerPrincipal.java @@ -9,21 +9,13 @@ */ public class VertxJwtCallerPrincipal extends DefaultJWTCallerPrincipal { private JwtClaims claims; - private String customPrincipalName; - public VertxJwtCallerPrincipal(final String customPrincipalName, final JwtClaims claims) { + public VertxJwtCallerPrincipal(final JwtClaims claims) { super(claims); this.claims = claims; - this.customPrincipalName = customPrincipalName; } public JwtClaims getClaims() { return claims; } - - @Override - public String getName() { - return customPrincipalName != null ? customPrincipalName : super.getName(); - } - } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxKeycloakRecorder.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxKeycloakRecorder.java index 520fbee1bf0be..201d4dd231b52 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxKeycloakRecorder.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxKeycloakRecorder.java @@ -57,7 +57,9 @@ public void handle(AsyncResult event) { }); OAuth2Auth auth = cf.join(); - beanContainer.instance(VertxOAuth2IdentityProvider.class).setAuth(auth); + VertxOAuth2IdentityProvider identityProvider = beanContainer.instance(VertxOAuth2IdentityProvider.class); + identityProvider.setAuth(auth); + identityProvider.setConfig(config); VertxOAuth2AuthenticationMechanism mechanism = beanContainer.instance(VertxOAuth2AuthenticationMechanism.class); mechanism.setAuth(auth); mechanism.setAuthServerURI(config.authServerUrl); diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxOAuth2IdentityProvider.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxOAuth2IdentityProvider.java index 15ea2e3bf6c8a..f09fecf8dae28 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxOAuth2IdentityProvider.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxOAuth2IdentityProvider.java @@ -5,9 +5,11 @@ import javax.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.jwt.JsonWebToken; import org.jose4j.jwt.JwtClaims; import org.jose4j.jwt.consumer.InvalidJwtException; +import io.quarkus.oidc.runtime.OidcUtils; import io.quarkus.security.identity.AuthenticationRequestContext; import io.quarkus.security.identity.IdentityProvider; import io.quarkus.security.identity.SecurityIdentity; @@ -15,8 +17,6 @@ import io.quarkus.security.runtime.QuarkusSecurityIdentity; import io.vertx.core.AsyncResult; import io.vertx.core.Handler; -import io.vertx.core.json.JsonArray; -import io.vertx.core.json.JsonObject; import io.vertx.ext.auth.oauth2.AccessToken; import io.vertx.ext.auth.oauth2.OAuth2Auth; @@ -24,6 +24,7 @@ public class VertxOAuth2IdentityProvider implements IdentityProvider { private volatile OAuth2Auth auth; + private volatile OidcConfig config; public OAuth2Auth getAuth() { return auth; @@ -34,11 +35,17 @@ public VertxOAuth2IdentityProvider setAuth(OAuth2Auth auth) { return this; } + public VertxOAuth2IdentityProvider setConfig(OidcConfig config) { + this.config = config; + return this; + } + @Override public Class getRequestType() { return TokenAuthenticationRequest.class; } + @SuppressWarnings("deprecation") @Override public CompletionStage authenticate(TokenAuthenticationRequest request, AuthenticationRequestContext context) { @@ -53,27 +60,24 @@ public void handle(AsyncResult event) { AccessToken token = event.result(); QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(); - //this is not great, we are re-parsing - //this needs some love from someone who knows smallrye JWT better to avoid re-parsing + JsonWebToken jwtPrincipal = null; try { - JwtClaims jwtClaims = JwtClaims.parse(token.accessToken().encode()); - - String username = token.principal().getString("username"); - builder.setPrincipal(new VertxJwtCallerPrincipal(username, jwtClaims)); + jwtPrincipal = new VertxJwtCallerPrincipal(JwtClaims.parse(token.accessToken().encode())); } catch (InvalidJwtException e) { result.completeExceptionally(e); return; } - - JsonObject realmAccess = token.accessToken().getJsonObject("realm_access"); - if (realmAccess != null) { - JsonArray roles = realmAccess.getJsonArray("roles"); - if (roles != null) { - for (Object authority : roles) { - builder.addRole(authority.toString()); - } + builder.setPrincipal(jwtPrincipal); + try { + String clientId = config.getClientId().isPresent() ? config.getClientId().get() : null; + for (String role : OidcUtils.findRoles(clientId, config.getRoles(), token.accessToken())) { + builder.addRole(role); } + } catch (Exception e) { + result.completeExceptionally(e); + return; } + builder.addCredential(request.getToken()); result.complete(builder.build()); } @@ -81,4 +85,5 @@ public void handle(AsyncResult event) { return result; } + } 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 new file mode 100644 index 0000000000000..458a5d2e05616 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java @@ -0,0 +1,75 @@ +package io.quarkus.oidc.runtime; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.stream.Collectors; + +import io.quarkus.oidc.OIDCException; +import io.quarkus.oidc.OidcConfig; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; + +public final class OidcUtils { + private OidcUtils() { + + } + + public static List findRoles(String clientId, OidcConfig.Roles rolesConfig, JsonObject json) throws Exception { + // If the user configured a specific path - check and enforce a claim at this path exists + if (rolesConfig.getRoleClaimPath().isPresent()) { + return findClaimWithRoles(rolesConfig, rolesConfig.getRoleClaimPath().get(), json, true); + } + + // Check 'groups' next + List groups = findClaimWithRoles(rolesConfig, "groups", json, false); + if (!groups.isEmpty()) { + return groups.stream().map(v -> v.toString()).collect(Collectors.toList()); + } else { + // Finally, check if this token has been issued by Keycloak. + // Return an empty or populated list of realm and resource access roles + List allRoles = new LinkedList<>(); + allRoles.addAll(findClaimWithRoles(rolesConfig, "realm_access/roles", json, false)); + if (clientId != null) { + allRoles.addAll(findClaimWithRoles(rolesConfig, "resource_access/" + clientId + "/roles", json, false)); + } + + return allRoles; + } + + } + + private static List findClaimWithRoles(OidcConfig.Roles rolesConfig, String claimPath, + JsonObject json, boolean mustExist) { + Object claimValue = findClaimValue(claimPath, json, claimPath.split("/"), 0, mustExist); + + if (claimValue instanceof JsonArray) { + return ((JsonArray) claimValue).stream().map(v -> v.toString()).collect(Collectors.toList()); + } else if (claimValue != null) { + String sep = rolesConfig.getRoleClaimSeparator().isPresent() ? rolesConfig.getRoleClaimSeparator().get() : " "; + return Arrays.asList(claimValue.toString().split(sep)); + } else { + return Collections.emptyList(); + } + } + + private static Object findClaimValue(String claimPath, JsonObject json, String[] pathArray, int step, boolean mustExist) { + Object claimValue = json.getValue(pathArray[step]); + if (claimValue == null) { + if (mustExist) { + throw new OIDCException( + "No claim exists at the path " + claimPath + " at the path segment " + pathArray[step]); + } + } else if (step + 1 < pathArray.length) { + if (claimValue instanceof JsonObject) { + int nextStep = step + 1; + return findClaimValue(claimPath, (JsonObject) claimValue, pathArray, nextStep, mustExist); + } else { + throw new OIDCException("Claim value at the path " + claimPath + " is not a json object"); + } + } + + return claimValue; + } +} 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 new file mode 100644 index 0000000000000..c4e5111db3aad --- /dev/null +++ b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java @@ -0,0 +1,115 @@ +package io.quarkus.oidc.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; + +import io.quarkus.oidc.OidcConfig; +import io.vertx.core.json.JsonObject; + +public class OidcUtilsTest { + + @Test + public void testKeycloakRealmAccessToken() throws Exception { + OidcConfig.Roles rolesCfg = OidcConfig.Roles.fromClaimPath(null); + List roles = OidcUtils.findRoles(null, rolesCfg, + read(getClass().getResourceAsStream("/tokenKeycloakRealmAccess.json"))); + assertEquals(2, roles.size()); + assertTrue(roles.contains("role1")); + assertTrue(roles.contains("role2")); + } + + @Test + public void testKeycloakRealmAndResourceAccessTokenClient1() throws Exception { + OidcConfig.Roles rolesCfg = OidcConfig.Roles.fromClaimPath(null); + List roles = OidcUtils.findRoles("client1", rolesCfg, + read(getClass().getResourceAsStream("/tokenKeycloakResourceAccess.json"))); + assertEquals(2, roles.size()); + assertTrue(roles.contains("realm1")); + assertTrue(roles.contains("role1")); + } + + @Test + public void testKeycloakRealmAndResourceAccessTokenClient2() throws Exception { + OidcConfig.Roles rolesCfg = OidcConfig.Roles.fromClaimPath(null); + List roles = OidcUtils.findRoles("client2", rolesCfg, + read(getClass().getResourceAsStream("/tokenKeycloakResourceAccess.json"))); + assertEquals(2, roles.size()); + assertTrue(roles.contains("realm1")); + assertTrue(roles.contains("role2")); + } + + @Test + public void testKeycloakRealmAndResourceAccessTokenNullClient() throws Exception { + OidcConfig.Roles rolesCfg = OidcConfig.Roles.fromClaimPath(null); + List roles = OidcUtils.findRoles(null, rolesCfg, + read(getClass().getResourceAsStream("/tokenKeycloakResourceAccess.json"))); + assertEquals(1, roles.size()); + assertTrue(roles.contains("realm1")); + } + + @Test + public void testTokenWithGroups() throws Exception { + OidcConfig.Roles rolesCfg = OidcConfig.Roles.fromClaimPath(null); + List roles = OidcUtils.findRoles(null, rolesCfg, read(getClass().getResourceAsStream("/tokenGroups.json"))); + assertEquals(2, roles.size()); + assertTrue(roles.contains("group1")); + assertTrue(roles.contains("group2")); + } + + @Test + public void testTokenWithCustomRoles() throws Exception { + OidcConfig.Roles rolesCfg = OidcConfig.Roles.fromClaimPath("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 testTokenWithScope() throws Exception { + OidcConfig.Roles rolesCfg = OidcConfig.Roles.fromClaimPath("scope"); + List roles = OidcUtils.findRoles(null, rolesCfg, read(getClass().getResourceAsStream("/tokenScope.json"))); + assertEquals(2, roles.size()); + assertTrue(roles.contains("s1")); + assertTrue(roles.contains("s2")); + } + + @Test + public void testTokenWithCustomScope() throws Exception { + OidcConfig.Roles rolesCfg = OidcConfig.Roles.fromClaimPathAndSeparator("customScope", ","); + List roles = OidcUtils.findRoles(null, rolesCfg, + read(getClass().getResourceAsStream("/tokenCustomScope.json"))); + assertEquals(2, roles.size()); + assertTrue(roles.contains("s1")); + assertTrue(roles.contains("s2")); + } + + @Test + public void testTokenWithCustomRolesWrongPath() throws Exception { + OidcConfig.Roles rolesCfg = OidcConfig.Roles.fromClaimPath("application-card/embedded/roles"); + InputStream is = getClass().getResourceAsStream("/tokenCustomPath.json"); + try { + OidcUtils.findRoles(null, rolesCfg, read(is)); + fail("Exception expected at the wrong path"); + } catch (Exception ex) { + // expected + } + } + + public static JsonObject read(InputStream input) throws IOException { + try (BufferedReader buffer = new BufferedReader(new InputStreamReader(input))) { + return new JsonObject(buffer.lines().collect(Collectors.joining("\n"))); + } + } + +} diff --git a/extensions/oidc/runtime/src/test/resources/tokenCustomPath.json b/extensions/oidc/runtime/src/test/resources/tokenCustomPath.json new file mode 100644 index 0000000000000..91cf65514e4a2 --- /dev/null +++ b/extensions/oidc/runtime/src/test/resources/tokenCustomPath.json @@ -0,0 +1,19 @@ +{ + "iss": "https://server.example.com", + "jti": "a-123", + "sub": "24400320", + "upn": "jdoe@example.com", + "preferred_username": "jdoe", + "aud": "s6BhdRkqt3", + "exp": 1311281970, + "iat": 1311280970, + "auth_time": 1311280969, + "application_card": { + "embedded": { + "roles": [ + "r1", + "r2" + ] + } + } +} diff --git a/extensions/oidc/runtime/src/test/resources/tokenCustomScope.json b/extensions/oidc/runtime/src/test/resources/tokenCustomScope.json new file mode 100644 index 0000000000000..e9977eb898f20 --- /dev/null +++ b/extensions/oidc/runtime/src/test/resources/tokenCustomScope.json @@ -0,0 +1,12 @@ +{ + "iss": "https://server.example.com", + "jti": "a-123", + "sub": "24400320", + "upn": "jdoe@example.com", + "preferred_username": "jdoe", + "aud": "s6BhdRkqt3", + "exp": 1311281970, + "iat": 1311280970, + "auth_time": 1311280969, + "customScope": "s1,s2" +} diff --git a/extensions/oidc/runtime/src/test/resources/tokenGroups.json b/extensions/oidc/runtime/src/test/resources/tokenGroups.json new file mode 100644 index 0000000000000..599a62dd1f55b --- /dev/null +++ b/extensions/oidc/runtime/src/test/resources/tokenGroups.json @@ -0,0 +1,15 @@ +{ + "iss": "https://server.example.com", + "jti": "a-123", + "sub": "24400320", + "upn": "jdoe@example.com", + "preferred_username": "jdoe", + "aud": "s6BhdRkqt3", + "exp": 1311281970, + "iat": 1311280970, + "auth_time": 1311280969, + "groups": [ + "group1", + "group2" + ] +} diff --git a/extensions/oidc/runtime/src/test/resources/tokenKeycloakRealmAccess.json b/extensions/oidc/runtime/src/test/resources/tokenKeycloakRealmAccess.json new file mode 100644 index 0000000000000..3311e0a917232 --- /dev/null +++ b/extensions/oidc/runtime/src/test/resources/tokenKeycloakRealmAccess.json @@ -0,0 +1,17 @@ +{ + "iss": "https://server.example.com", + "jti": "a-123", + "sub": "24400320", + "upn": "jdoe@example.com", + "preferred_username": "jdoe", + "aud": "s6BhdRkqt3", + "exp": 1311281970, + "iat": 1311280970, + "auth_time": 1311280969, + "realm_access": { + "roles": [ + "role1", + "role2" + ] + } +} diff --git a/extensions/oidc/runtime/src/test/resources/tokenKeycloakResourceAccess.json b/extensions/oidc/runtime/src/test/resources/tokenKeycloakResourceAccess.json new file mode 100644 index 0000000000000..e299a67222b78 --- /dev/null +++ b/extensions/oidc/runtime/src/test/resources/tokenKeycloakResourceAccess.json @@ -0,0 +1,28 @@ +{ + "iss": "https://server.example.com", + "jti": "a-123", + "sub": "24400320", + "upn": "jdoe@example.com", + "preferred_username": "jdoe", + "aud": "s6BhdRkqt3", + "exp": 1311281970, + "iat": 1311280970, + "auth_time": 1311280969, + "realm_access": { + "roles": [ + "realm1" + ] + }, + "resource_access": { + "client1": { + "roles": [ + "role1" + ] + }, + "client2": { + "roles": [ + "role2" + ] + } + } +} diff --git a/extensions/oidc/runtime/src/test/resources/tokenScope.json b/extensions/oidc/runtime/src/test/resources/tokenScope.json new file mode 100644 index 0000000000000..74d45f31ef44b --- /dev/null +++ b/extensions/oidc/runtime/src/test/resources/tokenScope.json @@ -0,0 +1,12 @@ +{ + "iss": "https://server.example.com", + "jti": "a-123", + "sub": "24400320", + "upn": "jdoe@example.com", + "preferred_username": "jdoe", + "aud": "s6BhdRkqt3", + "exp": 1311281970, + "iat": 1311280970, + "auth_time": 1311280969, + "scope": "s1 s2" +}