From 4b2c71b96e1e55c2baf2435e25ee4e83c254f411 Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Fri, 25 Oct 2019 18:41:17 -0300 Subject: [PATCH] [fixes #4480] - Initial Code Flow Support --- azure-pipelines.yml | 3 +- docs/src/main/asciidoc/index.adoc | 3 +- .../src/main/asciidoc/oidc-web-app-guide.adoc | 162 +++++++++++ .../pep/KeycloakPolicyEnforcerBuildStep.java | 2 +- .../pep/KeycloakPolicyEnforcerAuthorizer.java | 2 +- .../pep/KeycloakPolicyEnforcerRecorder.java | 2 +- .../deployment/OidcBuildStep.java} | 30 +- .../main/java/io/quarkus/oidc/IdToken.java | 17 ++ .../java/io/quarkus/oidc/RefreshToken.java | 19 ++ .../oidc/VertxJwtPrincipalProducer.java | 57 ---- .../VertxOAuth2AuthenticationMechanism.java | 103 ------- .../AbstractOidcAuthenticationMechanism.java | 29 ++ .../oidc/runtime/AccessTokenCredential.java | 10 + .../BearerAuthenticationMechanism.java | 59 ++++ .../runtime/CodeAuthenticationMechanism.java | 166 +++++++++++ .../oidc/runtime/IdTokenCredential.java | 9 + .../oidc/{ => runtime}/OidcConfig.java | 45 ++- .../OidcIdentityProvider.java} | 16 +- .../runtime/OidcJsonWebTokenProducer.java | 99 +++++++ .../OidcJwtCallerPrincipal.java} | 6 +- .../OidcRecorder.java} | 20 +- .../io/quarkus/oidc/runtime/OidcUtils.java | 1 - .../io/quarkus/oidc/runtime/RefreshToken.java | 14 + .../quarkus/oidc/runtime/OidcUtilsTest.java | 1 - .../META-INF/resources/index.html | 9 + integration-tests/oidc-code-flow/README.md | 27 ++ integration-tests/oidc-code-flow/pom.xml | 274 ++++++++++++++++++ .../it/keycloak/ProtectedResource.java | 32 ++ .../resources/META-INF/resources/index.html | 9 + .../src/main/resources/application.properties | 6 + .../it/keycloak/CodeFlowInGraalITCase.java | 15 + .../io/quarkus/it/keycloak/CodeFlowTest.java | 234 +++++++++++++++ .../it/keycloak/KeycloakTestResource.java | 24 ++ .../oidc/META-INF/resources/index.html | 9 + integration-tests/oidc/pom.xml | 17 ++ .../BearerTokenAuthorizationTest.java | 25 +- integration-tests/pom.xml | 1 + 37 files changed, 1336 insertions(+), 221 deletions(-) create mode 100644 docs/src/main/asciidoc/oidc-web-app-guide.adoc rename extensions/oidc/deployment/src/main/java/io/quarkus/{vertx/keycloak/deployment/VertxKeycloakBuildStep.java => oidc/deployment/OidcBuildStep.java} (50%) create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/IdToken.java create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/RefreshToken.java delete mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxJwtPrincipalProducer.java delete mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxOAuth2AuthenticationMechanism.java create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AbstractOidcAuthenticationMechanism.java create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AccessTokenCredential.java create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/BearerAuthenticationMechanism.java create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/IdTokenCredential.java rename extensions/oidc/runtime/src/main/java/io/quarkus/oidc/{ => runtime}/OidcConfig.java (72%) rename extensions/oidc/runtime/src/main/java/io/quarkus/oidc/{VertxOAuth2IdentityProvider.java => runtime/OidcIdentityProvider.java} (83%) create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcJsonWebTokenProducer.java rename extensions/oidc/runtime/src/main/java/io/quarkus/oidc/{VertxJwtCallerPrincipal.java => runtime/OidcJwtCallerPrincipal.java} (67%) rename extensions/oidc/runtime/src/main/java/io/quarkus/oidc/{VertxKeycloakRecorder.java => runtime/OidcRecorder.java} (79%) create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/RefreshToken.java create mode 100644 integration-tests/oidc-code-flow/META-INF/resources/index.html create mode 100644 integration-tests/oidc-code-flow/README.md create mode 100644 integration-tests/oidc-code-flow/pom.xml create mode 100644 integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java create mode 100644 integration-tests/oidc-code-flow/src/main/resources/META-INF/resources/index.html create mode 100644 integration-tests/oidc-code-flow/src/main/resources/application.properties create mode 100644 integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowInGraalITCase.java create mode 100644 integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java create mode 100644 integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/KeycloakTestResource.java create mode 100644 integration-tests/oidc/META-INF/resources/index.html diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 02231b722d466..aec4331654beb 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -233,10 +233,11 @@ stages: - template: ci-templates/native-build-steps.yaml parameters: - timeoutInMinutes: 20 + timeoutInMinutes: 25 modules: - elytron-resteasy - oidc + - oidc-code-flow - vault-app - keycloak-authorization name: security_2 diff --git a/docs/src/main/asciidoc/index.adoc b/docs/src/main/asciidoc/index.adoc index 3a21820871330..216aa92c4dfe9 100644 --- a/docs/src/main/asciidoc/index.adoc +++ b/docs/src/main/asciidoc/index.adoc @@ -45,7 +45,8 @@ include::quarkus-intro.adoc[tag=intro] * link:writing-native-applications-tips.html[Tips for writing native applications] _(advanced)_ * 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:oidc-guide.html[Using OpenID Connect Adapter to Protect JAX-RS Applications] +* link:oidc-web-app-guide.html[Protecting Web Applications Using OpenID Connect] * 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] diff --git a/docs/src/main/asciidoc/oidc-web-app-guide.adoc b/docs/src/main/asciidoc/oidc-web-app-guide.adoc new file mode 100644 index 0000000000000..07ecd4ea240a1 --- /dev/null +++ b/docs/src/main/asciidoc/oidc-web-app-guide.adoc @@ -0,0 +1,162 @@ +//// +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 - Protecting Web Applications Using OpenID Connect + +include::./attributes.adoc[] + +This guide demonstrates how to use the OpenID Connect Extension to protect your application using Quarkus, where authentication and authorization are based on tokens issued by OpenId Connect and OAuth 2.0 compliant Authorization Servers such as https://www.keycloak.org/about.html[Keycloak]. + +The extension allows you to easily enable authentication to your web application based on the Authorization Code Flow so that your users are redirected to a +OpenID Connect Provider (e.g.: Keycloak) to authenticate and, once the authentication is complete, return back to your application. + +We are going to give you a guideline on how to use OpenId Connect to authenticate users using the Quarkus OpenID Connect Extenson. + +== 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 web application with a single page: + +* `/index.html` + +This page is protected and can only be accessed by authenticated users. + +== 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 `openid-connect-web-authentication` {quickstarts-tree-url}/openid-connect-web-authentication[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=openid-connect-web-authentication \ + -Dextensions="oidc" +---- + +== Configuring the application + +The OpenID Connect extension allows you to define the configuration using the `application.properties` file which should be located at the `src/main/resources` directory. + +=== Configuring using the application.properties file + +[source,properties] +---- +quarkus.oidc.auth-server-url=http://localhost:8180/auth/realms/quarkus +quarkus.oidc.client-id=frontend +quarkus.oidc.client-type=web-app + +---- + +Note that the `quarkus.oidc.client-type` is set to `web-app`. This setting tells Quarkus that you want to enable the OpenID Connect Authorization Code Flow, so that your users are redirected to the OpenID Connect Provider to authenticate. + +== 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}/openid-connect-web-authentication/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/openid-connect-runner.jar +---- + +=== Running in Native Mode + +This same demo can be compiled into native code: no modifications required. + +This implies that you no longer need to install a JVM on your +production environment, as the runtime technology is included in +the produced binary, and optimized to run with minimal resource overhead. + +Compilation will take a bit longer, so this step is disabled by default; +let's build again by enabling the `native` profile: + +[source,bash] +---- +./mvnw package -Pnative +---- + +After getting a cup of coffee, you'll be able to run this binary directly: + +[source,bash] +---- +./target/openid-connect-web-authentication-runner +---- + +== Testing the Application + +To test the application, you should open your browser and access the following URL: + +* http://localhost:8080[http://localhost:8080] + +If everything is working as expected, you should be redirected to the Keycloak server to authenticate. + +In order to authenticate to the application you should type the following credentials when at the Keycloak login page: + +* Username: *alice* +* Password: *alice* + +After clicking the `Login` button you should be redirected back to the application. + +== Logout + +The extension only supports logout based on the expiration time of the ID Token issued by the OpenID Connect Provider. When the token expires, users are redirected to the OpenID Connect Provider again to authenticate. If the session at the OpenID Connect Provider is still active, users are automatically re-authenticated without having to provide their credentials again. + +== Configuration Reference + +include::{generated-dir}/config/quarkus-oidc.adoc[opts=optional] + +== References + +* https://www.keycloak.org/documentation.html[Keycloak Documentation] +* https://openid.net/connect/[OpenID Connect] +* https://tools.ietf.org/html/rfc7519[JSON Web Token] \ No newline at end of file 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 index 9f422f66c94d4..893c50d3d7887 100644 --- 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 @@ -6,7 +6,7 @@ import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.EnableAllSecurityServicesBuildItem; -import io.quarkus.oidc.OidcConfig; +import io.quarkus.oidc.runtime.OidcConfig; public class KeycloakPolicyEnforcerBuildStep { 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 index df4eb95815587..42579507d7ca5 100644 --- 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 @@ -19,7 +19,7 @@ import org.keycloak.representations.adapters.config.PolicyEnforcerConfig; import io.quarkus.arc.AlternativePriority; -import io.quarkus.oidc.OidcConfig; +import io.quarkus.oidc.runtime.OidcConfig; import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.runtime.QuarkusSecurityIdentity; import io.quarkus.vertx.http.runtime.security.HttpAuthorizer; 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 index 722460e58f77d..1b25bb9364976 100644 --- 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 @@ -1,7 +1,7 @@ package io.quarkus.keycloak.pep; import io.quarkus.arc.runtime.BeanContainer; -import io.quarkus.oidc.OidcConfig; +import io.quarkus.oidc.runtime.OidcConfig; import io.quarkus.runtime.annotations.Recorder; @Recorder diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/vertx/keycloak/deployment/VertxKeycloakBuildStep.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java similarity index 50% rename from extensions/oidc/deployment/src/main/java/io/quarkus/vertx/keycloak/deployment/VertxKeycloakBuildStep.java rename to extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java index 5a8d4991554c1..43c346e089113 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/vertx/keycloak/deployment/VertxKeycloakBuildStep.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java @@ -1,4 +1,4 @@ -package io.quarkus.vertx.keycloak.deployment; +package io.quarkus.oidc.deployment; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.BeanContainerBuildItem; @@ -7,14 +7,15 @@ import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.EnableAllSecurityServicesBuildItem; import io.quarkus.deployment.builditem.FeatureBuildItem; -import io.quarkus.oidc.OidcConfig; -import io.quarkus.oidc.VertxJwtPrincipalProducer; -import io.quarkus.oidc.VertxKeycloakRecorder; -import io.quarkus.oidc.VertxOAuth2AuthenticationMechanism; -import io.quarkus.oidc.VertxOAuth2IdentityProvider; +import io.quarkus.oidc.runtime.BearerAuthenticationMechanism; +import io.quarkus.oidc.runtime.CodeAuthenticationMechanism; +import io.quarkus.oidc.runtime.OidcConfig; +import io.quarkus.oidc.runtime.OidcIdentityProvider; +import io.quarkus.oidc.runtime.OidcJsonWebTokenProducer; +import io.quarkus.oidc.runtime.OidcRecorder; import io.quarkus.vertx.deployment.VertxBuildItem; -public class VertxKeycloakBuildStep { +public class OidcBuildStep { @BuildStep FeatureBuildItem featureBuildItem() { @@ -24,10 +25,15 @@ FeatureBuildItem featureBuildItem() { @BuildStep public AdditionalBeanBuildItem beans(OidcConfig config) { if (config.enabled) { - return AdditionalBeanBuildItem.builder().setUnremovable() - .addBeanClass(VertxOAuth2AuthenticationMechanism.class) - .addBeanClass(VertxJwtPrincipalProducer.class) - .addBeanClass(VertxOAuth2IdentityProvider.class).build(); + AdditionalBeanBuildItem.Builder beans = AdditionalBeanBuildItem.builder().setUnremovable(); + + if (OidcConfig.ApplicationType.SERVICE.equals(config.getApplicationType())) { + beans.addBeanClass(BearerAuthenticationMechanism.class); + } else if (OidcConfig.ApplicationType.WEB_APP.equals(config.getApplicationType())) { + beans.addBeanClass(CodeAuthenticationMechanism.class); + } + + return beans.addBeanClass(OidcJsonWebTokenProducer.class).addBeanClass(OidcIdentityProvider.class).build(); } return null; @@ -40,7 +46,7 @@ EnableAllSecurityServicesBuildItem security() { @Record(ExecutionTime.RUNTIME_INIT) @BuildStep - public void setup(OidcConfig config, VertxKeycloakRecorder recorder, VertxBuildItem vertxBuildItem, + public void setup(OidcConfig config, OidcRecorder recorder, VertxBuildItem vertxBuildItem, BeanContainerBuildItem bc) { if (config.enabled) { recorder.setup(config, vertxBuildItem.getVertx(), bc.getValue()); diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/IdToken.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/IdToken.java new file mode 100644 index 0000000000000..ec2c04f9859af --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/IdToken.java @@ -0,0 +1,17 @@ +package io.quarkus.oidc; + +import static java.lang.annotation.ElementType.CONSTRUCTOR; +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import javax.inject.Qualifier; + +@Qualifier +@Target({ FIELD, CONSTRUCTOR, METHOD }) +@Retention(RetentionPolicy.RUNTIME) +public @interface IdToken { +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/RefreshToken.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/RefreshToken.java new file mode 100644 index 0000000000000..0ca4f0d35ebf6 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/RefreshToken.java @@ -0,0 +1,19 @@ +package io.quarkus.oidc; + +import io.quarkus.security.credential.TokenCredential; + +/** + * Represents a refresh token issued to the application. + */ +public class RefreshToken extends TokenCredential { + + public RefreshToken() { + this(null); + } + + public RefreshToken(String token) { + super(token, "refresh_token"); + } + + // TODO: more methods to help the application to refresh tokens +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxJwtPrincipalProducer.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxJwtPrincipalProducer.java deleted file mode 100644 index d996216528d73..0000000000000 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxJwtPrincipalProducer.java +++ /dev/null @@ -1,57 +0,0 @@ -package io.quarkus.oidc; - -import java.util.Set; - -import javax.annotation.Priority; -import javax.enterprise.context.RequestScoped; -import javax.enterprise.inject.Alternative; -import javax.enterprise.inject.Produces; -import javax.inject.Inject; - -import org.eclipse.microprofile.jwt.JsonWebToken; - -import io.quarkus.security.identity.SecurityIdentity; - -@Priority(2) -@Alternative -@RequestScoped -public class VertxJwtPrincipalProducer { - - @Inject - SecurityIdentity identity; - - /** - * The producer method for the current JsonWebToken - * - * @return JsonWebToken - */ - @Produces - @RequestScoped - JsonWebToken currentJWTPrincipalOrNull() { - if (identity.isAnonymous()) { - return new NullJsonWebToken(); - } - if (identity.getPrincipal() instanceof JsonWebToken) { - return (JsonWebToken) identity.getPrincipal(); - } - throw new IllegalStateException("Current principal " + identity.getPrincipal() + " is not a JSON web token"); - } - - private static class NullJsonWebToken implements JsonWebToken { - - @Override - public String getName() { - return null; - } - - @Override - public Set getClaimNames() { - return null; - } - - @Override - public T getClaim(String claimName) { - return null; - } - } -} \ No newline at end of file diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxOAuth2AuthenticationMechanism.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxOAuth2AuthenticationMechanism.java deleted file mode 100644 index 0dba72250a684..0000000000000 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxOAuth2AuthenticationMechanism.java +++ /dev/null @@ -1,103 +0,0 @@ -package io.quarkus.oidc; - -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionStage; - -import javax.enterprise.context.ApplicationScoped; - -import io.quarkus.security.credential.TokenCredential; -import io.quarkus.security.identity.IdentityProviderManager; -import io.quarkus.security.identity.SecurityIdentity; -import io.quarkus.security.identity.request.TokenAuthenticationRequest; -import io.quarkus.vertx.http.runtime.security.ChallengeData; -import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; -import io.vertx.core.http.HttpHeaders; -import io.vertx.core.http.HttpServerRequest; -import io.vertx.core.json.JsonObject; -import io.vertx.ext.auth.oauth2.OAuth2Auth; -import io.vertx.ext.web.RoutingContext; - -@ApplicationScoped -public class VertxOAuth2AuthenticationMechanism implements HttpAuthenticationMechanism { - - private static final String BEARER = "Bearer"; - - private volatile String authServerURI; - private volatile OAuth2Auth auth; - - public String getAuthServerURI() { - return authServerURI; - } - - public VertxOAuth2AuthenticationMechanism setAuthServerURI(String authServerURI) { - this.authServerURI = authServerURI; - return this; - } - - public OAuth2Auth getAuth() { - return auth; - } - - public VertxOAuth2AuthenticationMechanism setAuth(OAuth2Auth auth) { - this.auth = auth; - return this; - } - - @Override - public CompletionStage authenticate(RoutingContext context, - IdentityProviderManager identityProviderManager) { - // when the handler is working as bearer only, then the `Authorization` header is required - - final HttpServerRequest request = context.request(); - final String authorization = request.headers().get(HttpHeaders.AUTHORIZATION); - - if (authorization == null) { - return CompletableFuture.completedFuture(null); - } - - int idx = authorization.indexOf(' '); - - if (idx <= 0) { - return CompletableFuture.completedFuture(null); - } - - if (!BEARER.equalsIgnoreCase(authorization.substring(0, idx))) { - return CompletableFuture.completedFuture(null); - } - - String token = authorization.substring(idx + 1); - return identityProviderManager.authenticate(new TokenAuthenticationRequest(new TokenCredential(token, BEARER))); - } - - @Override - public CompletionStage getChallenge(RoutingContext context) { - ChallengeData result = new ChallengeData( - 302, - HttpHeaders.LOCATION, - authURI(authServerURI)); - return CompletableFuture.completedFuture(result); - } - - private String authURI(String redirectURL) { - final JsonObject config = new JsonObject() - .put("state", redirectURL); - - config.put("redirect_uri", authServerURI); - - // if (extraParams != null) { - // config.mergeIn(extraParams); - // } - // - // if (scopes.size() > 0) { - // JsonArray _scopes = new JsonArray(); - // // scopes are passed as an array because the auth provider has the knowledge on how to encode them - // for (String authority : scopes) { - // _scopes.add(authority); - // } - // - // config.put("scopes", _scopes); - // } - - return auth.authorizeURL(config); - } -} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AbstractOidcAuthenticationMechanism.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AbstractOidcAuthenticationMechanism.java new file mode 100644 index 0000000000000..137d72b9640ab --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AbstractOidcAuthenticationMechanism.java @@ -0,0 +1,29 @@ +package io.quarkus.oidc.runtime; + +import java.util.concurrent.CompletionStage; + +import io.quarkus.security.credential.TokenCredential; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.TokenAuthenticationRequest; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.vertx.ext.auth.oauth2.OAuth2Auth; + +abstract class AbstractOidcAuthenticationMechanism implements HttpAuthenticationMechanism { + + protected static final String BEARER = "Bearer"; + + protected volatile OAuth2Auth auth; + protected OidcConfig config; + + public AbstractOidcAuthenticationMechanism setAuth(OAuth2Auth auth, OidcConfig config) { + this.auth = auth; + this.config = config; + return this; + } + + protected CompletionStage authenticate(IdentityProviderManager identityProviderManager, + TokenCredential token) { + return identityProviderManager.authenticate(new TokenAuthenticationRequest(token)); + } +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AccessTokenCredential.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AccessTokenCredential.java new file mode 100644 index 0000000000000..59c67bd5121ce --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AccessTokenCredential.java @@ -0,0 +1,10 @@ +package io.quarkus.oidc.runtime; + +import io.quarkus.security.credential.TokenCredential; + +public class AccessTokenCredential extends TokenCredential { + + public AccessTokenCredential(String token) { + super(token, "bearer"); + } +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/BearerAuthenticationMechanism.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/BearerAuthenticationMechanism.java new file mode 100644 index 0000000000000..0e65a4286d057 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/BearerAuthenticationMechanism.java @@ -0,0 +1,59 @@ +package io.quarkus.oidc.runtime; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import javax.enterprise.context.ApplicationScoped; + +import io.netty.handler.codec.http.HttpResponseStatus; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.ext.web.RoutingContext; + +@ApplicationScoped +public class BearerAuthenticationMechanism extends AbstractOidcAuthenticationMechanism { + + public CompletionStage authenticate(RoutingContext context, + IdentityProviderManager identityProviderManager) { + String token = extractBearerToken(context); + + // if a bearer token is provided try to authenticate + if (token != null) { + return authenticate(identityProviderManager, new AccessTokenCredential(token)); + } + + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage getChallenge(RoutingContext context) { + String bearerToken = extractBearerToken(context); + + if (bearerToken == null) { + return CompletableFuture.completedFuture(new ChallengeData(HttpResponseStatus.UNAUTHORIZED.code(), null, null)); + } + + return CompletableFuture.completedFuture(new ChallengeData(HttpResponseStatus.FORBIDDEN.code(), null, null)); + } + + private String extractBearerToken(RoutingContext context) { + final HttpServerRequest request = context.request(); + final String authorization = request.headers().get(HttpHeaders.AUTHORIZATION); + + if (authorization == null) { + return null; + } + + int idx = authorization.indexOf(' '); + + if (idx <= 0 || !BEARER.equalsIgnoreCase(authorization.substring(0, idx))) { + return null; + } + + String token = authorization.substring(idx + 1); + return token; + } +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java new file mode 100644 index 0000000000000..120980ea29aa5 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java @@ -0,0 +1,166 @@ +package io.quarkus.oidc.runtime; + +import java.net.URI; +import java.security.Permission; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Function; + +import javax.enterprise.context.ApplicationScoped; + +import io.netty.handler.codec.http.HttpResponseStatus; +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.vertx.core.http.Cookie; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.oauth2.AccessToken; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.impl.CookieImpl; + +@ApplicationScoped +public class CodeAuthenticationMechanism extends AbstractOidcAuthenticationMechanism { + + private static final String STATE_COOKIE_NAME = "q_auth"; + private static final String SESSION_COOKIE_NAME = "q_session"; + private static final String SESSION_COOKIE_DELIM = "___"; + + private static QuarkusSecurityIdentity augmentIdentity(SecurityIdentity securityIdentity, + String accessToken, + String refreshToken) { + return QuarkusSecurityIdentity.builder() + .setPrincipal(securityIdentity.getPrincipal()) + .addCredentials(securityIdentity.getCredentials()) + .addCredential(new AccessTokenCredential(accessToken)) + .addCredential(new RefreshToken(refreshToken)) + .addRoles(securityIdentity.getRoles()) + .addAttributes(securityIdentity.getAttributes()) + .addPermissionChecker(new Function>() { + @Override + public CompletionStage apply(Permission permission) { + return securityIdentity.checkPermission(permission); + } + }) + .build(); + } + + @Override + public CompletionStage authenticate(RoutingContext context, + IdentityProviderManager identityProviderManager) { + Cookie sessionCookie = context.request().getCookie(SESSION_COOKIE_NAME); + + // if session already established, try to re-authenticate + if (sessionCookie != null) { + String[] tokens = sessionCookie.getValue().split(SESSION_COOKIE_DELIM); + return authenticate(identityProviderManager, new IdTokenCredential(tokens[0])) + .thenCompose(new Function>() { + @Override + public CompletionStage apply(SecurityIdentity securityIdentity) { + return CompletableFuture.completedFuture(augmentIdentity(securityIdentity, tokens[1], tokens[2])); + } + }); + } + + // start a new session by starting the code flow dance + return performCodeFlow(identityProviderManager, context); + } + + @Override + public CompletionStage getChallenge(RoutingContext context) { + removeSessionCookie(context); + ChallengeData challenge; + + JsonObject params = new JsonObject(); + + List scopes = new ArrayList<>(); + + scopes.add("openid"); + scopes.addAll(config.authentication.scopes); + + params.put("scopes", new JsonArray(scopes)); + params.put("redirect_uri", buildRedirectUri(context)); + params.put("state", generateState(context)); + + challenge = new ChallengeData(HttpResponseStatus.FOUND.code(), HttpHeaders.LOCATION, auth.authorizeURL(params)); + + return CompletableFuture.completedFuture(challenge); + } + + private CompletionStage performCodeFlow(IdentityProviderManager identityProviderManager, + RoutingContext context) { + CompletableFuture cf = new CompletableFuture<>(); + JsonObject params = new JsonObject(); + + params.put("code", context.request().getParam("code")); + params.put("redirect_uri", buildRedirectUri(context)); + + auth.authenticate(params, userAsyncResult -> { + if (userAsyncResult.failed()) { + cf.completeExceptionally(new AuthenticationFailedException()); + } else { + AccessToken result = AccessToken.class.cast(userAsyncResult.result()); + + authenticate(identityProviderManager, new IdTokenCredential(result.opaqueIdToken())) + .whenCompleteAsync((securityIdentity, throwable) -> { + if (throwable != null) { + cf.completeExceptionally(throwable); + } else { + processSuccessfulAuthentication(context, cf, result, securityIdentity); + } + }); + } + }); + + return cf; + } + + private void processSuccessfulAuthentication(RoutingContext context, CompletableFuture cf, + AccessToken result, SecurityIdentity securityIdentity) { + removeSessionCookie(context); + CookieImpl cookie = new CookieImpl(SESSION_COOKIE_NAME, new StringBuilder(result.opaqueIdToken()) + .append(SESSION_COOKIE_DELIM) + .append(result.opaqueAccessToken()) + .append(SESSION_COOKIE_DELIM) + .append(result.opaqueRefreshToken()).toString()); + + cookie.setMaxAge(result.idToken().getInteger("exp")); + cookie.setSecure(context.request().isSSL()); + cookie.setHttpOnly(true); + + context.response().addCookie(cookie); + cf.complete(augmentIdentity(securityIdentity, result.opaqueAccessToken(), + result.opaqueRefreshToken())); + } + + private String generateState(RoutingContext context) { + CookieImpl cookie = new CookieImpl(STATE_COOKIE_NAME, UUID.randomUUID().toString()); + + cookie.setHttpOnly(true); + cookie.setSecure(context.request().isSSL()); + cookie.setMaxAge(-1); + + context.response().addCookie(cookie); + + return cookie.getValue(); + } + + private String buildRedirectUri(RoutingContext context) { + URI absoluteUri = URI.create(context.request().absoluteURI()); + StringBuilder builder = new StringBuilder(context.request().scheme()).append("://") + .append(absoluteUri.getAuthority()) + .append(absoluteUri.getPath()); + + return builder.toString(); + } + + private void removeSessionCookie(RoutingContext context) { + context.response().removeCookie(SESSION_COOKIE_NAME, true); + } +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/IdTokenCredential.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/IdTokenCredential.java new file mode 100644 index 0000000000000..d154485418082 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/IdTokenCredential.java @@ -0,0 +1,9 @@ +package io.quarkus.oidc.runtime; + +import io.quarkus.security.credential.TokenCredential; + +public class IdTokenCredential extends TokenCredential { + public IdTokenCredential(String token) { + super(token, "id_token"); + } +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcConfig.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfig.java similarity index 72% rename from extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcConfig.java rename to extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfig.java index e5dc662e22772..7a8cc60c6dad7 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcConfig.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfig.java @@ -1,5 +1,6 @@ -package io.quarkus.oidc; +package io.quarkus.oidc.runtime; +import java.util.List; import java.util.Optional; import io.quarkus.runtime.annotations.ConfigGroup; @@ -61,6 +62,17 @@ public class OidcConfig { @ConfigItem Credentials credentials; + /** + * Different options to configure authorization requests + */ + Authentication authentication; + + /** + * The application type, which can be one of the following values from enum {@link ApplicationType}.. + */ + @ConfigItem(defaultValue = "service") + ApplicationType applicationType; + public String getAuthServerUrl() { return authServerUrl; } @@ -77,6 +89,10 @@ public Roles getRoles() { return roles; } + public ApplicationType getApplicationType() { + return applicationType; + } + @ConfigGroup public static class Credentials { @@ -130,4 +146,31 @@ public static Roles fromClaimPathAndSeparator(String path, String sep) { } } + @ConfigGroup + public static class Authentication { + + /** + * Defines a fixed list of scopes which should be added to authorization requests when authenticating users using the + * Authorization Code Grant Type. + * + */ + @ConfigItem + public List scopes; + } + + public enum ApplicationType { + /** + * A {@code WEB_APP} is a client that server pages, usually a frontend application. For this type of client the + * Authorization Code Flow is + * defined as the preferred method for authenticating users. + */ + WEB_APP, + + /** + * A {@code SERVICE} is a client that has a set of protected HTTP resources, usually a backend application following the + * RESTful Architectural Design. For this type of client, the Bearer Authorization method is defined as the preferred + * method for authenticating and authorizing users. + */ + SERVICE + } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxOAuth2IdentityProvider.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java similarity index 83% rename from extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxOAuth2IdentityProvider.java rename to extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java index f09fecf8dae28..3acc3f79df4aa 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxOAuth2IdentityProvider.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java @@ -1,4 +1,4 @@ -package io.quarkus.oidc; +package io.quarkus.oidc.runtime; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -9,7 +9,7 @@ import org.jose4j.jwt.JwtClaims; import org.jose4j.jwt.consumer.InvalidJwtException; -import io.quarkus.oidc.runtime.OidcUtils; +import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.identity.AuthenticationRequestContext; import io.quarkus.security.identity.IdentityProvider; import io.quarkus.security.identity.SecurityIdentity; @@ -21,7 +21,7 @@ import io.vertx.ext.auth.oauth2.OAuth2Auth; @ApplicationScoped -public class VertxOAuth2IdentityProvider implements IdentityProvider { +public class OidcIdentityProvider implements IdentityProvider { private volatile OAuth2Auth auth; private volatile OidcConfig config; @@ -30,12 +30,12 @@ public OAuth2Auth getAuth() { return auth; } - public VertxOAuth2IdentityProvider setAuth(OAuth2Auth auth) { + public OidcIdentityProvider setAuth(OAuth2Auth auth) { this.auth = auth; return this; } - public VertxOAuth2IdentityProvider setConfig(OidcConfig config) { + public OidcIdentityProvider setConfig(OidcConfig config) { this.config = config; return this; } @@ -54,15 +54,15 @@ public CompletionStage authenticate(TokenAuthenticationRequest @Override public void handle(AsyncResult event) { if (event.failed()) { - result.completeExceptionally(event.cause()); + result.completeExceptionally(new AuthenticationFailedException()); return; } AccessToken token = event.result(); QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(); - JsonWebToken jwtPrincipal = null; + JsonWebToken jwtPrincipal; try { - jwtPrincipal = new VertxJwtCallerPrincipal(JwtClaims.parse(token.accessToken().encode())); + jwtPrincipal = new OidcJwtCallerPrincipal(JwtClaims.parse(token.accessToken().encode())); } catch (InvalidJwtException e) { result.completeExceptionally(e); return; diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcJsonWebTokenProducer.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcJsonWebTokenProducer.java new file mode 100644 index 0000000000000..b3b3437acfda3 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcJsonWebTokenProducer.java @@ -0,0 +1,99 @@ +package io.quarkus.oidc.runtime; + +import java.util.Set; + +import javax.annotation.Priority; +import javax.enterprise.context.RequestScoped; +import javax.enterprise.inject.Alternative; +import javax.enterprise.inject.Produces; +import javax.inject.Inject; + +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.jose4j.jwt.JwtClaims; +import org.jose4j.jwt.consumer.InvalidJwtException; +import org.jose4j.jwt.consumer.JwtConsumerBuilder; + +import io.quarkus.oidc.IdToken; +import io.quarkus.security.credential.TokenCredential; +import io.quarkus.security.identity.SecurityIdentity; + +@Priority(2) +@Alternative +@RequestScoped +public class OidcJsonWebTokenProducer { + + @Inject + SecurityIdentity identity; + + /** + * The producer method for the current access token + * + * @return the access token + */ + @Produces + @RequestScoped + JsonWebToken currentAccessToken() { + return getTokenCredential(AccessTokenCredential.class); + } + + /** + * The producer method for the current id token + * + * @return the id token + */ + @Produces + @IdToken + @RequestScoped + JsonWebToken currentIdToken() { + return getTokenCredential(IdTokenCredential.class); + } + + /** + * The producer method for the current id token + * + * @return the id token + */ + @Produces + @RequestScoped + RefreshToken currentRefreshToken() { + return identity.getCredential(RefreshToken.class); + } + + private JsonWebToken getTokenCredential(Class type) { + if (identity.isAnonymous()) { + return new NullJsonWebToken(); + } + TokenCredential credential = identity.getCredential(type); + if (credential != null) { + JwtClaims jwtClaims; + try { + jwtClaims = new JwtConsumerBuilder() + .setSkipSignatureVerification() + .setSkipAllValidators() + .build().processToClaims(credential.getToken()); + } catch (InvalidJwtException e) { + throw new RuntimeException(e); + } + return new OidcJwtCallerPrincipal(jwtClaims); + } + throw new IllegalStateException("Current identity not associated with an access token"); + } + + private static class NullJsonWebToken implements JsonWebToken { + + @Override + public String getName() { + return null; + } + + @Override + public Set getClaimNames() { + return null; + } + + @Override + public T getClaim(String claimName) { + return null; + } + } +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxJwtCallerPrincipal.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcJwtCallerPrincipal.java similarity index 67% rename from extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxJwtCallerPrincipal.java rename to extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcJwtCallerPrincipal.java index 067b8a31d7866..74218301408dd 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxJwtCallerPrincipal.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcJwtCallerPrincipal.java @@ -1,4 +1,4 @@ -package io.quarkus.oidc; +package io.quarkus.oidc.runtime; import org.jose4j.jwt.JwtClaims; @@ -7,10 +7,10 @@ /** * An implementation of JWTCallerPrincipal that builds on the Elytron attributes */ -public class VertxJwtCallerPrincipal extends DefaultJWTCallerPrincipal { +public class OidcJwtCallerPrincipal extends DefaultJWTCallerPrincipal { private JwtClaims claims; - public VertxJwtCallerPrincipal(final JwtClaims claims) { + public OidcJwtCallerPrincipal(final JwtClaims claims) { super(claims); this.claims = claims; } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxKeycloakRecorder.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java similarity index 79% rename from extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxKeycloakRecorder.java rename to extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java index 201d4dd231b52..5a023a7408de5 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxKeycloakRecorder.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java @@ -1,8 +1,9 @@ -package io.quarkus.oidc; +package io.quarkus.oidc.runtime; import java.util.concurrent.CompletableFuture; import io.quarkus.arc.runtime.BeanContainer; +import io.quarkus.oidc.OIDCException; import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.annotations.Recorder; import io.vertx.core.AsyncResult; @@ -14,7 +15,7 @@ import io.vertx.ext.auth.oauth2.providers.KeycloakAuth; @Recorder -public class VertxKeycloakRecorder { +public class OidcRecorder { public void setup(OidcConfig config, RuntimeValue vertx, BeanContainer beanContainer) { OAuth2ClientOptions options = new OAuth2ClientOptions(); @@ -57,12 +58,19 @@ public void handle(AsyncResult event) { }); OAuth2Auth auth = cf.join(); - VertxOAuth2IdentityProvider identityProvider = beanContainer.instance(VertxOAuth2IdentityProvider.class); + + OidcIdentityProvider identityProvider = beanContainer.instance(OidcIdentityProvider.class); identityProvider.setAuth(auth); identityProvider.setConfig(config); - VertxOAuth2AuthenticationMechanism mechanism = beanContainer.instance(VertxOAuth2AuthenticationMechanism.class); - mechanism.setAuth(auth); - mechanism.setAuthServerURI(config.authServerUrl); + AbstractOidcAuthenticationMechanism mechanism = null; + + if (OidcConfig.ApplicationType.SERVICE.equals(config.applicationType)) { + mechanism = beanContainer.instance(BearerAuthenticationMechanism.class); + } else if (OidcConfig.ApplicationType.WEB_APP.equals(config.applicationType)) { + mechanism = beanContainer.instance(CodeAuthenticationMechanism.class); + } + + mechanism.setAuth(auth, config); } protected static OIDCException toOidcException(Throwable cause) { diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java index 458a5d2e05616..450549d7cd066 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java @@ -7,7 +7,6 @@ import java.util.stream.Collectors; import io.quarkus.oidc.OIDCException; -import io.quarkus.oidc.OidcConfig; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/RefreshToken.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/RefreshToken.java new file mode 100644 index 0000000000000..001b4f7ff7f0f --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/RefreshToken.java @@ -0,0 +1,14 @@ +package io.quarkus.oidc.runtime; + +import io.quarkus.security.credential.TokenCredential; + +public class RefreshToken extends TokenCredential { + + public RefreshToken() { + this(null); + } + + public RefreshToken(String token) { + super(token, "refresh_token"); + } +} diff --git a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java index c4e5111db3aad..911caa77375fb 100644 --- a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java +++ b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java @@ -13,7 +13,6 @@ import org.junit.jupiter.api.Test; -import io.quarkus.oidc.OidcConfig; import io.vertx.core.json.JsonObject; public class OidcUtilsTest { diff --git a/integration-tests/oidc-code-flow/META-INF/resources/index.html b/integration-tests/oidc-code-flow/META-INF/resources/index.html new file mode 100644 index 0000000000000..2c950d499ab61 --- /dev/null +++ b/integration-tests/oidc-code-flow/META-INF/resources/index.html @@ -0,0 +1,9 @@ + + + + + Welcome to Test App + + + + \ No newline at end of file diff --git a/integration-tests/oidc-code-flow/README.md b/integration-tests/oidc-code-flow/README.md new file mode 100644 index 0000000000000..0fb20fef25488 --- /dev/null +++ b/integration-tests/oidc-code-flow/README.md @@ -0,0 +1,27 @@ +# JAX-RS example using Keycloak adapter 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/oidc-code-flow/pom.xml b/integration-tests/oidc-code-flow/pom.xml new file mode 100644 index 0000000000000..08a177230f7b9 --- /dev/null +++ b/integration-tests/oidc-code-flow/pom.xml @@ -0,0 +1,274 @@ + + + + quarkus-integration-tests-parent + io.quarkus + 999-SNAPSHOT + ../ + + 4.0.0 + + quarkus-integration-test-oidc-code-flow + Quarkus - Integration Tests - OpenID Connect Adapter Code Flow + Module that contains OpenID Connect Code Flow related tests + + + http://localhost:8180/auth + 2.36.0 + + + + + io.quarkus + quarkus-oidc + + + io.quarkus + quarkus-resteasy-jackson + + + org.keycloak + keycloak-adapter-core + + + org.keycloak + keycloak-core + + + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + net.sourceforge.htmlunit + htmlunit + ${htmlunit.version} + test + + + org.apache.httpcomponents + httpmime + + + commons-logging + commons-logging + + + + + + + + + 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 + ${project.version} + + + + 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 + ${project.version} + + + 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/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java new file mode 100644 index 0000000000000..143736261fbfe --- /dev/null +++ b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java @@ -0,0 +1,32 @@ +package io.quarkus.it.keycloak; + +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +import org.eclipse.microprofile.jwt.JsonWebToken; + +import io.quarkus.oidc.IdToken; +import io.quarkus.oidc.runtime.RefreshToken; + +@Path("/web-app") +public class ProtectedResource { + + @Inject + @IdToken + JsonWebToken idToken; + + @Inject + RefreshToken refreshToken; + + @GET + public String get() { + return idToken.getClaim("preferred_username"); + } + + @GET + @Path("refresh") + public String refresh() { + return refreshToken.getToken() != null ? "injected" : null; + } +} diff --git a/integration-tests/oidc-code-flow/src/main/resources/META-INF/resources/index.html b/integration-tests/oidc-code-flow/src/main/resources/META-INF/resources/index.html new file mode 100644 index 0000000000000..2c950d499ab61 --- /dev/null +++ b/integration-tests/oidc-code-flow/src/main/resources/META-INF/resources/index.html @@ -0,0 +1,9 @@ + + + + + Welcome to Test App + + + + \ No newline at end of file diff --git a/integration-tests/oidc-code-flow/src/main/resources/application.properties b/integration-tests/oidc-code-flow/src/main/resources/application.properties new file mode 100644 index 0000000000000..2da25d4727ac6 --- /dev/null +++ b/integration-tests/oidc-code-flow/src/main/resources/application.properties @@ -0,0 +1,6 @@ +# Configuration file +quarkus.oidc.auth-server-url=${keycloak.url}/realms/quarkus +quarkus.oidc.client-id=quarkus-app +quarkus.oidc.authentication.scopes=profile,email,phone +quarkus.http.cors=true +quarkus.oidc.application-type=web-app \ No newline at end of file diff --git a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowInGraalITCase.java b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowInGraalITCase.java new file mode 100644 index 0000000000000..11f4232609327 --- /dev/null +++ b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowInGraalITCase.java @@ -0,0 +1,15 @@ +package io.quarkus.it.keycloak; + +import org.junit.jupiter.api.Disabled; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.SubstrateTest; + +/** + * @author Pedro Igor + */ +@QuarkusTestResource(KeycloakTestResource.class) +@SubstrateTest +@Disabled("While figuring out how to have different application.properties for different tests") +public class CodeFlowInGraalITCase extends CodeFlowTest { +} diff --git a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java new file mode 100644 index 0000000000000..e020849e88644 --- /dev/null +++ b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java @@ -0,0 +1,234 @@ +package io.quarkus.it.keycloak; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +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.util.JsonSerialization; + +import com.gargoylesoftware.htmlunit.WebClient; +import com.gargoylesoftware.htmlunit.html.HtmlForm; +import com.gargoylesoftware.htmlunit.html.HtmlPage; +import com.gargoylesoftware.htmlunit.util.Cookie; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; + +/** + * @author Pedro Igor + */ +@QuarkusTest +public class CodeFlowTest { + + 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).thenReturn().prettyPrint(); + } + + 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<>()); + realm.setSsoSessionMaxLifespan(2); // sec + realm.setAccessTokenLifespan(3); // 3 seconds + + 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(true); + client.setDirectAccessGrantsEnabled(true); + client.setEnabled(true); + client.setRedirectUris(Arrays.asList("*")); + + return client; + } + + 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 testCodeFlowNoConsent() throws IOException { + try (final WebClient webClient = new WebClient()) { + HtmlPage page = webClient.getPage("http://localhost:8081/index.html"); + + assertEquals("Log in to quarkus", page.getTitleText()); + + HtmlForm loginForm = page.getForms().get(0); + + loginForm.getInputByName("username").setValueAttribute("alice"); + loginForm.getInputByName("password").setValueAttribute("alice"); + + page = loginForm.getInputByName("login").click(); + + assertEquals("Welcome to Test App", page.getTitleText()); + + page = webClient.getPage("http://localhost:8081/index.html"); + + assertEquals("Welcome to Test App", page.getTitleText(), + "A second request should not redirect and just re-authenticate the user"); + } + } + + @Test + public void testTokenTimeoutLogout() throws IOException, InterruptedException { + try (final WebClient webClient = new WebClient()) { + HtmlPage page = webClient.getPage("http://localhost:8081/index.html"); + + assertEquals("Log in to quarkus", page.getTitleText()); + + HtmlForm loginForm = page.getForms().get(0); + + loginForm.getInputByName("username").setValueAttribute("alice"); + loginForm.getInputByName("password").setValueAttribute("alice"); + + page = loginForm.getInputByName("login").click(); + + assertEquals("Welcome to Test App", page.getTitleText()); + + Thread.sleep(5000); + + page = webClient.getPage("http://localhost:8081/index.html"); + + Cookie sessionCookie = getSessionCookie(webClient); + + assertNull(sessionCookie); + + page = webClient.getPage("http://localhost:8081/index.html"); + + assertEquals("Log in to quarkus", page.getTitleText()); + } + } + + @Test + public void testIdTokenInjection() throws IOException, InterruptedException { + try (final WebClient webClient = new WebClient()) { + HtmlPage page = webClient.getPage("http://localhost:8081/index.html"); + + assertEquals("Log in to quarkus", page.getTitleText()); + + HtmlForm loginForm = page.getForms().get(0); + + loginForm.getInputByName("username").setValueAttribute("alice"); + loginForm.getInputByName("password").setValueAttribute("alice"); + + page = loginForm.getInputByName("login").click(); + + assertEquals("Welcome to Test App", page.getTitleText()); + + page = webClient.getPage("http://localhost:8081/web-app"); + + assertEquals("alice", page.getBody().asText()); + } + } + + @Test + public void testRefreshTokenInjection() throws IOException, InterruptedException { + try (final WebClient webClient = new WebClient()) { + HtmlPage page = webClient.getPage("http://localhost:8081/index.html"); + + assertEquals("Log in to quarkus", page.getTitleText()); + + HtmlForm loginForm = page.getForms().get(0); + + loginForm.getInputByName("username").setValueAttribute("alice"); + loginForm.getInputByName("password").setValueAttribute("alice"); + + page = loginForm.getInputByName("login").click(); + + assertEquals("Welcome to Test App", page.getTitleText()); + + page = webClient.getPage("http://localhost:8081/web-app/refresh"); + + assertEquals("injected", page.getBody().asText()); + } + } + + private Cookie getSessionCookie(WebClient webClient) { + return webClient.getCookieManager().getCookie("q_session"); + } +} diff --git a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/KeycloakTestResource.java b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/KeycloakTestResource.java new file mode 100644 index 0000000000000..12efe03006421 --- /dev/null +++ b/integration-tests/oidc-code-flow/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/oidc/META-INF/resources/index.html b/integration-tests/oidc/META-INF/resources/index.html new file mode 100644 index 0000000000000..2c950d499ab61 --- /dev/null +++ b/integration-tests/oidc/META-INF/resources/index.html @@ -0,0 +1,9 @@ + + + + + Welcome to Test App + + + + \ No newline at end of file diff --git a/integration-tests/oidc/pom.xml b/integration-tests/oidc/pom.xml index e2d1e290b62b0..6421acc825e78 100644 --- a/integration-tests/oidc/pom.xml +++ b/integration-tests/oidc/pom.xml @@ -16,6 +16,7 @@ http://localhost:8180/auth + 2.36.0 @@ -47,6 +48,22 @@ rest-assured test + + net.sourceforge.htmlunit + htmlunit + ${htmlunit.version} + test + + + org.apache.httpcomponents + httpmime + + + commons-logging + commons-logging + + + diff --git a/integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java b/integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java index a6d2b866e291e..5aa15bd170013 100644 --- a/integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java +++ b/integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java @@ -1,7 +1,6 @@ package io.quarkus.it.keycloak; import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.everyItem; import java.io.IOException; import java.util.ArrayList; @@ -11,7 +10,6 @@ import org.hamcrest.Matchers; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.idm.ClientRepresentation; @@ -170,26 +168,6 @@ public void testAccessAdminResource() { .body(Matchers.containsString("granted")); } - @Test - @Disabled("Need to figure out exactly what is being reported here") - public void testPermissionClaimsInformationProvider() { - RestAssured.given().auth().oauth2(getAccessToken("alice")) - .when().get("/api/permission/claims-cip") - .then() - .statusCode(200) - .body("claims", everyItem(Matchers.hasKey("claim-a"))); - RestAssured.given().auth().oauth2(getAccessToken("alice")) - .when().get("/api/permission/claims-cip") - .then() - .statusCode(200) - .body("claims", everyItem(Matchers.hasKey("global-claim"))); - RestAssured.given().auth().oauth2(getAccessToken("admin")) - .when().get("/api/permission/claims-cip") - .then() - .statusCode(200) - .body("claims", everyItem(Matchers.hasKey("global-claim"))); - } - @Test public void testPermissionHttpInformationProvider() { RestAssured.given().auth().oauth2(getAccessToken("alice")) @@ -208,11 +186,10 @@ public void testDeniedAccessAdminResource() { } @Test - @Disabled public void testDeniedNoBearerToken() { RestAssured.given() .when().get("/api/users/me").then() - .statusCode(403); + .statusCode(401); } private String getAccessToken(String userName) { diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 68b27059936a0..71c6f558fb1b4 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -53,6 +53,7 @@ elytron-undertow flyway oidc + oidc-code-flow keycloak-authorization reactive-pg-client reactive-mysql-client