From 2848c9c467741cd9c956d4bd36eef1c04423b614 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 --- .../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 +-- .../VertxOAuth2AuthenticationMechanism.java | 103 ---------- .../AbstractOidcAuthenticationMechanism.java | 28 +++ .../BearerAuthenticationMechanism.java | 59 ++++++ .../runtime/CodeAuthenticationMechanism.java | 132 ++++++++++++ .../oidc/{ => runtime}/OidcConfig.java | 36 +++- .../OidcIdentityProvider.java} | 11 +- .../OidcJwtCallerPrincipal.java} | 6 +- .../OidcJwtPrincipalProducer.java} | 4 +- .../OidcRecorder.java} | 18 +- .../oidc/META-INF/resources/index.html | 9 + integration-tests/oidc/pom.xml | 17 ++ .../resources/META-INF/resources/index.html | 9 + .../it/keycloak/CodeFlowInGraalITCase.java | 15 ++ .../io/quarkus/it/keycloak/CodeFlowTest.java | 193 ++++++++++++++++++ 19 files changed, 703 insertions(+), 135 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} (51%) 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/BearerAuthenticationMechanism.java create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java rename extensions/oidc/runtime/src/main/java/io/quarkus/oidc/{ => runtime}/OidcConfig.java (63%) rename extensions/oidc/runtime/src/main/java/io/quarkus/oidc/{VertxOAuth2IdentityProvider.java => runtime/OidcIdentityProvider.java} (87%) rename extensions/oidc/runtime/src/main/java/io/quarkus/oidc/{VertxJwtCallerPrincipal.java => runtime/OidcJwtCallerPrincipal.java} (74%) rename extensions/oidc/runtime/src/main/java/io/quarkus/oidc/{VertxJwtPrincipalProducer.java => runtime/OidcJwtPrincipalProducer.java} (94%) rename extensions/oidc/runtime/src/main/java/io/quarkus/oidc/{VertxKeycloakRecorder.java => runtime/OidcRecorder.java} (78%) create mode 100644 integration-tests/oidc/META-INF/resources/index.html create mode 100644 integration-tests/oidc/src/main/resources/META-INF/resources/index.html create mode 100644 integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/CodeFlowInGraalITCase.java create mode 100644 integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java 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 00000000000000..814ac6935e83b5 --- /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 - Using OpenID Connect Adapter to Protect Web Applications + +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]. + +In regards to authentication, 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 in your web application 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, resteasy" +---- + +== 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 9f422f66c94d47..893c50d3d7887c 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 df4eb958155877..42579507d7ca59 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 722460e58f77dc..1b25bb93649763 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 51% 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 5a8d4991554c1c..b23ab302c41d4f 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.OidcJwtPrincipalProducer; +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.ClientType.SERVICE.equals(config.getClientType())) { + beans.addBeanClass(BearerAuthenticationMechanism.class); + } else if (OidcConfig.ClientType.WEB_APP.equals(config.getClientType())) { + beans.addBeanClass(CodeAuthenticationMechanism.class); + } + + return beans.addBeanClass(OidcJwtPrincipalProducer.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/VertxOAuth2AuthenticationMechanism.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxOAuth2AuthenticationMechanism.java index 0dba72250a684f..e69de29bb2d1d6 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxOAuth2AuthenticationMechanism.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxOAuth2AuthenticationMechanism.java @@ -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 00000000000000..4bf71967698935 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AbstractOidcAuthenticationMechanism.java @@ -0,0 +1,28 @@ +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 reAuthenticate(IdentityProviderManager identityProviderManager, String token) { + return identityProviderManager.authenticate(new TokenAuthenticationRequest(new TokenCredential(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 00000000000000..67dd233964a03b --- /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 reAuthenticate(identityProviderManager, 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 00000000000000..d6a1c737848fd5 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java @@ -0,0 +1,132 @@ +package io.quarkus.oidc.runtime; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +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.AuthenticationFailedException; +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.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"; + + @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) { + return reAuthenticate(identityProviderManager, sessionCookie.getValue()); + } + + // 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.defaultScopes); + + 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()); + + reAuthenticate(identityProviderManager, 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, result.opaqueIdToken()); + + cookie.setMaxAge(result.idToken().getInteger("exp")); + cookie.setSecure(context.request().isSSL()); + cookie.setHttpOnly(true); + + context.response().addCookie(cookie); + cf.complete(securityIdentity); + } + + 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/OidcConfig.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfig.java similarity index 63% 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 e4930e24e32920..abd7d36ebaf1cb 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; @@ -55,6 +56,20 @@ public class OidcConfig { @ConfigItem Credentials credentials; + /** + * The client type, which can be one of the following values from enum {@link ClientType}.. + */ + @ConfigItem(defaultValue = "service") + ClientType clientType; + + /** + * 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 defaultScopes; + public String getAuthServerUrl() { return authServerUrl; } @@ -67,6 +82,10 @@ public Credentials getCredentials() { return credentials; } + public ClientType getClientType() { + return clientType; + } + @ConfigGroup public static class Credentials { @@ -81,4 +100,19 @@ public Optional getSecret() { } } + public enum ClientType { + /** + * 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 87% 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 15ea2e3bf6c8ab..99cd4be96b9cd0 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; @@ -8,6 +8,7 @@ import org.jose4j.jwt.JwtClaims; import org.jose4j.jwt.consumer.InvalidJwtException; +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 +22,7 @@ import io.vertx.ext.auth.oauth2.OAuth2Auth; @ApplicationScoped -public class VertxOAuth2IdentityProvider implements IdentityProvider { +public class OidcIdentityProvider implements IdentityProvider { private volatile OAuth2Auth auth; @@ -29,7 +30,7 @@ public OAuth2Auth getAuth() { return auth; } - public VertxOAuth2IdentityProvider setAuth(OAuth2Auth auth) { + public OidcIdentityProvider setAuth(OAuth2Auth auth) { this.auth = auth; return this; } @@ -47,7 +48,7 @@ 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(); @@ -59,7 +60,7 @@ public void handle(AsyncResult event) { JwtClaims jwtClaims = JwtClaims.parse(token.accessToken().encode()); String username = token.principal().getString("username"); - builder.setPrincipal(new VertxJwtCallerPrincipal(username, jwtClaims)); + builder.setPrincipal(new OidcJwtCallerPrincipal(username, jwtClaims)); } catch (InvalidJwtException e) { result.completeExceptionally(e); return; 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 74% 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 3718fe446c3a82..a4b93632b5ce30 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,11 +7,11 @@ /** * An implementation of JWTCallerPrincipal that builds on the Elytron attributes */ -public class VertxJwtCallerPrincipal extends DefaultJWTCallerPrincipal { +public class OidcJwtCallerPrincipal extends DefaultJWTCallerPrincipal { private JwtClaims claims; private String customPrincipalName; - public VertxJwtCallerPrincipal(final String customPrincipalName, final JwtClaims claims) { + public OidcJwtCallerPrincipal(final String customPrincipalName, final JwtClaims claims) { super(claims); this.claims = claims; this.customPrincipalName = customPrincipalName; diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxJwtPrincipalProducer.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcJwtPrincipalProducer.java similarity index 94% rename from extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxJwtPrincipalProducer.java rename to extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcJwtPrincipalProducer.java index d996216528d736..7cc661057ec60d 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/VertxJwtPrincipalProducer.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcJwtPrincipalProducer.java @@ -1,4 +1,4 @@ -package io.quarkus.oidc; +package io.quarkus.oidc.runtime; import java.util.Set; @@ -15,7 +15,7 @@ @Priority(2) @Alternative @RequestScoped -public class VertxJwtPrincipalProducer { +public class OidcJwtPrincipalProducer { @Inject SecurityIdentity identity; 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 78% 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 e9e7fba401c300..19a8c8c1b11770 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,4 +1,4 @@ -package io.quarkus.oidc; +package io.quarkus.oidc.runtime; import java.util.concurrent.CompletableFuture; @@ -14,7 +14,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,9 +57,15 @@ public void handle(AsyncResult event) { }); OAuth2Auth auth = cf.join(); - beanContainer.instance(VertxOAuth2IdentityProvider.class).setAuth(auth); - VertxOAuth2AuthenticationMechanism mechanism = beanContainer.instance(VertxOAuth2AuthenticationMechanism.class); - mechanism.setAuth(auth); - mechanism.setAuthServerURI(config.authServerUrl); + beanContainer.instance(OidcIdentityProvider.class).setAuth(auth); + AbstractOidcAuthenticationMechanism mechanism = null; + + if (OidcConfig.ClientType.SERVICE.equals(config.clientType)) { + mechanism = beanContainer.instance(BearerAuthenticationMechanism.class); + } else if (OidcConfig.ClientType.WEB_APP.equals(config.clientType)) { + mechanism = beanContainer.instance(CodeAuthenticationMechanism.class); + } + + mechanism.setAuth(auth, config); } } 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 00000000000000..2c950d499ab614 --- /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 e2d1e290b62b04..6421acc825e78d 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/main/resources/META-INF/resources/index.html b/integration-tests/oidc/src/main/resources/META-INF/resources/index.html new file mode 100644 index 00000000000000..2c950d499ab614 --- /dev/null +++ b/integration-tests/oidc/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/src/test/java/io/quarkus/it/keycloak/CodeFlowInGraalITCase.java b/integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/CodeFlowInGraalITCase.java new file mode 100644 index 00000000000000..11f42326093272 --- /dev/null +++ b/integration-tests/oidc/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/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java b/integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java new file mode 100644 index 00000000000000..8d5f2411a90c33 --- /dev/null +++ b/integration-tests/oidc/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java @@ -0,0 +1,193 @@ +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.Disabled; +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 +@Disabled("While figuring out how to have different application.properties for different tests") +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(false); + client.setSecret("secret"); + 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()); + } + } + + private Cookie getSessionCookie(WebClient webClient) { + return webClient.getCookieManager().getCookie("q_session"); + } +}