diff --git a/integration-tests/oidc-wiremock/pom.xml b/integration-tests/oidc-wiremock/pom.xml index 155a9ab6893896..196cdce43b11d5 100644 --- a/integration-tests/oidc-wiremock/pom.xml +++ b/integration-tests/oidc-wiremock/pom.xml @@ -49,6 +49,17 @@ <artifactId>jakarta.servlet-api</artifactId> <scope>test</scope> </dependency> + <dependency> + <groupId>net.sourceforge.htmlunit</groupId> + <artifactId>htmlunit</artifactId> + <scope>test</scope> + <exclusions> + <exclusion> + <groupId>org.eclipse.jetty</groupId> + <artifactId>*</artifactId> + </exclusion> + </exclusions> + </dependency> <!-- Minimal test dependencies to *-deployment artifacts for consistent build order --> <dependency> <groupId>io.quarkus</groupId> diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowResource.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowResource.java new file mode 100644 index 00000000000000..ad9fe768cf9043 --- /dev/null +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowResource.java @@ -0,0 +1,15 @@ +package io.quarkus.it.keycloak; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +import io.quarkus.security.Authenticated; + +@Path("/code-flow") +@Authenticated +public class CodeFlowResource { + + @GET + public void access() { + } +} diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java new file mode 100644 index 00000000000000..4b8c71899a9842 --- /dev/null +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java @@ -0,0 +1,19 @@ +package io.quarkus.it.keycloak; + +import javax.enterprise.context.ApplicationScoped; + +import io.quarkus.oidc.TenantResolver; +import io.vertx.ext.web.RoutingContext; + +@ApplicationScoped +public class CustomTenantResolver implements TenantResolver { + + @Override + public String resolve(RoutingContext context) { + String path = context.normalisedPath(); + if (path.endsWith("code-flow")) { + return "code-flow"; + } + return null; + } +} \ No newline at end of file diff --git a/integration-tests/oidc-wiremock/src/main/resources/META-INF/resources/index.html b/integration-tests/oidc-wiremock/src/main/resources/META-INF/resources/index.html new file mode 100644 index 00000000000000..a5410d867f2f8a --- /dev/null +++ b/integration-tests/oidc-wiremock/src/main/resources/META-INF/resources/index.html @@ -0,0 +1,8 @@ +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Welcome to Test App</title> +</head> +<body> +</body> +</html> \ No newline at end of file diff --git a/integration-tests/oidc-wiremock/src/main/resources/application.properties b/integration-tests/oidc-wiremock/src/main/resources/application.properties index 3f6978e14c1530..6e35a7f68e32f6 100644 --- a/integration-tests/oidc-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-wiremock/src/main/resources/application.properties @@ -2,6 +2,12 @@ quarkus.oidc.auth-server-url=${keycloak.url}/realms/quarkus/ quarkus.oidc.client-id=quarkus-app quarkus.oidc.credentials.secret=secret -quarkus.oidc.token.principal-claim=email +quarkus.oidc.authentication.scopes=profile,email,phone -smallrye.jwt.sign.key-location=privateKey.jwk +quarkus.oidc.code-flow.auth-server-url=${keycloak.url}/realms/quarkus/ +quarkus.oidc.code-flow.client-id=quarkus-web-app +quarkus.oidc.code-flow.credentials.secret=secret +quarkus.oidc.code-flow.application-type=web-app +quarkus.oidc.code-flow.authentication.redirect-path=/index.html + +smallrye.jwt.sign.key-location=privateKey.jwk \ No newline at end of file diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java new file mode 100644 index 00000000000000..e3b37e303cd7a2 --- /dev/null +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java @@ -0,0 +1,68 @@ +package io.quarkus.it.keycloak; + +import com.gargoylesoftware.htmlunit.SilentCssErrorHandler; +import com.gargoylesoftware.htmlunit.WebClient; +import com.gargoylesoftware.htmlunit.WebRequest; +import com.gargoylesoftware.htmlunit.WebResponse; +import com.gargoylesoftware.htmlunit.html.HtmlForm; +import com.gargoylesoftware.htmlunit.html.HtmlPage; +import com.gargoylesoftware.htmlunit.util.Cookie; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.URI; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@QuarkusTest +@QuarkusTestResource(KeycloakTestResource.class) +public class CodeFlowAuthorizationTest { + + @Test + public void testCodeFlow() throws IOException { + try (final WebClient webClient = createWebClient()) { + webClient.getOptions().setRedirectEnabled(false); + WebResponse webResponse = webClient + .loadWebResponse(new WebRequest(URI.create("http://localhost:8081/code-flow").toURL())); + verifyLocationHeader(webClient, webResponse.getResponseHeaderValue("location"), "code-flow", "index.html", false); + + webClient.getOptions().setRedirectEnabled(true); + HtmlPage page = webClient.getPage("http://localhost:8081/code-flow"); + + HtmlForm form = page.getFormByName("form"); + form.getInputByName("username").type("alice"); + form.getInputByName("password").type("alice"); + + page = form.getInputByValue("login").click(); + + assertEquals("Welcome to Test App", page.getTitleText()); + } + } + + private WebClient createWebClient() { + WebClient webClient = new WebClient(); + webClient.setCssErrorHandler(new SilentCssErrorHandler()); + return webClient; + } + + private void verifyLocationHeader(WebClient webClient, String loc, String tenant, String path, boolean forceHttps) { + assertTrue(loc.contains("/auth")); + String scheme = forceHttps ? "https" : "http"; + assertTrue(loc.contains("redirect_uri=" + scheme + "%3A%2F%2Flocalhost%3A8081%2F" + path)); + assertTrue(loc.contains("state=" + getStateCookieStateParam(webClient, tenant))); + assertTrue(loc.contains("scope=openid")); + assertTrue(loc.contains("response_type=code")); + assertTrue(loc.contains("client_id=quarkus-web-app")); + } + + private Cookie getStateCookie(WebClient webClient, String tenantId) { + return webClient.getCookieManager().getCookie("q_auth" + (tenantId == null ? "" : "_" + tenantId)); + } + + private String getStateCookieStateParam(WebClient webClient, String tenantId) { + return getStateCookie(webClient, tenantId).getValue().split("\\|")[0]; + } +} diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/KeycloakTestResource.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/KeycloakTestResource.java index 27357e02c04f2c..725cea37e65ecc 100644 --- a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/KeycloakTestResource.java +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/KeycloakTestResource.java @@ -1,27 +1,26 @@ package io.quarkus.it.keycloak; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.extension.responsetemplating.ResponseTemplateTransformer; +import com.google.common.collect.ImmutableSet; +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; +import org.jboss.logging.Logger; + +import javax.ws.rs.core.MediaType; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; import static com.github.tomakehurst.wiremock.client.WireMock.get; import static com.github.tomakehurst.wiremock.client.WireMock.matching; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; import static java.util.Collections.emptySet; import static java.util.stream.Collectors.joining; -import java.util.Collections; -import java.util.Map; -import java.util.Set; - -import javax.ws.rs.core.MediaType; - -import org.jboss.logging.Logger; - -import com.github.tomakehurst.wiremock.WireMockServer; -import com.github.tomakehurst.wiremock.client.WireMock; -import com.google.common.collect.ImmutableSet; - -import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; - public class KeycloakTestResource implements QuarkusTestResourceLifecycleManager { private static final Logger LOG = Logger.getLogger(KeycloakTestResource.class); @@ -31,7 +30,10 @@ public class KeycloakTestResource implements QuarkusTestResourceLifecycleManager @Override public Map<String, String> start() { - server = new WireMockServer(wireMockConfig().dynamicPort()); + server = new WireMockServer( + wireMockConfig() + .extensions(new ResponseTemplateTransformer(false)) + .dynamicPort()); server.start(); server.stubFor( @@ -42,7 +44,8 @@ public Map<String, String> start() { " \"jwks_uri\": \"" + server.baseUrl() + "/auth/realms/quarkus/protocol/openid-connect/certs\",\n" + " \"token_introspection_endpoint\": \"" + server.baseUrl() - + "/auth/realms/quarkus/protocol/openid-connect/token/introspect\"\n" + + + "/auth/realms/quarkus/protocol/openid-connect/token/introspect\",\n" + + " \"authorization_endpoint\": \"" + server.baseUrl() + "/auth/realms/quarkus\"" + "}"))); server.stubFor( @@ -71,8 +74,42 @@ public Map<String, String> start() { // Invalid defineInvalidIntrospectionMockTokenStubForUserWithRoles("expired", emptySet()); + // Login Page + server.stubFor( + get(urlPathMatching("/auth/realms/quarkus")) + .willReturn(aResponse() + .withHeader("Content-Type", MediaType.TEXT_HTML) + .withBody("<html>\n" + + "<body>\n" + + " <form action=\"/login\" name=\"form\">\n" + + " <input type=\"text\" id=\"username\" name=\"username\"/>\n" + + " <input type=\"password\" id=\"password\" name=\"password\"/>\n" + + " <input type=\"hidden\" id=\"state\" name=\"state\" value=\"{{request.query.state}}\"/>\n" + + + " <input type=\"hidden\" id=\"redirect_uri\" name=\"redirect_uri\" value=\"{{request.query.redirect_uri}}\"/>\n" + + + " <input type=\"submit\" id=\"login\" value=\"login\"/>\n" + + "</form>\n" + + "</body>\n" + + "</html> ") + .withTransformers("response-template"))); + + // Login Request + server.stubFor( + get(urlPathMatching("/login")) + .willReturn(aResponse() + .withHeader("Location", "{{request.query.redirect_uri}}") + .withHeader("state", "{{request.query.state}}") + .withStatus(302) + .withTransformers("response-template"))); + LOG.infof("Keycloak started in mock mode: %s", server.baseUrl()); - return Collections.singletonMap("quarkus.oidc.auth-server-url", server.baseUrl() + "/auth/realms/quarkus"); + Map<String, String> conf = new HashMap<>(); + conf.put("quarkus.oidc.auth-server-url", server.baseUrl() + "/auth/realms/quarkus"); + conf.put("quarkus.oidc.code-flow.auth-server-url", server.baseUrl() + "/auth/realms/quarkus"); + conf.put("keycloak-url", server.baseUrl()); + + return conf; } private void defineValidIntrospectionMockTokenStubForUserWithRoles(String user, Set<String> roles) {