• 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 static org.junit.Assert.assertEquals;
35 import static org.junit.Assert.assertFalse;
36 import static org.junit.Assert.assertTrue;
37 import static org.junit.Assert.fail;
38 import static org.mockito.ArgumentMatchers.anyLong;
39 import static org.mockito.ArgumentMatchers.eq;
40 import static org.mockito.Mockito.any;
41 import static org.mockito.Mockito.times;
42 import static org.mockito.Mockito.verify;
43 import static org.mockito.Mockito.when;
44 
45 import com.google.api.client.json.GenericJson;
46 import com.google.auth.oauth2.ExecutableHandler.ExecutableOptions;
47 import com.google.auth.oauth2.PluggableAuthHandler.InternalProcessBuilder;
48 import com.google.common.collect.ImmutableMap;
49 import java.io.ByteArrayInputStream;
50 import java.io.File;
51 import java.io.IOException;
52 import java.nio.charset.StandardCharsets;
53 import java.time.Instant;
54 import java.util.Arrays;
55 import java.util.HashMap;
56 import java.util.List;
57 import java.util.Map;
58 import java.util.concurrent.TimeUnit;
59 import javax.annotation.Nullable;
60 import org.junit.Test;
61 import org.junit.runner.RunWith;
62 import org.mockito.Mockito;
63 import org.mockito.junit.MockitoJUnitRunner;
64 
65 /** Tests for {@link PluggableAuthHandler}. */
66 @RunWith(MockitoJUnitRunner.class)
67 public class PluggableAuthHandlerTest {
68   private static final String TOKEN_TYPE_OIDC = "urn:ietf:params:oauth:token-type:id_token";
69   private static final String TOKEN_TYPE_SAML = "urn:ietf:params:oauth:token-type:saml2";
70   private static final String ID_TOKEN = "header.payload.signature";
71   private static final String SAML_RESPONSE = "samlResponse";
72 
73   private static final int EXECUTABLE_SUPPORTED_MAX_VERSION = 1;
74   private static final int EXPIRATION_DURATION = 3600;
75   private static final int EXIT_CODE_SUCCESS = 0;
76   private static final int EXIT_CODE_FAIL = 1;
77 
78   private static final ExecutableOptions DEFAULT_OPTIONS =
79       new ExecutableOptions() {
80         @Override
81         public String getExecutableCommand() {
82           return "/path/to/executable";
83         }
84 
85         @Override
86         public Map<String, String> getEnvironmentMap() {
87           return ImmutableMap.of("optionKey1", "optionValue1", "optionValue2", "optionValue2");
88         }
89 
90         @Override
91         public int getExecutableTimeoutMs() {
92           return 30000;
93         }
94 
95         @Nullable
96         @Override
97         public String getOutputFilePath() {
98           return null;
99         }
100       };
101 
102   @Test
retrieveTokenFromExecutable_oidcResponse()103   public void retrieveTokenFromExecutable_oidcResponse() throws IOException, InterruptedException {
104     TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
105     environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
106 
107     Map<String, String> currentEnv = new HashMap<>();
108     currentEnv.put("currentEnvKey1", "currentEnvValue1");
109     currentEnv.put("currentEnvKey2", "currentEnvValue2");
110 
111     // Expected environment mappings.
112     HashMap<String, String> expectedMap = new HashMap<>();
113     expectedMap.putAll(DEFAULT_OPTIONS.getEnvironmentMap());
114     expectedMap.putAll(currentEnv);
115 
116     // Mock executable handling.
117     Process mockProcess = Mockito.mock(Process.class);
118     when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
119     when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
120 
121     when(mockProcess.getInputStream())
122         .thenReturn(
123             new ByteArrayInputStream(
124                 buildOidcResponse().toString().getBytes(StandardCharsets.UTF_8)));
125 
126     InternalProcessBuilder processBuilder =
127         buildInternalProcessBuilder(
128             currentEnv, mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
129 
130     PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
131 
132     // Call retrieveTokenFromExecutable().
133     String token = handler.retrieveTokenFromExecutable(DEFAULT_OPTIONS);
134 
135     verify(mockProcess, times(1)).destroy();
136     verify(mockProcess, times(1))
137         .waitFor(
138             eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
139     assertEquals(ID_TOKEN, token);
140 
141     // Current env map should include the mappings from options.
142     assertEquals(4, currentEnv.size());
143     assertEquals(expectedMap, currentEnv);
144   }
145 
146   @Test
retrieveTokenFromExecutable_samlResponse()147   public void retrieveTokenFromExecutable_samlResponse() throws IOException, InterruptedException {
148     TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
149     environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
150 
151     Map<String, String> currentEnv = new HashMap<>();
152     currentEnv.put("currentEnvKey1", "currentEnvValue1");
153     currentEnv.put("currentEnvKey2", "currentEnvValue2");
154 
155     // Expected environment mappings.
156     HashMap<String, String> expectedMap = new HashMap<>();
157     expectedMap.putAll(DEFAULT_OPTIONS.getEnvironmentMap());
158     expectedMap.putAll(currentEnv);
159 
160     // Mock executable handling.
161     Process mockProcess = Mockito.mock(Process.class);
162     when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
163     when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
164 
165     // SAML response.
166     when(mockProcess.getInputStream())
167         .thenReturn(
168             new ByteArrayInputStream(
169                 buildSamlResponse().toString().getBytes(StandardCharsets.UTF_8)));
170 
171     InternalProcessBuilder processBuilder =
172         buildInternalProcessBuilder(
173             currentEnv, mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
174 
175     PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
176 
177     // Call retrieveTokenFromExecutable().
178     String token = handler.retrieveTokenFromExecutable(DEFAULT_OPTIONS);
179 
180     verify(mockProcess, times(1)).destroy();
181     verify(mockProcess, times(1))
182         .waitFor(
183             eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
184     assertEquals(SAML_RESPONSE, token);
185 
186     // Current env map should include the mappings from options.
187     assertEquals(4, currentEnv.size());
188     assertEquals(expectedMap, currentEnv);
189   }
190 
191   @Test
retrieveTokenFromExecutable_errorResponse_throws()192   public void retrieveTokenFromExecutable_errorResponse_throws()
193       throws InterruptedException, IOException {
194     TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
195     environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
196 
197     // Mock executable handling.
198     Process mockProcess = Mockito.mock(Process.class);
199     when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
200     when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
201 
202     // Error response.
203     when(mockProcess.getInputStream())
204         .thenReturn(
205             new ByteArrayInputStream(
206                 buildErrorResponse().toString().getBytes(StandardCharsets.UTF_8)));
207 
208     InternalProcessBuilder processBuilder =
209         buildInternalProcessBuilder(
210             new HashMap<>(), mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
211 
212     PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
213 
214     // Call retrieveTokenFromExecutable().
215     try {
216       handler.retrieveTokenFromExecutable(DEFAULT_OPTIONS);
217       fail("Should not be able to continue without exception.");
218     } catch (PluggableAuthException e) {
219       assertEquals("401", e.getErrorCode());
220       assertEquals("Caller not authorized.", e.getErrorDescription());
221     }
222   }
223 
224   @Test
retrieveTokenFromExecutable_successResponseWithoutExpirationTimeField()225   public void retrieveTokenFromExecutable_successResponseWithoutExpirationTimeField()
226       throws InterruptedException, IOException {
227     TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
228     environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
229 
230     // Expected environment mappings.
231     HashMap<String, String> expectedMap = new HashMap<>();
232     expectedMap.putAll(DEFAULT_OPTIONS.getEnvironmentMap());
233 
234     Map<String, String> currentEnv = new HashMap<>();
235 
236     // Mock executable handling.
237     Process mockProcess = Mockito.mock(Process.class);
238     when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
239     when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
240 
241     // Remove expiration_time from the executable responses.
242     GenericJson oidcResponse = buildOidcResponse();
243     oidcResponse.remove("expiration_time");
244 
245     GenericJson samlResponse = buildSamlResponse();
246     samlResponse.remove("expiration_time");
247 
248     List<GenericJson> responses = Arrays.asList(oidcResponse, samlResponse);
249     for (int i = 0; i < responses.size(); i++) {
250       when(mockProcess.getInputStream())
251           .thenReturn(
252               new ByteArrayInputStream(
253                   responses.get(i).toString().getBytes(StandardCharsets.UTF_8)));
254 
255       InternalProcessBuilder processBuilder =
256           buildInternalProcessBuilder(
257               currentEnv, mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
258 
259       PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
260 
261       // Call retrieveTokenFromExecutable().
262       String token = handler.retrieveTokenFromExecutable(DEFAULT_OPTIONS);
263 
264       verify(mockProcess, times(i + 1)).destroy();
265       verify(mockProcess, times(i + 1))
266           .waitFor(
267               eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())),
268               eq(TimeUnit.MILLISECONDS));
269 
270       if (responses.get(i).equals(oidcResponse)) {
271         assertEquals(ID_TOKEN, token);
272       } else {
273         assertEquals(SAML_RESPONSE, token);
274       }
275 
276       // Current env map should have the mappings from options.
277       assertEquals(2, currentEnv.size());
278       assertEquals(expectedMap, currentEnv);
279     }
280   }
281 
282   @Test
283   public void
retrieveTokenFromExecutable_successResponseWithoutExpirationTimeFieldWithOutputFileSpecified_throws()284       retrieveTokenFromExecutable_successResponseWithoutExpirationTimeFieldWithOutputFileSpecified_throws()
285           throws InterruptedException, IOException {
286     TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
287     environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
288 
289     // Options with output file specified.
290     ExecutableOptions options =
291         new ExecutableOptions() {
292           @Override
293           public String getExecutableCommand() {
294             return "/path/to/executable";
295           }
296 
297           @Override
298           public Map<String, String> getEnvironmentMap() {
299             return ImmutableMap.of();
300           }
301 
302           @Override
303           public int getExecutableTimeoutMs() {
304             return 30000;
305           }
306 
307           @Override
308           public String getOutputFilePath() {
309             return "/path/to/output/file";
310           }
311         };
312 
313     // Mock executable handling.
314     Process mockProcess = Mockito.mock(Process.class);
315     when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
316     when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
317 
318     // Remove expiration_time from the executable responses.
319     GenericJson oidcResponse = buildOidcResponse();
320     oidcResponse.remove("expiration_time");
321 
322     GenericJson samlResponse = buildSamlResponse();
323     samlResponse.remove("expiration_time");
324 
325     List<GenericJson> responses = Arrays.asList(oidcResponse, samlResponse);
326     for (int i = 0; i < responses.size(); i++) {
327       when(mockProcess.getInputStream())
328           .thenReturn(
329               new ByteArrayInputStream(
330                   responses.get(i).toString().getBytes(StandardCharsets.UTF_8)));
331 
332       InternalProcessBuilder processBuilder =
333           buildInternalProcessBuilder(new HashMap<>(), mockProcess, options.getExecutableCommand());
334 
335       PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
336 
337       // Call retrieveTokenFromExecutable() should throw an exception as the STDOUT response
338       // is missing
339       // the `expiration_time` field and an output file was specified in the configuration.
340       try {
341         handler.retrieveTokenFromExecutable(options);
342         fail("Should not be able to continue without exception.");
343       } catch (PluggableAuthException exception) {
344         assertEquals(
345             "Error code INVALID_EXECUTABLE_RESPONSE: The executable response must contain the "
346                 + "`expiration_time` field for successful responses when an output_file has been specified in the"
347                 + " configuration.",
348             exception.getMessage());
349       }
350 
351       verify(mockProcess, times(i + 1)).destroy();
352       verify(mockProcess, times(i + 1))
353           .waitFor(eq(Long.valueOf(options.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
354     }
355   }
356 
357   @Test
358   public void
retrieveTokenFromExecutable_successResponseInOutputFileMissingExpirationTimeField_throws()359       retrieveTokenFromExecutable_successResponseInOutputFileMissingExpirationTimeField_throws()
360           throws InterruptedException, IOException {
361     TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
362     environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
363 
364     // Build output_file.
365     File file = File.createTempFile("output_file", /* suffix= */ null, /* directory= */ null);
366     file.deleteOnExit();
367 
368     // Options with output file specified.
369     ExecutableOptions options =
370         new ExecutableOptions() {
371           @Override
372           public String getExecutableCommand() {
373             return "/path/to/executable";
374           }
375 
376           @Override
377           public Map<String, String> getEnvironmentMap() {
378             return ImmutableMap.of();
379           }
380 
381           @Override
382           public int getExecutableTimeoutMs() {
383             return 30000;
384           }
385 
386           @Override
387           public String getOutputFilePath() {
388             return file.getAbsolutePath();
389           }
390         };
391 
392     // Mock executable handling that does nothing since we are using the output file.
393     Process mockProcess = Mockito.mock(Process.class);
394     InternalProcessBuilder processBuilder =
395         buildInternalProcessBuilder(new HashMap<>(), mockProcess, options.getExecutableCommand());
396 
397     // Remove expiration_time from the executable responses.
398     GenericJson oidcResponse = buildOidcResponse();
399     oidcResponse.remove("expiration_time");
400 
401     GenericJson samlResponse = buildSamlResponse();
402     samlResponse.remove("expiration_time");
403 
404     List<GenericJson> responses = Arrays.asList(oidcResponse, samlResponse);
405     for (GenericJson json : responses) {
406       OAuth2Utils.writeInputStreamToFile(
407           new ByteArrayInputStream(json.toString().getBytes(StandardCharsets.UTF_8)),
408           file.getAbsolutePath());
409 
410       PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
411 
412       // Call retrieveTokenFromExecutable() which should throw an exception as the output file
413       // response is missing
414       // the `expiration_time` field.
415       try {
416         handler.retrieveTokenFromExecutable(options);
417         fail("Should not be able to continue without exception.");
418       } catch (PluggableAuthException exception) {
419         assertEquals(
420             "Error code INVALID_EXECUTABLE_RESPONSE: The executable response must contain the "
421                 + "`expiration_time` field for successful responses when an output_file has been specified in the"
422                 + " configuration.",
423             exception.getMessage());
424       }
425 
426       // Validate executable not invoked.
427       verify(mockProcess, times(0)).destroyForcibly();
428       verify(mockProcess, times(0))
429           .waitFor(eq(Long.valueOf(options.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
430     }
431   }
432 
433   @Test
retrieveTokenFromExecutable_withOutputFile_usesCachedResponse()434   public void retrieveTokenFromExecutable_withOutputFile_usesCachedResponse()
435       throws IOException, InterruptedException {
436     TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
437     environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
438 
439     // Build output_file.
440     File file = File.createTempFile("output_file", /* suffix= */ null, /* directory= */ null);
441     file.deleteOnExit();
442 
443     OAuth2Utils.writeInputStreamToFile(
444         new ByteArrayInputStream(buildOidcResponse().toString().getBytes(StandardCharsets.UTF_8)),
445         file.getAbsolutePath());
446 
447     // Options with output file specified.
448     ExecutableOptions options =
449         new ExecutableOptions() {
450           @Override
451           public String getExecutableCommand() {
452             return "/path/to/executable";
453           }
454 
455           @Override
456           public Map<String, String> getEnvironmentMap() {
457             return ImmutableMap.of();
458           }
459 
460           @Override
461           public int getExecutableTimeoutMs() {
462             return 30000;
463           }
464 
465           @Override
466           public String getOutputFilePath() {
467             return file.getAbsolutePath();
468           }
469         };
470 
471     // Mock executable handling that does nothing since we are using the output file.
472     Process mockProcess = Mockito.mock(Process.class);
473     InternalProcessBuilder processBuilder =
474         buildInternalProcessBuilder(new HashMap<>(), mockProcess, options.getExecutableCommand());
475 
476     PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
477 
478     // Call retrieveTokenFromExecutable().
479     String token = handler.retrieveTokenFromExecutable(options);
480 
481     // Validate executable not invoked.
482     verify(mockProcess, times(0)).destroyForcibly();
483     verify(mockProcess, times(0))
484         .waitFor(eq(Long.valueOf(options.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
485 
486     assertEquals(ID_TOKEN, token);
487   }
488 
489   @Test
retrieveTokenFromExecutable_withInvalidOutputFile_throws()490   public void retrieveTokenFromExecutable_withInvalidOutputFile_throws()
491       throws IOException, InterruptedException {
492     TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
493     environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
494 
495     // Build output_file.
496     File file = File.createTempFile("output_file", /* suffix= */ null, /* directory= */ null);
497     file.deleteOnExit();
498 
499     OAuth2Utils.writeInputStreamToFile(
500         new ByteArrayInputStream("Bad response.".getBytes(StandardCharsets.UTF_8)),
501         file.getAbsolutePath());
502 
503     // Options with output file specified.
504     ExecutableOptions options =
505         new ExecutableOptions() {
506           @Override
507           public String getExecutableCommand() {
508             return "/path/to/executable";
509           }
510 
511           @Override
512           public Map<String, String> getEnvironmentMap() {
513             return ImmutableMap.of();
514           }
515 
516           @Override
517           public int getExecutableTimeoutMs() {
518             return 30000;
519           }
520 
521           @Override
522           public String getOutputFilePath() {
523             return file.getAbsolutePath();
524           }
525         };
526 
527     // Mock executable handling that does nothing since we are using the output file.
528     Process mockProcess = Mockito.mock(Process.class);
529     InternalProcessBuilder processBuilder =
530         buildInternalProcessBuilder(new HashMap<>(), mockProcess, options.getExecutableCommand());
531 
532     PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
533 
534     // Call retrieveTokenFromExecutable().
535     try {
536       handler.retrieveTokenFromExecutable(options);
537       fail("Should not be able to continue without exception.");
538     } catch (PluggableAuthException e) {
539       assertEquals("INVALID_OUTPUT_FILE", e.getErrorCode());
540     }
541   }
542 
543   @Test
retrieveTokenFromExecutable_expiredOutputFileResponse_callsExecutable()544   public void retrieveTokenFromExecutable_expiredOutputFileResponse_callsExecutable()
545       throws IOException, InterruptedException {
546     TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
547     environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
548 
549     // Build output_file.
550     File file = File.createTempFile("output_file", /* suffix= */ null, /* directory= */ null);
551     file.deleteOnExit();
552 
553     // Create an expired response.
554     GenericJson json = buildOidcResponse();
555     json.put("expiration_time", Instant.now().getEpochSecond() - 1);
556 
557     OAuth2Utils.writeInputStreamToFile(
558         new ByteArrayInputStream(json.toString().getBytes(StandardCharsets.UTF_8)),
559         file.getAbsolutePath());
560 
561     // Options with output file specified.
562     ExecutableOptions options =
563         new ExecutableOptions() {
564           @Override
565           public String getExecutableCommand() {
566             return "/path/to/executable";
567           }
568 
569           @Override
570           public Map<String, String> getEnvironmentMap() {
571             return ImmutableMap.of();
572           }
573 
574           @Override
575           public int getExecutableTimeoutMs() {
576             return 30000;
577           }
578 
579           @Override
580           public String getOutputFilePath() {
581             return file.getAbsolutePath();
582           }
583         };
584 
585     // Mock executable handling.
586     Process mockProcess = Mockito.mock(Process.class);
587     when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
588     when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
589     when(mockProcess.getInputStream())
590         .thenReturn(
591             new ByteArrayInputStream(
592                 buildOidcResponse().toString().getBytes(StandardCharsets.UTF_8)));
593 
594     InternalProcessBuilder processBuilder =
595         buildInternalProcessBuilder(new HashMap<>(), mockProcess, options.getExecutableCommand());
596 
597     PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
598 
599     // Call retrieveTokenFromExecutable().
600     String token = handler.retrieveTokenFromExecutable(options);
601 
602     // Validate that the executable was called.
603     verify(mockProcess, times(1)).destroy();
604     verify(mockProcess, times(1))
605         .waitFor(eq(Long.valueOf(options.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
606 
607     assertEquals(ID_TOKEN, token);
608   }
609 
610   @Test
retrieveTokenFromExecutable_expiredResponse_throws()611   public void retrieveTokenFromExecutable_expiredResponse_throws()
612       throws InterruptedException, IOException {
613     TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
614     environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
615 
616     // Create expired response.
617     GenericJson json = buildOidcResponse();
618     json.put("expiration_time", Instant.now().getEpochSecond() - 1);
619 
620     // Mock executable handling.
621     Process mockProcess = Mockito.mock(Process.class);
622     when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
623     when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
624     when(mockProcess.getInputStream())
625         .thenReturn(new ByteArrayInputStream(json.toString().getBytes(StandardCharsets.UTF_8)));
626 
627     InternalProcessBuilder processBuilder =
628         buildInternalProcessBuilder(
629             new HashMap<>(), mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
630 
631     PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
632 
633     // Call retrieveTokenFromExecutable().
634     try {
635       handler.retrieveTokenFromExecutable(DEFAULT_OPTIONS);
636       fail("Should not be able to continue without exception.");
637     } catch (PluggableAuthException e) {
638       assertEquals("INVALID_RESPONSE", e.getErrorCode());
639       assertEquals("The executable response is expired.", e.getErrorDescription());
640     }
641   }
642 
643   @Test
retrieveTokenFromExecutable_invalidVersion_throws()644   public void retrieveTokenFromExecutable_invalidVersion_throws()
645       throws InterruptedException, IOException {
646     TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
647     environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
648 
649     // Mock executable handling.
650     Process mockProcess = Mockito.mock(Process.class);
651     when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
652     when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
653 
654     // SAML response.
655     GenericJson json = buildSamlResponse();
656     // Only version `1` is supported.
657     json.put("version", 2);
658     when(mockProcess.getInputStream())
659         .thenReturn(new ByteArrayInputStream(json.toString().getBytes(StandardCharsets.UTF_8)));
660 
661     InternalProcessBuilder processBuilder =
662         buildInternalProcessBuilder(
663             new HashMap<>(), mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
664 
665     PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
666 
667     // Call retrieveTokenFromExecutable().
668     try {
669       handler.retrieveTokenFromExecutable(DEFAULT_OPTIONS);
670       fail("Should not be able to continue without exception.");
671     } catch (PluggableAuthException e) {
672       assertEquals("UNSUPPORTED_VERSION", e.getErrorCode());
673       assertEquals(
674           "The version of the executable response is not supported. "
675               + String.format(
676                   "The maximum version currently supported is %s.",
677                   EXECUTABLE_SUPPORTED_MAX_VERSION),
678           e.getErrorDescription());
679     }
680   }
681 
682   @Test
retrieveTokenFromExecutable_allowExecutablesDisabled_throws()683   public void retrieveTokenFromExecutable_allowExecutablesDisabled_throws() throws IOException {
684     // In order to use Pluggable Auth, GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES must be set to 1.
685     // If set to 0, a runtime exception should be thrown.
686     TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
687     environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "0");
688 
689     PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider);
690 
691     try {
692       handler.retrieveTokenFromExecutable(DEFAULT_OPTIONS);
693       fail("Should not be able to continue without exception.");
694     } catch (PluggableAuthException e) {
695       assertEquals("PLUGGABLE_AUTH_DISABLED", e.getErrorCode());
696       assertEquals(
697           "Pluggable Auth executables need to be explicitly allowed to run by "
698               + "setting the GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment variable to 1.",
699           e.getErrorDescription());
700     }
701   }
702 
703   @Test
getExecutableResponse_oidcResponse()704   public void getExecutableResponse_oidcResponse() throws IOException, InterruptedException {
705     TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
706     environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
707 
708     Map<String, String> currentEnv = new HashMap<>();
709     currentEnv.put("currentEnvKey1", "currentEnvValue1");
710     currentEnv.put("currentEnvKey2", "currentEnvValue2");
711 
712     // Expected environment mappings.
713     HashMap<String, String> expectedMap = new HashMap<>();
714     expectedMap.putAll(DEFAULT_OPTIONS.getEnvironmentMap());
715     expectedMap.putAll(currentEnv);
716 
717     // Mock executable handling.
718     Process mockProcess = Mockito.mock(Process.class);
719     when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
720     when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
721 
722     // OIDC response.
723     when(mockProcess.getInputStream())
724         .thenReturn(
725             new ByteArrayInputStream(
726                 buildOidcResponse().toString().getBytes(StandardCharsets.UTF_8)));
727 
728     InternalProcessBuilder processBuilder =
729         buildInternalProcessBuilder(
730             currentEnv, mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
731 
732     PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
733 
734     ExecutableResponse response = handler.getExecutableResponse(DEFAULT_OPTIONS);
735 
736     verify(mockProcess, times(1)).destroy();
737     verify(mockProcess, times(1))
738         .waitFor(
739             eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
740     assertEquals(EXECUTABLE_SUPPORTED_MAX_VERSION, response.getVersion());
741     assertTrue(response.isSuccessful());
742     assertEquals(TOKEN_TYPE_OIDC, response.getTokenType());
743     assertEquals(ID_TOKEN, response.getSubjectToken());
744     assertTrue(
745         Instant.now().getEpochSecond() + EXPIRATION_DURATION == response.getExpirationTime());
746     // Current env map should include the mappings from options.
747     assertEquals(4, currentEnv.size());
748     assertEquals(expectedMap, currentEnv);
749   }
750 
751   @Test
getExecutableResponse_samlResponse()752   public void getExecutableResponse_samlResponse() throws IOException, InterruptedException {
753     TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
754     environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
755 
756     Map<String, String> currentEnv = new HashMap<>();
757     currentEnv.put("currentEnvKey1", "currentEnvValue1");
758     currentEnv.put("currentEnvKey2", "currentEnvValue2");
759 
760     // Expected environment mappings.
761     HashMap<String, String> expectedMap = new HashMap<>();
762     expectedMap.putAll(DEFAULT_OPTIONS.getEnvironmentMap());
763     expectedMap.putAll(currentEnv);
764 
765     // Mock executable handling.
766     Process mockProcess = Mockito.mock(Process.class);
767     when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
768     when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
769 
770     // SAML response.
771     when(mockProcess.getInputStream())
772         .thenReturn(
773             new ByteArrayInputStream(
774                 buildSamlResponse().toString().getBytes(StandardCharsets.UTF_8)));
775 
776     InternalProcessBuilder processBuilder =
777         buildInternalProcessBuilder(
778             currentEnv, mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
779 
780     PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
781     ExecutableResponse response = handler.getExecutableResponse(DEFAULT_OPTIONS);
782 
783     verify(mockProcess, times(1)).destroy();
784     verify(mockProcess, times(1))
785         .waitFor(
786             eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
787     assertEquals(EXECUTABLE_SUPPORTED_MAX_VERSION, response.getVersion());
788     assertTrue(response.isSuccessful());
789     assertEquals(TOKEN_TYPE_SAML, response.getTokenType());
790     assertEquals(SAML_RESPONSE, response.getSubjectToken());
791     assertTrue(
792         Instant.now().getEpochSecond() + EXPIRATION_DURATION == response.getExpirationTime());
793 
794     // Current env map should include the mappings from options.
795     assertEquals(4, currentEnv.size());
796     assertEquals(expectedMap, currentEnv);
797 
798     verify(mockProcess, times(1)).destroy();
799   }
800 
801   @Test
getExecutableResponse_errorResponse()802   public void getExecutableResponse_errorResponse() throws IOException, InterruptedException {
803     TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
804     environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
805 
806     Map<String, String> currentEnv = new HashMap<>();
807     currentEnv.put("currentEnvKey1", "currentEnvValue1");
808     currentEnv.put("currentEnvKey2", "currentEnvValue2");
809 
810     // Expected environment mappings.
811     HashMap<String, String> expectedMap = new HashMap<>();
812     expectedMap.putAll(DEFAULT_OPTIONS.getEnvironmentMap());
813     expectedMap.putAll(currentEnv);
814 
815     // Mock executable handling.
816     Process mockProcess = Mockito.mock(Process.class);
817 
818     when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
819     when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
820 
821     // Error response.
822     when(mockProcess.getInputStream())
823         .thenReturn(
824             new ByteArrayInputStream(
825                 buildErrorResponse().toString().getBytes(StandardCharsets.UTF_8)));
826 
827     InternalProcessBuilder processBuilder =
828         buildInternalProcessBuilder(
829             currentEnv, mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
830     PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
831 
832     // Call getExecutableResponse().
833     ExecutableResponse response = handler.getExecutableResponse(DEFAULT_OPTIONS);
834 
835     verify(mockProcess, times(1)).destroy();
836     verify(mockProcess, times(1))
837         .waitFor(
838             eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
839     assertEquals(EXECUTABLE_SUPPORTED_MAX_VERSION, response.getVersion());
840     assertFalse(response.isSuccessful());
841     assertEquals("401", response.getErrorCode());
842     assertEquals("Caller not authorized.", response.getErrorMessage());
843 
844     // Current env map should include the mappings from options.
845     assertEquals(4, currentEnv.size());
846     assertEquals(expectedMap, currentEnv);
847   }
848 
849   @Test
getExecutableResponse_timeoutExceeded_throws()850   public void getExecutableResponse_timeoutExceeded_throws()
851       throws InterruptedException, IOException {
852     TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
853     environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
854 
855     // Mock executable handling.
856     Process mockProcess = Mockito.mock(Process.class);
857     when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(false);
858 
859     InternalProcessBuilder processBuilder =
860         buildInternalProcessBuilder(
861             new HashMap<>(), mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
862     PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
863 
864     // Call getExecutableResponse().
865     try {
866       handler.getExecutableResponse(DEFAULT_OPTIONS);
867       fail("Should not be able to continue without exception.");
868     } catch (PluggableAuthException e) {
869       assertEquals("TIMEOUT_EXCEEDED", e.getErrorCode());
870       assertEquals(
871           "The executable failed to finish within the timeout specified.", e.getErrorDescription());
872     }
873 
874     verify(mockProcess, times(1))
875         .waitFor(
876             eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
877     verify(mockProcess, times(1)).destroy();
878   }
879 
880   @Test
getExecutableResponse_nonZeroExitCode_throws()881   public void getExecutableResponse_nonZeroExitCode_throws()
882       throws InterruptedException, IOException {
883     TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
884     environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
885 
886     // Mock executable handling.
887     Process mockProcess = Mockito.mock(Process.class);
888     when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
889     when(mockProcess.exitValue()).thenReturn(EXIT_CODE_FAIL);
890 
891     InternalProcessBuilder processBuilder =
892         buildInternalProcessBuilder(
893             new HashMap<>(), mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
894 
895     PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
896 
897     // Call getExecutableResponse().
898     try {
899       handler.getExecutableResponse(DEFAULT_OPTIONS);
900       fail("Should not be able to continue without exception.");
901     } catch (PluggableAuthException e) {
902       assertEquals("EXIT_CODE", e.getErrorCode());
903       assertEquals(
904           String.format("The executable failed with exit code %s.", EXIT_CODE_FAIL),
905           e.getErrorDescription());
906     }
907 
908     verify(mockProcess, times(1))
909         .waitFor(
910             eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
911     verify(mockProcess, times(1)).destroy();
912   }
913 
914   @Test
getExecutableResponse_processInterrupted_throws()915   public void getExecutableResponse_processInterrupted_throws()
916       throws InterruptedException, IOException {
917     TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
918     environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
919 
920     // Mock executable handling.
921     Process mockProcess = Mockito.mock(Process.class);
922     when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenThrow(new InterruptedException());
923 
924     InternalProcessBuilder processBuilder =
925         buildInternalProcessBuilder(
926             new HashMap<>(), mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
927 
928     PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
929 
930     // Call getExecutableResponse().
931     try {
932       handler.getExecutableResponse(DEFAULT_OPTIONS);
933       fail("Should not be able to continue without exception.");
934     } catch (PluggableAuthException e) {
935       assertEquals("INTERRUPTED", e.getErrorCode());
936       assertEquals(
937           String.format("The execution was interrupted: %s.", new InterruptedException()),
938           e.getErrorDescription());
939     }
940 
941     verify(mockProcess, times(1))
942         .waitFor(
943             eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
944     verify(mockProcess, times(1)).destroy();
945   }
946 
947   @Test
getExecutableResponse_invalidResponse_throws()948   public void getExecutableResponse_invalidResponse_throws()
949       throws InterruptedException, IOException {
950     TestEnvironmentProvider environmentProvider = new TestEnvironmentProvider();
951     environmentProvider.setEnv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", "1");
952 
953     // Mock executable handling.
954     Process mockProcess = Mockito.mock(Process.class);
955     when(mockProcess.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true);
956     when(mockProcess.exitValue()).thenReturn(EXIT_CODE_SUCCESS);
957 
958     // Mock bad executable response.
959     String badResponse = "badResponse";
960     when(mockProcess.getInputStream())
961         .thenReturn(new ByteArrayInputStream(badResponse.getBytes(StandardCharsets.UTF_8)));
962 
963     InternalProcessBuilder processBuilder =
964         buildInternalProcessBuilder(
965             new HashMap<>(), mockProcess, DEFAULT_OPTIONS.getExecutableCommand());
966 
967     PluggableAuthHandler handler = new PluggableAuthHandler(environmentProvider, processBuilder);
968 
969     // Call getExecutableResponse().
970     try {
971       handler.getExecutableResponse(DEFAULT_OPTIONS);
972       fail("Should not be able to continue without exception.");
973     } catch (PluggableAuthException e) {
974       assertEquals("INVALID_RESPONSE", e.getErrorCode());
975       assertEquals(
976           String.format("The executable returned an invalid response: %s.", badResponse),
977           e.getErrorDescription());
978     }
979 
980     verify(mockProcess, times(1))
981         .waitFor(
982             eq(Long.valueOf(DEFAULT_OPTIONS.getExecutableTimeoutMs())), eq(TimeUnit.MILLISECONDS));
983     verify(mockProcess, times(1)).destroy();
984   }
985 
buildOidcResponse()986   private static GenericJson buildOidcResponse() {
987     GenericJson json = new GenericJson();
988     json.setFactory(OAuth2Utils.JSON_FACTORY);
989     json.put("version", EXECUTABLE_SUPPORTED_MAX_VERSION);
990     json.put("success", true);
991     json.put("token_type", TOKEN_TYPE_OIDC);
992     json.put("id_token", ID_TOKEN);
993     json.put("expiration_time", Instant.now().getEpochSecond() + EXPIRATION_DURATION);
994     return json;
995   }
996 
buildSamlResponse()997   private static GenericJson buildSamlResponse() {
998     GenericJson json = new GenericJson();
999     json.setFactory(OAuth2Utils.JSON_FACTORY);
1000     json.put("version", EXECUTABLE_SUPPORTED_MAX_VERSION);
1001     json.put("success", true);
1002     json.put("token_type", TOKEN_TYPE_SAML);
1003     json.put("saml_response", SAML_RESPONSE);
1004     json.put("expiration_time", Instant.now().getEpochSecond() + EXPIRATION_DURATION);
1005     return json;
1006   }
1007 
buildErrorResponse()1008   private static GenericJson buildErrorResponse() {
1009     GenericJson json = new GenericJson();
1010     json.setFactory(OAuth2Utils.JSON_FACTORY);
1011     json.put("version", EXECUTABLE_SUPPORTED_MAX_VERSION);
1012     json.put("success", false);
1013     json.put("code", "401");
1014     json.put("message", "Caller not authorized.");
1015     return json;
1016   }
1017 
buildInternalProcessBuilder( Map<String, String> currentEnv, Process process, String command)1018   private static InternalProcessBuilder buildInternalProcessBuilder(
1019       Map<String, String> currentEnv, Process process, String command) {
1020     return new InternalProcessBuilder() {
1021 
1022       @Override
1023       Map<String, String> environment() {
1024         return currentEnv;
1025       }
1026 
1027       @Override
1028       InternalProcessBuilder redirectErrorStream(boolean redirectErrorStream) {
1029         return this;
1030       }
1031 
1032       @Override
1033       Process start() {
1034         return process;
1035       }
1036     };
1037   }
1038 }
1039