• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright 2022 Google LLC
3  *
4  * Redistribution and use in source and binary forms, with or without
5  * modification, are permitted provided that the following conditions are
6  * met:
7  *
8  *    * Redistributions of source code must retain the above copyright
9  * notice, this list of conditions and the following disclaimer.
10  *    * Redistributions in binary form must reproduce the above
11  * copyright notice, this list of conditions and the following disclaimer
12  * in the documentation and/or other materials provided with the
13  * distribution.
14  *
15  *    * Neither the name of Google LLC nor the names of its
16  * contributors may be used to endorse or promote products derived from
17  * this software without specific prior written permission.
18  *
19  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20  * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21  * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22  * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23  * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24  * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25  * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26  * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27  * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28  * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29  * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30  */
31 
32 package com.google.auth.oauth2;
33 
34 import com.google.api.client.json.GenericJson;
35 import com.google.api.client.json.JsonParser;
36 import com.google.common.annotations.VisibleForTesting;
37 import com.google.common.base.Splitter;
38 import java.io.BufferedReader;
39 import java.io.File;
40 import java.io.FileInputStream;
41 import java.io.IOException;
42 import java.io.InputStream;
43 import java.io.InputStreamReader;
44 import java.nio.charset.StandardCharsets;
45 import java.util.List;
46 import java.util.Map;
47 import java.util.concurrent.ExecutionException;
48 import java.util.concurrent.ExecutorService;
49 import java.util.concurrent.Executors;
50 import java.util.concurrent.Future;
51 import java.util.concurrent.TimeUnit;
52 import javax.annotation.Nullable;
53 
54 /**
55  * Internal handler for retrieving 3rd party tokens from user defined scripts/executables for
56  * workload identity federation.
57  *
58  * <p>See {@link PluggableAuthCredentials}.
59  */
60 final class PluggableAuthHandler implements ExecutableHandler {
61 
62   // The maximum supported version for the executable response.
63   // The executable response always includes a version number that is used
64   // to detect compatibility with the response and library verions.
65   private static final int EXECUTABLE_SUPPORTED_MAX_VERSION = 1;
66 
67   // The GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES dictates if this feature is enabled.
68   // The GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment variable must be set to '1' for
69   // security reasons.
70   private static final String GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES =
71       "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES";
72 
73   // The exit status of the 3P script that represents a successful execution.
74   private static final int EXIT_CODE_SUCCESS = 0;
75 
76   private final EnvironmentProvider environmentProvider;
77   private InternalProcessBuilder internalProcessBuilder;
78 
PluggableAuthHandler(EnvironmentProvider environmentProvider)79   PluggableAuthHandler(EnvironmentProvider environmentProvider) {
80     this.environmentProvider = environmentProvider;
81   }
82 
83   @VisibleForTesting
PluggableAuthHandler( EnvironmentProvider environmentProvider, InternalProcessBuilder internalProcessBuilder)84   PluggableAuthHandler(
85       EnvironmentProvider environmentProvider, InternalProcessBuilder internalProcessBuilder) {
86     this.environmentProvider = environmentProvider;
87     this.internalProcessBuilder = internalProcessBuilder;
88   }
89 
90   @Override
retrieveTokenFromExecutable(ExecutableOptions options)91   public String retrieveTokenFromExecutable(ExecutableOptions options) throws IOException {
92     // Validate that executables are allowed to run. To use Pluggable Auth,
93     // The GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment variable must be set to 1
94     // for security reasons.
95     if (!"1".equals(this.environmentProvider.getEnv(GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES))) {
96       throw new PluggableAuthException(
97           "PLUGGABLE_AUTH_DISABLED",
98           "Pluggable Auth executables need "
99               + "to be explicitly allowed to run by setting the "
100               + "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment variable to 1.");
101     }
102 
103     // Users can specify an output file path in the Pluggable Auth ADC configuration.
104     // This is the file's absolute path. Their executable will handle writing the 3P credentials to
105     // this file.
106     // If specified, we will first check if we have valid unexpired credentials stored in this
107     // location to avoid running the executable until they are expired.
108     ExecutableResponse executableResponse = getCachedExecutableResponse(options);
109 
110     // If the output_file does not contain a valid response, call the executable.
111     if (executableResponse == null) {
112       executableResponse = getExecutableResponse(options);
113     }
114 
115     // If an output file is specified, successful responses must contain the `expiration_time`
116     // field.
117     if (options.getOutputFilePath() != null
118         && !options.getOutputFilePath().isEmpty()
119         && executableResponse.isSuccessful()
120         && executableResponse.getExpirationTime() == null) {
121       throw new PluggableAuthException(
122           "INVALID_EXECUTABLE_RESPONSE",
123           "The executable response must contain the `expiration_time` field for successful responses when an "
124               + "output_file has been specified in the configuration.");
125     }
126 
127     // The executable response includes a version. Validate that the version is compatible
128     // with the library.
129     if (executableResponse.getVersion() > EXECUTABLE_SUPPORTED_MAX_VERSION) {
130       throw new PluggableAuthException(
131           "UNSUPPORTED_VERSION",
132           "The version of the executable response is not supported. "
133               + String.format(
134                   "The maximum version currently supported is %s.",
135                   EXECUTABLE_SUPPORTED_MAX_VERSION));
136     }
137 
138     if (!executableResponse.isSuccessful()) {
139       throw new PluggableAuthException(
140           executableResponse.getErrorCode(), executableResponse.getErrorMessage());
141     }
142 
143     if (executableResponse.isExpired()) {
144       throw new PluggableAuthException("INVALID_RESPONSE", "The executable response is expired.");
145     }
146 
147     // Subject token is valid and can be returned.
148     return executableResponse.getSubjectToken();
149   }
150 
151   @Nullable
getCachedExecutableResponse(ExecutableOptions options)152   ExecutableResponse getCachedExecutableResponse(ExecutableOptions options)
153       throws PluggableAuthException {
154     ExecutableResponse executableResponse = null;
155     if (options.getOutputFilePath() != null && !options.getOutputFilePath().isEmpty()) {
156       // Try reading cached response from output_file.
157       try {
158         File outputFile = new File(options.getOutputFilePath());
159         // Check if the output file is valid and not empty.
160         if (outputFile.isFile() && outputFile.length() > 0) {
161           InputStream inputStream = new FileInputStream(options.getOutputFilePath());
162           BufferedReader reader =
163               new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
164           JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(reader);
165           ExecutableResponse cachedResponse =
166               new ExecutableResponse(parser.parseAndClose(GenericJson.class));
167           // If the cached response is successful and unexpired, we can use it.
168           // Response version will be validated below.
169           if (cachedResponse.isValid()) {
170             executableResponse = cachedResponse;
171           }
172         }
173       } catch (Exception e) {
174         throw new PluggableAuthException(
175             "INVALID_OUTPUT_FILE",
176             "The output_file specified contains an invalid or malformed response." + e);
177       }
178     }
179     return executableResponse;
180   }
181 
getExecutableResponse(ExecutableOptions options)182   ExecutableResponse getExecutableResponse(ExecutableOptions options) throws IOException {
183     List<String> components = Splitter.on(" ").splitToList(options.getExecutableCommand());
184 
185     // Create the process.
186     InternalProcessBuilder processBuilder = getProcessBuilder(components);
187 
188     // Inject environment variables.
189     Map<String, String> envMap = processBuilder.environment();
190     envMap.putAll(options.getEnvironmentMap());
191 
192     // Redirect error stream.
193     processBuilder.redirectErrorStream(true);
194 
195     // Start the process.
196     Process process = processBuilder.start();
197 
198     ExecutableResponse execResp;
199     String executableOutput = "";
200     ExecutorService executor = Executors.newSingleThreadExecutor();
201     try {
202       // Consume the input stream while waiting for the program to finish so that
203       // the process won't hang if the STDOUT buffer is filled.
204       Future<String> future =
205           executor.submit(
206               () -> {
207                 BufferedReader reader =
208                     new BufferedReader(
209                         new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
210 
211                 StringBuilder sb = new StringBuilder();
212                 String line;
213                 while ((line = reader.readLine()) != null) {
214                   sb.append(line).append(System.lineSeparator());
215                 }
216                 return sb.toString().trim();
217               });
218 
219       boolean success = process.waitFor(options.getExecutableTimeoutMs(), TimeUnit.MILLISECONDS);
220       if (!success) {
221         // Process has not terminated within the specified timeout.
222         throw new PluggableAuthException(
223             "TIMEOUT_EXCEEDED", "The executable failed to finish within the timeout specified.");
224       }
225       int exitCode = process.exitValue();
226       if (exitCode != EXIT_CODE_SUCCESS) {
227         throw new PluggableAuthException(
228             "EXIT_CODE", String.format("The executable failed with exit code %s.", exitCode));
229       }
230 
231       executableOutput = future.get();
232       executor.shutdownNow();
233 
234       JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(executableOutput);
235       execResp = new ExecutableResponse(parser.parseAndClose(GenericJson.class));
236     } catch (IOException e) {
237       // Destroy the process.
238       process.destroy();
239 
240       // Shutdown executor if needed.
241       if (!executor.isShutdown()) {
242         executor.shutdownNow();
243       }
244 
245       if (e instanceof PluggableAuthException) {
246         throw e;
247       }
248       // An error may have occurred in the executable and should be surfaced.
249       throw new PluggableAuthException(
250           "INVALID_RESPONSE",
251           String.format("The executable returned an invalid response: %s.", executableOutput));
252     } catch (InterruptedException | ExecutionException e) {
253       // Destroy the process.
254       process.destroy();
255 
256       throw new PluggableAuthException(
257           "INTERRUPTED", String.format("The execution was interrupted: %s.", e));
258     }
259 
260     process.destroy();
261     return execResp;
262   }
263 
getProcessBuilder(List<String> commandComponents)264   InternalProcessBuilder getProcessBuilder(List<String> commandComponents) {
265     if (internalProcessBuilder != null) {
266       return internalProcessBuilder;
267     }
268     return new DefaultProcessBuilder(new ProcessBuilder(commandComponents));
269   }
270 
271   /**
272    * An interface for creating and managing a process.
273    *
274    * <p>ProcessBuilder is final and does not implement any interface. This class allows concrete
275    * implementations to be specified to test these changes.
276    */
277   abstract static class InternalProcessBuilder {
278 
environment()279     abstract Map<String, String> environment();
280 
redirectErrorStream(boolean redirectErrorStream)281     abstract InternalProcessBuilder redirectErrorStream(boolean redirectErrorStream);
282 
start()283     abstract Process start() throws IOException;
284   }
285 
286   /**
287    * A default implementation for {@link InternalProcessBuilder} that wraps {@link ProcessBuilder}.
288    */
289   static final class DefaultProcessBuilder extends InternalProcessBuilder {
290     ProcessBuilder processBuilder;
291 
DefaultProcessBuilder(ProcessBuilder processBuilder)292     DefaultProcessBuilder(ProcessBuilder processBuilder) {
293       this.processBuilder = processBuilder;
294     }
295 
296     @Override
environment()297     Map<String, String> environment() {
298       return this.processBuilder.environment();
299     }
300 
301     @Override
redirectErrorStream(boolean redirectErrorStream)302     InternalProcessBuilder redirectErrorStream(boolean redirectErrorStream) {
303       this.processBuilder.redirectErrorStream(redirectErrorStream);
304       return this;
305     }
306 
307     @Override
start()308     Process start() throws IOException {
309       return this.processBuilder.start();
310     }
311   }
312 }
313