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