diff --git a/java-spanner/google-cloud-spanner-executor/src/main/java/com/google/cloud/executor/spanner/AccessTokenFileCredentials.java b/java-spanner/google-cloud-spanner-executor/src/main/java/com/google/cloud/executor/spanner/AccessTokenFileCredentials.java new file mode 100644 index 000000000000..ce0d8bfeea34 --- /dev/null +++ b/java-spanner/google-cloud-spanner-executor/src/main/java/com/google/cloud/executor/spanner/AccessTokenFileCredentials.java @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Google LLC + * + * 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 + * + * http://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 com.google.cloud.executor.spanner; + +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.OAuth2Credentials; +import com.google.common.base.Preconditions; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Date; +import org.apache.commons.io.FileUtils; + +/** + * {@link OAuth2Credentials} backed by a local file containing a raw OAuth2 bearer access token. + * + *
An external host process periodically refreshes and atomically replaces the token file before
+ * the underlying OAuth2 token expires. Because the file contains only the raw token string without
+ * expiry metadata, each read assigns a bounded synthetic lifetime so {@link OAuth2Credentials}
+ * periodically re-reads the file via {@link #refreshAccessToken()}.
+ */
+final class AccessTokenFileCredentials extends OAuth2Credentials {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Assign a 15-minute synthetic lifetime to each file read. With {@link OAuth2Credentials}'s
+ * default refresh/expiration margins (~3m45s / 3m), cached tokens are refreshed from disk roughly
+ * every 11 minutes, well within a typical 1-hour OAuth2 access token lifetime.
+ */
+ static final Duration DEFAULT_SYNTHETIC_LIFETIME = Duration.ofMinutes(15);
+
+ private final File tokenFile;
+ private final Duration syntheticLifetime;
+
+ AccessTokenFileCredentials(File tokenFile) {
+ super(null);
+ this.tokenFile = Preconditions.checkNotNull(tokenFile, "tokenFile");
+ this.syntheticLifetime = DEFAULT_SYNTHETIC_LIFETIME;
+ }
+
+ AccessTokenFileCredentials(
+ File tokenFile,
+ Duration syntheticLifetime,
+ Duration refreshMargin,
+ Duration expirationMargin) {
+ super(null, refreshMargin, expirationMargin);
+ this.tokenFile = Preconditions.checkNotNull(tokenFile, "tokenFile");
+ this.syntheticLifetime = Preconditions.checkNotNull(syntheticLifetime, "syntheticLifetime");
+ Preconditions.checkArgument(
+ !syntheticLifetime.isNegative(), "syntheticLifetime cannot be negative");
+ }
+
+ @Override
+ public AccessToken refreshAccessToken() throws IOException {
+ if (!tokenFile.isFile()) {
+ throw new IOException(
+ "Access token file does not exist or is not a regular file: " + tokenFile);
+ }
+ String token = FileUtils.readFileToString(tokenFile, StandardCharsets.UTF_8).trim();
+ if (token.isEmpty()) {
+ throw new IOException("Access token file is empty: " + tokenFile);
+ }
+ Date expirationTime = new Date(System.currentTimeMillis() + syntheticLifetime.toMillis());
+ return new AccessToken(token, expirationTime);
+ }
+}
diff --git a/java-spanner/google-cloud-spanner-executor/src/main/java/com/google/cloud/executor/spanner/CloudClientExecutor.java b/java-spanner/google-cloud-spanner-executor/src/main/java/com/google/cloud/executor/spanner/CloudClientExecutor.java
index 0238dc8f0651..efac840b0687 100644
--- a/java-spanner/google-cloud-spanner-executor/src/main/java/com/google/cloud/executor/spanner/CloudClientExecutor.java
+++ b/java-spanner/google-cloud-spanner-executor/src/main/java/com/google/cloud/executor/spanner/CloudClientExecutor.java
@@ -24,7 +24,6 @@
import com.google.api.gax.rpc.TransportChannelProvider;
import com.google.api.gax.rpc.UnavailableException;
import com.google.auth.Credentials;
-import com.google.auth.oauth2.ServiceAccountCredentials;
import com.google.cloud.ByteArray;
import com.google.cloud.Date;
import com.google.cloud.NoCredentials;
@@ -165,8 +164,6 @@
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;
-import java.io.ByteArrayInputStream;
-import java.io.File;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
@@ -192,7 +189,6 @@
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
-import org.apache.commons.io.FileUtils;
/**
* Implementation of the SpannerExecutorProxy gRPC service that proxies action request through the
@@ -874,18 +870,9 @@ private synchronized Spanner getClient(boolean useMultiplexedSession) throws IOE
private synchronized Spanner initializeClient(long timeoutSeconds, boolean useMultiplexedSession)
throws IOException {
// Create a cloud spanner client
- Credentials credentials;
- if (WorkerProxy.serviceKeyFile == null
- || WorkerProxy.serviceKeyFile.isEmpty()
- || !new File(WorkerProxy.serviceKeyFile).exists()
- || new File(WorkerProxy.serviceKeyFile).isDirectory()) {
+ Credentials credentials = WorkerProxy.getCredentialsFromFlags();
+ if (credentials == null) {
credentials = NoCredentials.getInstance();
- } else {
- credentials =
- ServiceAccountCredentials.fromStream(
- new ByteArrayInputStream(
- FileUtils.readFileToByteArray(new File(WorkerProxy.serviceKeyFile))),
- HTTP_TRANSPORT_FACTORY);
}
TransportChannelProvider channelProvider =
@@ -959,18 +946,9 @@ private synchronized TraceServiceClient getTraceServiceClient() throws IOExcepti
return traceServiceClient;
}
// Create a trace service client
- Credentials credentials;
- if (WorkerProxy.serviceKeyFile == null
- || WorkerProxy.serviceKeyFile.isEmpty()
- || !new File(WorkerProxy.serviceKeyFile).exists()
- || new File(WorkerProxy.serviceKeyFile).isDirectory()) {
+ Credentials credentials = WorkerProxy.getCredentialsFromFlags();
+ if (credentials == null) {
credentials = NoCredentials.getInstance();
- } else {
- credentials =
- ServiceAccountCredentials.fromStream(
- new ByteArrayInputStream(
- FileUtils.readFileToByteArray(new File(WorkerProxy.serviceKeyFile))),
- HTTP_TRANSPORT_FACTORY);
}
TraceServiceSettings traceServiceSettings =
diff --git a/java-spanner/google-cloud-spanner-executor/src/main/java/com/google/cloud/executor/spanner/WorkerProxy.java b/java-spanner/google-cloud-spanner-executor/src/main/java/com/google/cloud/executor/spanner/WorkerProxy.java
index a0bc70d0fd2e..12d4d66cfd71 100644
--- a/java-spanner/google-cloud-spanner-executor/src/main/java/com/google/cloud/executor/spanner/WorkerProxy.java
+++ b/java-spanner/google-cloud-spanner-executor/src/main/java/com/google/cloud/executor/spanner/WorkerProxy.java
@@ -42,6 +42,7 @@
import java.io.IOException;
import java.util.logging.Level;
import java.util.logging.Logger;
+import javax.annotation.Nullable;
import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.CommandLineParser;
import org.apache.commons.cli.DefaultParser;
@@ -56,11 +57,13 @@
public class WorkerProxy {
private static final Logger LOGGER = Logger.getLogger(WorkerProxy.class.getName());
+ private static final HttpTransportFactory HTTP_TRANSPORT_FACTORY = NetHttpTransport::new;
- private static final String OPTION_SPANNER_PORT = "spanner_port";
- private static final String OPTION_PROXY_PORT = "proxy_port";
- private static final String OPTION_CERTIFICATE = "cert";
- private static final String OPTION_SERVICE_KEY_FILE = "service_key_file";
+ static final String OPTION_SPANNER_PORT = "spanner_port";
+ static final String OPTION_PROXY_PORT = "proxy_port";
+ static final String OPTION_CERTIFICATE = "cert";
+ static final String OPTION_SERVICE_KEY_FILE = "service_key_file";
+ static final String OPTION_ACCESS_TOKEN_FILE = "access_token_file";
private static final String OPTION_USE_PLAIN_TEXT_CHANNEL = "use_plain_text_channel";
private static final String OPTION_ENABLE_GRPC_FAULT_INJECTOR = "enable_grpc_fault_injector";
private static final String OPTION_MULTIPLEXED_SESSION_OPERATIONS_RATIO =
@@ -70,6 +73,7 @@ public class WorkerProxy {
public static int proxyPort = 0;
public static String cert = "";
public static String serviceKeyFile = "";
+ public static String accessTokenFile = "";
public static double multiplexedSessionOperationsRatio = 1.0;
public static boolean usePlainTextChannel = false;
public static boolean enableGrpcFaultInjector = false;
@@ -83,25 +87,41 @@ public class WorkerProxy {
private static final int MIN_PORT = 0, MAX_PORT = 65535;
private static final double MIN_RATIO = 0.0, MAX_RATIO = 1.0, TRACE_SAMPLING_RATE = 0.01;
+ @Nullable
+ static Credentials getCredentialsFromFlags() throws IOException {
+ boolean hasAccessTokenFile = accessTokenFile != null && !accessTokenFile.isEmpty();
+ boolean hasServiceKeyFile = serviceKeyFile != null && !serviceKeyFile.isEmpty();
+ if (hasAccessTokenFile && hasServiceKeyFile) {
+ throw new IllegalArgumentException(
+ "Cannot specify both --"
+ + OPTION_SERVICE_KEY_FILE
+ + " and --"
+ + OPTION_ACCESS_TOKEN_FILE);
+ }
+ if (hasAccessTokenFile) {
+ return new AccessTokenFileCredentials(new File(accessTokenFile));
+ }
+ if (!hasServiceKeyFile) {
+ return null;
+ }
+ File keyFile = new File(serviceKeyFile);
+ if (!keyFile.exists() || keyFile.isDirectory()) {
+ return null;
+ }
+ return ServiceAccountCredentials.fromStream(
+ new ByteArrayInputStream(FileUtils.readFileToByteArray(keyFile)), HTTP_TRANSPORT_FACTORY);
+ }
+
public static OpenTelemetrySdk setupOpenTelemetrySdk() throws Exception {
- if (serviceKeyFile == null
- || serviceKeyFile.isEmpty()
- || !new File(serviceKeyFile).exists()
- || new File(serviceKeyFile).isDirectory()) {
+ Credentials credentials = getCredentialsFromFlags();
+ if (credentials == null) {
LOGGER.log(
Level.WARNING,
- "serviceKeyFile is empty or invalid; starting without OpenTelemetry trace export: "
- + serviceKeyFile);
+ "Credentials are empty or invalid; starting without OpenTelemetry trace export.");
return OpenTelemetrySdk.builder()
.setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance()))
.build();
}
- // Read credentials from the serviceKeyFile.
- HttpTransportFactory HTTP_TRANSPORT_FACTORY = NetHttpTransport::new;
- Credentials credentials =
- ServiceAccountCredentials.fromStream(
- new ByteArrayInputStream(FileUtils.readFileToByteArray(new File(serviceKeyFile))),
- HTTP_TRANSPORT_FACTORY);
// OpenTelemetry configuration.
SpanExporter spanExporter =
@@ -157,9 +177,14 @@ public static void main(String[] args) throws Exception {
"Certificate need to be assigned in order to start worker proxy.");
}
cert = commandLine.getOptionValue(OPTION_CERTIFICATE);
- if (commandLine.hasOption(OPTION_SERVICE_KEY_FILE)) {
- serviceKeyFile = commandLine.getOptionValue(OPTION_SERVICE_KEY_FILE);
- }
+ serviceKeyFile =
+ commandLine.hasOption(OPTION_SERVICE_KEY_FILE)
+ ? commandLine.getOptionValue(OPTION_SERVICE_KEY_FILE)
+ : "";
+ accessTokenFile =
+ commandLine.hasOption(OPTION_ACCESS_TOKEN_FILE)
+ ? commandLine.getOptionValue(OPTION_ACCESS_TOKEN_FILE)
+ : "";
usePlainTextChannel = commandLine.hasOption(OPTION_USE_PLAIN_TEXT_CHANNEL);
enableGrpcFaultInjector = commandLine.hasOption(OPTION_ENABLE_GRPC_FAULT_INJECTOR);
@@ -210,7 +235,7 @@ public static void main(String[] args) throws Exception {
server.awaitTermination();
}
- private static CommandLine buildOptions(String[] args) {
+ static CommandLine buildOptions(String[] args) {
Options options = new Options();
options.addOption(
@@ -220,6 +245,11 @@ private static CommandLine buildOptions(String[] args) {
null, OPTION_CERTIFICATE, true, "Certificate used to connect to Spanner GFE.");
options.addOption(
null, OPTION_SERVICE_KEY_FILE, true, "Service key file used to set authentication.");
+ options.addOption(
+ null,
+ OPTION_ACCESS_TOKEN_FILE,
+ true,
+ "File containing an OAuth2 access token used to set authentication.");
options.addOption(
null,
OPTION_USE_PLAIN_TEXT_CHANNEL,
@@ -238,7 +268,15 @@ private static CommandLine buildOptions(String[] args) {
CommandLineParser parser = new DefaultParser();
try {
- return parser.parse(options, args);
+ CommandLine parsed = parser.parse(options, args);
+ if (parsed.hasOption(OPTION_SERVICE_KEY_FILE) && parsed.hasOption(OPTION_ACCESS_TOKEN_FILE)) {
+ throw new IllegalArgumentException(
+ "Cannot specify both --"
+ + OPTION_SERVICE_KEY_FILE
+ + " and --"
+ + OPTION_ACCESS_TOKEN_FILE);
+ }
+ return parsed;
} catch (ParseException e) {
throw new IllegalArgumentException(e.getMessage());
}
diff --git a/java-spanner/google-cloud-spanner-executor/src/test/java/com/google/cloud/executor/spanner/AccessTokenFileCredentialsTest.java b/java-spanner/google-cloud-spanner-executor/src/test/java/com/google/cloud/executor/spanner/AccessTokenFileCredentialsTest.java
new file mode 100644
index 000000000000..f4131e8ffa48
--- /dev/null
+++ b/java-spanner/google-cloud-spanner-executor/src/test/java/com/google/cloud/executor/spanner/AccessTokenFileCredentialsTest.java
@@ -0,0 +1,168 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * 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
+ *
+ * http://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 com.google.cloud.executor.spanner;
+
+import com.google.auth.Credentials;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.io.FileUtils;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class AccessTokenFileCredentialsTest {
+
+ @Rule public TemporaryFolder tempFolder = new TemporaryFolder();
+
+ @Before
+ public void setUp() {
+ WorkerProxy.serviceKeyFile = "";
+ WorkerProxy.accessTokenFile = "";
+ }
+
+ @After
+ public void tearDown() {
+ WorkerProxy.serviceKeyFile = "";
+ WorkerProxy.accessTokenFile = "";
+ }
+
+ @Test
+ public void getRequestMetadata_readsAndTrimsBearerTokenFromFile() throws IOException {
+ File tokenFile = tempFolder.newFile("access_token");
+ FileUtils.writeStringToFile(tokenFile, " test-token-123 \n", StandardCharsets.UTF_8);
+ AccessTokenFileCredentials credentials = new AccessTokenFileCredentials(tokenFile);
+
+ Map