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