diff --git a/composer.json b/composer.json index 338e46f37..41a1d0532 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,8 @@ "sebastian/comparator": ">=1.2.3", "phpseclib/phpseclib": "^3.0.35", "kelvinmo/simplejwt": "0.7.1", - "webmozart/assert": "^1.11" + "webmozart/assert": "^1.11", + "symfony/process": "^6.0||^7.0" }, "suggest": { "phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2." diff --git a/src/CredentialSource/ExecutableSource.php b/src/CredentialSource/ExecutableSource.php new file mode 100644 index 000000000..7661fc9cc --- /dev/null +++ b/src/CredentialSource/ExecutableSource.php @@ -0,0 +1,260 @@ + + * OIDC response sample: + * { + * "version": 1, + * "success": true, + * "token_type": "urn:ietf:params:oauth:token-type:id_token", + * "id_token": "HEADER.PAYLOAD.SIGNATURE", + * "expiration_time": 1620433341 + * } + * + * SAML2 response sample: + * { + * "version": 1, + * "success": true, + * "token_type": "urn:ietf:params:oauth:token-type:saml2", + * "saml_response": "...", + * "expiration_time": 1620433341 + * } + * + * Error response sample: + * { + * "version": 1, + * "success": false, + * "code": "401", + * "message": "Error message." + * } + * + * + * The "expiration_time" field in the JSON response is only required for successful + * responses when an output file was specified in the credential configuration + * + * The auth libraries will populate certain environment variables that will be accessible by the + * executable, such as: GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE, GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE, + * GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE, GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL, and + * GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE. + */ +class ExecutableSource implements ExternalAccountCredentialSourceInterface +{ + private const GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES = 'GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES'; + private const SAML_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:saml2'; + private const OIDC_SUBJECT_TOKEN_TYPE1 = 'urn:ietf:params:oauth:token-type:id_token'; + private const OIDC_SUBJECT_TOKEN_TYPE2 = 'urn:ietf:params:oauth:token-type:jwt'; + + private string $command; + private ExecutableHandler $executableHandler; + private ?string $outputFile; + + /** + * @param string $command The string command to run to get the subject token. + * @param string $outputFile + */ + public function __construct( + string $command, + ?string $outputFile, + ExecutableHandler $executableHandler = null, + ) { + $this->command = $command; + $this->outputFile = $outputFile; + $this->executableHandler = $executableHandler ?: new ExecutableHandler(); + } + + /** + * @param callable $httpHandler unused. + * @return string + * @throws RuntimeException if the executable is not allowed to run. + * @throws ExecutableResponseError if the executable response is invalid. + */ + public function fetchSubjectToken(callable $httpHandler = null): string + { + // Check if the executable is allowed to run. + if (getenv(self::GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES) !== '1') { + throw new RuntimeException( + 'Pluggable Auth executables need to be explicitly allowed to run by ' + . 'setting the GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment ' + . 'Variable to 1.' + ); + } + + if (!$executableResponse = $this->getCachedExecutableResponse()) { + // Run the executable. + $exitCode = ($this->executableHandler)($this->command); + $output = $this->executableHandler->getOutput(); + + // If the exit code is not 0, throw an exception with the output as the error details + if ($exitCode !== 0) { + throw new ExecutableResponseError( + 'The executable failed to run' + . ($output ? ' with the following error: ' . $output : '.'), + (string) $exitCode + ); + } + + $executableResponse = $this->parseExecutableResponse($output); + + // Validate expiration. + if (isset($executableResponse['expiration_time']) && time() >= $executableResponse['expiration_time']) { + throw new ExecutableResponseError('Executable response is expired.'); + } + } + + // Throw error when the request was unsuccessful + if ($executableResponse['success'] === false) { + throw new ExecutableResponseError($executableResponse['message'], (string) $executableResponse['code']); + } + + // Return subject token field based on the token type + return $executableResponse['token_type'] === self::SAML_SUBJECT_TOKEN_TYPE + ? $executableResponse['saml_response'] + : $executableResponse['id_token']; + } + + /** + * @return array|null + */ + private function getCachedExecutableResponse(): ?array + { + if ( + $this->outputFile + && file_exists($this->outputFile) + && !empty(trim($outputFileContents = (string) file_get_contents($this->outputFile))) + ) { + try { + $executableResponse = $this->parseExecutableResponse($outputFileContents); + } catch (ExecutableResponseError $e) { + throw new ExecutableResponseError( + 'Error in output file: ' . $e->getMessage(), + 'INVALID_OUTPUT_FILE' + ); + } + + if ($executableResponse['success'] === false) { + // If the cached token was unsuccessful, run the executable to get a new one. + return null; + } + + if (isset($executableResponse['expiration_time']) && time() >= $executableResponse['expiration_time']) { + // If the cached token is expired, run the executable to get a new one. + return null; + } + + return $executableResponse; + } + + return null; + } + + /** + * @return array + */ + private function parseExecutableResponse(string $response): array + { + $executableResponse = json_decode($response, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new ExecutableResponseError( + 'The executable returned an invalid response: ' . $response, + 'INVALID_RESPONSE' + ); + } + if (!array_key_exists('version', $executableResponse)) { + throw new ExecutableResponseError('Executable response must contain a "version" field.'); + } + if (!array_key_exists('success', $executableResponse)) { + throw new ExecutableResponseError('Executable response must contain a "success" field.'); + } + + // Validate required fields for a successful response. + if ($executableResponse['success']) { + // Validate token type field. + $tokenTypes = [self::SAML_SUBJECT_TOKEN_TYPE, self::OIDC_SUBJECT_TOKEN_TYPE1, self::OIDC_SUBJECT_TOKEN_TYPE2]; + if (!isset($executableResponse['token_type'])) { + throw new ExecutableResponseError( + 'Executable response must contain a "token_type" field when successful' + ); + } + if (!in_array($executableResponse['token_type'], $tokenTypes)) { + throw new ExecutableResponseError(sprintf( + 'Executable response "token_type" field must be one of %s.', + implode(', ', $tokenTypes) + )); + } + + // Validate subject token for SAML and OIDC. + if ($executableResponse['token_type'] === self::SAML_SUBJECT_TOKEN_TYPE) { + if (empty($executableResponse['saml_response'])) { + throw new ExecutableResponseError(sprintf( + 'Executable response must contain a "saml_response" field when token_type=%s.', + self::SAML_SUBJECT_TOKEN_TYPE + )); + } + } elseif (empty($executableResponse['id_token'])) { + throw new ExecutableResponseError(sprintf( + 'Executable response must contain a "id_token" field when ' + . 'token_type=%s.', + $executableResponse['token_type'] + )); + } + + // Validate expiration exists when an output file is specified. + if ($this->outputFile) { + if (!isset($executableResponse['expiration_time'])) { + throw new ExecutableResponseError( + 'The executable response must contain a "expiration_time" field for successful responses ' . + 'when an output_file has been specified in the configuration.' + ); + } + } + } else { + // Both code and message must be provided for unsuccessful responses. + if (!array_key_exists('code', $executableResponse)) { + throw new ExecutableResponseError('Executable response must contain a "code" field when unsuccessful.'); + } + if (empty($executableResponse['message'])) { + throw new ExecutableResponseError('Executable response must contain a "message" field when unsuccessful.'); + } + } + + return $executableResponse; + } +} diff --git a/src/Credentials/ExternalAccountCredentials.php b/src/Credentials/ExternalAccountCredentials.php index c3a8c628a..98f427a33 100644 --- a/src/Credentials/ExternalAccountCredentials.php +++ b/src/Credentials/ExternalAccountCredentials.php @@ -18,8 +18,10 @@ namespace Google\Auth\Credentials; use Google\Auth\CredentialSource\AwsNativeSource; +use Google\Auth\CredentialSource\ExecutableSource; use Google\Auth\CredentialSource\FileSource; use Google\Auth\CredentialSource\UrlSource; +use Google\Auth\ExecutableHandler\ExecutableHandler; use Google\Auth\ExternalAccountCredentialSourceInterface; use Google\Auth\FetchAuthTokenInterface; use Google\Auth\GetQuotaProjectInterface; @@ -150,11 +152,6 @@ private static function buildCredentialSource(array $jsonKey): ExternalAccountCr 'The regional_cred_verification_url field is required for aws1 credential source.' ); } - if (!array_key_exists('audience', $jsonKey)) { - throw new InvalidArgumentException( - 'aws1 credential source requires an audience to be set in the JSON file.' - ); - } return new AwsNativeSource( $jsonKey['audience'], @@ -174,6 +171,43 @@ private static function buildCredentialSource(array $jsonKey): ExternalAccountCr ); } + if (isset($credentialSource['executable'])) { + if (!array_key_exists('command', $credentialSource['executable'])) { + throw new InvalidArgumentException( + 'executable source requires a command to be set in the JSON file.' + ); + } + + // Build command environment variables + $env = [ + 'GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE' => $jsonKey['audience'], + 'GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE' => $jsonKey['subject_token_type'], + // Always set to 0 because interactive mode is not supported. + 'GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE' => '0', + ]; + + if ($outputFile = $credentialSource['executable']['output_file'] ?? null) { + $env['GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE'] = $outputFile; + } + + if ($serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url'] ?? null) { + // Parse email from URL. The formal looks as follows: + // https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken + $regex = '/serviceAccounts\/(?[^:]+):generateAccessToken$/'; + if (preg_match($regex, $serviceAccountImpersonationUrl, $matches)) { + $env['GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL'] = $matches['email']; + } + } + + $timeoutMs = $credentialSource['executable']['timeout_millis'] ?? null; + + return new ExecutableSource( + $credentialSource['executable']['command'], + $outputFile, + $timeoutMs ? new ExecutableHandler($env, $timeoutMs) : new ExecutableHandler($env) + ); + } + throw new InvalidArgumentException('Unable to determine credential source from json key.'); } diff --git a/src/ExecutableHandler/ExecutableHandler.php b/src/ExecutableHandler/ExecutableHandler.php new file mode 100644 index 000000000..8f5e13f4e --- /dev/null +++ b/src/ExecutableHandler/ExecutableHandler.php @@ -0,0 +1,83 @@ + */ + private array $env = []; + + private ?string $output = null; + + /** + * @param array $env + */ + public function __construct( + array $env = [], + int $timeoutMs = self::DEFAULT_EXECUTABLE_TIMEOUT_MILLIS, + ) { + if (!class_exists(Process::class)) { + throw new RuntimeException(sprintf( + 'The "symfony/process" package is required to use %s.', + self::class + )); + } + $this->env = $env; + $this->timeoutMs = $timeoutMs; + } + + /** + * @param string $command + * @return int + */ + public function __invoke(string $command): int + { + $process = Process::fromShellCommandline( + $command, + null, + $this->env, + null, + ($this->timeoutMs / 1000) + ); + + try { + $process->run(); + } catch (ProcessTimedOutException $e) { + throw new ExecutableResponseError( + 'The executable failed to finish within the timeout specified.', + 'TIMEOUT_EXCEEDED' + ); + } + + $this->output = $process->getOutput() . $process->getErrorOutput(); + + return $process->getExitCode(); + } + + public function getOutput(): ?string + { + return $this->output; + } +} diff --git a/src/ExecutableHandler/ExecutableResponseError.php b/src/ExecutableHandler/ExecutableResponseError.php new file mode 100644 index 000000000..441090250 --- /dev/null +++ b/src/ExecutableHandler/ExecutableResponseError.php @@ -0,0 +1,27 @@ +expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Pluggable Auth executables need to be explicitly allowed to run by setting the ' + . 'GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment Variable to 1.' + ); + + // Ensure env var does not equal 0 + putenv('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES='); + $source = new ExecutableSource('some-command', null, null); + $source->fetchSubjectToken(); + } + + /** + * @dataProvider provideFetchSubjectToken + * @runInSeparateProcess + */ + public function testFetchSubjectToken(string $successToken) + { + putenv('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1'); + + $cmd = 'fake-command'; + + $executableHandler = $this->prophesize(ExecutableHandler::class); + $executableHandler->__invoke($cmd) + ->shouldBeCalledOnce() + ->willReturn(0); + $executableHandler->getOutput() + ->shouldBeCalledOnce() + ->willReturn($successToken); + + $source = new ExecutableSource($cmd, null, $executableHandler->reveal()); + $subjectToken = $source->fetchSubjectToken(); + $this->assertEquals('abc', $subjectToken); + } + + public function provideFetchSubjectToken() + { + return [ + ['{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:id_token", "id_token": "abc"}'], + ['{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:jwt", "id_token": "abc"}'], + ['{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:saml2", "saml_response": "abc"}'] + ]; + } + + /** + * @dataProvider provideFetchSubjectTokenWithError + * @runInSeparateProcess + */ + public function testFetchSubjectTokenWithError( + int $returnCode, + string $output, + string $expectedExceptionMessage, + string $outputFile = null + ) { + $this->expectException(ExecutableResponseError::class); + $this->expectExceptionMessage($expectedExceptionMessage); + + putenv('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1'); + + $cmd = 'fake-command'; + + $handler = $this->prophesize(ExecutableHandler::class); + $handler->__invoke($cmd) + ->shouldBeCalledOnce() + ->willReturn($returnCode); + $handler->getOutput() + ->shouldBeCalledOnce() + ->willReturn($output); + + $source = new ExecutableSource($cmd, $outputFile, $handler->reveal()); + $source->fetchSubjectToken(); + } + + public function provideFetchSubjectTokenWithError() + { + return [ + [1, '', 'The executable failed to run.'], + [1, 'error', 'The executable failed to run with the following error: error'], + [0, '{', 'The executable returned an invalid response: {'], + [0, '{}', 'Executable response must contain a "version" field'], + [0, '{"version": 1}', 'Executable response must contain a "success" field'], + [0, '{"version": 1, "success": false}', 'Executable response must contain a "code" field when unsuccessful'], + [0, '{"version": 1, "success": false, "code": 1}', 'Executable response must contain a "message" field when unsuccessful'], + [0, '{"version": 1, "success": false, "code": 1, "message": "error!"}', 'error!'], + [0, '{"version": 1, "success": true}', 'Executable response must contain a "token_type" field'], + [0, '{"version": 1, "success": true, "token_type": "wrong"}', 'Executable response "token_type" field must be one of'], + [ + 0, + '{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:saml2"}', + 'Executable response must contain a "saml_response" field when token_type=urn:ietf:params:oauth:token-type:saml2' + ], + [ + 0, + '{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:id_token"}', + 'Executable response must contain a "id_token" field when token_type=urn:ietf:params:oauth:token-type:id_token' + ], + [ + 0, + '{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:jwt"}', + 'Executable response must contain a "id_token" field when token_type=urn:ietf:params:oauth:token-type:jwt' + ], + [ + 0, + '{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:jwt", "id_token": "abc", "expiration_time": 1}', + 'Executable response is expired.', + ], + [ + 0, + '{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:jwt", "id_token": "abc"}', + 'The executable response must contain a "expiration_time" field for successful responses when an output_file has been specified in the configuration.', + '/some/output/file', + ], + ]; + } + + /** + * @dataProvider provideCachedTokenWithError + * @runInSeparateProcess + */ + public function testCachedTokenWithError( + string $cachedToken, + string $expectedExceptionMessage + ) { + $this->expectException(ExecutableResponseError::class); + $this->expectExceptionMessage($expectedExceptionMessage); + + putenv('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1'); + + $outputFile = tempnam(sys_get_temp_dir(), 'token'); + file_put_contents($outputFile, $cachedToken); + + $cmd = 'fake-command'; + $handler = $this->prophesize(ExecutableHandler::class); + $handler->__invoke($cmd)->shouldNotBeCalled(); + $handler->getOutput()->shouldNotBeCalled(); + + $source = new ExecutableSource($cmd, $outputFile, $handler->reveal()); + $source->fetchSubjectToken(); + } + + public function provideCachedTokenWithError() + { + return [ + ['{', 'Error in output file: Error code INVALID_RESPONSE: The executable returned an invalid response: {'], + ['{}', 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: Executable response must contain a "version" field'], + ['{"version": 1}', 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: Executable response must contain a "success" field'], + ['{"version": 1, "success": false}', 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: Executable response must contain a "code" field when unsuccessful'], + ['{"version": 1, "success": false, "code": 1}', 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: Executable response must contain a "message" field when unsuccessful'], + ['{"version": 1, "success": true}', 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: Executable response must contain a "token_type" field'], + ['{"version": 1, "success": true, "token_type": "wrong"}', 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: Executable response "token_type" field must be one of'], + [ + '{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:saml2"}', + 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: Executable response must contain a "saml_response" field when token_type=urn:ietf:params:oauth:token-type:saml2' + ], + [ + '{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:id_token"}', + 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: Executable response must contain a "id_token" field when token_type=urn:ietf:params:oauth:token-type:id_token' + ], + [ + '{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:jwt"}', + 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: Executable response must contain a "id_token" field when token_type=urn:ietf:params:oauth:token-type:jwt' + ], + [ + '{"version": 1, "success": true, "token_type": "urn:ietf:params:oauth:token-type:jwt", "id_token": "abc"}', + 'Error in output file: Error code INVALID_EXECUTABLE_RESPONSE: The executable response must contain a "expiration_time" field for successful responses when an output_file has been specified in the configuration.' + ], + ]; + } + + /** + * @runInSeparateProcess + */ + public function testCachedTokenFile() + { + putenv('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1'); + + $outputFile = tempnam(sys_get_temp_dir(), 'token'); + file_put_contents($outputFile, json_encode([ + 'version' => 1, + 'success' => true, + 'token_type' => 'urn:ietf:params:oauth:token-type:id_token', + 'id_token' => 'abc', + 'expiration_time' => time() + 100, + ])); + + $source = new ExecutableSource('fake-command', $outputFile); + $subjectToken = $source->fetchSubjectToken(); + $this->assertEquals('abc', $subjectToken); + } + + /** + * @runInSeparateProcess + */ + public function testCachedTokenFileExpiredCallsExecutable() + { + putenv('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1'); + + $cachedToken = [ + 'version' => 1, + 'success' => true, + 'token_type' => 'urn:ietf:params:oauth:token-type:id_token', + 'id_token' => 'abc', + // token is expired + 'expiration_time' => time() - 100, + ]; + $successToken = ['expiration_time' => time() + 100] + $cachedToken; + $outputFile = tempnam(sys_get_temp_dir(), 'token'); + file_put_contents($outputFile, json_encode($cachedToken)); + + $executableHandler = $this->prophesize(ExecutableHandler::class); + $executableHandler->__invoke('fake-command') + ->shouldBeCalledOnce() + ->willReturn(0); + $executableHandler->getOutput() + ->shouldBeCalledOnce() + ->willReturn(json_encode($successToken)); + + $source = new ExecutableSource('fake-command', $outputFile, $executableHandler->reveal()); + $subjectToken = $source->fetchSubjectToken(); + $this->assertEquals('abc', $subjectToken); + } + + /** + * @runInSeparateProcess + */ + public function testCachedTokenFileWithSuccessFalseCallsExecutable() + { + putenv('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1'); + + $cachedToken = [ + 'version' => 1, + // token has success=false + 'success' => false, + 'code' => 0, + 'message' => 'error!' + ]; + $successToken = [ + 'version' => 1, + 'success' => true, + 'token_type' => 'urn:ietf:params:oauth:token-type:id_token', + 'id_token' => 'abc', + 'expiration_time' => time() + 100, + ]; + $outputFile = tempnam(sys_get_temp_dir(), 'token'); + file_put_contents($outputFile, json_encode($cachedToken)); + + $executableHandler = $this->prophesize(ExecutableHandler::class); + $executableHandler->__invoke('fake-command') + ->shouldBeCalledOnce() + ->willReturn(0); + $executableHandler->getOutput() + ->shouldBeCalledOnce() + ->willReturn(json_encode($successToken)); + + $source = new ExecutableSource('fake-command', $outputFile, $executableHandler->reveal()); + $subjectToken = $source->fetchSubjectToken(); + $this->assertEquals('abc', $subjectToken); + } + + /** + * @runInSeparateProcess + */ + public function testEmptyCachedTokenFileCallsExecutable() + { + putenv('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1'); + + $successToken = [ + 'version' => 1, + 'success' => true, + 'token_type' => 'urn:ietf:params:oauth:token-type:id_token', + 'id_token' => 'abc', + 'expiration_time' => time() + 100, + ]; + $outputFile = tempnam(sys_get_temp_dir(), 'token'); + file_put_contents($outputFile, "\n"); + + $executableHandler = $this->prophesize(ExecutableHandler::class); + $executableHandler->__invoke('fake-command') + ->shouldBeCalledOnce() + ->willReturn(0); + $executableHandler->getOutput() + ->shouldBeCalledOnce() + ->willReturn(json_encode($successToken)); + + $source = new ExecutableSource('fake-command', $outputFile, $executableHandler->reveal()); + $subjectToken = $source->fetchSubjectToken(); + $this->assertEquals('abc', $subjectToken); + } +} diff --git a/tests/Credentials/ExternalAccountCredentialsTest.php b/tests/Credentials/ExternalAccountCredentialsTest.php index 657dbe711..c658054ec 100644 --- a/tests/Credentials/ExternalAccountCredentialsTest.php +++ b/tests/Credentials/ExternalAccountCredentialsTest.php @@ -520,4 +520,66 @@ public function testFetchAuthTokenWithWorkforcePoolCredentials() $this->assertEquals('def', $authToken['access_token']); $this->assertEquals(strtotime($expiry), $authToken['expires_at']); } + + /** + * @runInSeparateProcess + */ + public function testExecutableCredentialSourceEnvironmentVars() + { + putenv('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1'); + $tmpFile = tempnam(sys_get_temp_dir(), 'test'); + $outputFile = tempnam(sys_get_temp_dir(), 'output'); + $fileContents = 'foo-' . rand(); + $successJson = json_encode([ + 'version' => 1, + 'success' => true, + 'token_type' => 'urn:ietf:params:oauth:token-type:id_token', + 'id_token' => 'abc', + 'expiration_time' => time() + 100, + ]); + $json = [ + 'audience' => 'test-audience', + 'subject_token_type' => 'test-token-type', + 'credential_source' => [ + 'executable' => [ + 'command' => sprintf( + 'echo $GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE,$GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE,%s > %s' . + ' && echo \'%s\' > $GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE ' . + ' && echo \'%s\'', + $fileContents, + $tmpFile, + $successJson, + $successJson, + ), + 'timeout_millis' => 5000, + 'output_file' => $outputFile, + ], + ], + ] + $this->baseCreds; + + $creds = new ExternalAccountCredentials('a-scope', $json); + $authToken = $creds->fetchAuthToken(function (RequestInterface $request) { + parse_str((string) $request->getBody(), $requestBody); + $this->assertEquals('abc', $requestBody['subject_token']); + + $body = $this->prophesize(StreamInterface::class); + $body->__toString()->willReturn('{"access_token": "def"}'); + + $response = $this->prophesize(ResponseInterface::class); + $response->getBody()->willReturn($body->reveal()); + + $response->hasHeader('Content-Type')->willReturn(false); + + return $response->reveal(); + }); + + $this->assertArrayHasKey('access_token', $authToken); + $this->assertEquals('def', $authToken['access_token']); + + $this->assertFileExists($tmpFile); + $this->assertEquals( + 'test-audience,test-token-type,' . $fileContents . PHP_EOL, + file_get_contents($tmpFile) + ); + } } diff --git a/tests/ExecutableHandler/ExecutableHandlerTest.php b/tests/ExecutableHandler/ExecutableHandlerTest.php new file mode 100644 index 000000000..16a3537db --- /dev/null +++ b/tests/ExecutableHandler/ExecutableHandlerTest.php @@ -0,0 +1,57 @@ + 'foo', 'ENV_VAR_2' => 'bar']); + $this->assertEquals(0, $handler('echo $ENV_VAR_1')); + $this->assertEquals("foo\n", $handler->getOutput()); + + $this->assertEquals(0, $handler('echo $ENV_VAR_2')); + $this->assertEquals("bar\n", $handler->getOutput()); + } + + public function testTimeoutMs() + { + $handler = new ExecutableHandler([], 300); + $this->assertEquals(0, $handler('sleep "0.2"')); + } + + public function testTimeoutMsExceeded() + { + $this->expectException(ExecutableResponseError::class); + $this->expectExceptionMessage('The executable failed to finish within the timeout specified.'); + + $handler = new ExecutableHandler([], 100); + $handler('sleep "0.2"'); + } + + public function testErrorOutputIsReturnedAsOutput() + { + $handler = new ExecutableHandler(); + $this->assertEquals(0, $handler('echo "Bad Response." >&2')); + $this->assertEquals("Bad Response.\n", $handler->getOutput()); + } +} diff --git a/tests/fixtures6/executable_credentials.json b/tests/fixtures6/executable_credentials.json new file mode 100644 index 000000000..e33affc43 --- /dev/null +++ b/tests/fixtures6/executable_credentials.json @@ -0,0 +1,14 @@ +{ + "type": "external_account", + "audience": "//iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/byoid-pool-php/providers/PROJECT_ID", + "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "executable": { + "command": "cmd.sh", + "timeout_millis": 5000, + "output_file": "test" + } + }, + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/byoid-test@cicpclientproj.iam.gserviceaccount.com:generateAccessToken" + }