diff --git a/bom/deployment/pom.xml b/bom/deployment/pom.xml index 79e489c3d7305..c7d7a6a02f1a0 100644 --- a/bom/deployment/pom.xml +++ b/bom/deployment/pom.xml @@ -286,6 +286,11 @@ quarkus-elytron-security-deployment ${project.version} + + io.quarkus + quarkus-elytron-security-oauth2-deployment + ${project.version} + io.quarkus quarkus-infinispan-client-deployment diff --git a/bom/runtime/pom.xml b/bom/runtime/pom.xml index 21d8b811a1f54..5da217fa83599 100644 --- a/bom/runtime/pom.xml +++ b/bom/runtime/pom.xml @@ -156,6 +156,7 @@ 4.3.1 0.19.1 2.2.0 + 2.23.2 @@ -265,6 +266,11 @@ quarkus-elytron-security ${project.version} + + io.quarkus + quarkus-elytron-security-oauth2 + ${project.version} + io.quarkus quarkus-flyway @@ -643,6 +649,11 @@ caffeine ${caffeine.version} + + com.github.tomakehurst + wiremock-jre8 + ${wiremock.version} + com.google.guava guava @@ -1935,6 +1946,28 @@ jetty-io ${jetty.version} + + org.eclipse.jetty + jetty-server + ${jetty.version} + + + org.eclipse.jetty + jetty-servlet + ${jetty.version} + + + org.eclipse.jetty + jetty-servlets + ${jetty.version} + + + org.eclipse.jetty + jetty-webapp + ${jetty.version} + + + org.webjars diff --git a/core/deployment/src/main/java/io/quarkus/deployment/builditem/FeatureBuildItem.java b/core/deployment/src/main/java/io/quarkus/deployment/builditem/FeatureBuildItem.java index c84b7e3b5a091..17a032e354fb7 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/builditem/FeatureBuildItem.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/builditem/FeatureBuildItem.java @@ -39,6 +39,7 @@ public final class FeatureBuildItem extends MultiBuildItem { public static final String REST_CLIENT = "rest-client"; public static final String SCHEDULER = "scheduler"; public static final String SECURITY = "security"; + public static final String SECURITY_OAUTH2 = "security-oauth2"; public static final String SMALLRYE_CONTEXT_PROPAGATION = "smallrye-context-propagation"; public static final String SMALLRYE_FAULT_TOLERANCE = "smallrye-fault-tolerance"; public static final String SMALLRYE_HEALTH = "smallrye-health"; diff --git a/devtools/common/src/main/filtered/extensions.json b/devtools/common/src/main/filtered/extensions.json index 1b855ce89c906..87177df40daa2 100644 --- a/devtools/common/src/main/filtered/extensions.json +++ b/devtools/common/src/main/filtered/extensions.json @@ -347,6 +347,15 @@ "artifactId": "quarkus-elytron-security", "guide": "https://quarkus.io/guides/security-guide" }, + { + "name": "Security OAuth2", + "labels": [ + "security", + "oauth2" + ], + "groupId": "io.quarkus", + "artifactId": "quarkus-elytron-security-oauth2" + }, { "name": "SmallRye Context Propagation", "shortName": "context propagation", diff --git a/docs/src/main/asciidoc/oauth2-guide.adoc b/docs/src/main/asciidoc/oauth2-guide.adoc new file mode 100644 index 0000000000000..50631f87d1412 --- /dev/null +++ b/docs/src/main/asciidoc/oauth2-guide.adoc @@ -0,0 +1,359 @@ +//// +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 OAuth2 RBAC + +include::./attributes.adoc[] +:extension-name: Elytron Security OAuth2 + +This guide explains how your Quarkus application can utilize OAuth2 tokens to provide secured access to the JAX-RS endpoints. + +OAuth2 is an authorization framework that enables applications to obtain access to an HTTP resource on behalf of a user. +It can be used to implement an application authentication mechanism based on tokens by delegating to an external server (the authentication server) the user authentification and providing a token for the authentication context. + +If your OAuth2 Authentication server provides JWT tokens, you should use link:jwt-guide.html[MicroProfile JWT RBAC] instead, this extension aims to be used with opaque tokens and validate the token by calling an introspection endpoint. + +== Configuration + +[cols=" + + + io.quarkus + quarkus-elytron-security-oauth2 + + +-- + + +=== Examine the JAX-RS resource + +Open the `src/main/java/org/acme/oauth2/TokenSecuredResource.java` file and see the following content: + +.Basic REST Endpoint +[source,java] +---- +package org.acme.oauth2; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; + +@Path("/secured") +public class TokenSecuredResource { + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String hello() { + return "hello"; + } +} +---- + +This is a basic REST endpoint that does not have any of the {extension-name} specific features, so let's add some. + +We will use the JSR 250 common security annotations, they are described in the link:security-guide.html[Using Security] guide. + +[source,java] +---- +package org.acme.oauth2; + +import java.security.Principal; + +import javax.annotation.security.PermitAll; +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.SecurityContext; + +@Path("/secured") +@ApplicationScoped +public class TokenSecuredResource { + + + @GET() + @Path("permit-all") + @PermitAll // <1> + @Produces(MediaType.TEXT_PLAIN) + public String hello(@Context SecurityContext ctx) { // <2> + Principal caller = ctx.getUserPrincipal(); <3> + String name = caller == null ? "anonymous" : caller.getName(); + String helloReply = String.format("hello + %s, isSecure: %s, authScheme: %s", name, ctx.isSecure(), ctx.getAuthenticationScheme()); + return helloReply; // <4> + } +} +---- +<1> `@PermitAll` indicates that the given endpoint is accessible by any caller, authenticated or not. +<2> Here we inject the JAX-RS `SecurityContext` to inspect the security state of the call. +<3> Here we obtain the current request user/caller `Principal`. For an unsecured call this will be null, so we build the user name by checking `caller` against null. +<4> The reply we build up makes use of the caller name, the `isSecure()` and `getAuthenticationScheme()` states of the request `SecurityContext`. + +== Run the application + +Now we are ready to run our application. Use: + +[source,shell] +---- +./mvnw compile quarkus:dev +---- + +and you should see output similar to: + +.quarkus:dev Output +[source,shell] +---- +$ mvn clean compile quarkus:dev +[INFO] Scanning for projects... +[INFO] +[INFO] ---------------------< org.acme:using-oauth2-rbac >--------------------- +[INFO] Building using-oauth2-rbac 1.0-SNAPSHOT +[INFO] --------------------------------[ jar ]--------------------------------- +... +[INFO] --- quarkus-maven-plugin:999-SNAPSHOT:dev (default-cli) @ using-oauth2-rbac --- +Listening for transport dt_socket at address: 5005 +2019-07-16 09:58:09,753 INFO [io.qua.dep.QuarkusAugmentor] (main) Beginning quarkus augmentation +2019-07-16 09:58:10,884 INFO [io.qua.dep.QuarkusAugmentor] (main) Quarkus augmentation completed in 1131ms +2019-07-16 09:58:11,385 INFO [io.quarkus] (main) Quarkus 0.20.0 started in 1.813s. Listening on: http://[::]:8080 +2019-07-16 09:58:11,391 INFO [io.quarkus] (main) Installed features: [cdi, resteasy, resteasy-jsonb, security, security-oauth2] + +---- + +Now that the REST endpoint is running, we can access it using a command line tool like curl: + +.curl command for /secured/permit-all +[source,shell] +---- +$ curl http://127.0.0.1:8080/secured/permit-all; echo +hello + anonymous, isSecure: false, authScheme: null +---- + +We have not provided any token in our request, so we would not expect that there is any security state seen by the endpoint, and the response is consistent with that: + +* user name is anonymous +* `isSecure` is false as https is not used +* `authScheme` is null + +So now let's actually secure something. Take a look at the new endpoint method `helloRolesAllowed` in the following: + +[source,java] +---- +package org.acme.oauth2; + +import java.security.Principal; + +import javax.annotation.security.PermitAll; +import javax.annotation.security.RolesAllowed; +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.SecurityContext; + +@Path("/secured") +@ApplicationScoped +public class TokenSecuredResource { + + @GET() + @Path("permit-all") + @PermitAll + @Produces(MediaType.TEXT_PLAIN) + public String hello(@Context SecurityContext ctx) { + Principal caller = ctx.getUserPrincipal(); + String name = caller == null ? "anonymous" : caller.getName(); + String helloReply = String.format("hello + %s, isSecure: %s, authScheme: %s", name, ctx.isSecure(), ctx.getAuthenticationScheme()); + return helloReply; + } + + @GET() + @Path("roles-allowed") // <1> + @RolesAllowed({"Echoer", "Subscriber"}) // <2> + @Produces(MediaType.TEXT_PLAIN) + public String helloRolesAllowed(@Context SecurityContext ctx) { + Principal caller = ctx.getUserPrincipal(); + String name = caller == null ? "anonymous" : caller.getName(); + String helloReply = String.format("hello + %s, isSecure: %s, authScheme: %s", name, ctx.isSecure(), ctx.getAuthenticationScheme()); + return helloReply; + } +} +---- +<1> This new endpoint will be located at `/secured/roles-allowed` +<2> `@RolesAllowed` indicates that the given endpoint is accessible by a caller if they have either a "Echoer" or "Subscriber" role assigned. + +After you make this addition to your `TokenSecuredResource`, try `curl -v http://127.0.0.1:8080/secured/roles-allowed; echo` to attempt to access the new endpoint. Your output should be: + +.curl command for /secured/roles-allowed +[source,shell] +---- +$ curl -v http://127.0.0.1:8080/secured/roles-allowed; echo +* Trying 127.0.0.1... +* TCP_NODELAY set +* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0) +> GET /secured/roles-allowed HTTP/1.1 +> Host: 127.0.0.1:8080 +> User-Agent: curl/7.54.0 +> Accept: */* +> +< HTTP/1.1 401 Unauthorized +< Connection: keep-alive +< Content-Type: text/html;charset=UTF-8 +< Content-Length: 14 +< Date: Sun, 03 Mar 2019 16:32:34 GMT +< +* Connection #0 to host 127.0.0.1 left intact +Not authorized +---- + +Excellent, we have not provided any OAuth2 token in the request, so we should not be able to access the endpoint, and we were not. Instead we received an HTTP 401 Unauthorized error. We need to obtain and pass in a valid OAuth2 token to access that endpoint. There are two steps to this, 1) configuring our {extension-name} extension with information on how to validate the token, and 2) generating a matching token with the appropriate claims. + +== Configuring the {extension-name} Extension Security Information + +In the <> section we introduce the configuration properties that affect the {extension-name} extension. + +=== Setting up application.properties + + For part A of step 1, create a `using-oauth2-rbac/src/main/resources/application.properties` with the following content: + +.application.properties for TokenSecuredResource +[source, properties] +---- +quarkus.oauth2.client-id=client_id +quarkus.oauth2.client-secret=secret +quarkus.oauth2.introspection-url=http://oauth-server/introspect +---- + +You need to specify the introspection URL of your authentication server and the `client-id` / `client-secret` that your application will use to authenticate itself to the authentication server. + +The extension will then use this information to validate the token and recover the information associate with it. + + +=== Generating a token + +You need to obtain the token from a standard OAuth2 authentication server (https://www.keycloak.org/[Keycloak] for example) using the token endpoint. + +You can find below a curl example of such call for a `client_credential` flow: + +[source,shell] +---- +curl -X POST "http://oauth-server/token?grant_type=client_credentials" \ +-H "Accept: application/json" -H "Authorization: Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=" +---- + +It should respond something like that... + +[source,json] +---- +{"access_token":"60acf56d-9daf-49ba-b3be-7a423d9c7288","token_type":"bearer","expires_in":1799,"scope":"READER"} +---- + + +== Finally, Secured Access to /secured/roles-allowed +Now let's use this to make a secured request to the `/secured/roles-allowed` endpoint + +.curl Command for /secured/roles-allowed With a token +[source,shell] +---- +$ curl -H "Authorization: Bearer 60acf56d-9daf-49ba-b3be-7a423d9c7288" http://127.0.0.1:8080/secured/roles-allowed; echo +hello + client_id isSecure: false, authScheme: OAuth2 +---- + +Success! We now have: + +* a non-anonymous caller name of client_id +* an authentication scheme of OAuth2 + +== Roles mapping + +Roles are mapped from one of the claims of the introspection endpoint response. By default, it's the `scope` claim. Roles are obtained by splitting the claim with a space separator. If the claim is an array, no splitting is done, the roles are obtained from the array. + +You can customize the name of the claim to use for the roles with the `quarkus.oauth2.role-claim` property. + +== Package and run the application + +As usual, the application can be packaged using `./mvnw clean package` and executed using the `-runner.jar` file: +.Runner jar Example +[source,shell] +---- +$ ./mvnw clean package +[INFO] Scanning for projects... +... +$ java -jar target/using-oauth2-rbac-runner.jar +2019-03-28 14:27:48,839 INFO [io.quarkus] (main) Quarkus 0.20.0 started in 0.796s. Listening on: http://[::]:8080 +2019-03-28 14:27:48,841 INFO [io.quarkus] (main) Installed features: [cdi, resteasy, resteasy-jsonb, security, security-oauth2] +---- + +You can also generate the native executable with `./mvnw clean package -Pnative`. +.Native Executable Example +[source,shell] +---- +$ ./mvnw clean package -Pnative +[INFO] Scanning for projects... +... +[using-oauth2-rbac-runner:25602] universe: 493.17 ms +[using-oauth2-rbac-runner:25602] (parse): 660.41 ms +[using-oauth2-rbac-runner:25602] (inline): 1,431.10 ms +[using-oauth2-rbac-runner:25602] (compile): 7,301.78 ms +[using-oauth2-rbac-runner:25602] compile: 10,542.16 ms +[using-oauth2-rbac-runner:25602] image: 2,797.62 ms +[using-oauth2-rbac-runner:25602] write: 988.24 ms +[using-oauth2-rbac-runner:25602] [total]: 43,778.16 ms +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 51.500 s +[INFO] Finished at: 2019-06-28T14:30:56-07:00 +[INFO] ------------------------------------------------------------------------ + +$ ./target/using-oauth2-rbac-runner +2019-03-28 14:31:37,315 INFO [io.quarkus] (main) Quarkus 0.20.0 started in 0.006s. Listening on: http://[::]:8080 +2019-03-28 14:31:37,316 INFO [io.quarkus] (main) Installed features: [cdi, resteasy, resteasy-jsonb, security, security-oauth2] +---- + +== Explore the Solution + +The solution repository located in the `using-oauth2-rbac` {quickstarts-archive-url}[directory] contains the examples we have worked through in this quickstart guide. +We suggest that you check out the quickstart solutions and explore the `using-oauth2-rbac` directory to learn more about the {extension-name} extension features. diff --git a/extensions/elytron-security-oauth2/deployment/pom.xml b/extensions/elytron-security-oauth2/deployment/pom.xml new file mode 100644 index 0000000000000..3bf834826b24d --- /dev/null +++ b/extensions/elytron-security-oauth2/deployment/pom.xml @@ -0,0 +1,58 @@ + + + + quarkus-elytron-security-oauth2-parent + io.quarkus + 999-SNAPSHOT + ../ + + 4.0.0 + + quarkus-elytron-security-oauth2-deployment + Quarkus - Security OAuth2 - Deployment + + + + io.quarkus + quarkus-core-deployment + + + io.quarkus + quarkus-elytron-security-oauth2 + + + io.quarkus + quarkus-elytron-security-deployment + + + + io.quarkus + quarkus-junit5-internal + test + + + io.rest-assured + rest-assured + test + + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + \ No newline at end of file diff --git a/extensions/elytron-security-oauth2/deployment/src/main/java/io/quarkus/elytron/security/oauth2/deployment/OAuth2DeploymentProcessor.java b/extensions/elytron-security-oauth2/deployment/src/main/java/io/quarkus/elytron/security/oauth2/deployment/OAuth2DeploymentProcessor.java new file mode 100644 index 0000000000000..954e0b38c1665 --- /dev/null +++ b/extensions/elytron-security-oauth2/deployment/src/main/java/io/quarkus/elytron/security/oauth2/deployment/OAuth2DeploymentProcessor.java @@ -0,0 +1,116 @@ +package io.quarkus.elytron.security.oauth2.deployment; + +import org.jboss.logging.Logger; +import org.wildfly.security.auth.server.SecurityRealm; + +import io.quarkus.arc.deployment.BeanContainerBuildItem; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.substrate.SubstrateResourceBuildItem; +import io.quarkus.elytron.security.deployment.AuthConfigBuildItem; +import io.quarkus.elytron.security.deployment.IdentityManagerBuildItem; +import io.quarkus.elytron.security.deployment.SecurityDomainBuildItem; +import io.quarkus.elytron.security.deployment.SecurityRealmBuildItem; +import io.quarkus.elytron.security.oauth2.runtime.OAuth2Config; +import io.quarkus.elytron.security.oauth2.runtime.OAuth2Recorder; +import io.quarkus.elytron.security.runtime.AuthConfig; +import io.quarkus.runtime.RuntimeValue; +import io.quarkus.undertow.deployment.ServletExtensionBuildItem; +import io.undertow.security.idm.IdentityManager; +import io.undertow.servlet.ServletExtension; + +/** + * The build time process for the OAUth2 security aspects of the deployment. This creates {@linkplain BuildStep}s for + * integration + * with the Elytron OAUth2 security services. This supports the Elytron OAuth2 + * {@linkplain org.wildfly.security.auth.realm.token.TokenSecurityRealm} realm implementations. + */ +class OAuth2DeploymentProcessor { + private static final Logger log = Logger.getLogger(OAuth2DeploymentProcessor.class.getName()); + private static final String REALM_NAME = "OAuth2"; + private static final String AUTH_MECHANISM = "BEARER_TOKEN"; + + OAuth2Config oauth2; + + /** + * If the configuration specified a deployment local key resource, register it with substrate + * + * @return SubstrateResourceBuildItem + */ + @BuildStep + SubstrateResourceBuildItem registerSubstrateResources() { + if (oauth2.caCertFile.isPresent()) { + String publicKeyLocation = oauth2.caCertFile.get(); + if (publicKeyLocation.indexOf(':') < 0 || publicKeyLocation.startsWith("classpath:")) { + log.infof("Adding %s to native image", publicKeyLocation); + return new SubstrateResourceBuildItem(publicKeyLocation); + } + } + return null; + } + + @BuildStep(providesCapabilities = "io.quarkus.elytron.security.oauth2") + FeatureBuildItem feature() { + return new FeatureBuildItem(FeatureBuildItem.SECURITY_OAUTH2); + } + + /** + * Configure a TokenSecurityRealm if enabled + * + * @param recorder - runtime OAuth2 security recorder + * @param securityRealm - the producer factory for the SecurityRealmBuildItem + * @return the AuthConfigBuildItem for the realm authentication mechanism if there was an enabled PropertiesRealmConfig, + * null otherwise + * @throws Exception - on any failure + */ + @BuildStep + @Record(ExecutionTime.STATIC_INIT) + AuthConfigBuildItem configureOauth2RealmAuthConfig(OAuth2Recorder recorder, + BuildProducer securityRealm) throws Exception { + if (oauth2.enabled) { + RuntimeValue realm = recorder.createRealm(oauth2); + + AuthConfig authConfig = new AuthConfig(); + authConfig.setAuthMechanism(AUTH_MECHANISM); + authConfig.setRealmName(REALM_NAME); + securityRealm.produce(new SecurityRealmBuildItem(realm, authConfig)); + return new AuthConfigBuildItem(authConfig); + } + return null; + } + + /** + * Create the OAuthZIdentityManager + * + * @param recorder - runtime recorder + * @param securityDomain - configured SecurityDomain + * @param identityManagerProducer - producer factory for IdentityManagerBuildItem + */ + @BuildStep + @Record(ExecutionTime.STATIC_INIT) + void configureIdentityManager(OAuth2Recorder recorder, SecurityDomainBuildItem securityDomain, + BuildProducer identityManagerProducer) { + if (oauth2.enabled) { + IdentityManager identityManager = recorder.createIdentityManager(securityDomain.getSecurityDomain(), oauth2); + identityManagerProducer.produce(new IdentityManagerBuildItem(identityManager)); + } + } + + /** + * Register the Oauth2 authentication servlet extension + * + * @param recorder - Oauth2 runtime recorder + * @param container - the BeanContainer for creating CDI beans + * @return servlet extension build item + */ + @BuildStep + @Record(ExecutionTime.STATIC_INIT) + ServletExtensionBuildItem registerAuthExtension(OAuth2Recorder recorder, BeanContainerBuildItem container) { + ServletExtension authExt = recorder.createAuthExtension(AUTH_MECHANISM, container.getValue()); + return new ServletExtensionBuildItem(authExt); + } + +} diff --git a/extensions/elytron-security-oauth2/pom.xml b/extensions/elytron-security-oauth2/pom.xml new file mode 100644 index 0000000000000..8db2a0bfd1fa7 --- /dev/null +++ b/extensions/elytron-security-oauth2/pom.xml @@ -0,0 +1,23 @@ + + + + quarkus-build-parent + io.quarkus + 999-SNAPSHOT + ../../build-parent/pom.xml + + 4.0.0 + + quarkus-elytron-security-oauth2-parent + Quarkus - Security OAuth2 + + pom + + runtime + deployment + + + + \ No newline at end of file diff --git a/extensions/elytron-security-oauth2/runtime/pom.xml b/extensions/elytron-security-oauth2/runtime/pom.xml new file mode 100644 index 0000000000000..1aad856df2788 --- /dev/null +++ b/extensions/elytron-security-oauth2/runtime/pom.xml @@ -0,0 +1,47 @@ + + + + quarkus-elytron-security-oauth2-parent + io.quarkus + 999-SNAPSHOT + ../ + + 4.0.0 + + quarkus-elytron-security-oauth2 + Quarkus - Security OAuth2 - Runtime + + + + io.quarkus + quarkus-elytron-security + + + org.wildfly.security + wildfly-elytron-realm-token + + + + + + + io.quarkus + quarkus-bootstrap-maven-plugin + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + \ No newline at end of file diff --git a/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/OAuth2Config.java b/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/OAuth2Config.java new file mode 100644 index 0000000000000..726f957903e2f --- /dev/null +++ b/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/OAuth2Config.java @@ -0,0 +1,49 @@ +package io.quarkus.elytron.security.oauth2.runtime; + +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigItem; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; + +/** + * See http://docs.wildfly.org/14/WildFly_Elytron_Security.html#validating-oauth2-bearer-tokens + */ +@ConfigRoot(name = "oauth2", phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED) +public class OAuth2Config { + /** + * If the OAuth2 extension is enabled. + */ + @ConfigItem(defaultValue = "true") + public boolean enabled; + + /** + * The identifier of the client on the OAuth2 Authorization Server + */ + @ConfigItem + public String clientId; + + /** + * The secret of the client + */ + @ConfigItem + public String clientSecret; + + /** + * The URL of token introspection endpoint + */ + @ConfigItem + public String introspectionUrl; + + /** + * The path to a ca custom cert file + */ + @ConfigItem + public Optional caCertFile; + + /** + * The claim that provides the roles + */ + @ConfigItem(defaultValue = "scope") + public String roleClaim; +} diff --git a/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/OAuth2Recorder.java b/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/OAuth2Recorder.java new file mode 100644 index 0000000000000..2b5d6f5155467 --- /dev/null +++ b/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/OAuth2Recorder.java @@ -0,0 +1,115 @@ +package io.quarkus.elytron.security.oauth2.runtime; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.HashMap; +import java.util.Map; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; + +import org.wildfly.security.auth.realm.token.TokenSecurityRealm; +import org.wildfly.security.auth.realm.token.validator.OAuth2IntrospectValidator; +import org.wildfly.security.auth.server.SecurityDomain; +import org.wildfly.security.auth.server.SecurityRealm; +import org.wildfly.security.authz.Attributes; + +import io.quarkus.arc.runtime.BeanContainer; +import io.quarkus.elytron.security.oauth2.runtime.auth.ElytronOAuth2CallerPrincipal; +import io.quarkus.elytron.security.oauth2.runtime.auth.OAuth2AuthMethodExtension; +import io.quarkus.elytron.security.oauth2.runtime.auth.OAuth2IdentityManager; +import io.quarkus.runtime.RuntimeValue; +import io.quarkus.runtime.annotations.Recorder; +import io.undertow.security.idm.IdentityManager; +import io.undertow.servlet.ServletExtension; + +@Recorder +public class OAuth2Recorder { + + public RuntimeValue createRealm(OAuth2Config config) + throws IOException, NoSuchAlgorithmException, CertificateException, KeyStoreException, KeyManagementException { + OAuth2IntrospectValidator.Builder validatorBuilder = OAuth2IntrospectValidator.builder() + .clientId(config.clientId) + .clientSecret(config.clientSecret) + .tokenIntrospectionUrl(URI.create(config.introspectionUrl).toURL()); + + if (config.caCertFile.isPresent()) { + validatorBuilder.useSslContext(createSSLContext(config)); + } + + OAuth2IntrospectValidator validator = validatorBuilder.build(); + + TokenSecurityRealm tokenRealm = TokenSecurityRealm.builder() + .validator(validator) + .claimToPrincipal(claims -> new ElytronOAuth2CallerPrincipal(attributesToMap(claims))) + .build(); + + return new RuntimeValue<>(tokenRealm); + } + + private Map attributesToMap(Attributes claims) { + Map attributeMap = new HashMap<>(); + for (Attributes.Entry entry : claims.entries()) { + if (entry.size() > 1) { + attributeMap.put(entry.getKey(), entry.subList(0, entry.size())); + } else { + attributeMap.put(entry.getKey(), entry.get(0)); + } + } + return attributeMap; + } + + private SSLContext createSSLContext(OAuth2Config config) + throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException { + try (InputStream is = new FileInputStream(config.caCertFile.get())) { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + X509Certificate caCert = (X509Certificate) cf.generateCertificate(is); + + TrustManagerFactory tmf = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()); + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null); // You don't need the KeyStore instance to come from a file. + ks.setCertificateEntry("caCert", caCert); + + tmf.init(ks); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, tmf.getTrustManagers(), null); + + return sslContext; + } + } + + /** + * Create an OAuth2IdentityManager for the given SecurityDomain + * + * @param domain - configured SecurityDomain + * @return runtime value for OAuth2IdentityManager + */ + public IdentityManager createIdentityManager(RuntimeValue domain, OAuth2Config config) { + return new OAuth2IdentityManager(domain.getValue(), config.roleClaim); + } + + /** + * Create the JWTAuthMethodExtension servlet extension + * + * @param authMechanism - name to use for MP-JWT auth mechanism + * @param container - bean container to create JWTAuthMethodExtension bean + * @return JWTAuthMethodExtension + */ + public ServletExtension createAuthExtension(String authMechanism, BeanContainer container) { + OAuth2AuthMethodExtension authExt = container.instance(OAuth2AuthMethodExtension.class); + authExt.setAuthMechanism(authMechanism); + return authExt; + } + +} diff --git a/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/ElytronOAuth2CallerPrincipal.java b/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/ElytronOAuth2CallerPrincipal.java new file mode 100644 index 0000000000000..0753c6a19a3aa --- /dev/null +++ b/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/ElytronOAuth2CallerPrincipal.java @@ -0,0 +1,38 @@ +package io.quarkus.elytron.security.oauth2.runtime.auth; + +import java.security.Principal; +import java.util.Map; +import java.util.Optional; + +/** + * An implementation of ElytronOAuth2CallerPrincipal that builds on the Elytron attributes + */ +public class ElytronOAuth2CallerPrincipal implements Principal { + private Map claims; + private String customPrincipalName; + + public ElytronOAuth2CallerPrincipal(final String customPrincipalName, final Map claims) { + this.claims = claims; + this.customPrincipalName = customPrincipalName; + } + + public ElytronOAuth2CallerPrincipal(final Map claims) { + this("username", claims); + } + + public Map getClaims() { + return claims; + } + + @Override + public String getName() { + return getClaimValueAsString(customPrincipalName).orElseGet(() -> getClaimValueAsString("client_id").orElse(null)); + } + + private Optional getClaimValueAsString(String key) { + if (getClaims().containsKey(key)) { + return Optional.of((String) getClaims().get(key)); + } + return Optional.empty(); + } +} diff --git a/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/OAuth2Account.java b/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/OAuth2Account.java new file mode 100644 index 0000000000000..dd9a91ff7d7bf --- /dev/null +++ b/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/OAuth2Account.java @@ -0,0 +1,32 @@ +package io.quarkus.elytron.security.oauth2.runtime.auth; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import org.wildfly.security.auth.server.SecurityIdentity; + +import io.quarkus.elytron.security.runtime.ElytronAccount; + +public class OAuth2Account extends ElytronAccount { + private Set roles = new HashSet<>(); + + public OAuth2Account(SecurityIdentity securityIdentity) { + super(securityIdentity); + + for (String i : securityIdentity.getRoles()) { + roles.add(i); + } + } + + public OAuth2Account(SecurityIdentity securityIdentity, String[] roles) { + super(securityIdentity); + + this.roles.addAll(Arrays.asList(roles)); + } + + @Override + public Set getRoles() { + return roles; + } +} diff --git a/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/OAuth2AuthMechanism.java b/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/OAuth2AuthMechanism.java new file mode 100644 index 0000000000000..0a2561c69ab89 --- /dev/null +++ b/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/OAuth2AuthMechanism.java @@ -0,0 +1,70 @@ +package io.quarkus.elytron.security.oauth2.runtime.auth; + +import static io.undertow.util.Headers.WWW_AUTHENTICATE; +import static io.undertow.util.StatusCodes.UNAUTHORIZED; + +import io.undertow.UndertowLogger; +import io.undertow.security.api.AuthenticationMechanism; +import io.undertow.security.api.SecurityContext; +import io.undertow.security.idm.Account; +import io.undertow.security.idm.IdentityManager; +import io.undertow.server.HttpServerExchange; + +/** + * An AuthenticationMechanism that validates a caller based on a bearer token + */ +public class OAuth2AuthMechanism implements AuthenticationMechanism { + + private IdentityManager identityManager; + + public OAuth2AuthMechanism(IdentityManager identityManager) { + this.identityManager = identityManager; + } + + /** + * Extract the Authorization header and validate the bearer token if it exists. If it does, and is validated, this + * builds the org.jboss.security.SecurityContext authenticated Subject that drives the container APIs as well as + * the authorization layers. + * + * @param exchange - the http request exchange object + * @param securityContext - the current security context that + * @return one of AUTHENTICATED, NOT_AUTHENTICATED or NOT_ATTEMPTED depending on the header and authentication outcome. + */ + @Override + public AuthenticationMechanismOutcome authenticate(HttpServerExchange exchange, SecurityContext securityContext) { + String authHeader = exchange.getRequestHeaders().getFirst("Authorization"); + String bearerToken = authHeader != null ? authHeader.substring(7) : null; + if (bearerToken != null) { + try { + Oauth2Credential credential = new Oauth2Credential(bearerToken); + if (UndertowLogger.SECURITY_LOGGER.isTraceEnabled()) { + UndertowLogger.SECURITY_LOGGER.tracef("Bearer token: %s", credential); + } + // Install the OAuth2 principal as the caller + Account account = identityManager.verify(credential); + if (account != null) { + securityContext.authenticationComplete(account, "BEARER_TOKEN", false); + UndertowLogger.SECURITY_LOGGER.debugf("Authenticated credential(%s) for path(%s) with roles: %s", + credential, exchange.getRequestPath(), account.getRoles()); + return AuthenticationMechanismOutcome.AUTHENTICATED; + } else { + UndertowLogger.SECURITY_LOGGER.info("Failed to authenticate OAuth2 bearer token"); + return AuthenticationMechanismOutcome.NOT_AUTHENTICATED; + } + } catch (Exception e) { + UndertowLogger.SECURITY_LOGGER.infof(e, "Failed to validate OAuth2 bearer token"); + return AuthenticationMechanismOutcome.NOT_AUTHENTICATED; + } + } + + // No suitable header has been found in this request, + return AuthenticationMechanismOutcome.NOT_ATTEMPTED; + } + + @Override + public ChallengeResult sendChallenge(HttpServerExchange exchange, SecurityContext securityContext) { + exchange.getResponseHeaders().add(WWW_AUTHENTICATE, "Bearer {token}"); + UndertowLogger.SECURITY_LOGGER.debugf("Sending Bearer {token} challenge for %s", exchange); + return new ChallengeResult(true, UNAUTHORIZED); + } +} diff --git a/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/OAuth2AuthMechanismFactory.java b/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/OAuth2AuthMechanismFactory.java new file mode 100644 index 0000000000000..5906bdbfdfa28 --- /dev/null +++ b/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/OAuth2AuthMechanismFactory.java @@ -0,0 +1,33 @@ +package io.quarkus.elytron.security.oauth2.runtime.auth; + +import java.util.Map; + +import io.undertow.security.api.AuthenticationMechanism; +import io.undertow.security.api.AuthenticationMechanismFactory; +import io.undertow.security.idm.IdentityManager; +import io.undertow.server.handlers.form.FormParserFactory; + +/** + * An AuthenticationMechanismFactory for OAuth2 + */ +public class OAuth2AuthMechanismFactory implements AuthenticationMechanismFactory { + + /** + * This builds the OAuth2AuthMechanism with a JWTAuthContextInfo containing the issuer and signer public key needed + * to validate the token. This information is currently taken from the query parameters passed in via the + * web.xml/login-config/auth-method value, or via CDI injection. + * + * @param mechanismName - the login-config/auth-method, which will be MP-JWT for OAuth2AuthMechanism + * @param formParserFactory - unused form type of authentication factory + * @param properties - the query parameters from the web.xml/login-config/auth-method value. We look for an issuedBy + * and signerPubKey property to use for token validation. + * @return the OAuth2AuthMechanism + * + */ + @Override + public AuthenticationMechanism create(String mechanismName, IdentityManager identityManager, + FormParserFactory formParserFactory, final Map properties) { + return new OAuth2AuthMechanism(identityManager); + } + +} diff --git a/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/OAuth2AuthMethodExtension.java b/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/OAuth2AuthMethodExtension.java new file mode 100644 index 0000000000000..771d9545b1973 --- /dev/null +++ b/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/OAuth2AuthMethodExtension.java @@ -0,0 +1,33 @@ +package io.quarkus.elytron.security.oauth2.runtime.auth; + +import javax.servlet.ServletContext; + +import io.undertow.servlet.ServletExtension; +import io.undertow.servlet.api.DeploymentInfo; + +/** + * An extension that adds support for the OAuth2 authentication mechanism + * Additionally, registers an Undertow handler that cleans up OAuth2 principal + */ +public class OAuth2AuthMethodExtension implements ServletExtension { + private String authMechanism; + + public String getAuthMechanism() { + return authMechanism; + } + + public void setAuthMechanism(String authMechanism) { + this.authMechanism = authMechanism; + } + + /** + * This registers the OAuth2AuthMechanismFactory under the "MP-JWT" mechanism name + * + * @param deploymentInfo - the deployment to augment + * @param servletContext - the ServletContext for the deployment + */ + @Override + public void handleDeployment(DeploymentInfo deploymentInfo, ServletContext servletContext) { + deploymentInfo.addAuthenticationMechanism(authMechanism, new OAuth2AuthMechanismFactory()); + } +} diff --git a/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/OAuth2IdentityManager.java b/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/OAuth2IdentityManager.java new file mode 100644 index 0000000000000..d80f485dbc0a6 --- /dev/null +++ b/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/OAuth2IdentityManager.java @@ -0,0 +1,74 @@ +package io.quarkus.elytron.security.oauth2.runtime.auth; + +import java.util.List; + +import org.jboss.logging.Logger; +import org.wildfly.security.auth.server.RealmUnavailableException; +import org.wildfly.security.auth.server.SecurityDomain; +import org.wildfly.security.auth.server.SecurityIdentity; +import org.wildfly.security.evidence.BearerTokenEvidence; + +import io.undertow.security.idm.Account; +import io.undertow.security.idm.Credential; +import io.undertow.security.idm.IdentityManager; + +public class OAuth2IdentityManager implements IdentityManager { + private static Logger log = Logger.getLogger(OAuth2IdentityManager.class); + private final SecurityDomain domain; + private String roleClaim; + + public OAuth2IdentityManager(SecurityDomain domain, String roleClaim) { + this.domain = domain; + this.roleClaim = roleClaim; + } + + @Override + public Account verify(Account account) { + return account; + } + + @Override + public Account verify(String id, Credential credential) { + return verify(null, credential); + } + + @Override + public Account verify(Credential credential) { + log.debugf("verify, credential=%s", credential); + try { + if (credential instanceof Oauth2Credential) { + Oauth2Credential oauth2Credential = (Oauth2Credential) credential; + + try { + BearerTokenEvidence evidence = new BearerTokenEvidence(oauth2Credential.getBearerToken()); + SecurityIdentity result = domain.authenticate(evidence); + String[] roles = extractRoles(result); + + log.debugf("authenticate, result=%s", result); + if (result != null) { + return new OAuth2Account(result, roles); + } + } catch (RealmUnavailableException e) { + log.debugf(e, "failed, credential=%s", credential); + } + } + } catch (Exception e) { + log.warnf(e, "Failed to verify credential=%s", credential); + } + return null; + } + + private String[] extractRoles(SecurityIdentity result) { + ElytronOAuth2CallerPrincipal principal = (ElytronOAuth2CallerPrincipal) result.getPrincipal(); + Object claims = principal.getClaims().get(roleClaim); + if (claims instanceof List) { + return ((List) claims).toArray(new String[0]); + } + + String claim = (String) principal.getClaims().get(roleClaim); + if (claim == null) { + return null; + } + return claim.split(" "); + } +} diff --git a/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/Oauth2Credential.java b/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/Oauth2Credential.java new file mode 100644 index 0000000000000..1db5eeee923b2 --- /dev/null +++ b/extensions/elytron-security-oauth2/runtime/src/main/java/io/quarkus/elytron/security/oauth2/runtime/auth/Oauth2Credential.java @@ -0,0 +1,16 @@ +package io.quarkus.elytron.security.oauth2.runtime.auth; + +import io.undertow.security.idm.Credential; + +public class Oauth2Credential implements Credential { + private String bearerToken; + + public Oauth2Credential(String bearerToken) { + this.bearerToken = bearerToken; + } + + public String getBearerToken() { + return bearerToken; + } + +} diff --git a/extensions/pom.xml b/extensions/pom.xml index 1a8de6515a978..c78a1e3a5021d 100644 --- a/extensions/pom.xml +++ b/extensions/pom.xml @@ -77,6 +77,7 @@ elytron-security + elytron-security-oauth2 smallrye-jwt keycloak diff --git a/integration-tests/elytron-security-oauth2/pom.xml b/integration-tests/elytron-security-oauth2/pom.xml new file mode 100644 index 0000000000000..5241088127bc7 --- /dev/null +++ b/integration-tests/elytron-security-oauth2/pom.xml @@ -0,0 +1,130 @@ + + + + quarkus-integration-tests-parent + io.quarkus + 999-SNAPSHOT + ../ + + 4.0.0 + + quarkus-integration-test-elytron-security-oauth2 + Quarkus - Integration Tests - Elytron Security OAuth2 + + + true + + + + + io.quarkus + quarkus-elytron-security-oauth2 + + + + + io.quarkus + quarkus-resteasy-jsonb + + + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + com.github.tomakehurst + wiremock-jre8 + test + + + + + + + src/main/resources + true + + + + + ${project.groupId} + quarkus-maven-plugin + + + + build + + + true + + + + + + + + + + native-image + + + native + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + ${project.build.directory}/${project.build.finalName}-runner + + + + + + + ${project.groupId} + quarkus-maven-plugin + + + native-image + + native-image + + + true + true + + ${graalvmHome} + + + + + + + + + + + + \ No newline at end of file diff --git a/integration-tests/elytron-security-oauth2/src/main/java/io/quarkus/it/elytron/oauth2/ElytronOauth2ExtensionResource.java b/integration-tests/elytron-security-oauth2/src/main/java/io/quarkus/it/elytron/oauth2/ElytronOauth2ExtensionResource.java new file mode 100644 index 0000000000000..96347f19cc1d1 --- /dev/null +++ b/integration-tests/elytron-security-oauth2/src/main/java/io/quarkus/it/elytron/oauth2/ElytronOauth2ExtensionResource.java @@ -0,0 +1,33 @@ +package io.quarkus.it.elytron.oauth2; + +import javax.annotation.security.RolesAllowed; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; + +@Path("/api") +@Produces(MediaType.APPLICATION_JSON) +public class ElytronOauth2ExtensionResource { + + @GET + @Path("/anonymous") + public String anonymous() { + return "anonymous"; + } + + @GET + @Path("/authenticated") + @RolesAllowed("READER") + public String authenticated() { + return "authenticated"; + } + + @GET + @Path("/forbidden") + @RolesAllowed("WRITER") + public String forbidden() { + return "forbidden"; + } + +} diff --git a/integration-tests/elytron-security-oauth2/src/main/resources/application.properties b/integration-tests/elytron-security-oauth2/src/main/resources/application.properties new file mode 100644 index 0000000000000..f39e325504cb2 --- /dev/null +++ b/integration-tests/elytron-security-oauth2/src/main/resources/application.properties @@ -0,0 +1,6 @@ +quarkus.oauth2.enabled=true +quarkus.oauth2.client-id=my_client_id +quarkus.oauth2.client-secret=secret +quarkus.oauth2.introspection-url=http://localhost:8080/introspect + +quarkus.http.port=8081 \ No newline at end of file diff --git a/integration-tests/elytron-security-oauth2/src/test/java/io/quarkus/it/elytron/oauth2/ElytronOauth2ExtensionResourceITCase.java b/integration-tests/elytron-security-oauth2/src/test/java/io/quarkus/it/elytron/oauth2/ElytronOauth2ExtensionResourceITCase.java new file mode 100644 index 0000000000000..4cc6bca7fcf1c --- /dev/null +++ b/integration-tests/elytron-security-oauth2/src/test/java/io/quarkus/it/elytron/oauth2/ElytronOauth2ExtensionResourceITCase.java @@ -0,0 +1,8 @@ +package io.quarkus.it.elytron.oauth2; + +import io.quarkus.test.junit.SubstrateTest; + +@SubstrateTest +class ElytronOauth2ExtensionResourcITCase extends ElytronOauth2ExtensionResourceTestCase { + +} diff --git a/integration-tests/elytron-security-oauth2/src/test/java/io/quarkus/it/elytron/oauth2/ElytronOauth2ExtensionResourceTestCase.java b/integration-tests/elytron-security-oauth2/src/test/java/io/quarkus/it/elytron/oauth2/ElytronOauth2ExtensionResourceTestCase.java new file mode 100644 index 0000000000000..aaec9c54a5c05 --- /dev/null +++ b/integration-tests/elytron-security-oauth2/src/test/java/io/quarkus/it/elytron/oauth2/ElytronOauth2ExtensionResourceTestCase.java @@ -0,0 +1,85 @@ +package io.quarkus.it.elytron.oauth2; + +import static org.hamcrest.Matchers.containsString; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; + +@QuarkusTest +class ElytronOauth2ExtensionResourceTestCase { + + private static final String BEARER_TOKEN = "337aab0f-b547-489b-9dbd-a54dc7bdf20d"; + + private static WireMockServer wireMockServer = new WireMockServer(); + + @BeforeAll + static void start() { + wireMockServer.start(); + + // define the mock for the introspect endpoint + WireMock.stubFor(WireMock.post("/introspect").willReturn(WireMock.aResponse() + .withBody( + "{\"active\":true,\"scope\":\"READER\",\"username\":null,\"iat\":1562315654,\"exp\":1562317454,\"expires_in\":1458,\"client_id\":\"my_client_id\"}"))); + } + + @AfterAll + static void stop() { + wireMockServer.stop(); + } + + @Test + void anonymous() { + RestAssured.given() + .when() + .get("/api/anonymous") + .then() + .statusCode(200) + .body(containsString("anonymous")); + } + + @Test + void authenticated() { + RestAssured.given() + .when() + .header("Authorization", "Bearer: " + BEARER_TOKEN) + .get("/api/authenticated") + .then() + .statusCode(200) + .body(containsString("authenticated")); + } + + @Test + void authenticated_not_authenticated() { + RestAssured.given() + .when() + .get("/api/authenticated") + .then() + .statusCode(401); + } + + @Test + void forbidden() { + RestAssured.given() + .when() + .header("Authorization", "Bearer: " + BEARER_TOKEN) + .get("/api/forbidden") + .then() + .statusCode(403); + } + + @Test + void forbidden_not_authenticated() { + RestAssured.given() + .when() + .get("/api/forbidden") + .then() + .statusCode(401); + } +} diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 9bad98a7c6e3c..5267c00b596a0 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -38,6 +38,7 @@ spring-di infinispan-cache-jpa elytron-security + elytron-security-oauth2 flyway keycloak reactive-pg-client