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> metadata = credentials.getRequestMetadata(null); + + Assert.assertEquals( + Collections.singletonList("Bearer test-token-123"), metadata.get("Authorization")); + } + + @Test + public void getRequestMetadata_cachesWhileFreshAndUpdatesOnExplicitRefresh() throws IOException { + File tokenFile = tempFolder.newFile("access_token"); + FileUtils.writeStringToFile(tokenFile, "token-v1\n", StandardCharsets.UTF_8); + AccessTokenFileCredentials credentials = new AccessTokenFileCredentials(tokenFile); + + Assert.assertEquals( + Collections.singletonList("Bearer token-v1"), + credentials.getRequestMetadata(null).get("Authorization")); + + FileUtils.writeStringToFile(tokenFile, "token-v2\n", StandardCharsets.UTF_8); + Assert.assertEquals( + Collections.singletonList("Bearer token-v1"), + credentials.getRequestMetadata(null).get("Authorization")); + + credentials.refresh(); + Assert.assertEquals( + Collections.singletonList("Bearer token-v2"), + credentials.getRequestMetadata(null).get("Authorization")); + } + + @Test + public void getRequestMetadata_automaticallyReReadsAtomicallyRotatedFileWhenExpired() + throws IOException { + File tokenFile = tempFolder.newFile("access_token"); + FileUtils.writeStringToFile(tokenFile, "token-v1\n", StandardCharsets.UTF_8); + AccessTokenFileCredentials credentials = + new AccessTokenFileCredentials(tokenFile, Duration.ZERO, Duration.ZERO, Duration.ZERO); + + Assert.assertEquals( + Collections.singletonList("Bearer token-v1"), + credentials.getRequestMetadata(null).get("Authorization")); + + File stagedTokenFile = tempFolder.newFile("access_token.tmp"); + FileUtils.writeStringToFile(stagedTokenFile, "token-v2\n", StandardCharsets.UTF_8); + Files.move(stagedTokenFile.toPath(), tokenFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + + Assert.assertEquals( + Collections.singletonList("Bearer token-v2"), + credentials.getRequestMetadata(null).get("Authorization")); + } + + @Test + public void refresh_throwsIOExceptionForInvalidTokenFiles() throws IOException { + File missingFile = new File(tempFolder.getRoot(), "does_not_exist"); + AccessTokenFileCredentials missingCredentials = new AccessTokenFileCredentials(missingFile); + Assert.assertThrows(IOException.class, missingCredentials::refresh); + + File directory = tempFolder.newFolder("token_dir"); + AccessTokenFileCredentials dirCredentials = new AccessTokenFileCredentials(directory); + Assert.assertThrows(IOException.class, dirCredentials::refresh); + + File emptyFile = tempFolder.newFile("empty_token"); + FileUtils.writeStringToFile(emptyFile, " \n", StandardCharsets.UTF_8); + AccessTokenFileCredentials emptyCredentials = new AccessTokenFileCredentials(emptyFile); + Assert.assertThrows(IOException.class, emptyCredentials::refresh); + } + + @Test + public void workerProxyBuildOptions_parsesAccessTokenFileAndRejectsBothCredentialFlags() { + CommandLine parsed = + WorkerProxy.buildOptions( + new String[] { + "--spanner_port", "1234", + "--proxy_port", "5678", + "--cert", "/tmp/cert.pem", + "--access_token_file", "/files/auth/access_token" + }); + Assert.assertEquals( + "/files/auth/access_token", parsed.getOptionValue(WorkerProxy.OPTION_ACCESS_TOKEN_FILE)); + + Assert.assertThrows( + IllegalArgumentException.class, + () -> + WorkerProxy.buildOptions( + new String[] { + "--service_key_file", "/files/key.json", + "--access_token_file", "/files/auth/access_token" + })); + } + + @Test + public void workerProxyGetCredentialsFromFlags_resolvesExpectedCredentials() throws IOException { + Assert.assertNull(WorkerProxy.getCredentialsFromFlags()); + + WorkerProxy.serviceKeyFile = new File(tempFolder.getRoot(), "missing.json").getPath(); + Assert.assertNull(WorkerProxy.getCredentialsFromFlags()); + WorkerProxy.serviceKeyFile = ""; + + File tokenFile = new File(tempFolder.getRoot(), "lazy_access_token"); + WorkerProxy.accessTokenFile = tokenFile.getPath(); + + Credentials credentials = WorkerProxy.getCredentialsFromFlags(); + Assert.assertNotNull(credentials); + FileUtils.writeStringToFile(tokenFile, "proxy-token\n", StandardCharsets.UTF_8); + Assert.assertEquals( + Collections.singletonList("Bearer proxy-token"), + credentials.getRequestMetadata(null).get("Authorization")); + + WorkerProxy.serviceKeyFile = "/files/key.json"; + Assert.assertThrows(IllegalArgumentException.class, WorkerProxy::getCredentialsFromFlags); + } +}