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"
+}