[5.4.0] Add integration tests for --experimental_credential_helper. (#16880)

Closes #15996.

PiperOrigin-RevId: 463851754
Change-Id: I13b851454f0b8e6550b2335caa2d802312931929
diff --git a/src/test/shell/bazel/remote/remote_execution_test.sh b/src/test/shell/bazel/remote/remote_execution_test.sh
index 6b3cab2..bd10beb 100755
--- a/src/test/shell/bazel/remote/remote_execution_test.sh
+++ b/src/test/shell/bazel/remote/remote_execution_test.sh
@@ -71,6 +71,70 @@
   declare -r EXE_EXT=""
 fi
 
+function setup_credential_helper() {
+  cat > "${TEST_TMPDIR}/credhelper" <<'EOF'
+#!/usr/bin/env python3
+print("""{"headers":{"Authorization":["Bearer secret_token"]}}""")
+EOF
+  chmod +x "${TEST_TMPDIR}/credhelper"
+}
+
+function test_credential_helper_remote_cache() {
+  setup_credential_helper
+
+  mkdir -p a
+
+  cat > a/BUILD <<'EOF'
+genrule(
+  name = "a",
+  outs = ["a.txt"],
+  cmd = "touch $(OUTS)",
+)
+EOF
+
+  stop_worker
+  start_worker --expected_authorization_token=secret_token
+
+  bazel build \
+      --remote_cache=grpc://localhost:${worker_port} \
+      //a:a >& $TEST_log && fail "Build without credentials should have failed"
+  expect_log "Failed to query remote execution capabilities"
+
+  bazel build \
+      --remote_cache=grpc://localhost:${worker_port} \
+      --experimental_credential_helper="${TEST_TMPDIR}/credhelper" \
+      //a:a >& $TEST_log || fail "Build with credentials should have succeeded"
+}
+
+function test_credential_helper_remote_execution() {
+  setup_credential_helper
+
+  mkdir -p a
+
+  cat > a/BUILD <<'EOF'
+genrule(
+  name = "a",
+  outs = ["a.txt"],
+  cmd = "touch $(OUTS)",
+)
+EOF
+
+  stop_worker
+  start_worker --expected_authorization_token=secret_token
+
+  bazel build \
+      --spawn_strategy=remote \
+      --remote_executor=grpc://localhost:${worker_port} \
+      //a:a >& $TEST_log && fail "Build without credentials should have failed"
+  expect_log "Failed to query remote execution capabilities"
+
+  bazel build \
+      --spawn_strategy=remote \
+      --remote_executor=grpc://localhost:${worker_port} \
+      --experimental_credential_helper="${TEST_TMPDIR}/credhelper" \
+      //a:a >& $TEST_log || fail "Build with credentials should have succeeded"
+}
+
 function test_remote_grpc_cache_with_protocol() {
   # Test that if 'grpc' is provided as a scheme for --remote_cache flag, remote cache works.
   mkdir -p a
diff --git a/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/RemoteWorker.java b/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/RemoteWorker.java
index f5689e2..afb28bf 100644
--- a/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/RemoteWorker.java
+++ b/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/RemoteWorker.java
@@ -49,9 +49,16 @@
 import com.google.devtools.build.remote.worker.http.HttpCacheServerInitializer;
 import com.google.devtools.common.options.OptionsParser;
 import com.google.devtools.common.options.OptionsParsingException;
+import io.grpc.Context;
+import io.grpc.Contexts;
+import io.grpc.Metadata;
 import io.grpc.Server;
+import io.grpc.ServerCall;
+import io.grpc.ServerCall.Listener;
+import io.grpc.ServerCallHandler;
 import io.grpc.ServerInterceptor;
 import io.grpc.ServerInterceptors;
+import io.grpc.Status;
 import io.grpc.netty.GrpcSslContexts;
 import io.grpc.netty.NettyServerBuilder;
 import io.netty.bootstrap.ServerBootstrap;
@@ -71,6 +78,9 @@
 import java.io.OutputStreamWriter;
 import java.io.Writer;
 import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Executors;
 import java.util.logging.Level;
@@ -107,6 +117,39 @@
     return new JavaIoFileSystem(hashFunction);
   }
 
+  /** A {@link ServerInterceptor} that rejects requests unless an authorization token is present. */
+  private static class AuthorizationTokenInterceptor implements ServerInterceptor {
+    private static final Metadata.Key<String> AUTHORIZATION_HEADER_KEY =
+        Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER);
+
+    private static final String BEARER_PREFIX = "Bearer ";
+
+    private final String expectedToken;
+
+    AuthorizationTokenInterceptor(String expectedToken) {
+      this.expectedToken = expectedToken;
+    }
+
+    private Optional<String> getTokenFromMetadata(Metadata headers) {
+      String val = headers.get(AUTHORIZATION_HEADER_KEY);
+      if (val != null && val.startsWith(BEARER_PREFIX)) {
+        return Optional.of(val.substring(BEARER_PREFIX.length()));
+      }
+      return Optional.empty();
+    }
+
+    @Override
+    public <ReqT, RespT> Listener<ReqT> interceptCall(
+        ServerCall<ReqT, RespT> call, Metadata headers, ServerCallHandler<ReqT, RespT> next) {
+      Optional<String> actualToken = getTokenFromMetadata(headers);
+      if (!expectedToken.equals(actualToken.get())) {
+        call.close(Status.PERMISSION_DENIED, new Metadata());
+        return new ServerCall.Listener<ReqT>() {};
+      }
+      return Contexts.interceptCall(Context.current(), call, headers, next);
+    }
+  }
+
   public RemoteWorker(
       FileSystem fs,
       RemoteWorkerOptions workerOptions,
@@ -149,20 +192,25 @@
   }
 
   public Server startServer() throws IOException {
-    ServerInterceptor headersInterceptor = new TracingMetadataUtils.ServerHeadersInterceptor();
+    List<ServerInterceptor> interceptors = new ArrayList<>();
+    interceptors.add(new TracingMetadataUtils.ServerHeadersInterceptor());
+    if (workerOptions.expectedAuthorizationToken != null) {
+      interceptors.add(new AuthorizationTokenInterceptor(workerOptions.expectedAuthorizationToken));
+    }
+
     NettyServerBuilder b =
         NettyServerBuilder.forPort(workerOptions.listenPort)
-            .addService(ServerInterceptors.intercept(actionCacheServer, headersInterceptor))
-            .addService(ServerInterceptors.intercept(bsServer, headersInterceptor))
-            .addService(ServerInterceptors.intercept(casServer, headersInterceptor))
-            .addService(ServerInterceptors.intercept(capabilitiesServer, headersInterceptor));
+            .addService(ServerInterceptors.intercept(actionCacheServer, interceptors))
+            .addService(ServerInterceptors.intercept(bsServer, interceptors))
+            .addService(ServerInterceptors.intercept(casServer, interceptors))
+            .addService(ServerInterceptors.intercept(capabilitiesServer, interceptors));
 
     if (workerOptions.tlsCertificate != null) {
       b.sslContext(getSslContextBuilder(workerOptions).build());
     }
 
     if (execServer != null) {
-      b.addService(ServerInterceptors.intercept(execServer, headersInterceptor));
+      b.addService(ServerInterceptors.intercept(execServer, interceptors));
     } else {
       logger.atInfo().log("Execution disabled, only serving cache requests");
     }
diff --git a/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/RemoteWorkerOptions.java b/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/RemoteWorkerOptions.java
index e219a0b..79e79ec 100644
--- a/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/RemoteWorkerOptions.java
+++ b/src/tools/remote/src/main/java/com/google/devtools/build/remote/worker/RemoteWorkerOptions.java
@@ -176,6 +176,16 @@
               + "requires client authentication (aka mTLS).")
   public String tlsCaCertificate;
 
+  @Option(
+      name = "expected_authorization_token",
+      defaultValue = "null",
+      documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
+      effectTags = {OptionEffectTag.UNKNOWN},
+      help =
+          "The authorization token expected to be present in every request. This is useful for"
+              + " testing only.")
+  public String expectedAuthorizationToken;
+
   private static final int MAX_JOBS = 16384;
 
   /**