diff --git a/bom/runtime/pom.xml b/bom/runtime/pom.xml index f0854fb883b234..43b10509d08b3f 100644 --- a/bom/runtime/pom.xml +++ b/bom/runtime/pom.xml @@ -166,6 +166,7 @@ 4.7.2 1.0.0.Alpha2 1.2 + 7.0.1 @@ -330,6 +331,11 @@ quarkus-oidc ${project.version} + + io.quarkus + quarkus-keycloak-authorization + ${project.version} + io.quarkus quarkus-flyway @@ -2430,6 +2436,28 @@ + + + + org.keycloak + keycloak-adapter-core + ${keycloak.version} + + + org.keycloak + keycloak-core + ${keycloak.version} + + + org.keycloak + keycloak-adapter-spi + ${keycloak.version} + + + org.keycloak + keycloak-authz-client + ${keycloak.version} + diff --git a/devtools/platform-descriptor-legacy/src/main/filtered/extensions.json b/devtools/platform-descriptor-legacy/src/main/filtered/extensions.json index df8922ec7500c6..0f457f018ec72b 100644 --- a/devtools/platform-descriptor-legacy/src/main/filtered/extensions.json +++ b/devtools/platform-descriptor-legacy/src/main/filtered/extensions.json @@ -300,6 +300,21 @@ "version": "${project.version}", "guide": "https://quarkus.io/guides/oidc-guide" }, + { + "name": "Keycloak Authorization", + "labels": [ + "oauth2", + "openid-connect", + "keycloak", + "authorization-services", + "policy-enforcer", + "fine-grained-permission", + "resource-based-authorization" + ], + "groupId": "io.quarkus", + "artifactId": "quarkus-keycloak-authorization", + "guide": "https://quarkus.io/guides/keycloak-authorization-guide" + }, { "name": "Kogito", "labels": [ diff --git a/docs/src/main/asciidoc/index.adoc b/docs/src/main/asciidoc/index.adoc index a3aa3e0ddd61fa..3a218208713306 100644 --- a/docs/src/main/asciidoc/index.adoc +++ b/docs/src/main/asciidoc/index.adoc @@ -46,6 +46,7 @@ include::quarkus-intro.adoc[tag=intro] * link:performance-measure.html[Measuring Performance] _(advanced)_ * link:cdi-reference.html[Contexts and Dependency Injection] _(advanced)_ * link:oidc-guide.html[Using OpenID Connect Adapter] +* link:keycloak-authorization-guide.html[Keycloak Authorization] * link:kogito-guide.html[Using Kogito (business automation with processes and rules)] * link:oauth2-guide.html[Using OAuth2 RBAC] * link:tika-guide.html[Using Apache Tika] diff --git a/docs/src/main/asciidoc/keycloak-authorization-guide.adoc b/docs/src/main/asciidoc/keycloak-authorization-guide.adoc new file mode 100644 index 00000000000000..ae1e765c84d157 --- /dev/null +++ b/docs/src/main/asciidoc/keycloak-authorization-guide.adoc @@ -0,0 +1,268 @@ +//// +This guide is maintained in the main Quarkus repository +and pull requests should be submitted there: +https://github.com/quarkusio/quarkus/tree/master/docs/src/main/asciidoc +//// += Quarkus - Using Keycloak Authorization Services and Policy Enforcer to Protect JAX-RS Applications + +include::./attributes.adoc[] + +This guide demonstrates how your Quarkus application can authorize access to protected resources using https://www.keycloak.org/docs/latest/authorization_services/index.html[Keycloak Authorization Services]. + +The `quarkus-keycloak-authorization` extension provides a policy enforcer that enforces access to protected resources based +on permissions managed by Keycloak. It provides a flexible and dynamic authorization capability based on Resource-Based Access Control. In other words, instead of explicitly enforce access based on some specific access control mechanism (e.g.: RBAC), you just check whether or not a request is allowed to access a resource based on its name, identifier or URI. + +By externalizing authorization from your application, you are allowed to protect your applications using different access control mechanisms as well as avoid re-deploying your application every time your security requirements change, where Keycloak will be acting as a centralized authorization service from where your protected resources and their associated permissions are managed. + +If you are already familiar with Keycloak, you’ll notice that the extension is basically another adapter implementation but specific for Quarkus applications. Otherwise, you can find more information in the Keycloak https://www.keycloak.org/docs/latest/authorization_services/index.html#_enforcer_overview[documentation]. + +== Prerequisites + +To complete this guide, you need: + +* less than 15 minutes +* an IDE +* JDK 1.8+ installed with `JAVA_HOME` configured appropriately +* Apache Maven 3.5.3+ +* https://stedolan.github.io/jq/[jq tool] +* Docker + +== Architecture + +In this example, we build a very simple microservice which offers two endpoints: + +* `/api/users/me` +* `/api/admin` + +These endpoints are protected and can only be accessed if a client is sending a bearer token along with the request, which must be valid (e.g.: signature, expiration and audience) and trusted by the microservice. + +The bearer token is issued by a Keycloak Server and represents the subject to which the token was issued for. For being an OAuth 2.0 Authorization Server, the token also references the client acting on behalf of the user. + +The `/api/users/me` endpoint can be accessed by any user with a valid token. As a response, it returns a JSON document with details about the user where these details are obtained from the information carried on the token. This endpoint is protected with RBAC (Role-Based Access Control) and only users granted with the `user` role can access this endpoint. + +The `/api/admin` endpoint is protected with RBAC (Role-Based Access Control) and only users granted with the `admin` role can access it. + +This is a very simple example using RBAC policies to govern access to your resources. However, Keycloak supports other types of +policies that you can use to perform even more fine-grained access control. By using this example, you'll see that your application is completely decoupled from your authorization policies with enforcement being purely based on the accessed resource. + +== Solution + +We recommend that you follow the instructions in the next sections and create the application step by step. +However, you can go right to the completed example. + +Clone the Git repository: `git clone {quickstarts-clone-url}`, or download an {quickstarts-archive-url}[archive]. + +The solution is located in the `keycloak-authorization` {quickstarts-tree-url}/keycloak-authorization[directory]. + +== Creating the Maven Project + +First, we need a new project. Create a new project with the following command: + +[source, subs=attributes+] +---- +mvn io.quarkus:quarkus-maven-plugin:{quarkus-version}:create \ + -DprojectGroupId=org.acme \ + -DprojectArtifactId=keycloak-authorization \ + -Dextensions="oidc, resteasy-jsonb" +---- + +This command generates a Maven project, importing the `keycloak-authorization` extension +which is an implementation of a Keycloak Adapter for Quarkus applications and provides all the necessary capabilities to integrate with a Keycloak Server and perform bearer token authorization. + +== Writing the application + +Let's start by implementing the `/api/users/me` endpoint. As you can see from the source code below it is just a regular JAX-RS resource: + +[source,java] +---- +package org.acme.keycloak; + +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; + +import org.jboss.resteasy.annotations.cache.NoCache; + +import io.quarkus.security.identity.SecurityIdentity; + +@Path("/api/users") +public class UsersResource { + + @Inject + SecurityIdentity identity; + + @GET + @Path("/me") + @Produces(MediaType.APPLICATION_JSON) + @NoCache + public User me() { + return new User(identity); + } + + public class User { + + private final String userName; + + User(SecurityIdentity identity) { + this.userName = identity.getPrincipal().getName(); + } + + public String getUserName() { + return userName; + } + } +} +---- + +The source code for the `/api/admin` endpoint is also very simple: + +[source,java] +---- +package org.acme.keycloak; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; + +import io.quarkus.security.Authenticated; + +@Path("/api/admin") +@Authenticated +public class AdminResource { + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String admin() { + return "granted"; + } +} +---- + +Note that we did not define any annotation such as `@RoleAllowed` to explicitly enforce access to a resource. The extension will +be responsible to map the URIs of the protected resources you have in Keycloak and evaluate the permissions accordingly, granting or denying access depending on the permissions that will be granted by Keycloak. + +== Configuring the application + +The OpenID Connect extension allows you to define the adapter configuration using the `application.properties` file which should be located at the `src/main/resources` directory. + +=== Configuring using the application.properties file + +[source,properties] +---- +# OIDC Configuration +quarkus.oidc.auth-server-url=http://localhost:8180/auth/realms/quarkus +quarkus.oidc.client-id=backend-service +quarkus.oidc.credentials.secret=secret + +# Enable Policy Enforcement +quarkus.keycloak.policy-enforcer.enable=true +---- + +=== Configuring CORS + +If you plan to consume this application from another application running on a different domain, you will need to configure CORS (Cross-Origin Resource Sharing). Please read the link:http-reference.html#cors-filter[HTTP CORS documentation] for more details. + +== Starting and Configuring the Keycloak Server + +To start a Keycloak Server you can use Docker and just run the following command: + +[source,bash] +---- +docker run --name keycloak -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=admin -p 8180:8080 quay.io/keycloak/keycloak +---- + +You should be able to access your Keycloak Server at http://localhost:8180/auth[localhost:8180/auth]. + +Log in as the `admin` user to access the Keycloak Administration Console. Username should be `admin` and password `admin`. + +Import the {quickstarts-tree-url}/keycloak-authorization/config/quarkus-realm.json[realm configuration file] to create a new realm. For more details, see the Keycloak documentation about how to https://www.keycloak.org/docs/latest/server_admin/index.html#_create-realm[create a new realm]. + +== Running and Using the Application + +=== Running in Developer Mode + +To run the microservice in dev mode, use `./mvnw clean compile quarkus:dev`. + +=== Running in JVM Mode + +When you're done playing with "dev-mode" you can run it as a standard Java application. + +First compile it: + +[source,bash] +---- +./mvnw package +---- + +Then run it: + +[source,bash] +---- +java -jar ./target/keycloak-authorization-runner.jar +---- + +=== Running in Native Mode + +Not yet supported. + +== Testing the Application + +The application is using bearer token authorization and the first +thing to do is obtain an access token from the Keycloak Server in +order to access the application resources: + +[source,bash] +---- +export access_token=$(\ + curl -X POST http://localhost:8180/auth/realms/quarkus/protocol/openid-connect/token \ + --user backend-service:secret \ + -H 'content-type: application/x-www-form-urlencoded' \ + -d 'username=alice&password=alice&grant_type=password' | jq --raw-output '.access_token' \ + ) +---- + +The example above obtains an access token for user `alice`. + +Any user is allowed to access the +`http://localhost:8080/api/users/me` endpoint +which basically returns a JSON payload with details about the user. + +[source,bash] +---- +curl -v -X GET \ + http://localhost:8080/api/users/me \ + -H "Authorization: Bearer "$access_token +---- + +The `http://localhost:8080/api/admin` endpoint can only be accessed by users with the `admin` role. If you try to access this endpoint with the + previously issued access token, you should get a `403` response + from the server. + +[source,bash] +---- + curl -v -X GET \ + http://localhost:8080/api/admin \ + -H "Authorization: Bearer "$access_token +---- + +In order to access the admin endpoint you should obtain a token for the `admin` user: + +[source,bash] +---- +export access_token=$(\ + curl -X POST http://localhost:8180/auth/realms/quarkus/protocol/openid-connect/token \ + --user backend-service:secret \ + -H 'content-type: application/x-www-form-urlencoded' \ + -d 'username=admin&password=admin&grant_type=password' | jq --raw-output '.access_token' \ + ) +---- + +== References + +* https://www.keycloak.org/documentation.html[Keycloak Documentation] +* https://www.keycloak.org/docs/latest/authorization_services/index.html[Keycloak Authorization Services Documentation] +* https://openid.net/connect/[OpenID Connect] +* https://tools.ietf.org/html/rfc7519[JSON Web Token] diff --git a/docs/src/main/asciidoc/security-guide.adoc b/docs/src/main/asciidoc/security-guide.adoc index 3e6bdb82ff372b..57538ee537c75f 100644 --- a/docs/src/main/asciidoc/security-guide.adoc +++ b/docs/src/main/asciidoc/security-guide.adoc @@ -86,6 +86,10 @@ very much a work in progress, so this list will be expanded over the coming week |link:oidc-guide.html[quarkus-oidc] |Provides support for authenticating via an OpenID Connect provider such as Keycloak. +|link:keycloak-authorization-guide.html[quarkus-keycloak-authorization] +|Provides support for a policy enforcer using Keycloak Authorization Services. + + |=== Please see the linked documents above for details on how to setup the various extensions. diff --git a/extensions/keycloak-authorization/deployment/pom.xml b/extensions/keycloak-authorization/deployment/pom.xml new file mode 100644 index 00000000000000..bf0c5a3c22b994 --- /dev/null +++ b/extensions/keycloak-authorization/deployment/pom.xml @@ -0,0 +1,63 @@ + + + + quarkus-keycloak-authorization-parent + io.quarkus + 999-SNAPSHOT + ../ + + 4.0.0 + + quarkus-keycloak-authorization-deployment + Quarkus - Keycloak Policy Enforcer - Deployment + + + + io.quarkus + quarkus-vertx-http-deployment + + + io.quarkus + quarkus-vertx-deployment + + + io.quarkus + quarkus-keycloak-authorization + + + io.quarkus + quarkus-security-deployment + + + + io.quarkus + quarkus-junit5-internal + test + + + io.rest-assured + rest-assured + test + + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + + diff --git a/extensions/keycloak-authorization/deployment/src/main/java/io/quarkus/keycloak/pep/KeycloakPolicyEnforcerBuildStep.java b/extensions/keycloak-authorization/deployment/src/main/java/io/quarkus/keycloak/pep/KeycloakPolicyEnforcerBuildStep.java new file mode 100644 index 00000000000000..9f422f66c94d47 --- /dev/null +++ b/extensions/keycloak-authorization/deployment/src/main/java/io/quarkus/keycloak/pep/KeycloakPolicyEnforcerBuildStep.java @@ -0,0 +1,30 @@ +package io.quarkus.keycloak.pep; + +import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.arc.deployment.BeanContainerBuildItem; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.EnableAllSecurityServicesBuildItem; +import io.quarkus.oidc.OidcConfig; + +public class KeycloakPolicyEnforcerBuildStep { + + @BuildStep + public AdditionalBeanBuildItem beans() { + return AdditionalBeanBuildItem.builder().setUnremovable() + .addBeanClass(KeycloakPolicyEnforcerAuthorizer.class).build(); + } + + @BuildStep + EnableAllSecurityServicesBuildItem security() { + return new EnableAllSecurityServicesBuildItem(); + } + + @Record(ExecutionTime.RUNTIME_INIT) + @BuildStep + public void setup(OidcConfig oidcConfig, KeycloakPolicyEnforcerConfig config, KeycloakPolicyEnforcerRecorder recorder, + BeanContainerBuildItem bc) { + recorder.setup(oidcConfig, config, bc.getValue()); + } +} diff --git a/extensions/keycloak-authorization/pom.xml b/extensions/keycloak-authorization/pom.xml new file mode 100644 index 00000000000000..c3cfcdd800f366 --- /dev/null +++ b/extensions/keycloak-authorization/pom.xml @@ -0,0 +1,20 @@ + + + + quarkus-build-parent + io.quarkus + 999-SNAPSHOT + ../../build-parent/pom.xml + + 4.0.0 + + quarkus-keycloak-authorization-parent + Quarkus - Keycloak Policy Enforcer + pom + + deployment + runtime + + diff --git a/extensions/keycloak-authorization/runtime/pom.xml b/extensions/keycloak-authorization/runtime/pom.xml new file mode 100644 index 00000000000000..004abfd2de21c2 --- /dev/null +++ b/extensions/keycloak-authorization/runtime/pom.xml @@ -0,0 +1,98 @@ + + + + quarkus-keycloak-authorization-parent + io.quarkus + 999-SNAPSHOT + ../ + + 4.0.0 + + quarkus-keycloak-authorization + Quarkus - Keycloak Policy Enforcer - Runtime + + + + io.quarkus + quarkus-core + + + io.quarkus + quarkus-vertx + + + io.quarkus + quarkus-vertx-http + + + io.quarkus + quarkus-security + + + io.quarkus + quarkus-oidc + + + io.smallrye + smallrye-jwt + + + io.vertx + vertx-auth-oauth2 + + + org.keycloak + keycloak-adapter-core + + + org.keycloak + keycloak-core + + + org.keycloak + keycloak-adapter-spi + + + org.keycloak + keycloak-authz-client + + + commons-logging + commons-logging + + + + + org.jboss.logging + commons-logging-jboss-logging + + + junit + junit + test + + + + + + + io.quarkus + quarkus-bootstrap-maven-plugin + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + diff --git a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/KeycloakPolicyEnforcerAuthorizer.java b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/KeycloakPolicyEnforcerAuthorizer.java new file mode 100644 index 00000000000000..576d305070de0f --- /dev/null +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/KeycloakPolicyEnforcerAuthorizer.java @@ -0,0 +1,191 @@ +package io.quarkus.keycloak.pep; + +import java.security.Permission; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Function; +import java.util.stream.Collectors; + +import javax.inject.Singleton; + +import org.keycloak.AuthorizationContext; +import org.keycloak.adapters.KeycloakDeploymentBuilder; +import org.keycloak.adapters.authorization.KeycloakAdapterPolicyEnforcer; +import org.keycloak.adapters.authorization.PolicyEnforcer; +import org.keycloak.representations.adapters.config.AdapterConfig; +import org.keycloak.representations.adapters.config.PolicyEnforcerConfig; + +import io.quarkus.arc.AlternativePriority; +import io.quarkus.oidc.OidcConfig; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.vertx.http.runtime.security.HttpAuthorizer; +import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; +import io.vertx.ext.web.RoutingContext; + +@Singleton +@AlternativePriority(1) +public class KeycloakPolicyEnforcerAuthorizer extends HttpAuthorizer { + + private KeycloakAdapterPolicyEnforcer delegate; + + @Override + public CompletionStage checkPermission(RoutingContext routingContext) { + VertxHttpFacade httpFacade = new VertxHttpFacade(routingContext); + AuthorizationContext result = delegate.authorize(httpFacade); + + if (result.isGranted()) { + QuarkusHttpUser user = (QuarkusHttpUser) routingContext.user(); + + if (user == null) { + return attemptAnonymousAuthentication(routingContext); + } + + return enhanceSecurityIdentity(user.getSecurityIdentity(), result); + } + + return CompletableFuture.completedFuture(null); + } + + private CompletableFuture enhanceSecurityIdentity(SecurityIdentity current, + AuthorizationContext context) { + Map attributes = new HashMap<>(current.getAttributes()); + + attributes.put("permissions", context.getPermissions()); + + return CompletableFuture.completedFuture(new QuarkusSecurityIdentity.Builder() + .addAttributes(attributes) + .setPrincipal(current.getPrincipal()) + .addRoles(current.getRoles()) + .addCredentials(current.getCredentials()) + .addPermissionChecker(new Function>() { + @Override + public CompletionStage apply(Permission permission) { + if (context != null) { + String scopes = permission.getActions(); + + if (scopes == null) { + return CompletableFuture.completedFuture(context.hasResourcePermission(permission.getName())); + } + + for (String scope : scopes.split(",")) { + if (!context.hasPermission(permission.getName(), scope)) { + return CompletableFuture.completedFuture(false); + } + } + + return CompletableFuture.completedFuture(true); + } + + return CompletableFuture.completedFuture(false); + } + }).build()); + } + + public void init(OidcConfig oidcConfig, KeycloakPolicyEnforcerConfig config) { + AdapterConfig adapterConfig = new AdapterConfig(); + String authServerUrl = oidcConfig.getAuthServerUrl(); + + try { + adapterConfig.setRealm(authServerUrl.substring(authServerUrl.lastIndexOf('/') + 1)); + adapterConfig.setAuthServerUrl(authServerUrl.substring(0, authServerUrl.lastIndexOf("/realms"))); + } catch (Exception cause) { + throw new RuntimeException("Failed to parse the realm name.", cause); + } + + adapterConfig.setResource(oidcConfig.getClientId().get()); + adapterConfig.setCredentials(getCredentials(oidcConfig)); + + PolicyEnforcerConfig enforcerConfig = getPolicyEnforcerConfig(config, adapterConfig); + + if (enforcerConfig == null) { + return; + } + + adapterConfig.setPolicyEnforcerConfig(enforcerConfig); + + this.delegate = new KeycloakAdapterPolicyEnforcer( + new PolicyEnforcer(KeycloakDeploymentBuilder.build(adapterConfig), adapterConfig)); + } + + private Map getCredentials(OidcConfig oidcConfig) { + Map credentials = new HashMap<>(); + Optional clientSecret = oidcConfig.getCredentials().getSecret(); + + if (clientSecret.isPresent()) { + credentials.put("secret", clientSecret.orElse(null)); + } + + return credentials; + } + + private Map> getClaimInformationPointConfig( + KeycloakPolicyEnforcerConfig.KeycloakConfigPolicyEnforcer.ClaimInformationPointConfig config) { + Map> cipConfig = new HashMap<>(); + + for (Map.Entry> entry : config.simpleConfig.entrySet()) { + cipConfig.put(entry.getKey(), new HashMap<>(entry.getValue())); + } + + for (Map.Entry>> entry : config.complexConfig.entrySet()) { + cipConfig.computeIfAbsent(entry.getKey(), s -> new HashMap<>()).putAll(new HashMap<>(entry.getValue())); + } + + return cipConfig; + } + + private PolicyEnforcerConfig getPolicyEnforcerConfig(KeycloakPolicyEnforcerConfig config, AdapterConfig adapterConfig) { + if (config.policyEnforcer != null && config.policyEnforcer.enable) { + PolicyEnforcerConfig enforcerConfig = new PolicyEnforcerConfig(); + + enforcerConfig.setLazyLoadPaths(config.policyEnforcer.lazyLoadPaths); + enforcerConfig.setEnforcementMode( + PolicyEnforcerConfig.EnforcementMode.valueOf(config.policyEnforcer.enforcementMode)); + enforcerConfig.setHttpMethodAsScope(config.policyEnforcer.httpMethodAsScope); + enforcerConfig.setOnDenyRedirectTo(config.policyEnforcer.onDenyRedirectTo.orElse(null)); + + PolicyEnforcerConfig.PathCacheConfig pathCacheConfig = new PolicyEnforcerConfig.PathCacheConfig(); + + pathCacheConfig.setLifespan(config.policyEnforcer.pathCache.lifespan); + pathCacheConfig.setMaxEntries(config.policyEnforcer.pathCache.maxEntries); + + enforcerConfig.setPathCacheConfig(pathCacheConfig); + + if (config.policyEnforcer.userManagedAccess) { + enforcerConfig.setUserManagedAccess(new PolicyEnforcerConfig.UserManagedAccessConfig()); + } + + enforcerConfig.setClaimInformationPointConfig( + getClaimInformationPointConfig(config.policyEnforcer.claimInformationPoint)); + enforcerConfig.setPaths(config.policyEnforcer.paths.values().stream().map( + pathConfig -> { + PolicyEnforcerConfig.PathConfig config1 = new PolicyEnforcerConfig.PathConfig(); + + config1.setName(pathConfig.name.orElse(null)); + config1.setPath(pathConfig.path.orElse(null)); + config1.setEnforcementMode(pathConfig.enforcementMode); + config1.setMethods(pathConfig.methods.values().stream().map( + methodConfig -> { + PolicyEnforcerConfig.MethodConfig mConfig = new PolicyEnforcerConfig.MethodConfig(); + + mConfig.setMethod(methodConfig.method); + mConfig.setScopes(methodConfig.scopes); + mConfig.setScopesEnforcementMode(methodConfig.scopesEnforcementMode); + + return mConfig; + }).collect(Collectors.toList())); + config1.setClaimInformationPointConfig( + getClaimInformationPointConfig(pathConfig.claimInformationPoint)); + + return config1; + }).collect(Collectors.toList())); + + return enforcerConfig; + } + + return null; + } +} diff --git a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/KeycloakPolicyEnforcerConfig.java b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/KeycloakPolicyEnforcerConfig.java new file mode 100644 index 00000000000000..8a504a13215beb --- /dev/null +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/KeycloakPolicyEnforcerConfig.java @@ -0,0 +1,192 @@ +package io.quarkus.keycloak.pep; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.keycloak.representations.adapters.config.PolicyEnforcerConfig; + +import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.runtime.annotations.ConfigItem; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.quarkus.runtime.annotations.DefaultConverter; + +@ConfigRoot(name = "keycloak", phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED) +public class KeycloakPolicyEnforcerConfig { + + /** + * Adapters will make separate HTTP invocations to the Keycloak server to turn an access code into an access token. + * This config option defines how many connections to the Keycloak server should be pooled + */ + @ConfigItem(defaultValue = "20") + int connectionPoolSize; + + /** + * Policy enforcement configuration when using Keycloak Authorization Services + */ + @ConfigItem + KeycloakConfigPolicyEnforcer policyEnforcer; + + @ConfigGroup + public static class KeycloakConfigPolicyEnforcer { + + /** + * Specifies how policies are enforced. + */ + @ConfigItem + boolean enable; + + /** + * Specifies how policies are enforced. + */ + @ConfigItem(defaultValue = "ENFORCING") + String enforcementMode; + + /** + * Specifies the paths to protect. + */ + @ConfigItem + Map paths; + + /** + * Defines how the policy enforcer should track associations between paths in your application and resources defined in + * Keycloak. + * The cache is needed to avoid unnecessary requests to a Keycloak server by caching associations between paths and + * protected resources + */ + @ConfigItem + PathCacheConfig pathCache; + + /** + * Specifies how the adapter should fetch the server for resources associated with paths in your application. If true, + * the + * policy + * enforcer is going to fetch resources on-demand accordingly with the path being requested + */ + @ConfigItem(defaultValue = "true") + boolean lazyLoadPaths; + + /** + * Defines a URL where a client request is redirected when an "access denied" message is obtained from the server. + * By default, the adapter responds with a 403 HTTP status code + */ + @ConfigItem + Optional onDenyRedirectTo; + + /** + * Specifies that the adapter uses the UMA protocol. + */ + @ConfigItem + boolean userManagedAccess; + + /** + * Defines a set of one or more claims that must be resolved and pushed to the Keycloak server in order to make these + * claims available to policies + */ + @ConfigItem + ClaimInformationPointConfig claimInformationPoint; + + /** + * Specifies how scopes should be mapped to HTTP methods. If set to true, the policy enforcer will use the HTTP method + * from + * the current request to check whether or not access should be granted + */ + @ConfigItem + boolean httpMethodAsScope; + + @ConfigGroup + public static class PathConfig { + + /** + * The name of a resource on the server that is to be associated with a given path + */ + @ConfigItem + Optional name; + + /** + * A URI relative to the application’s context path that should be protected by the policy enforcer + */ + @ConfigItem + Optional path; + + /** + * The HTTP methods (for example, GET, POST, PATCH) to protect and how they are associated with the scopes for a + * given + * resource in the server + */ + @ConfigItem + Map methods; + + /** + * Specifies how policies are enforced + */ + @DefaultConverter + @ConfigItem(defaultValue = "ENFORCING") + PolicyEnforcerConfig.EnforcementMode enforcementMode; + + /** + * Defines a set of one or more claims that must be resolved and pushed to the Keycloak server in order to make + * these + * claims available to policies + */ + @ConfigItem + ClaimInformationPointConfig claimInformationPoint; + } + + @ConfigGroup + public static class MethodConfig { + + /** + * The name of the HTTP method + */ + @ConfigItem + String method; + + /** + * An array of strings with the scopes associated with the method + */ + @ConfigItem + List scopes; + + /** + * A string referencing the enforcement mode for the scopes associated with a method + */ + @DefaultConverter + @ConfigItem(defaultValue = "ALL") + PolicyEnforcerConfig.ScopeEnforcementMode scopesEnforcementMode; + } + + @ConfigGroup + public static class PathCacheConfig { + + /** + * Defines the time in milliseconds when the entry should be expired + */ + @ConfigItem(defaultValue = "1000") + int maxEntries = 1000; + + /** + * Defines the limit of entries that should be kept in the cache + */ + @ConfigItem(defaultValue = "30000") + long lifespan = 30000; + } + + @ConfigGroup + public static class ClaimInformationPointConfig { + + /** + * + */ + @ConfigItem(name = ConfigItem.PARENT) + Map>> complexConfig; + + /** + * + */ + @ConfigItem(name = ConfigItem.PARENT) + Map> simpleConfig; + } + } +} diff --git a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/KeycloakPolicyEnforcerRecorder.java b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/KeycloakPolicyEnforcerRecorder.java new file mode 100644 index 00000000000000..722460e58f77dc --- /dev/null +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/KeycloakPolicyEnforcerRecorder.java @@ -0,0 +1,13 @@ +package io.quarkus.keycloak.pep; + +import io.quarkus.arc.runtime.BeanContainer; +import io.quarkus.oidc.OidcConfig; +import io.quarkus.runtime.annotations.Recorder; + +@Recorder +public class KeycloakPolicyEnforcerRecorder { + + public void setup(OidcConfig oidcConfig, KeycloakPolicyEnforcerConfig config, BeanContainer beanContainer) { + beanContainer.instance(KeycloakPolicyEnforcerAuthorizer.class).init(oidcConfig, config); + } +} diff --git a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/VertxHttpFacade.java b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/VertxHttpFacade.java new file mode 100644 index 00000000000000..2bf551cc4f504e --- /dev/null +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/VertxHttpFacade.java @@ -0,0 +1,233 @@ +package io.quarkus.keycloak.pep; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.util.List; + +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.security.cert.X509Certificate; + +import org.keycloak.KeycloakSecurityContext; +import org.keycloak.adapters.OIDCHttpFacade; +import org.keycloak.adapters.spi.AuthenticationError; +import org.keycloak.adapters.spi.LogoutError; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.jose.jws.JWSInputException; +import org.keycloak.representations.AccessToken; + +import io.netty.handler.codec.http.HttpHeaderNames; +import io.quarkus.security.credential.TokenCredential; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.core.http.impl.CookieImpl; +import io.vertx.ext.web.RoutingContext; + +public class VertxHttpFacade implements OIDCHttpFacade { + + private final Response response; + private final RoutingContext routingContext; + private final Request request; + + public VertxHttpFacade(RoutingContext routingContext) { + this.routingContext = routingContext; + request = createRequest(routingContext); + response = createResponse(routingContext); + } + + @Override + public Request getRequest() { + return request; + } + + @Override + public Response getResponse() { + return response; + } + + @Override + public X509Certificate[] getCertificateChain() { + try { + return routingContext.request().peerCertificateChain(); + } catch (SSLPeerUnverifiedException e) { + throw new RuntimeException("Failed to fetch certificates from request", e); + } + } + + private Request createRequest(RoutingContext routingContext) { + HttpServerRequest request = routingContext.request(); + return new Request() { + @Override + public String getMethod() { + return request.rawMethod(); + } + + @Override + public String getURI() { + return request.absoluteURI(); + } + + @Override + public String getRelativePath() { + return URI.create(request.uri()).getPath(); + } + + @Override + public boolean isSecure() { + return request.isSSL(); + } + + @Override + public String getFirstParam(String param) { + return request.getParam(param); + } + + @Override + public String getQueryParamValue(String param) { + return request.getParam(param); + } + + @Override + public Cookie getCookie(String cookieName) { + io.vertx.core.http.Cookie c = request.getCookie(cookieName); + + if (c == null) { + return null; + } + + return new Cookie(c.getName(), c.getValue(), 1, c.getDomain(), c.getPath()); + } + + @Override + public String getHeader(String name) { + return request.getHeader(name); + } + + @Override + public List getHeaders(String name) { + return request.headers().getAll(name); + } + + @Override + public InputStream getInputStream() { + return getInputStream(false); + } + + @Override + public InputStream getInputStream(boolean buffered) { + return new BufferedInputStream(new ByteArrayInputStream(routingContext.getBody().getBytes())); + } + + @Override + public String getRemoteAddr() { + return request.remoteAddress().host(); + } + + @Override + public void setError(AuthenticationError error) { + // no-op + } + + @Override + public void setError(LogoutError error) { + // no-op + } + }; + } + + private Response createResponse(RoutingContext routingContext) { + HttpServerResponse response = routingContext.response(); + + return new Response() { + @Override + public void setStatus(int status) { + response.setStatusCode(status); + } + + @Override + public void addHeader(String name, String value) { + response.headers().add(name, value); + } + + @Override + public void setHeader(String name, String value) { + response.headers().set(name, value); + } + + @Override + public void resetCookie(String name, String path) { + response.removeCookie(name, true); + } + + @Override + public void setCookie(String name, String value, String path, String domain, int maxAge, boolean secure, + boolean httpOnly) { + CookieImpl cookie = new CookieImpl(name, value); + + cookie.setPath(path); + cookie.setDomain(domain); + cookie.setMaxAge(maxAge); + cookie.setSecure(secure); + cookie.setHttpOnly(httpOnly); + + response.addCookie(cookie); + } + + @Override + public OutputStream getOutputStream() { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + + response.headersEndHandler(event -> response.write(Buffer.buffer().appendBytes(os.toByteArray()))); + + return os; + } + + @Override + public void sendError(int code) { + response.setStatusCode(code); + } + + @Override + public void sendError(int code, String message) { + response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/html"); + response.setStatusCode(code); + response.setStatusMessage(message); + } + + @Override + public void end() { + response.end(); + } + }; + } + + @Override + public KeycloakSecurityContext getSecurityContext() { + QuarkusHttpUser user = (QuarkusHttpUser) routingContext.user(); + + if (user == null) { + return null; + } + + SecurityIdentity identity = user.getSecurityIdentity(); + TokenCredential credential = identity.getCredential(TokenCredential.class); + + if (credential == null) { + return null; + } + + String token = credential.getToken(); + + try { + return new KeycloakSecurityContext(token, new JWSInput(token).readJsonContent(AccessToken.class), null, null); + } catch (JWSInputException e) { + throw new RuntimeException("Failed to create access token", e); + } + } +} diff --git a/extensions/keycloak-authorization/runtime/src/main/resources/META-INF/quarkus-extension.json b/extensions/keycloak-authorization/runtime/src/main/resources/META-INF/quarkus-extension.json new file mode 100644 index 00000000000000..f4d3b75e20ba74 --- /dev/null +++ b/extensions/keycloak-authorization/runtime/src/main/resources/META-INF/quarkus-extension.json @@ -0,0 +1,14 @@ + +{ + "name": "Keycloak Authorization", + "labels": [ + "oauth2", + "openid-connect", + "keycloak", + "authorization-services", + "policy-enforcer", + "fine-grained-permission", + "resource-based-authorization" + ], + "guide": "https://quarkus.io/guides/keycloak-authorization-guide" +} \ No newline at end of file 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 f4d08c4e722f59..be5c6b71ac7c9a 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,18 @@ public class OidcConfig { @ConfigItem Credentials credentials; + public String getAuthServerUrl() { + return authServerUrl; + } + + public Optional getClientId() { + return clientId; + } + + public Credentials getCredentials() { + return credentials; + } + @ConfigGroup public static class Credentials { @@ -58,6 +70,9 @@ public static class Credentials { @ConfigItem Optional secret; + public Optional getSecret() { + return secret; + } } } diff --git a/extensions/pom.xml b/extensions/pom.xml index 9b9039bf4b249c..514afdc0f8045b 100644 --- a/extensions/pom.xml +++ b/extensions/pom.xml @@ -95,6 +95,7 @@ elytron-security-oauth2 smallrye-jwt oidc + keycloak-authorization infinispan-client diff --git a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusSecurityIdentity.java b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusSecurityIdentity.java index 5454c1c2e807cb..84242de205275f 100644 --- a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusSecurityIdentity.java +++ b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/QuarkusSecurityIdentity.java @@ -65,7 +65,7 @@ public Set getCredentials() { @Override public T getAttribute(String name) { - return null; + return (T) attributes.get(name); } @Override diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthorizer.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthorizer.java index 5b5a5460fb339e..8e4c369ddf8537 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthorizer.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthorizer.java @@ -7,6 +7,8 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.function.BiFunction; import java.util.function.Supplier; @@ -27,63 +29,80 @@ @Singleton public class HttpAuthorizer { + private final PathMatcher> pathMatcher = new PathMatcher<>(); @Inject HttpAuthenticator httpAuthenticator; - @Inject IdentityProviderManager identityProviderManager; - private final PathMatcher> pathMatcher = new PathMatcher<>(); - - public void checkPermission(RoutingContext routingContext) { + public CompletionStage checkPermission(RoutingContext routingContext) { QuarkusHttpUser user = (QuarkusHttpUser) routingContext.user(); - if (user != null) { - //we have a user, check their permissions - doPermissionCheck(routingContext, user.getSecurityIdentity()); - } else { - //otherwise check the anonymous identity - identityProviderManager.authenticate(AnonymousAuthenticationRequest.INSTANCE) - .handle(new BiFunction() { - @Override - public Object apply(SecurityIdentity identity, Throwable throwable) { - if (throwable != null) { - routingContext.fail(throwable); - } else { - doPermissionCheck(routingContext, identity); - } - return null; - } - }); + if (user == null) { + //check the anonymous identity + return attemptAnonymousAuthentication(routingContext); } + //we have a user, check their permissions + return doPermissionCheck(routingContext, user.getSecurityIdentity()); } - private void doPermissionCheck(RoutingContext routingContext, SecurityIdentity securityIdentity) { + protected CompletableFuture attemptAnonymousAuthentication(RoutingContext routingContext) { + CompletableFuture latch = new CompletableFuture<>(); + identityProviderManager.authenticate(AnonymousAuthenticationRequest.INSTANCE) + .handle(new BiFunction() { + @Override + public Object apply(SecurityIdentity identity, Throwable throwable) { + if (throwable != null) { + latch.completeExceptionally(throwable); + } else { + doPermissionCheck(routingContext, identity).handle( + new BiFunction() { + @Override + public SecurityIdentity apply(SecurityIdentity identity, + Throwable throwable) { + if (throwable != null) { + latch.completeExceptionally(throwable); + return null; + } + latch.complete(identity); + return identity; + } + }); + } + return null; + } + }); + return latch; + } + private CompletionStage doPermissionCheck(RoutingContext routingContext, + SecurityIdentity identity) { + CompletableFuture latch = new CompletableFuture<>(); List permissionCheckers = findPermissionCheckers(routingContext.request()); - doPermissionCheck(routingContext, securityIdentity, 0, permissionCheckers); + doPermissionCheck(routingContext, latch, identity, 0, permissionCheckers); + return latch; } - private void doPermissionCheck(RoutingContext routingContext, SecurityIdentity securityIdentity, int index, + private void doPermissionCheck(RoutingContext routingContext, CompletableFuture latch, + SecurityIdentity identity, int index, List permissionCheckers) { if (index == permissionCheckers.size()) { - //we passed, nothing rejected it - routingContext.next(); + latch.complete(identity); return; } //get the current checker HttpSecurityPolicy res = permissionCheckers.get(index); - res.checkPermission(routingContext.request(), securityIdentity) + res.checkPermission(routingContext.request(), identity) .handle(new BiFunction() { @Override public Object apply(HttpSecurityPolicy.CheckResult checkResult, Throwable throwable) { if (throwable != null) { - routingContext.fail(throwable); + latch.completeExceptionally(throwable); } else { if (checkResult == HttpSecurityPolicy.CheckResult.DENY) { - doDeny(securityIdentity, routingContext); + doDeny(identity, routingContext, latch); } else { //attempt to run the next checker - doPermissionCheck(routingContext, securityIdentity, index + 1, permissionCheckers); + doPermissionCheck(routingContext, latch, identity, index + 1, permissionCheckers); } } return null; @@ -91,9 +110,10 @@ public Object apply(HttpSecurityPolicy.CheckResult checkResult, Throwable throwa }); } - private void doDeny(SecurityIdentity securityIdentity, RoutingContext routingContext) { + private void doDeny(SecurityIdentity identity, RoutingContext routingContext, + CompletableFuture latch) { //if we were denied we send a challenge if we are not authenticated, otherwise we send a 403 - if (securityIdentity.isAnonymous()) { + if (identity.isAnonymous()) { httpAuthenticator.sendChallenge(routingContext, new Runnable() { @Override public void run() { @@ -103,6 +123,7 @@ public void run() { } else { routingContext.fail(403); } + latch.complete(null); } void init(HttpBuildTimeConfig config, Map> supplierMap) { @@ -172,4 +193,4 @@ static class HttpMatcher { this.checker = checker; } } -} \ No newline at end of file +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java index 0637ec29de32ec..f77bb322f75f79 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java @@ -71,7 +71,22 @@ public void handle(RoutingContext event) { if (authorizer == null) { authorizer = CDI.current().select(HttpAuthorizer.class).get(); } - authorizer.checkPermission(event); + authorizer.checkPermission(event).handle(new BiFunction() { + @Override + public SecurityIdentity apply(SecurityIdentity identity, Throwable throwable) { + if (throwable != null) { + event.fail(throwable); + return null; + } + if (identity != null) { + event.setUser(new QuarkusHttpUser(identity)); + event.next(); + return identity; + } + event.response().end(); + return null; + } + }); } }; } diff --git a/integration-tests/keycloak-authorization/README.md b/integration-tests/keycloak-authorization/README.md new file mode 100644 index 00000000000000..0f4b1759b7b3ce --- /dev/null +++ b/integration-tests/keycloak-authorization/README.md @@ -0,0 +1,27 @@ +# JAX-RS example using Keycloak Policy Enforcer to Protect Resources + +## Running the tests + +By default, the tests of this module are disabled. + +To run the tests in a standard JVM with Keycloak Server started as a Docker container, you can run the following command: + +``` +mvn clean install -Dtest-keycloak -Ddocker +``` + +Additionally, you can generate a native image and run the tests for this native image by adding `-Dnative`: + +``` +mvn clean install -Dtest-keycloak -Ddocker -Dnative +``` + +If you don't want to run Keycloak Server as a Docker container, you can start your own Keycloak server. It needs to listen on the default port `8180`. + +You can then run the tests as follows (either with `-Dnative` or not): + +``` +mvn clean install -Dtest-keycloak +``` + +If you have specific requirements, you can define a specific connection URL with `-Dkeycloak.url=http://keycloak.server.domain:8180/auth`. diff --git a/integration-tests/keycloak-authorization/pom.xml b/integration-tests/keycloak-authorization/pom.xml new file mode 100644 index 00000000000000..c3a00069af4374 --- /dev/null +++ b/integration-tests/keycloak-authorization/pom.xml @@ -0,0 +1,251 @@ + + + + quarkus-integration-tests-parent + io.quarkus + 999-SNAPSHOT + ../ + + 4.0.0 + + quarkus-integration-test-keycloak-authorization + Quarkus - Integration Tests - Keycloak Policy Enforcer + Module that contains Keycloak Policy Enforcer related tests + + + http://localhost:8180/auth + + + + + io.quarkus + quarkus-keycloak-authorization + + + io.quarkus + quarkus-resteasy-jsonb + + + io.quarkus + quarkus-oidc + + + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + + + + + src/main/resources + true + + + + + maven-surefire-plugin + + true + + + + maven-failsafe-plugin + + true + + + + io.quarkus + quarkus-maven-plugin + ${project.version} + + + + build + + + + + + + + + + test-keycloak + + + test-keycloak + + + + + + maven-surefire-plugin + + false + + ${keycloak.url} + + + + + maven-failsafe-plugin + + false + + ${keycloak.url} + + + + + io.quarkus + quarkus-maven-plugin + + + + build + + + + + + + + + + native-image + + + native + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + ${project.build.directory}/${project.build.finalName}-runner + + + + + + + io.quarkus + quarkus-maven-plugin + + + native-image + + native-image + + + false + true + true + false + false + + ${graalvmHome} + false + + + + + + + + + + docker-keycloak + + + docker + + + + http://localhost:8180/auth + + + + + io.fabric8 + docker-maven-plugin + 0.28.0 + + + + quay.io/keycloak/keycloak + quarkus-test-keycloak + + + 8180:8080 + + + admin + admin + + + Keycloak: + default + cyan + + + + + http://localhost:8180 + + + + + + + true + + + + docker-start + compile + + stop + start + + + + docker-stop + post-integration-test + + stop + + + + + + + + + + + diff --git a/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java b/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java new file mode 100644 index 00000000000000..e188d5f7ae4699 --- /dev/null +++ b/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java @@ -0,0 +1,45 @@ +package io.quarkus.it.keycloak; + +import java.util.List; + +import javax.inject.Inject; +import javax.security.auth.AuthPermission; +import javax.ws.rs.ForbiddenException; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; + +import org.keycloak.representations.idm.authorization.Permission; + +import io.quarkus.security.identity.SecurityIdentity; + +@Path("/api/permission") +public class ProtectedResource { + + @Inject + SecurityIdentity identity; + + @GET + @Produces(MediaType.APPLICATION_JSON) + public List permissions() { + if (identity.checkPermissionBlocking(new AuthPermission("Permission Resource"))) { + return identity.getAttribute("permissions"); + } + throw new ForbiddenException(); + } + + @Path("/claim-protected") + @GET + @Produces(MediaType.APPLICATION_JSON) + public List claimProtected() { + return identity.getAttribute("permissions"); + } + + @Path("/http-response-claim-protected") + @GET + @Produces(MediaType.APPLICATION_JSON) + public List httpResponseClaimProtected() { + return identity.getAttribute("permissions"); + } +} diff --git a/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/PublicResource.java b/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/PublicResource.java new file mode 100644 index 00000000000000..a692256020eb27 --- /dev/null +++ b/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/PublicResource.java @@ -0,0 +1,13 @@ +package io.quarkus.it.keycloak; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +@Path("/api/public") +public class PublicResource { + + @GET + public void serve() { + // no-op + } +} diff --git a/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/UsersResource.java b/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/UsersResource.java new file mode 100644 index 00000000000000..3b45ebe8c8275a --- /dev/null +++ b/integration-tests/keycloak-authorization/src/main/java/io/quarkus/it/keycloak/UsersResource.java @@ -0,0 +1,36 @@ +package io.quarkus.it.keycloak; + +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; + +import io.quarkus.security.identity.SecurityIdentity; + +@Path("/api/users") +public class UsersResource { + + @Inject + SecurityIdentity keycloakSecurityContext; + + @GET + @Path("/me") + @Produces(MediaType.APPLICATION_JSON) + public User me() { + return new User(keycloakSecurityContext); + } + + public class User { + + private final String userName; + + User(SecurityIdentity securityContext) { + this.userName = securityContext.getPrincipal().getName(); + } + + public String getUserName() { + return userName; + } + } +} diff --git a/integration-tests/keycloak-authorization/src/main/resources/application.properties b/integration-tests/keycloak-authorization/src/main/resources/application.properties new file mode 100644 index 00000000000000..1aef8e6e968db1 --- /dev/null +++ b/integration-tests/keycloak-authorization/src/main/resources/application.properties @@ -0,0 +1,34 @@ +# Configuration file +quarkus.oidc.auth-server-url=${keycloak.url}/realms/quarkus +quarkus.oidc.client-id=quarkus-app +quarkus.oidc.credentials.secret=secret +quarkus.http.cors=true + +# Enable Policy Enforcement +quarkus.keycloak.policy-enforcer.enable=true +quarkus.keycloak.policy-enforcer.enforcement-mode=PERMISSIVE + +# Defines a global claim to be sent to Keycloak when evaluating permissions for any requesting coming to the application +quarkus.keycloak.policy-enforcer.claim-information-point.claims.request-uri={request.relativePath} +quarkus.keycloak.policy-enforcer.claim-information-point.claims.request-method={request.method} + +# Defines a static claim that is only sent to Keycloak when evaluating permissions for a specific path +quarkus.keycloak.policy-enforcer.paths.1.name=Permission Resource +quarkus.keycloak.policy-enforcer.paths.1.path=/api/permission +quarkus.keycloak.policy-enforcer.paths.1.claim-information-point.claims.static-claim=static-claim + +# Defines a claim which value references a request parameter +quarkus.keycloak.policy-enforcer.paths.2.path=/api/permission/claim-protected +quarkus.keycloak.policy-enforcer.paths.2.claim-information-point.claims.grant={request.parameter['grant']} + +# Defines a claim which value is based on the response from an external service +quarkus.keycloak.policy-enforcer.paths.3.path=/api/permission/http-response-claim-protected +quarkus.keycloak.policy-enforcer.paths.3.claim-information-point.http.claims.user-name=/userName +quarkus.keycloak.policy-enforcer.paths.3.claim-information-point.http.url=http://localhost:8081/api/users/me +quarkus.keycloak.policy-enforcer.paths.3.claim-information-point.http.method=GET +quarkus.keycloak.policy-enforcer.paths.3.claim-information-point.http.headers.Content-Type=application/x-www-form-urlencoded +quarkus.keycloak.policy-enforcer.paths.3.claim-information-point.http.headers.Authorization=Bearer {keycloak.access_token} + +# Disables policy enforcement for a path +quarkus.keycloak.policy-enforcer.paths.4.path=/api/public +quarkus.keycloak.policy-enforcer.paths.4.enforcement-mode=DISABLED diff --git a/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/KeycloakTestResource.java b/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/KeycloakTestResource.java new file mode 100644 index 00000000000000..12efe03006421d --- /dev/null +++ b/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/KeycloakTestResource.java @@ -0,0 +1,24 @@ +package io.quarkus.it.keycloak; + +import java.util.HashMap; +import java.util.Map; + +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; + +public class KeycloakTestResource implements QuarkusTestResourceLifecycleManager { + @Override + public Map start() { + HashMap map = new HashMap<>(); + + // a workaround to set system properties defined when executing tests. Looks like this commit introduced an + // unexpected behavior: 3ca0b323dd1c6d80edb66136eb42be7f9bde3310 + map.put("keycloak.url", System.getProperty("keycloak.url")); + + return map; + } + + @Override + public void stop() { + + } +} diff --git a/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/PolicyEnforcerTest.java b/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/PolicyEnforcerTest.java new file mode 100644 index 00000000000000..0b87ae3d1a65fe --- /dev/null +++ b/integration-tests/keycloak-authorization/src/test/java/io/quarkus/it/keycloak/PolicyEnforcerTest.java @@ -0,0 +1,283 @@ +package io.quarkus.it.keycloak; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.RoleRepresentation; +import org.keycloak.representations.idm.RolesRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.idm.authorization.PolicyRepresentation; +import org.keycloak.representations.idm.authorization.ResourceRepresentation; +import org.keycloak.representations.idm.authorization.ResourceServerRepresentation; +import org.keycloak.util.JsonSerialization; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; + +/** + * @author Pedro Igor + */ +@QuarkusTest +public class PolicyEnforcerTest { + + private static final String KEYCLOAK_SERVER_URL = System.getProperty("keycloak.url", "http://localhost:8180/auth"); + private static final String KEYCLOAK_REALM = "quarkus"; + + @BeforeAll + public static void configureKeycloakRealm() throws IOException { + RealmRepresentation realm = createRealm(KEYCLOAK_REALM); + + realm.getClients().add(createClient("quarkus-app")); + realm.getUsers().add(createUser("alice", "user")); + realm.getUsers().add(createUser("admin", "user", "admin")); + realm.getUsers().add(createUser("jdoe", "user", "confidential")); + + RestAssured + .given() + .auth().oauth2(getAdminAccessToken()) + .contentType("application/json") + .body(JsonSerialization.writeValueAsBytes(realm)) + .when() + .post(KEYCLOAK_SERVER_URL + "/admin/realms").then() + .statusCode(201); + } + + @AfterAll + public static void removeKeycloakRealm() { + RestAssured + .given() + .auth().oauth2(getAdminAccessToken()) + .when() + .delete(KEYCLOAK_SERVER_URL + "/admin/realms/" + KEYCLOAK_REALM).then().statusCode(204); + } + + private static String getAdminAccessToken() { + return RestAssured + .given() + .param("grant_type", "password") + .param("username", "admin") + .param("password", "admin") + .param("client_id", "admin-cli") + .when() + .post(KEYCLOAK_SERVER_URL + "/realms/master/protocol/openid-connect/token") + .as(AccessTokenResponse.class).getToken(); + } + + private static RealmRepresentation createRealm(String name) { + RealmRepresentation realm = new RealmRepresentation(); + + realm.setRealm(name); + realm.setEnabled(true); + realm.setUsers(new ArrayList<>()); + realm.setClients(new ArrayList<>()); + + RolesRepresentation roles = new RolesRepresentation(); + List realmRoles = new ArrayList<>(); + + roles.setRealm(realmRoles); + realm.setRoles(roles); + + realm.getRoles().getRealm().add(new RoleRepresentation("user", null, false)); + realm.getRoles().getRealm().add(new RoleRepresentation("admin", null, false)); + realm.getRoles().getRealm().add(new RoleRepresentation("confidential", null, false)); + + return realm; + } + + private static ClientRepresentation createClient(String clientId) { + ClientRepresentation client = new ClientRepresentation(); + + client.setClientId(clientId); + client.setPublicClient(false); + client.setSecret("secret"); + client.setDirectAccessGrantsEnabled(true); + client.setEnabled(true); + + client.setAuthorizationServicesEnabled(true); + + ResourceServerRepresentation authorizationSettings = new ResourceServerRepresentation(); + + authorizationSettings.setResources(new ArrayList<>()); + authorizationSettings.setPolicies(new ArrayList<>()); + + configurePermissionResourcePermission(authorizationSettings); + configureClaimBasedPermission(authorizationSettings); + configureHttpResponseClaimBasedPermission(authorizationSettings); + + client.setAuthorizationSettings(authorizationSettings); + + return client; + } + + private static void configurePermissionResourcePermission(ResourceServerRepresentation settings) { + PolicyRepresentation policy = createJSPolicy("Confidential Policy", "var identity = $evaluation.context.identity;\n" + + "\n" + + "if (identity.hasRealmRole(\"confidential\")) {\n" + + "$evaluation.grant();\n" + + "}", settings); + createPermission(settings, createResource(settings, "Permission Resource", "/api/permission"), policy); + } + + private static void configureClaimBasedPermission(ResourceServerRepresentation settings) { + PolicyRepresentation policy = createJSPolicy("Claim-Based Policy", "var context = $evaluation.getContext();\n" + + "var attributes = context.getAttributes();\n" + + "\n" + + "if (attributes.containsValue('grant', 'true')) {\n" + + " $evaluation.grant();\n" + + "}", settings); + createPermission(settings, createResource(settings, "Claim Protected Resource", "/api/permission/claim-protected"), + policy); + } + + private static void configureHttpResponseClaimBasedPermission(ResourceServerRepresentation settings) { + PolicyRepresentation policy = createJSPolicy("Http Response Claim-Based Policy", + "var context = $evaluation.getContext();\n" + + "var attributes = context.getAttributes();\n" + + "\n" + + "if (attributes.containsValue('user-name', 'alice')) {\n" + + " $evaluation.grant();\n" + + "}", + settings); + createPermission(settings, createResource(settings, "Http Response Claim Protected Resource", + "/api/permission/http-response-claim-protected"), policy); + } + + private static void createPermission(ResourceServerRepresentation settings, ResourceRepresentation resource, + PolicyRepresentation policy) { + PolicyRepresentation permission = new PolicyRepresentation(); + + permission.setName(resource.getName() + " Permission"); + permission.setType("resource"); + permission.setResources(new HashSet<>()); + permission.getResources().add(resource.getName()); + permission.setPolicies(new HashSet<>()); + permission.getPolicies().add(policy.getName()); + + settings.getPolicies().add(permission); + } + + private static ResourceRepresentation createResource(ResourceServerRepresentation authorizationSettings, String name, + String uri) { + ResourceRepresentation resource = new ResourceRepresentation(name); + + resource.setUris(Collections.singleton(uri)); + + authorizationSettings.getResources().add(resource); + return resource; + } + + private static PolicyRepresentation createJSPolicy(String name, String code, ResourceServerRepresentation settings) { + PolicyRepresentation policy = new PolicyRepresentation(); + + policy.setName(name); + policy.setType("js"); + policy.setConfig(new HashMap<>()); + policy.getConfig().put("code", code); + + settings.getPolicies().add(policy); + + return policy; + } + + private static UserRepresentation createUser(String username, String... realmRoles) { + UserRepresentation user = new UserRepresentation(); + + user.setUsername(username); + user.setEnabled(true); + user.setCredentials(new ArrayList<>()); + user.setRealmRoles(Arrays.asList(realmRoles)); + + CredentialRepresentation credential = new CredentialRepresentation(); + + credential.setType(CredentialRepresentation.PASSWORD); + credential.setValue(username); + credential.setTemporary(false); + + user.getCredentials().add(credential); + + return user; + } + + @Test + public void testUserHasRoleConfidential() { + RestAssured.given().auth().oauth2(getAccessToken("alice")) + .when().get("/api/permission") + .then() + .statusCode(403); + RestAssured.given().auth().oauth2(getAccessToken("jdoe")) + .when().get("/api/permission") + .then() + .statusCode(200) + .and().body(Matchers.containsString("Permission Resource")); + ; + RestAssured.given().auth().oauth2(getAccessToken("admin")) + .when().get("/api/permission") + .then() + .statusCode(403); + } + + @Test + public void testRequestParameterAsClaim() { + RestAssured.given().auth().oauth2(getAccessToken("alice")) + .when().get("/api/permission/claim-protected?grant=true") + .then() + .statusCode(200) + .and().body(Matchers.containsString("Claim Protected Resource")); + ; + RestAssured.given().auth().oauth2(getAccessToken("alice")) + .when().get("/api/permission/claim-protected?grant=false") + .then() + .statusCode(403); + RestAssured.given().auth().oauth2(getAccessToken("alice")) + .when().get("/api/permission/claim-protected") + .then() + .statusCode(403); + } + + @Test + public void testHttpResponseFromExternalServiceAsClaim() { + RestAssured.given().auth().oauth2(getAccessToken("alice")) + .when().get("/api/permission/http-response-claim-protected") + .then() + .statusCode(200) + .and().body(Matchers.containsString("Http Response Claim Protected Resource")); + RestAssured.given().auth().oauth2(getAccessToken("jdoe")) + .when().get("/api/permission/http-response-claim-protected") + .then() + .statusCode(403); + } + + @Test + public void testPublicResource() { + RestAssured.given() + .when().get("/api/public") + .then() + .statusCode(204); + } + + private String getAccessToken(String userName) { + return RestAssured + .given() + .param("grant_type", "password") + .param("username", userName) + .param("password", userName) + .param("client_id", "quarkus-app") + .param("client_secret", "secret") + .when() + .post(KEYCLOAK_SERVER_URL + "/realms/" + KEYCLOAK_REALM + "/protocol/openid-connect/token") + .as(AccessTokenResponse.class).getToken(); + } +} diff --git a/integration-tests/oidc/pom.xml b/integration-tests/oidc/pom.xml index f84aae8a48c74f..e6ecf7ad3fbf1c 100644 --- a/integration-tests/oidc/pom.xml +++ b/integration-tests/oidc/pom.xml @@ -28,9 +28,12 @@ quarkus-resteasy-jackson - io.quarkus.keycloak - quarkus-keycloak-adapter - 1.0.0.Alpha1 + org.keycloak + keycloak-adapter-core + + + org.keycloak + keycloak-core diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 177655000ae4c3..a06e7a11b57f74 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -53,6 +53,7 @@ elytron-undertow flyway oidc + keycloak-authorization reactive-pg-client reactive-mysql-client amazon-dynamodb