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());
+ }
}