diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 99d8a3fea..6cdef2764 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ micronaut-test = "3.1.1" micronaut-gradle-plugin = "3.4.1" groovy = "3.0.10" spock = "2.1-groovy-3.0" +testcontainers-oauth2-keycloak = "2.2.2" # Managed versions appear in the BOM managed-testcontainers = "1.17.2" @@ -39,6 +40,7 @@ testcontainers-mongodb = { module = "org.testcontainers:mongodb", version = "" } testcontainers-mssql = { module = "org.testcontainers:mssqlserver", version = "" } testcontainers-mysql = { module = "org.testcontainers:mysql", version = "" } testcontainers-neo4j = { module = "org.testcontainers:neo4j", version = "" } +testcontainers-oauth2-keycloak = { module = "com.github.dasniko:testcontainers-keycloak", version.ref = "testcontainers-oauth2-keycloak"} testcontainers-oracle-xe = { module = "org.testcontainers:oracle-xe", version = "" } testcontainers-postgres = { module = "org.testcontainers:postgresql", version = "" } testcontainers-rabbitmq = { module = "org.testcontainers:rabbitmq", version = "" } diff --git a/settings.gradle b/settings.gradle index c8f36ad02..dcc934952 100644 --- a/settings.gradle +++ b/settings.gradle @@ -39,6 +39,7 @@ include 'test-resources-hivemq' include 'test-resources-kafka' include 'test-resources-mongodb' include 'test-resources-neo4j' +include 'test-resources-oauth2' include 'test-resources-redis' include 'test-resources-rabbitmq' include 'test-resources-server' diff --git a/test-resources-build-tools/src/main/java/io/micronaut/testresources/buildtools/TestResourcesClasspath.java b/test-resources-build-tools/src/main/java/io/micronaut/testresources/buildtools/TestResourcesClasspath.java index 7dce5bf14..e360ac8b4 100644 --- a/test-resources-build-tools/src/main/java/io/micronaut/testresources/buildtools/TestResourcesClasspath.java +++ b/test-resources-build-tools/src/main/java/io/micronaut/testresources/buildtools/TestResourcesClasspath.java @@ -44,7 +44,7 @@ public final class TestResourcesClasspath implements KnownModules { private static final String MICRONAUT_RABBITMQ = "micronaut-rabbitmq"; private static final String MICRONAUT_REDIS = "micronaut-redis-lettuce"; private static final String MICRONAUT_DISCOVERY_CLIENT = "micronaut-discovery-client"; - + private static final String MICRONAUT_OAUTH2 = "micronaut-security-oauth2"; private static final String MICRONAUT_NEO4J = "micronaut-neo4j"; private static final String MICRONAUT_DATA_MONGODB = "micronaut-data-mongodb"; private static final String MICRONAUT_DATA_R2DBC = "micronaut-data-r2dbc"; @@ -94,6 +94,7 @@ public final class TestResourcesClasspath implements KnownModules { private static final String REACTIVE_MSSQL_MODULE = "r2dbc-mssql"; private static final String HASHICORP_VAULT_MODULE = "hashicorp-vault"; private static final String REACTIVE_POOL_MODULE = "r2dbc-pool"; + private static final String OAUTH2_MODULE = "oauth2"; private TestResourcesClasspath() { @@ -138,6 +139,7 @@ private static Stream inferSingle(MavenDependency input, List name.startsWith(MICRONAUT_NEO4J), deps -> true, NEO4J_MODULE); m.onArtifact(name -> name.startsWith(MICRONAUT_DATA_PREFIX), deps -> deps.anyMatch(artifactEquals(MYSQL_CONNECTOR_JAVA)), MYSQL_MODULE); diff --git a/test-resources-build-tools/src/test/groovy/io/micronaut/testresources/buildtools/TestResourcesClasspathTest.groovy b/test-resources-build-tools/src/test/groovy/io/micronaut/testresources/buildtools/TestResourcesClasspathTest.groovy index 30c5b470e..d3c17c3d1 100644 --- a/test-resources-build-tools/src/test/groovy/io/micronaut/testresources/buildtools/TestResourcesClasspathTest.groovy +++ b/test-resources-build-tools/src/test/groovy/io/micronaut/testresources/buildtools/TestResourcesClasspathTest.groovy @@ -36,6 +36,7 @@ class TestResourcesClasspathTest extends Specification { 'redis-lettuce' | 'redis' 'elasticsearch' | 'elasticsearch' 'discovery-client' | 'hashicorp-vault' + 'security-oauth2' | 'oauth2' } def "passes through driver #driver"() { diff --git a/test-resources-oauth2/build.gradle b/test-resources-oauth2/build.gradle new file mode 100644 index 000000000..68e8b4a6c --- /dev/null +++ b/test-resources-oauth2/build.gradle @@ -0,0 +1,17 @@ +plugins { + id 'io.micronaut.build.internal.testcontainers-module' +} + +description = """ +Provides support for OAuth2 test containers. +""" + +dependencies { + implementation libs.testcontainers.oauth2.keycloak + + testImplementation mn.micronaut.security + testImplementation mn.micronaut.http.server.netty + testImplementation mn.micronaut.http.client + + testRuntimeOnly mn.micronaut.security.oauth2 +} diff --git a/test-resources-oauth2/src/main/java/io/micronaut/testresources/oauth2/keycloak/KeycloakTestResourceProvider.java b/test-resources-oauth2/src/main/java/io/micronaut/testresources/oauth2/keycloak/KeycloakTestResourceProvider.java new file mode 100644 index 000000000..ccb3cf0c2 --- /dev/null +++ b/test-resources-oauth2/src/main/java/io/micronaut/testresources/oauth2/keycloak/KeycloakTestResourceProvider.java @@ -0,0 +1,145 @@ +/* + * Copyright 2017-2021 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.testresources.oauth2.keycloak; + +import dasniko.testcontainers.keycloak.KeycloakContainer; +import io.micronaut.testresources.testcontainers.AbstractTestContainersProvider; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.representations.idm.ClientRepresentation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.utility.DockerImageName; + +import javax.ws.rs.core.Response; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * A test resource provider which will spawn a Keycloak test container. + */ +public class KeycloakTestResourceProvider extends AbstractTestContainersProvider { + private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakTestResourceProvider.class); + + private static final String KEYCLOAK_PREFIX = "micronaut.security.oauth2.clients.keycloak."; + private static final String CLIENT_SECRET = KEYCLOAK_PREFIX + "client-secret"; + private static final String CLIENT_ID = KEYCLOAK_PREFIX + "client-id"; + private static final String ISSUER = KEYCLOAK_PREFIX + "openid.issuer"; + private static final String JWT_TOKEN_URL = "micronaut.security.token.jwt.signatures.jwks.keycloak.url"; + + private static final String DEFAULT_IMAGE = "quay.io/keycloak/keycloak"; + + private static final List SUPPORTED_PROPERTIES_LIST; + private static final Set SUPPORTED_PROPERTIES_SET; + + private static final String TEST_REALM = "realm"; + private static final String TEST_REALM_DEFAULT = "master"; + private static final String TEST_CLIENT = "client-id"; + private static final String TEST_CLIENT_DEFAULT = "client-id"; + private static final String TEST_SECRET = "client-secret"; + private static final String TEST_SECRET_DEFAULT = "test-secret"; + + static { + List supported = new ArrayList<>(); + supported.add(CLIENT_SECRET); + supported.add(CLIENT_ID); + supported.add(ISSUER); + supported.add(JWT_TOKEN_URL); + SUPPORTED_PROPERTIES_LIST = Collections.unmodifiableList(supported); + SUPPORTED_PROPERTIES_SET = Collections.unmodifiableSet(new HashSet<>(supported)); + } + + private final AtomicBoolean clientConfigured = new AtomicBoolean(); + private String realm; + private String clientId; + private String clientSecret; + + @Override + public List getResolvableProperties(Map> propertyEntries, Map testResourcesConfig) { + return SUPPORTED_PROPERTIES_LIST; + } + + @Override + protected String getSimpleName() { + return "keycloak"; + } + + @Override + protected String getDefaultImageName() { + return DEFAULT_IMAGE; + } + + @Override + protected KeycloakContainer createContainer(DockerImageName imageName, Map requestedProperties, Map testResourcesConfiguration) { + return new KeycloakContainer(imageName.asCanonicalNameString()) { + @Override + public void start() { + super.start(); + assertConfigured(this, testResourcesConfiguration); + } + }; + } + + @Override + protected Optional resolveProperty(String propertyName, KeycloakContainer container) { + switch (propertyName) { + case CLIENT_SECRET: + return Optional.of(clientSecret); + case CLIENT_ID: + return Optional.of(clientId); + case ISSUER: + return Optional.of(container.getAuthServerUrl() + "/realm/" + realm); + case JWT_TOKEN_URL: + return Optional.of(container.getAuthServerUrl() + "/realm/" + realm + "/protocol/openid-connect/certs"); + default: + } + return Optional.empty(); + } + + private void assertConfigured(KeycloakContainer container, Map testResourcesConfiguration) { + if (clientConfigured.compareAndSet(false, true)) { + Keycloak keycloakAdminClient = container.getKeycloakAdminClient(); + ClientRepresentation clientRepresentation = new ClientRepresentation(); + realm = fromConfigurationOrDefault(testResourcesConfiguration, TEST_REALM, TEST_REALM_DEFAULT); + clientId = fromConfigurationOrDefault(testResourcesConfiguration, TEST_CLIENT, TEST_CLIENT_DEFAULT); + clientSecret = fromConfigurationOrDefault(testResourcesConfiguration, TEST_SECRET, TEST_SECRET_DEFAULT); + clientRepresentation.setClientId(clientId); + clientRepresentation.setClientId(clientSecret); + try (Response response = keycloakAdminClient.realm(realm).clients().create(clientRepresentation)) { + LOGGER.debug("Keycloak admin client answered: {}", response.getStatusInfo()); + } + } + } + + @Override + protected boolean shouldAnswer(String propertyName, Map requestedProperties, Map testResourcesConfiguration) { + return SUPPORTED_PROPERTIES_SET.contains(propertyName); + } + + private static String fromConfigurationOrDefault(Map testResourcesConfiguration, String key, String defaultValue) { + Object value = testResourcesConfiguration.get("containers.keycloak." + key); + if (value != null) { + return String.valueOf(value); + } + return defaultValue; + } +} diff --git a/test-resources-oauth2/src/main/resources/META-INF/services/io.micronaut.testresources.core.TestResourcesResolver b/test-resources-oauth2/src/main/resources/META-INF/services/io.micronaut.testresources.core.TestResourcesResolver new file mode 100644 index 000000000..59811e498 --- /dev/null +++ b/test-resources-oauth2/src/main/resources/META-INF/services/io.micronaut.testresources.core.TestResourcesResolver @@ -0,0 +1 @@ +io.micronaut.testresources.oauth2.keycloak.KeycloakTestResourceProvider diff --git a/test-resources-oauth2/src/test/groovy/io/micronaut/testresources/oauth2/keycloak/AbstractKeycloakSpec.groovy b/test-resources-oauth2/src/test/groovy/io/micronaut/testresources/oauth2/keycloak/AbstractKeycloakSpec.groovy new file mode 100644 index 000000000..a207ed281 --- /dev/null +++ b/test-resources-oauth2/src/test/groovy/io/micronaut/testresources/oauth2/keycloak/AbstractKeycloakSpec.groovy @@ -0,0 +1,12 @@ +package io.micronaut.testresources.oauth2.keycloak + +import io.micronaut.testresources.testcontainers.AbstractTestContainersSpec + +abstract class AbstractKeycloakSpec extends AbstractTestContainersSpec { + + @Override + String getScopeName() { + 'keycloak' + } + +} diff --git a/test-resources-oauth2/src/test/groovy/io/micronaut/testresources/oauth2/keycloak/KeycloakTest.groovy b/test-resources-oauth2/src/test/groovy/io/micronaut/testresources/oauth2/keycloak/KeycloakTest.groovy new file mode 100644 index 000000000..1bc60249a --- /dev/null +++ b/test-resources-oauth2/src/test/groovy/io/micronaut/testresources/oauth2/keycloak/KeycloakTest.groovy @@ -0,0 +1,44 @@ +package io.micronaut.testresources.oauth2.keycloak + +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Get +import io.micronaut.http.client.annotation.Client +import io.micronaut.security.annotation.Secured +import io.micronaut.security.rules.SecurityRule +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject + +@MicronautTest +class KeycloakTest extends AbstractKeycloakSpec { + + @Inject + MyClient client + + def "starts a keycloak server"() { + when: + String message = client.index() + + then: + message == "Hello, Keycloak!" + + listContainers().size() == 1 + } + + static interface MyApi { + @Get("/") + String index() + } + + @Controller("/") + static class MyController implements MyApi { + @Secured(SecurityRule.IS_ANONYMOUS) + @Get("/") + String index() { + "Hello, Keycloak!" + } + } + + @Client("/") + static interface MyClient extends MyApi { + } +} diff --git a/test-resources-oauth2/src/test/resources/application-test.yml b/test-resources-oauth2/src/test/resources/application-test.yml new file mode 100644 index 000000000..2f257711c --- /dev/null +++ b/test-resources-oauth2/src/test/resources/application-test.yml @@ -0,0 +1,5 @@ +micronaut: + application: + name: keycloak-test + security: + authentication: idtoken diff --git a/test-resources-oauth2/src/test/resources/logback.xml b/test-resources-oauth2/src/test/resources/logback.xml new file mode 100644 index 000000000..44b79c40d --- /dev/null +++ b/test-resources-oauth2/src/test/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + +