diff --git a/gcp-common/build.gradle b/gcp-common/build.gradle index 707e5ba6c..46aac05b9 100644 --- a/gcp-common/build.gradle +++ b/gcp-common/build.gradle @@ -1,5 +1,6 @@ plugins { id("io.micronaut.build.internal.gcp-module") + id("java-test-fixtures") } dependencies { @@ -10,9 +11,14 @@ dependencies { implementation(mn.micronaut.json.core) implementation(mn.jackson.annotations) + testFixturesApi(platform(mn.micronaut.core.bom)) + testAnnotationProcessor(mn.micronaut.inject.java) testImplementation(mn.micronaut.discovery.core) + testImplementation(mn.micronaut.http.server.netty) testImplementation(mnSerde.micronaut.serde.jackson) + testImplementation(libs.system.stubs.core) + } diff --git a/gcp-common/src/main/java/io/micronaut/gcp/credentials/AuthenticationLoggingInterceptor.java b/gcp-common/src/main/java/io/micronaut/gcp/credentials/AuthenticationLoggingInterceptor.java new file mode 100644 index 000000000..e825a5483 --- /dev/null +++ b/gcp-common/src/main/java/io/micronaut/gcp/credentials/AuthenticationLoggingInterceptor.java @@ -0,0 +1,93 @@ +/* + * Copyright 2017-2023 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.gcp.credentials; + +import com.google.auth.RequestMetadataCallback; +import io.micronaut.aop.MethodInterceptor; +import io.micronaut.aop.MethodInvocationContext; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.type.MutableArgumentValue; +import jakarta.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * An interceptor for managed instances of {@link com.google.auth.oauth2.GoogleCredentials} that logs certain types of + * authentication errors that the GCP libraries handle silently as infinitely retryable events. + * + * @author Jeremy Grelle + * @since 5.2.0 + */ +@Singleton +public class AuthenticationLoggingInterceptor implements MethodInterceptor { + + private static final Logger LOG = LoggerFactory.getLogger(AuthenticationLoggingInterceptor.class); + private static final String LOGGED_AUTHENTICATION_METHOD = "getRequestMetadata"; + + /** + * Intercepts the "getRequestMetadata" call and logs any retryable errors before allowing the GCP library to continue + * its normal retry algorithm. + * + * @param context The method invocation context + * @return the result of the method invocation + */ + @Override + public @Nullable Object intercept(MethodInvocationContext context) { + if (!context.getExecutableMethod().getMethodName().equals(LOGGED_AUTHENTICATION_METHOD)) { + return context.proceed(); + } + Map> params = context.getParameters(); + params.entrySet().stream().filter(entry -> entry.getValue().getType().equals(RequestMetadataCallback.class)) + .findFirst() + .ifPresent(entry -> { + @SuppressWarnings("unchecked") MutableArgumentValue argValue = (MutableArgumentValue) entry.getValue(); + RequestMetadataCallback callback = argValue.getValue(); + argValue.setValue(new LoggingRequestMetadataCallback(callback)); + }); + return context.proceed(); + } + + /** + * A wrapper {@link RequestMetadataCallback} implementation that logs failures with a warning before proceeding with + * the original callback. + */ + private static final class LoggingRequestMetadataCallback implements RequestMetadataCallback { + + private final RequestMetadataCallback callback; + + private LoggingRequestMetadataCallback(RequestMetadataCallback callback) { + this.callback = callback; + } + + @Override + public void onSuccess(Map> metadata) { + this.callback.onSuccess(metadata); + } + + @Override + public void onFailure(Throwable ex) { + if (ex instanceof IOException) { + LOG.warn("A failure occurred while attempting to build credential metadata for a GCP API request. The GCP libraries treat this as " + + "a retryable error, but misconfigured credentials can keep it from ever succeeding.", ex); + } + this.callback.onFailure(ex); + } + } +} diff --git a/gcp-common/src/main/java/io/micronaut/gcp/credentials/GoogleCredentialsFactory.java b/gcp-common/src/main/java/io/micronaut/gcp/credentials/GoogleCredentialsFactory.java index f687bea97..5eaad5bab 100644 --- a/gcp-common/src/main/java/io/micronaut/gcp/credentials/GoogleCredentialsFactory.java +++ b/gcp-common/src/main/java/io/micronaut/gcp/credentials/GoogleCredentialsFactory.java @@ -21,13 +21,13 @@ import io.micronaut.context.annotation.Primary; import io.micronaut.context.annotation.Requires; import io.micronaut.context.exceptions.ConfigurationException; +import io.micronaut.core.annotation.NonNull; import io.micronaut.core.util.ArgumentUtils; import io.micronaut.core.util.StringUtils; +import jakarta.inject.Singleton; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import io.micronaut.core.annotation.NonNull; -import jakarta.inject.Singleton; import java.io.ByteArrayInputStream; import java.io.FileInputStream; import java.io.IOException; @@ -83,11 +83,11 @@ public GoogleCredentialsFactory(@NonNull GoogleCredentialsConfiguration configur * @return The {@link GoogleCredentials} * @throws IOException An exception if an error occurs */ - @Requires(missingBeans = GoogleCredentials.class) - @Requires(classes = com.google.auth.oauth2.GoogleCredentials.class) + @Requires(classes = GoogleCredentials.class) @Requires(property = GoogleCredentialsConfiguration.PREFIX + ".enabled", value = StringUtils.TRUE, defaultValue = StringUtils.TRUE) @Primary @Singleton + @LogAuthenticationFailures protected GoogleCredentials defaultGoogleCredentials() throws IOException { final List scopes = configuration.getScopes().stream() .map(URI::toString).collect(Collectors.toList()); diff --git a/gcp-common/src/main/java/io/micronaut/gcp/credentials/LogAuthenticationFailures.java b/gcp-common/src/main/java/io/micronaut/gcp/credentials/LogAuthenticationFailures.java new file mode 100644 index 000000000..266939f97 --- /dev/null +++ b/gcp-common/src/main/java/io/micronaut/gcp/credentials/LogAuthenticationFailures.java @@ -0,0 +1,42 @@ +/* + * Copyright 2017-2023 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.gcp.credentials; + +import com.google.auth.oauth2.GoogleCredentials; +import io.micronaut.aop.Around; +import io.micronaut.context.annotation.Type; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Annotation for applying authentication failure logging AOP advice to a managed instance of + * {@link GoogleCredentials}. + * + * @author Jeremy Grelle + * @since 5.2.0 + */ +@Documented +@Retention(RUNTIME) +@Target({ElementType.METHOD, ElementType.TYPE}) +@Around(proxyTargetMode = Around.ProxyTargetConstructorMode.ALLOW) +@Type(AuthenticationLoggingInterceptor.class) +public @interface LogAuthenticationFailures { +} diff --git a/gcp-common/src/test/groovy/io/micronaut/gcp/credentials/GoogleCredentialsFactorySpec.groovy b/gcp-common/src/test/groovy/io/micronaut/gcp/credentials/GoogleCredentialsFactorySpec.groovy index 587fe98fd..b6a49e88f 100644 --- a/gcp-common/src/test/groovy/io/micronaut/gcp/credentials/GoogleCredentialsFactorySpec.groovy +++ b/gcp-common/src/test/groovy/io/micronaut/gcp/credentials/GoogleCredentialsFactorySpec.groovy @@ -1,16 +1,65 @@ package io.micronaut.gcp.credentials +import com.google.auth.RequestMetadataCallback import com.google.auth.oauth2.GoogleCredentials +import com.google.auth.oauth2.ImpersonatedCredentials +import com.google.auth.oauth2.ServiceAccountCredentials +import com.google.auth.oauth2.UserCredentials +import com.google.common.util.concurrent.MoreExecutors import io.micronaut.context.ApplicationContext +import io.micronaut.context.annotation.Requires +import io.micronaut.context.exceptions.BeanInstantiationException +import io.micronaut.context.exceptions.ConfigurationException import io.micronaut.context.exceptions.NoSuchBeanException +import io.micronaut.core.reflect.ReflectionUtils +import io.micronaut.http.HttpResponse +import io.micronaut.http.MediaType +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Post +import io.micronaut.runtime.server.EmbeddedServer +import org.spockframework.runtime.IStandardStreamsListener +import org.spockframework.runtime.StandardStreamsCapturer +import spock.lang.AutoCleanup import spock.lang.Specification +import spock.util.concurrent.PollingConditions +import uk.org.webcompere.systemstubs.environment.EnvironmentVariables +import uk.org.webcompere.systemstubs.properties.SystemProperties +import uk.org.webcompere.systemstubs.resource.Resources + +import java.security.PrivateKey + +import static io.micronaut.gcp.credentials.fixture.ServiceAccountCredentialsTestHelper.* class GoogleCredentialsFactorySpec extends Specification { - void "it can disable GoogleCredentials bean"() { + SimpleStreamsListener captured = new SimpleStreamsListener() + + @AutoCleanup("stop") + StandardStreamsCapturer capturer = new StandardStreamsCapturer() + + PollingConditions conditions = new PollingConditions(timeout: 30) + + void setup() { + capturer.addStandardStreamsListener(captured) + capturer.start() + } + + def cleanup() { + URL testCredentialsFile = this.getClass().getResource("/test-user-account/.config/gcloud/application_default_credentials.json") + EnvironmentVariables env = new EnvironmentVariables("GOOGLE_APPLICATION_CREDENTIALS", testCredentialsFile.getPath()) + env.execute { + GoogleCredentials gc = GoogleCredentials.getApplicationDefault() + ReflectionUtils.getFieldValue(GoogleCredentials.class, "defaultCredentialsProvider", gc) + .ifPresent { + ReflectionUtils.setField(it.getClass(), "cachedCredentials", it, null) + } + } + } + + void "GoogleCredentials factory method can be disabled via configuration"() { given: def ctx = ApplicationContext.run([ - (GoogleCredentialsConfiguration.PREFIX + ".enabled"): false + (GoogleCredentialsConfiguration.PREFIX + ".enabled") : false ]) when: @@ -19,4 +68,259 @@ class GoogleCredentialsFactorySpec extends Specification { then: thrown(NoSuchBeanException) } + + void "configuring both credentials location and encoded-key throws an exception"() { + given: + def ctx = ApplicationContext.run([ + (GoogleCredentialsConfiguration.PREFIX + ".location") : "foo", + (GoogleCredentialsConfiguration.PREFIX + ".encoded-key") : "bar" + ]) + + when: + ctx.getBean(GoogleCredentials) + + then: + def ex = thrown(BeanInstantiationException) + ex.getCause() instanceof ConfigurationException + } + + void "default configuration without GCP SDK installed fails"() { + given: + SystemProperties props = new SystemProperties() + + URL testHomeDir = this.getClass().getResource("/") + props.set("user.home", testHomeDir.getPath()) + props.set("os.name", "linux") + + when: + GoogleCredentials gc = props.execute(() -> { + def ctx = ApplicationContext.run() + ctx.getBean(GoogleCredentials) + }) + + then: + def ex = thrown (BeanInstantiationException) + ex.getCause() instanceof IOException + ex.getMessage().contains("Your default credentials were not found.") + } + + void "user account credentials can be loaded via known environment variable"() { + given: + EnvironmentVariables env = new EnvironmentVariables() + + URL testCredentialsFile = this.getClass().getResource("/test-user-account/.config/gcloud/application_default_credentials.json") + env.set("GOOGLE_APPLICATION_CREDENTIALS", testCredentialsFile.getPath()) + + when: + GoogleCredentials gc = env.execute(() -> { + def ctx = ApplicationContext.run() + ctx.getBean(GoogleCredentials) + }) + + then: + matchesJsonUserCredentials(gc) + } + + void "user account credentials can be loaded from SDK"() { + given: + SystemProperties props = new SystemProperties() + + URL testHomeDir = this.getClass().getResource("/test-user-account") + props.set("user.home", testHomeDir.getPath()) + props.set("os.name", "linux") + + when: + GoogleCredentials gc = props.execute(() -> { + def ctx = ApplicationContext.run() + ctx.getBean(GoogleCredentials) + }) + + then: + matchesJsonUserCredentials(gc) + } + + void "user account credentials can be loaded from SDK on windows"() { + given: + EnvironmentVariables env = new EnvironmentVariables() + SystemProperties props = new SystemProperties() + + URL testAppDataDir = this.getClass().getResource("/test-user-account/.config") + env.set("APPDATA", testAppDataDir.getPath()) + props.set("os.name", "windows") + + when: + GoogleCredentials gc = Resources.execute(() -> { + def ctx = ApplicationContext.run() + ctx.getBean(GoogleCredentials) + }, env, props) + + then: + matchesJsonUserCredentials(gc) + } + + void "user account credentials can be loaded from custom SDK location"() { + given: + EnvironmentVariables env = new EnvironmentVariables() + + URL testGcloudDir = this.getClass().getResource("/test-user-account/.config/gcloud") + env.set("CLOUDSDK_CONFIG", testGcloudDir.getPath()) + + when: + GoogleCredentials gc = env.execute(() -> { + def ctx = ApplicationContext.run() + ctx.getBean(GoogleCredentials) + }) + + then: + matchesJsonUserCredentials(gc) + } + + void "impersonated service account credentials can be loaded from SDK"() { + given: + SystemProperties props = new SystemProperties() + + URL testHomeDir = this.getClass().getResource("/test-impersonated-service-account") + props.set("user.home", testHomeDir.getPath()) + props.set("os.name", "linux") + + when: + GoogleCredentials gc = props.execute(() -> { + def ctx = ApplicationContext.run() + ctx.getBean(GoogleCredentials) + }) + + then: + gc != null + ImpersonatedCredentials ic = gc.$target + UserCredentials uc = (UserCredentials) ic.getSourceCredentials() + ic.getAccount() == "sa-test1@micronaut-gcp-testing.iam.gserviceaccount.com" + with(uc) { + getClientId() == "client-id-1.apps.googleusercontent.com" + getClientSecret() == "client-secret-1" + getRefreshToken() == "refresh-token-1" + } + } + + void "service account credentials can be loaded via environment variable"() { + given: + EnvironmentVariables env = new EnvironmentVariables() + + PrivateKey pk = generatePrivateKey() + File serviceAccountCredentials = writeServiceCredentialsToTempFile(pk) + env.set("GOOGLE_APPLICATION_CREDENTIALS", serviceAccountCredentials.getPath()) + + when: + GoogleCredentials gc = env.execute(() -> { + def ctx = ApplicationContext.run() + ctx.getBean(GoogleCredentials) + }) + + then: + matchesJsonServiceAccountCredentials(pk, gc) + } + + void "service account credentials can be loaded via configured location"() { + given: + PrivateKey pk = generatePrivateKey() + File serviceAccountCredentials = writeServiceCredentialsToTempFile(pk) + + when: + def ctx = ApplicationContext.run([ + (GoogleCredentialsConfiguration.PREFIX + ".location"): serviceAccountCredentials.getPath() + ]) + GoogleCredentials gc = ctx.getBean(GoogleCredentials) + + then: + matchesJsonServiceAccountCredentials(pk, gc) + } + + void "service account credentials can be loaded via configured Base64-encoded key"() { + given: + PrivateKey pk = generatePrivateKey() + String encodedServiceAccountCredentials = encodeServiceCredentials(pk) + + when: + def ctx = ApplicationContext.run([ + (GoogleCredentialsConfiguration.PREFIX + ".encoded-key"): encodedServiceAccountCredentials + ]) + GoogleCredentials gc = ctx.getBean(GoogleCredentials) + + then: + matchesJsonServiceAccountCredentials(pk, gc) + } + + void "invalid credentials cause a warning to be logged when metadata is requested"(){ + given: + PrivateKey pk = generatePrivateKey() + String encodedServiceAccountCredentials = encodeServiceCredentials(pk) + EmbeddedServer gcp = ApplicationContext.run(EmbeddedServer, [ + "spec.name" : "GoogleCredentialsFactorySpec", + "micronaut.server.port" : 8080 + ]) + def ctx = ApplicationContext.run([ + (GoogleCredentialsConfiguration.PREFIX + ".encoded-key"): encodedServiceAccountCredentials + ]) + GoogleCredentials gc = ctx.getBean(GoogleCredentials) + + when: + gc.getRequestMetadata(null, MoreExecutors.directExecutor(), new RequestMetadataCallback() { + @Override + void onSuccess(Map> metadata) { + + } + + @Override + void onFailure(Throwable exception) { + + } + }) + + then: + conditions.eventually { + captured.messages.any { + it.contains("WARN") + it.contains("A failure occurred while attempting to build credential metadata for a GCP API request. The GCP libraries treat this as " + + "a retryable error, but misconfigured credentials can keep it from ever succeeding.") + } + } + + cleanup: + ctx.stop() + gcp.stop() + } + + private void matchesJsonUserCredentials(GoogleCredentials gc) { + assert gc != null && gc.$target != null && gc.$target instanceof UserCredentials + UserCredentials uc = (UserCredentials) gc.$target + assert uc.getClientId() == "client-id-1.apps.googleusercontent.com" + assert uc.getClientSecret() == "client-secret-1" + assert uc.getQuotaProjectId() == "micronaut-gcp-test" + assert uc.getRefreshToken() == "refresh-token-1" + } + + private void matchesJsonServiceAccountCredentials(PrivateKey pk, GoogleCredentials gc) { + assert gc != null && gc.$target != null && gc.$target instanceof ServiceAccountCredentials + ServiceAccountCredentials sc = (ServiceAccountCredentials) gc.$target + assert sc.getAccount() == "sa-test1@micronaut-gcp-testing.iam.gserviceaccount.com" + assert sc.getClientId() == "client-id-1" + assert sc.getProjectId() == "micronaut-gcp-testing" + assert sc.getPrivateKeyId() == "private-key-id-1" + assert sc.getPrivateKey() == pk + } +} + +@Requires(property = "spec.name", value = "GoogleCredentialsFactorySpec") +@Controller +class GoogleAuth { + + @Post(value="/token", processes = MediaType.APPLICATION_FORM_URLENCODED) + HttpResponse getToken() { + return HttpResponse.unauthorized() + } +} + +class SimpleStreamsListener implements IStandardStreamsListener { + List messages = [] + @Override void standardOut(String m) { messages << m } + @Override void standardErr(String m) { messages << m } } diff --git a/gcp-common/src/test/resources/test-impersonated-service-account/.config/gcloud/application_default_credentials.json b/gcp-common/src/test/resources/test-impersonated-service-account/.config/gcloud/application_default_credentials.json new file mode 100644 index 000000000..e53698fc4 --- /dev/null +++ b/gcp-common/src/test/resources/test-impersonated-service-account/.config/gcloud/application_default_credentials.json @@ -0,0 +1,11 @@ +{ + "delegates": [], + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/sa-test1@micronaut-gcp-testing.iam.gserviceaccount.com:generateAccessToken", + "source_credentials": { + "client_id": "client-id-1.apps.googleusercontent.com", + "client_secret": "client-secret-1", + "refresh_token": "refresh-token-1", + "type": "authorized_user" + }, + "type": "impersonated_service_account" +} diff --git a/gcp-common/src/test/resources/test-user-account/.config/gcloud/application_default_credentials.json b/gcp-common/src/test/resources/test-user-account/.config/gcloud/application_default_credentials.json new file mode 100644 index 000000000..c833ca899 --- /dev/null +++ b/gcp-common/src/test/resources/test-user-account/.config/gcloud/application_default_credentials.json @@ -0,0 +1,7 @@ +{ + "client_id": "client-id-1.apps.googleusercontent.com", + "client_secret": "client-secret-1", + "quota_project_id": "micronaut-gcp-test", + "refresh_token": "refresh-token-1", + "type": "authorized_user" +} diff --git a/gcp-common/src/testFixtures/java/io/micronaut/gcp/credentials/fixture/ServiceAccountCredentialsTestHelper.java b/gcp-common/src/testFixtures/java/io/micronaut/gcp/credentials/fixture/ServiceAccountCredentialsTestHelper.java new file mode 100644 index 000000000..299235ff9 --- /dev/null +++ b/gcp-common/src/testFixtures/java/io/micronaut/gcp/credentials/fixture/ServiceAccountCredentialsTestHelper.java @@ -0,0 +1,73 @@ +package io.micronaut.gcp.credentials.fixture; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.JsonGenerator; +import com.google.api.client.json.JsonObjectParser; +import com.google.api.client.json.gson.GsonFactory; +import io.micronaut.core.annotation.Internal; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.util.Base64; + +/** + * An internal test fixture for generating mock service account credentials for tests. + * + * @author Jeremy Grelle + * @since 5.2.0 + */ +@Internal +public class ServiceAccountCredentialsTestHelper { + + public static PrivateKey generatePrivateKey() throws NoSuchAlgorithmException { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + return kp.getPrivate(); + } + + public static File writeServiceCredentialsToTempFile(PrivateKey pk) throws IOException { + File tmpSACredentials = File.createTempFile("GoogleCredentialsFactorySpec-", ".json"); + tmpSACredentials.deleteOnExit(); + GenericJson fileContents = buildServiceAccountJson(pk); + writeJsonToOutputStream(new FileOutputStream(tmpSACredentials), fileContents); + return tmpSACredentials; + } + + public static String encodeServiceCredentials(PrivateKey pk) throws IOException { + GenericJson serviceAccountCredentials = buildServiceAccountJson(pk); + ByteArrayOutputStream serviceAccountCredentialsByteStream = new ByteArrayOutputStream(); + writeJsonToOutputStream(serviceAccountCredentialsByteStream, serviceAccountCredentials); + return Base64.getEncoder().encodeToString(serviceAccountCredentialsByteStream.toByteArray()); + } + + public static void writeJsonToOutputStream(OutputStream outputStream, GenericJson jsonContent) throws IOException { + JsonFactory jsonFactory = GsonFactory.getDefaultInstance(); + try (JsonGenerator jsonGenerator = jsonFactory.createJsonGenerator(outputStream, StandardCharsets.UTF_8)) { + jsonGenerator.serialize(jsonContent); + } + } + + public static GenericJson buildServiceAccountJson(PrivateKey pk) throws IOException { + String jsonPrivateKey = """ + -----BEGIN PRIVATE KEY----- + %s + -----END PRIVATE KEY----- + """.formatted(Base64.getEncoder().encodeToString(pk.getEncoded())); + + JsonFactory jsonFactory = GsonFactory.getDefaultInstance(); + + try (InputStream templateKeyFile = ServiceAccountCredentialsTestHelper.class.getResourceAsStream("/sa-fake-private-key.json")) { + JsonObjectParser parser = new JsonObjectParser(jsonFactory); + GenericJson fileContents = + parser.parseAndClose(templateKeyFile, StandardCharsets.UTF_8, GenericJson.class); + fileContents.set("private_key", jsonPrivateKey); + return fileContents; + } + } +} diff --git a/gcp-common/src/testFixtures/resources/sa-fake-private-key.json b/gcp-common/src/testFixtures/resources/sa-fake-private-key.json new file mode 100644 index 000000000..aee2966ea --- /dev/null +++ b/gcp-common/src/testFixtures/resources/sa-fake-private-key.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "micronaut-gcp-testing", + "private_key_id": "private-key-id-1", + "private_key": "-----BEGIN PRIVATE KEY-----\nREPLACE_WITH_TEST_PRIVATE_KEY\n-----END PRIVATE KEY-----\n", + "client_email": "sa-test1@micronaut-gcp-testing.iam.gserviceaccount.com", + "client_id": "client-id-1", + "auth_uri": "https://localhost:8080/o/oauth2/auth", + "token_uri": "http://localhost:8080/token", + "auth_provider_x509_cert_url": "https://localhost:8080/oauth2/v1/certs", + "client_x509_cert_url": "https://localhost:8080/robot/v1/metadata/x509/sa-test1%40micronaut-gcp-testing.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} diff --git a/gcp-pubsub/build.gradle b/gcp-pubsub/build.gradle index 6ecd55b28..3a1fbc9cd 100644 --- a/gcp-pubsub/build.gradle +++ b/gcp-pubsub/build.gradle @@ -15,6 +15,8 @@ dependencies { testRuntimeOnly(mn.micronaut.discovery.core) testImplementation(mnRxjava2.micronaut.rxjava2) testImplementation libs.testcontainers.spock + testImplementation(mn.micronaut.http.server.netty) + testImplementation(testFixtures(project(":micronaut-gcp-common"))) testAnnotationProcessor(mnSerde.micronaut.serde.processor) testImplementation(mnSerde.micronaut.serde.jackson) diff --git a/gcp-pubsub/src/test/groovy/io/micronaut/gcp/pubsub/bind/SubscriberAuthenticationFailureSpec.groovy b/gcp-pubsub/src/test/groovy/io/micronaut/gcp/pubsub/bind/SubscriberAuthenticationFailureSpec.groovy new file mode 100644 index 000000000..7652a284a --- /dev/null +++ b/gcp-pubsub/src/test/groovy/io/micronaut/gcp/pubsub/bind/SubscriberAuthenticationFailureSpec.groovy @@ -0,0 +1,105 @@ +package io.micronaut.gcp.pubsub.bind + +import com.google.auth.oauth2.GoogleCredentials +import io.micronaut.context.ApplicationContext +import io.micronaut.context.annotation.Requires +import io.micronaut.core.reflect.ReflectionUtils +import io.micronaut.gcp.credentials.GoogleCredentialsConfiguration +import io.micronaut.gcp.pubsub.annotation.PubSubListener +import io.micronaut.gcp.pubsub.annotation.Subscription +import io.micronaut.http.HttpResponse +import io.micronaut.http.MediaType +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Post +import io.micronaut.runtime.server.EmbeddedServer +import org.spockframework.runtime.IStandardStreamsListener +import org.spockframework.runtime.StandardStreamsCapturer +import spock.lang.AutoCleanup +import spock.lang.Specification +import spock.util.concurrent.PollingConditions + +import java.security.PrivateKey + +import static io.micronaut.gcp.credentials.fixture.ServiceAccountCredentialsTestHelper.encodeServiceCredentials +import static io.micronaut.gcp.credentials.fixture.ServiceAccountCredentialsTestHelper.generatePrivateKey + +class SubscriberAuthenticationFailureSpec extends Specification { + + ServiceAccountTestListener listener + + PollingConditions conditions = new PollingConditions(timeout: 30) + + SimpleStreamsListener captured = new SimpleStreamsListener() + + @AutoCleanup("stop") + StandardStreamsCapturer capturer = new StandardStreamsCapturer() + + void setup() { + capturer.addStandardStreamsListener(captured) + capturer.start() + } + + void "authentication failure on subscription should be logged as a warning"() { + given: + PrivateKey pk = generatePrivateKey() + String encodedServiceAccountCredentials = encodeServiceCredentials(pk) + EmbeddedServer gcp = ApplicationContext.run(EmbeddedServer, [ + "server.name" : "GoogleAuthServerTestFake", + "micronaut.server.port" : 8080 + ]) + def ctx = ApplicationContext.run([ + "spec.name" : "AuthenticationFailureSpec", + "gcp.projectId" : "micronaut-gcp-testing", + (GoogleCredentialsConfiguration.PREFIX + ".encoded-key"): encodedServiceAccountCredentials + ]) + + when: + def gc = ctx.getBean(GoogleCredentials) + listener = ctx.getBean(ServiceAccountTestListener) + + then: + gc != null + listener != null + conditions.eventually { + captured.messages.any { + it.contains("WARN") + it.contains("A failure occurred while attempting to build credential metadata for a GCP API request. The GCP libraries treat this as " + + "a retryable error, but misconfigured credentials can keep it from ever succeeding.") + } + } + + cleanup: + ReflectionUtils.getFieldValue(GoogleCredentials.class, "defaultCredentialsProvider", gc) + .ifPresent { + ReflectionUtils.setField(it.getClass(), "cachedCredentials", it, null) + } + ctx.close() + gcp.close() + } +} + +@PubSubListener +@Requires(property = "spec.name", value = "AuthenticationFailureSpec") +class ServiceAccountTestListener { + + @Subscription("micronaut-gcp-topic1-sub") + void receive(byte[] data) { + + } +} + +@Requires(property = "server.name", value = "GoogleAuthServerTestFake") +@Controller +class GoogleAuth { + + @Post(value="/token", processes = MediaType.APPLICATION_FORM_URLENCODED) + HttpResponse getToken() { + return HttpResponse.unauthorized() + } +} + +class SimpleStreamsListener implements IStandardStreamsListener { + List messages = [] + @Override void standardOut(String m) { messages << m } + @Override void standardErr(String m) { messages << m } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 59e412190..3b50e867c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ jetty-servlet = "11.0.16" logback-json-classic = "0.1.5" testcontainers = "1.18.3" zipkin-sender-stackdriver = "1.0.4" +system-stubs-core = "2.1.2" micronaut-logging = "1.0.0" micronaut-rxjava2 = "2.0.1" @@ -73,6 +74,7 @@ kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = logback-json-classic = { module = "ch.qos.logback.contrib:logback-json-classic", version.ref = "logback-json-classic" } testcontainers-spock = { module = "org.testcontainers:spock", version.ref = "testcontainers" } zipkin-sender-stackdriver = { module = "io.zipkin.gcp:zipkin-sender-stackdriver", version.ref = "zipkin-sender-stackdriver" } +system-stubs-core = { module = "uk.org.webcompere:system-stubs-core", version.ref = "system-stubs-core" } # Plugins gradle-micronaut = { module = "io.micronaut.gradle:micronaut-gradle-plugin", version.ref = "micronaut-gradle-plugin" }