[fixes #4480] - Initial Code Flow Support

This commit is contained in:
Pedro Igor
2019-10-25 18:41:17 -03:00
committed by Stuart Douglas
parent fd5265169d
commit 4b2c71b96e
37 changed files with 1336 additions and 221 deletions

View File

@@ -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

View File

@@ -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]

View File

@@ -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]

View File

@@ -6,7 +6,7 @@ import io.quarkus.deployment.annotations.BuildStep;
import io.quarkus.deployment.annotations.ExecutionTime;
import io.quarkus.deployment.annotations.Record;
import io.quarkus.deployment.builditem.EnableAllSecurityServicesBuildItem;
import io.quarkus.oidc.OidcConfig;
import io.quarkus.oidc.runtime.OidcConfig;
public class KeycloakPolicyEnforcerBuildStep {

View File

@@ -19,7 +19,7 @@ import org.keycloak.representations.adapters.config.AdapterConfig;
import org.keycloak.representations.adapters.config.PolicyEnforcerConfig;
import io.quarkus.arc.AlternativePriority;
import io.quarkus.oidc.OidcConfig;
import io.quarkus.oidc.runtime.OidcConfig;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
import io.quarkus.vertx.http.runtime.security.HttpAuthorizer;

View File

@@ -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

View File

@@ -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.ExecutionTime;
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 @@ public class VertxKeycloakBuildStep {
@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 @@ public class VertxKeycloakBuildStep {
@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());

View File

@@ -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 {
}

View File

@@ -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
}

View File

@@ -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<String> getClaimNames() {
return null;
}
@Override
public <T> T getClaim(String claimName) {
return null;
}
}
}

View File

@@ -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<SecurityIdentity> 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<ChallengeData> 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);
}
}

View File

@@ -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<SecurityIdentity> authenticate(IdentityProviderManager identityProviderManager,
TokenCredential token) {
return identityProviderManager.authenticate(new TokenAuthenticationRequest(token));
}
}

View File

@@ -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");
}
}

View File

@@ -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<SecurityIdentity> 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<ChallengeData> 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;
}
}

View File

@@ -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<Permission, CompletionStage<Boolean>>() {
@Override
public CompletionStage<Boolean> apply(Permission permission) {
return securityIdentity.checkPermission(permission);
}
})
.build();
}
@Override
public CompletionStage<SecurityIdentity> 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<SecurityIdentity, CompletionStage<SecurityIdentity>>() {
@Override
public CompletionStage<SecurityIdentity> 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<ChallengeData> getChallenge(RoutingContext context) {
removeSessionCookie(context);
ChallengeData challenge;
JsonObject params = new JsonObject();
List<Object> 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<SecurityIdentity> performCodeFlow(IdentityProviderManager identityProviderManager,
RoutingContext context) {
CompletableFuture<SecurityIdentity> 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<SecurityIdentity> 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);
}
}

View File

@@ -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");
}
}

View File

@@ -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 class OidcConfig {
return roles;
}
public ApplicationType getApplicationType() {
return applicationType;
}
@ConfigGroup
public static class Credentials {
@@ -130,4 +146,31 @@ public class OidcConfig {
}
}
@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<String> 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
}
}

View File

@@ -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.eclipse.microprofile.jwt.JsonWebToken;
import org.jose4j.jwt.JwtClaims;
import org.jose4j.jwt.consumer.InvalidJwtException;
import io.quarkus.oidc.runtime.OidcUtils;
import io.quarkus.security.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.AccessToken;
import io.vertx.ext.auth.oauth2.OAuth2Auth;
@ApplicationScoped
public class VertxOAuth2IdentityProvider implements IdentityProvider<TokenAuthenticationRequest> {
public class OidcIdentityProvider implements IdentityProvider<TokenAuthenticationRequest> {
private volatile OAuth2Auth auth;
private volatile OidcConfig config;
@@ -30,12 +30,12 @@ public class VertxOAuth2IdentityProvider implements IdentityProvider<TokenAuthen
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 class VertxOAuth2IdentityProvider implements IdentityProvider<TokenAuthen
@Override
public void handle(AsyncResult<AccessToken> 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;

View File

@@ -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<? extends TokenCredential> 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<String> getClaimNames() {
return null;
}
@Override
public <T> T getClaim(String claimName) {
return null;
}
}
}

View File

@@ -1,4 +1,4 @@
package io.quarkus.oidc;
package io.quarkus.oidc.runtime;
import org.jose4j.jwt.JwtClaims;
@@ -7,10 +7,10 @@ import io.smallrye.jwt.auth.principal.DefaultJWTCallerPrincipal;
/**
* 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;
}

View File

@@ -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.OAuth2ClientOptions;
import io.vertx.ext.auth.oauth2.providers.KeycloakAuth;
@Recorder
public class VertxKeycloakRecorder {
public class OidcRecorder {
public void setup(OidcConfig config, RuntimeValue<Vertx> vertx, BeanContainer beanContainer) {
OAuth2ClientOptions options = new OAuth2ClientOptions();
@@ -57,12 +58,19 @@ public class VertxKeycloakRecorder {
});
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) {

View File

@@ -7,7 +7,6 @@ import java.util.List;
import java.util.stream.Collectors;
import io.quarkus.oidc.OIDCException;
import io.quarkus.oidc.OidcConfig;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;

View File

@@ -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");
}
}

View File

@@ -13,7 +13,6 @@ import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import io.quarkus.oidc.OidcConfig;
import io.vertx.core.json.JsonObject;
public class OidcUtilsTest {

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Welcome to Test App</title>
</head>
<body>
</body>
</html>

View File

@@ -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`.

View File

@@ -0,0 +1,274 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>quarkus-integration-tests-parent</artifactId>
<groupId>io.quarkus</groupId>
<version>999-SNAPSHOT</version>
<relativePath>../</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>quarkus-integration-test-oidc-code-flow</artifactId>
<name>Quarkus - Integration Tests - OpenID Connect Adapter Code Flow</name>
<description>Module that contains OpenID Connect Code Flow related tests</description>
<properties>
<keycloak.url>http://localhost:8180/auth</keycloak.url>
<htmlunit.version>2.36.0</htmlunit.version>
</properties>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-oidc</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-adapter-core</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
</dependency>
<!-- test dependencies -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.sourceforge.htmlunit</groupId>
<artifactId>htmlunit</artifactId>
<version>${htmlunit.version}</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpmime</artifactId>
</exclusion>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
<plugin>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${project.version}</version>
<executions>
<execution>
<goals>
<goal>build</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>test-keycloak</id>
<activation>
<property>
<name>test-keycloak</name>
</property>
</activation>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<skip>false</skip>
<systemPropertyVariables>
<keycloak.url>${keycloak.url}</keycloak.url>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<configuration>
<skip>false</skip>
<systemPropertyVariables>
<keycloak.url>${keycloak.url}</keycloak.url>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${project.version}</version>
<executions>
<execution>
<goals>
<goal>build</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
<profile>
<id>native-image</id>
<activation>
<property>
<name>native</name>
</property>
</activation>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
<configuration>
<systemProperties>
<native.image.path>${project.build.directory}/${project.build.finalName}-runner</native.image.path>
</systemProperties>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${project.version}</version>
<executions>
<execution>
<id>native-image</id>
<goals>
<goal>native-image</goal>
</goals>
<configuration>
<reportErrorsAtRuntime>false</reportErrorsAtRuntime>
<cleanupServer>true</cleanupServer>
<enableHttpUrlHandler>true</enableHttpUrlHandler>
<enableServer>false</enableServer>
<dumpProxies>false</dumpProxies>
<!-- Requires Quarkus Graal fork to work, will fail otherwise
<enableRetainedHeapReporting>true</enableRetainedHeapReporting>
<enableCodeSizeReporting>true</enableCodeSizeReporting>
-->
<graalvmHome>${graalvmHome}</graalvmHome>
<debugBuildProcess>false</debugBuildProcess>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
<profile>
<id>docker-keycloak</id>
<activation>
<property>
<name>docker</name>
</property>
</activation>
<properties>
<keycloak.url>http://localhost:8180/auth</keycloak.url>
</properties>
<build>
<plugins>
<plugin>
<groupId>io.fabric8</groupId>
<artifactId>docker-maven-plugin</artifactId>
<version>0.28.0</version>
<configuration>
<images>
<image>
<name>quay.io/keycloak/keycloak</name>
<alias>quarkus-test-keycloak</alias>
<run>
<ports>
<port>8180:8080</port>
</ports>
<env>
<KEYCLOAK_USER>admin</KEYCLOAK_USER>
<KEYCLOAK_PASSWORD>admin</KEYCLOAK_PASSWORD>
</env>
<log>
<prefix>Keycloak:</prefix>
<date>default</date>
<color>cyan</color>
</log>
<wait>
<!-- good docs found at: http://dmp.fabric8.io/#build-healthcheck -->
<http>
<url>http://localhost:8180</url>
</http>
<time>100000</time>
</wait>
</run>
</image>
</images>
<allContainers>true</allContainers>
</configuration>
<executions>
<execution>
<id>docker-start</id>
<phase>compile</phase>
<goals>
<goal>stop</goal>
<goal>start</goal>
</goals>
</execution>
<execution>
<id>docker-stop</id>
<phase>post-integration-test</phase>
<goals>
<goal>stop</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

View File

@@ -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;
}
}

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Welcome to Test App</title>
</head>
<body>
</body>
</html>

View File

@@ -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

View File

@@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
@QuarkusTestResource(KeycloakTestResource.class)
@SubstrateTest
@Disabled("While figuring out how to have different application.properties for different tests")
public class CodeFlowInGraalITCase extends CodeFlowTest {
}

View File

@@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
@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<RoleRepresentation> 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");
}
}

View File

@@ -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<String, String> start() {
HashMap<String, String> 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() {
}
}

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Welcome to Test App</title>
</head>
<body>
</body>
</html>

View File

@@ -16,6 +16,7 @@
<properties>
<keycloak.url>http://localhost:8180/auth</keycloak.url>
<htmlunit.version>2.36.0</htmlunit.version>
</properties>
<dependencies>
@@ -47,6 +48,22 @@
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.sourceforge.htmlunit</groupId>
<artifactId>htmlunit</artifactId>
<version>${htmlunit.version}</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpmime</artifactId>
</exclusion>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>

View File

@@ -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 java.util.List;
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 class BearerTokenAuthorizationTest {
.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 class BearerTokenAuthorizationTest {
}
@Test
@Disabled
public void testDeniedNoBearerToken() {
RestAssured.given()
.when().get("/api/users/me").then()
.statusCode(403);
.statusCode(401);
}
private String getAccessToken(String userName) {

View File

@@ -53,6 +53,7 @@
<module>elytron-undertow</module>
<module>flyway</module>
<module>oidc</module>
<module>oidc-code-flow</module>
<module>keycloak-authorization</module>
<module>reactive-pg-client</module>
<module>reactive-mysql-client</module>