From c7f419f6c7357d707af77aa3ec6071d97c66b212 Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Tue, 19 Sep 2023 18:09:45 +0100 Subject: [PATCH] Add OidcTestClient --- ...rity-oidc-bearer-token-authentication.adoc | 64 ++++++ .../propagation/OidcTokenPropagationTest.java | 13 +- test-framework/oidc-server/pom.xml | 8 + .../test/oidc/client/OidcTestClient.java | 204 ++++++++++++++++++ .../oidc/server/OidcWiremockTestResource.java | 25 +++ 5 files changed, 311 insertions(+), 3 deletions(-) create mode 100644 test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/client/OidcTestClient.java diff --git a/docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc b/docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc index 30b4f0d207f84..0ff32483394aa 100644 --- a/docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc +++ b/docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc @@ -515,6 +515,70 @@ public class CustomOidcWireMockStubTest { } ---- +[[integration-testing-oidc-test-client]] +=== OidcTestClient + +If you work with SaaS OIDC providers such as `Auth0` and would like to run tests against the test (development) domain or prefer to run tests against a remote Keycloak test realm, when you already have `quarkus.oidc.auth-server-url` configured, you can use `OidcTestClient`. + +For example, lets assume you have the following configuration: + +[source,properties] +---- +%test.quarkus.oidc.auth-server-url=https://dev-123456.eu.auth0.com/ +%test.quarkus.oidc.client-id=test-auth0-client +%test.quarkus.oidc.credentials.secret=secret +---- + +Start with addding the same dependency as in the <> section, `quarkus-test-oidc-server`. + +Next, write the test code like this: + +[source, java] +---- +package org.acme; + +import org.junit.jupiter.api.AfterAll; +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.is; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.oidc.client.OidcTestClient; + +@QuarkusTest +public class GreetingResourceTest { + + static OidcTestClient oidcTestClient = new OidcTestClient(); + + @AfterAll + public static void close() { + client.close(); + } + + @Test + public void testHelloEndpoint() { + given() + .auth().oauth2(getAccessToken("alice", "alice")) + .when().get("/hello") + .then() + .statusCode(200) + .body(is("Hello, Alice")); + } + + private String getAccessToken(String name, String secret) { + return oidcTestClient.getAccessToken(name, secret, + Map.of("audience", "https://dev-123456.eu.auth0.com/api/v2/", + "scope", "profile")); + } +} +---- + +This test code acquires a token using a `password` grant from the test `Auth0` domain which has an application with the client id `test-auth0-client` registered, and which has a user `alice` with a password `alice` created. The test `Auth0` application must have the `password` grant enabled for a test like this one to work. This example code also shows how to pass additional parameters. For `Auth0`, these are the `audience` and `scope` parameters. + + [[integration-testing-keycloak-devservices]] ==== Dev Services for Keycloak diff --git a/extensions/oidc-token-propagation/deployment/src/test/java/io/quarkus/oidc/token/propagation/OidcTokenPropagationTest.java b/extensions/oidc-token-propagation/deployment/src/test/java/io/quarkus/oidc/token/propagation/OidcTokenPropagationTest.java index 74af148742129..2ad093b9bec6d 100644 --- a/extensions/oidc-token-propagation/deployment/src/test/java/io/quarkus/oidc/token/propagation/OidcTokenPropagationTest.java +++ b/extensions/oidc-token-propagation/deployment/src/test/java/io/quarkus/oidc/token/propagation/OidcTokenPropagationTest.java @@ -2,19 +2,21 @@ import static org.hamcrest.Matchers.equalTo; -import java.util.Set; - +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.test.QuarkusUnitTest; import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.oidc.client.OidcTestClient; import io.quarkus.test.oidc.server.OidcWiremockTestResource; import io.restassured.RestAssured; @QuarkusTestResource(OidcWiremockTestResource.class) public class OidcTokenPropagationTest { + final static OidcTestClient client = new OidcTestClient(); + private static Class[] testClasses = { FrontendResource.class, ProtectedResource.class, @@ -27,6 +29,11 @@ public class OidcTokenPropagationTest { .addClasses(testClasses) .addAsResource("application.properties")); + @AfterAll + public static void close() { + client.close(); + } + @Test public void testGetUserNameWithTokenPropagation() { RestAssured.given().auth().oauth2(getBearerAccessToken()) @@ -37,7 +44,7 @@ public void testGetUserNameWithTokenPropagation() { } public String getBearerAccessToken() { - return OidcWiremockTestResource.getAccessToken("alice", Set.of("admin")); + return client.getAccessToken("alice", "alice"); } } diff --git a/test-framework/oidc-server/pom.xml b/test-framework/oidc-server/pom.xml index 84b923627046f..850d44d016a43 100644 --- a/test-framework/oidc-server/pom.xml +++ b/test-framework/oidc-server/pom.xml @@ -29,6 +29,14 @@ io.quarkus quarkus-test-common + + io.smallrye.reactive + smallrye-mutiny-vertx-web-client + + + org.awaitility + awaitility + diff --git a/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/client/OidcTestClient.java b/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/client/OidcTestClient.java new file mode 100644 index 0000000000000..d0964004442a3 --- /dev/null +++ b/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/client/OidcTestClient.java @@ -0,0 +1,204 @@ +package io.quarkus.test.oidc.client; + +import static org.awaitility.Awaitility.await; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; + +import org.eclipse.microprofile.config.ConfigProvider; + +import io.vertx.core.MultiMap; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.ext.web.client.WebClient; + +public class OidcTestClient { + + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10); + private final static String CLIENT_AUTH_SERVER_URL_PROP = "client.quarkus.oidc.auth-server-url"; + private final static String AUTH_SERVER_URL_PROP = "quarkus.oidc.auth-server-url"; + private final static String CLIENT_ID_PROP = "quarkus.oidc.client-id"; + private final static String CLIENT_SECRET_PROP = "quarkus.oidc.credentials.secret"; + + Vertx vertx = Vertx.vertx(); + WebClient client = WebClient.create(vertx); + + private String authServerUrl; + private String tokenUrl; + + /** + * Get an access token a client_credentials grant. + * Client id must be configured with the `quarkus.oidc.client-id` property. + * Client secret must be configured with the `quarkus.oidc.credentials.secret` property. + */ + public String getClientAccessToken() { + return getClientAccessToken(null); + } + + /** + * Get an access token a client_credentials grant with additional properties. + * Client id must be configured with the `quarkus.oidc.client-id` property. + * Client secret must be configured with the `quarkus.oidc.credentials.secret` property. + */ + public String getClientAccessToken(Map extraProps) { + return getClientAccessToken(getClientId(), getClientSecret(), extraProps); + } + + /** + * Get an access token from the default tenant realm using a client_credentials grant with a + * the provided client id and secret. + */ + public String getClientAccessToken(String clientId, String clientSecret) { + return getClientAccessToken(clientId, clientSecret, null); + } + + /** + * Get an access token using a client_credentials grant with the provided client id and secret, + * and additional properties. + */ + public String getClientAccessToken(String clientId, String clientSecret, Map extraProps) { + MultiMap requestMap = MultiMap.caseInsensitiveMultiMap(); + requestMap.add("grant_type", "client_credentials") + .add("client_id", clientId); + if (clientSecret != null && !clientSecret.isBlank()) { + requestMap.add("client_secret", clientSecret); + } + return getAccessTokenInternal(requestMap, extraProps); + } + + /** + * Get an access token from the default tenant realm using a password grant with the provided user name, user secret. + * Client id must be configured with the `quarkus.oidc.client-id` property. + * Client secret must be configured with the `quarkus.oidc.credentials.secret` property. + */ + public String getAccessToken(String userName, String userSecret) { + return getAccessToken(userName, userSecret, null); + } + + /** + * Get an access token from the default tenant realm using a password grant with the provided user name, user secret, + * and additional properties. + * Client id must be configured with the `quarkus.oidc.client-id` property. + * Client secret must be configured with the `quarkus.oidc.credentials.secret` property. + */ + public String getAccessToken(String userName, String userSecret, Map extraProps) { + return getAccessToken(getClientId(), getClientSecret(), userName, userSecret, extraProps); + } + + /** + * Get an access token from the default tenant realm using a password grant with the provided client id, client secret, user + * name, user secret, client + * id and user secret. + */ + public String getAccessToken(String clientId, String clientSecret, String userName, String userSecret) { + return getAccessToken(userName, userSecret, clientId, clientSecret, null); + } + + /** + * Get an access token using a password grant with the provided user name, user secret, client + * id and secret, and scopes. + */ + public String getAccessToken(String clientId, String clientSecret, String userName, String userSecret, + Map extraProps) { + + MultiMap requestMap = MultiMap.caseInsensitiveMultiMap(); + requestMap.add("grant_type", "password") + .add("username", userName) + .add("password", userSecret); + + requestMap.add("client_id", clientId); + if (clientSecret != null && !clientSecret.isBlank()) { + requestMap.add("client_secret", clientSecret); + } + return getAccessTokenInternal(requestMap, extraProps); + } + + private String getAccessTokenInternal(MultiMap requestMap, Map extraProps) { + + if (extraProps != null) { + requestMap = requestMap.addAll(extraProps); + } + + var result = client.postAbs(getTokenUrl()) + .putHeader("Content-Type", "application/x-www-form-urlencoded") + .sendBuffer(encodeForm(requestMap)); + await().atMost(REQUEST_TIMEOUT).until(result::isComplete); + + return result.result().bodyAsJsonObject().getString("access_token"); + } + + private String getClientId() { + return getPropertyValue(CLIENT_ID_PROP); + } + + private String getClientSecret() { + return getPropertyValue(CLIENT_SECRET_PROP); + } + + /** + * Return URL string configured with a 'quarkus.oidc.auth-server' property. + */ + public String getAuthServerUrl() { + if (authServerUrl == null) { + authServerUrl = getOptionalPropertyValue(CLIENT_AUTH_SERVER_URL_PROP, AUTH_SERVER_URL_PROP); + } + return authServerUrl; + } + + /** + * Return URL string configured with a 'quarkus.oidc.auth-server' property. + */ + public String getTokenUrl() { + if (tokenUrl == null) { + getAuthServerUrl(); + var result = client.getAbs(authServerUrl + "/.well-known/openid-configuration") + .send(); + await().atMost(REQUEST_TIMEOUT).until(result::isComplete); + tokenUrl = result.result().bodyAsJsonObject().getString("token_endpoint"); + } + return tokenUrl; + } + + private String getPropertyValue(String prop) { + return ConfigProvider.getConfig().getValue(prop, String.class); + } + + private String getOptionalPropertyValue(String prop, String defaultProp) { + return ConfigProvider.getConfig().getOptionalValue(prop, String.class) + .orElseGet(() -> ConfigProvider.getConfig().getValue(defaultProp, String.class)); + } + + public static Buffer encodeForm(MultiMap form) { + Buffer buffer = Buffer.buffer(); + for (Map.Entry entry : form) { + if (buffer.length() != 0) { + buffer.appendByte((byte) '&'); + } + buffer.appendString(entry.getKey()); + buffer.appendByte((byte) '='); + buffer.appendString(urlEncode(entry.getValue())); + } + return buffer; + } + + private static String urlEncode(String value) { + try { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + } + + public void close() { + if (client != null) { + client.close(); + client = null; + } + if (vertx != null) { + vertx.close().toCompletionStage().toCompletableFuture().join(); + vertx = null; + } + } +} diff --git a/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java b/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java index 7b4ffff14d1f6..8c1533edc2430 100644 --- a/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java +++ b/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java @@ -205,6 +205,9 @@ public Map start() { "") .withTransformers("response-template"))); + definePasswordGrantTokenStub(); + defineClientCredGrantTokenStub(); + LOG.infof("Keycloak started in mock mode: %s", server.baseUrl()); Map conf = new HashMap<>(); conf.put("keycloak.url", server.baseUrl() + "/auth"); @@ -293,6 +296,28 @@ private void defineCodeFlowAuthorizationMockTokenStub() { "}"))); } + private void definePasswordGrantTokenStub() { + server.stubFor(post("/auth/realms/quarkus/token") + .withRequestBody(containing("grant_type=password")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\n" + + " \"access_token\": \"" + + getAccessToken("alice", getAdminRoles()) + "\",\n" + + " \"refresh_token\": \"07e08903-1263-4dd1-9fd1-4a59b0db5283\"}"))); + } + + private void defineClientCredGrantTokenStub() { + server.stubFor(post("/auth/realms/quarkus/token") + .withRequestBody(containing("grant_type=client_credentials")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\n" + + " \"access_token\": \"" + + getAccessToken("alice", getAdminRoles()) + "\",\n" + + " \"refresh_token\": \"07e08903-1263-4dd1-9fd1-4a59b0db5283\"}"))); + } + private void defineCodeFlowAuthorizationMockEncryptedTokenStub() { server.stubFor(post("/auth/realms/quarkus/encrypted-id-token") .withRequestBody(containing("authorization_code"))