diff --git a/google-cloud-spanner/clirr-ignored-differences.xml b/google-cloud-spanner/clirr-ignored-differences.xml index c6796085d83..4e03926c66e 100644 --- a/google-cloud-spanner/clirr-ignored-differences.xml +++ b/google-cloud-spanner/clirr-ignored-differences.xml @@ -822,4 +822,10 @@ java.lang.Object runTransaction(com.google.cloud.spanner.connection.Connection$TransactionCallable) + + + 7012 + com/google/cloud/spanner/SpannerOptions$SpannerEnvironment + com.google.auth.oauth2.GoogleCredentials getDefaultExternalHostCredentials() + diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java index 5b63ff4fe44..43978a1d04c 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java @@ -34,6 +34,8 @@ import com.google.api.gax.tracing.ApiTracerFactory; import com.google.api.gax.tracing.BaseApiTracerFactory; import com.google.api.gax.tracing.OpencensusTracerFactory; +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.GoogleCredentials; import com.google.cloud.NoCredentials; import com.google.cloud.ServiceDefaults; import com.google.cloud.ServiceOptions; @@ -56,6 +58,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.base.MoreObjects; import com.google.common.base.Preconditions; +import com.google.common.base.Strings; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.util.concurrent.ThreadFactoryBuilder; @@ -79,8 +82,11 @@ import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Paths; import java.time.Duration; import java.util.ArrayList; +import java.util.Base64; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -92,6 +98,7 @@ import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Pattern; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.GuardedBy; @@ -110,6 +117,11 @@ public class SpannerOptions extends ServiceOptions { private static final String API_SHORT_NAME = "Spanner"; private static final String DEFAULT_HOST = "https://spanner.googleapis.com"; + private static final String CLOUD_SPANNER_HOST_FORMAT = ".*\\.googleapis\\.com.*"; + + @VisibleForTesting + static final Pattern CLOUD_SPANNER_HOST_PATTERN = Pattern.compile(CLOUD_SPANNER_HOST_FORMAT); + private static final ImmutableSet SCOPES = ImmutableSet.of( "https://www.googleapis.com/auth/spanner.admin", @@ -843,8 +855,15 @@ default boolean isEnableEndToEndTracing() { default String getMonitoringHost() { return null; } + + default GoogleCredentials getDefaultExternalHostCredentials() { + return null; + } } + static final String DEFAULT_SPANNER_EXTERNAL_HOST_CREDENTIALS = + "SPANNER_EXTERNAL_HOST_AUTH_TOKEN"; + /** * Default implementation of {@link SpannerEnvironment}. Reads all configuration from environment * variables. @@ -900,6 +919,11 @@ public boolean isEnableEndToEndTracing() { public String getMonitoringHost() { return System.getenv(SPANNER_MONITORING_HOST); } + + @Override + public GoogleCredentials getDefaultExternalHostCredentials() { + return getOAuthTokenFromFile(System.getenv(DEFAULT_SPANNER_EXTERNAL_HOST_CREDENTIALS)); + } } /** Builder for {@link SpannerOptions} instances. */ @@ -967,6 +991,7 @@ public static class Builder private boolean enableBuiltInMetrics = SpannerOptions.environment.isEnableBuiltInMetrics(); private String monitoringHost = SpannerOptions.environment.getMonitoringHost(); private SslContext mTLSContext = null; + private boolean isExternalHost = false; private static String createCustomClientLibToken(String token) { return token + " " + ServiceOptions.getGoogApiClientLibName(); @@ -1459,6 +1484,9 @@ public Builder setDecodeMode(DecodeMode decodeMode) { @Override public Builder setHost(String host) { super.setHost(host); + if (!CLOUD_SPANNER_HOST_PATTERN.matcher(host).matches()) { + this.isExternalHost = true; + } // Setting a host should override any SPANNER_EMULATOR_HOST setting. setEmulatorHost(null); return this; @@ -1629,6 +1657,8 @@ public SpannerOptions build() { this.setChannelConfigurator(ManagedChannelBuilder::usePlaintext); // As we are using plain text, we should never send any credentials. this.setCredentials(NoCredentials.getInstance()); + } else if (isExternalHost && credentials == null) { + credentials = environment.getDefaultExternalHostCredentials(); } if (this.numChannels == null) { this.numChannels = @@ -1669,6 +1699,24 @@ public static void useDefaultEnvironment() { SpannerOptions.environment = SpannerEnvironmentImpl.INSTANCE; } + @InternalApi + public static GoogleCredentials getDefaultExternalHostCredentialsFromSysEnv() { + return getOAuthTokenFromFile(System.getenv(DEFAULT_SPANNER_EXTERNAL_HOST_CREDENTIALS)); + } + + private static @Nullable GoogleCredentials getOAuthTokenFromFile(@Nullable String file) { + if (!Strings.isNullOrEmpty(file)) { + String token; + try { + token = Base64.getEncoder().encodeToString(Files.readAllBytes(Paths.get(file))); + } catch (IOException e) { + throw SpannerExceptionFactory.newSpannerException(e); + } + return GoogleCredentials.create(new AccessToken(token, null)); + } + return null; + } + /** * Enables OpenTelemetry traces. Enabling OpenTelemetry traces will disable OpenCensus traces. By * default, OpenCensus traces are enabled. diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionOptions.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionOptions.java index d205699bb9e..deb279d8e3e 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionOptions.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionOptions.java @@ -921,6 +921,8 @@ private ConnectionOptions(Builder builder) { getInitialConnectionPropertyValue(AUTO_CONFIG_EMULATOR), usePlainText, System.getenv()); + GoogleCredentials defaultExternalHostCredentials = + SpannerOptions.getDefaultExternalHostCredentialsFromSysEnv(); // Using credentials on a plain text connection is not allowed, so if the user has not specified // any credentials and is using a plain text connection, we should not try to get the // credentials from the environment, but default to NoCredentials. @@ -935,6 +937,8 @@ && getInitialConnectionPropertyValue(OAUTH_TOKEN) == null this.credentials = new GoogleCredentials( new AccessToken(getInitialConnectionPropertyValue(OAUTH_TOKEN), null)); + } else if (isExternalHost && defaultExternalHostCredentials != null) { + this.credentials = defaultExternalHostCredentials; } else if (getInitialConnectionPropertyValue(CREDENTIALS_PROVIDER) != null) { try { this.credentials = getInitialConnectionPropertyValue(CREDENTIALS_PROVIDER).getCredentials(); diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/SpannerOptionsTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/SpannerOptionsTest.java index 70482c0ffdd..72bbdf82eae 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/SpannerOptionsTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/SpannerOptionsTest.java @@ -16,6 +16,7 @@ package com.google.cloud.spanner; +import static com.google.cloud.spanner.SpannerOptions.CLOUD_SPANNER_HOST_PATTERN; import static com.google.common.truth.Truth.assertThat; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; @@ -1164,4 +1165,12 @@ public void checkGlobalOpenTelemetryWhenNotInjected() { .build(); assertEquals(GlobalOpenTelemetry.get(), options.getOpenTelemetry()); } + + @Test + public void testCloudSpannerHostPattern() { + assertTrue(CLOUD_SPANNER_HOST_PATTERN.matcher("https://spanner.googleapis.com").matches()); + assertTrue( + CLOUD_SPANNER_HOST_PATTERN.matcher("https://product-area.googleapis.com:443").matches()); + assertFalse(CLOUD_SPANNER_HOST_PATTERN.matcher("https://some-company.com:443").matches()); + } }