From d98900d47bb5d6eeeaf64fc2a6a8dbde5797f338 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 24 Apr 2024 07:10:51 -0700 Subject: [PATCH 01/17] feat: add ExecutableSource credentials (#525) --- composer.json | 3 +- src/CredentialSource/ExecutableSource.php | 260 ++++++++++++++ .../ExternalAccountCredentials.php | 44 ++- src/ExecutableHandler/ExecutableHandler.php | 83 +++++ .../ExecutableResponseError.php | 27 ++ tests/ApplicationDefaultCredentialsTest.php | 1 + .../CredentialSource/ExecutableSourceTest.php | 328 ++++++++++++++++++ .../ExternalAccountCredentialsTest.php | 62 ++++ .../ExecutableHandlerTest.php | 57 +++ tests/fixtures6/executable_credentials.json | 14 + 10 files changed, 873 insertions(+), 6 deletions(-) create mode 100644 src/CredentialSource/ExecutableSource.php create mode 100644 src/ExecutableHandler/ExecutableHandler.php create mode 100644 src/ExecutableHandler/ExecutableResponseError.php create mode 100644 tests/CredentialSource/ExecutableSourceTest.php create mode 100644 tests/ExecutableHandler/ExecutableHandlerTest.php create mode 100644 tests/fixtures6/executable_credentials.json 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" + } From 5d0c47368fc644d98d0a6299362d4ce03a0ef250 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 24 Apr 2024 11:36:53 -0700 Subject: [PATCH 02/17] chore(main): release 1.38.0 (#548) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e5bc0b15..ae7c3c9c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ * [feat]: add support for Firebase v6.0 (#391) +## [1.38.0](https://github.com/googleapis/google-auth-library-php/compare/v1.37.1...v1.38.0) (2024-04-24) + + +### Features + +* Add ExecutableSource credentials ([#525](https://github.com/googleapis/google-auth-library-php/issues/525)) ([d98900d](https://github.com/googleapis/google-auth-library-php/commit/d98900d47bb5d6eeeaf64fc2a6a8dbde5797f338)) + ## [1.37.1](https://github.com/googleapis/google-auth-library-php/compare/v1.37.0...v1.37.1) (2024-03-07) From 23b1fc1d8275f5c81a3e11d0c680ddabca308cf9 Mon Sep 17 00:00:00 2001 From: Yash Sahu <54198301+yash30201@users.noreply.github.com> Date: Tue, 30 Apr 2024 21:45:02 +0530 Subject: [PATCH 03/17] fix: Release Please version file config (#549) --- .github/release-please.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/release-please.yml b/.github/release-please.yml index ddc69395d..520fa5d60 100644 --- a/.github/release-please.yml +++ b/.github/release-please.yml @@ -1,3 +1,4 @@ releaseType: simple handleGHRelease: true primaryBranch: main +versionFile: VERSION From 6495f31061d2d51a173a968dbe65db8dfc6ac3cc Mon Sep 17 00:00:00 2001 From: Yash Sahu <54198301+yash30201@users.noreply.github.com> Date: Thu, 2 May 2024 20:59:03 +0530 Subject: [PATCH 04/17] feat: enable auth observability metrics (#509) --- src/Credentials/GCECredentials.php | 25 ++- .../ImpersonatedServiceAccountCredentials.php | 13 +- src/Credentials/ServiceAccountCredentials.php | 16 +- .../ServiceAccountJwtAccessCredentials.php | 12 ++ src/Credentials/UserRefreshCredentials.php | 24 ++- src/MetricsTrait.php | 120 +++++++++++ src/OAuth2.php | 12 +- src/UpdateMetadataTrait.php | 14 +- tests/Credentials/GCECredentialsTest.php | 18 ++ tests/MetricsTraitTest.php | 63 ++++++ tests/ObservabilityMetricsTest.php | 203 ++++++++++++++++++ 11 files changed, 505 insertions(+), 15 deletions(-) create mode 100644 src/MetricsTrait.php create mode 100644 tests/MetricsTraitTest.php create mode 100644 tests/ObservabilityMetricsTest.php diff --git a/src/Credentials/GCECredentials.php b/src/Credentials/GCECredentials.php index 2b704aa4a..7ef8f7045 100644 --- a/src/Credentials/GCECredentials.php +++ b/src/Credentials/GCECredentials.php @@ -110,6 +110,8 @@ class GCECredentials extends CredentialsLoader implements */ private const GKE_PRODUCT_NAME_FILE = '/sys/class/dmi/id/product_name'; + private const CRED_TYPE = 'mds'; + /** * Note: the explicit `timeout` and `tries` below is a workaround. The underlying * issue is that resolving an unknown host on some networks will take @@ -359,7 +361,10 @@ public static function onGce(callable $httpHandler = null) new Request( 'GET', $checkUri, - [self::FLAVOR_HEADER => 'Google'] + [ + self::FLAVOR_HEADER => 'Google', + self::$metricMetadataKey => self::getMetricsHeader('', 'mds') + ] ), ['timeout' => self::COMPUTE_PING_CONNECTION_TIMEOUT_S] ); @@ -421,7 +426,11 @@ public function fetchAuthToken(callable $httpHandler = null) return []; // return an empty array with no access token } - $response = $this->getFromMetadata($httpHandler, $this->tokenUri); + $response = $this->getFromMetadata( + $httpHandler, + $this->tokenUri, + $this->applyTokenEndpointMetrics([], $this->targetAudience ? 'it' : 'at') + ); if ($this->targetAudience) { return $this->lastReceivedToken = ['id_token' => $response]; @@ -579,15 +588,18 @@ public function getUniverseDomain(callable $httpHandler = null): string * * @param callable $httpHandler An HTTP Handler to deliver PSR7 requests. * @param string $uri The metadata URI. + * @param array $headers [optional] If present, add these headers to the token + * endpoint request. + * * @return string */ - private function getFromMetadata(callable $httpHandler, $uri) + private function getFromMetadata(callable $httpHandler, $uri, array $headers = []) { $resp = $httpHandler( new Request( 'GET', $uri, - [self::FLAVOR_HEADER => 'Google'] + [self::FLAVOR_HEADER => 'Google'] + $headers ) ); @@ -619,4 +631,9 @@ public function setIsOnGce($isOnGce) // Set isOnGce $this->isOnGce = $isOnGce; } + + protected function getCredType(): string + { + return self::CRED_TYPE; + } } diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index 1b4e46eaf..791fe985a 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -26,6 +26,8 @@ class ImpersonatedServiceAccountCredentials extends CredentialsLoader implements { use IamSignerTrait; + private const CRED_TYPE = 'imp'; + /** * @var string */ @@ -121,7 +123,11 @@ public function getClientName(callable $unusedHttpHandler = null) */ public function fetchAuthToken(callable $httpHandler = null) { - return $this->sourceCredentials->fetchAuthToken($httpHandler); + // We don't support id token endpoint requests as of now for Impersonated Cred + return $this->sourceCredentials->fetchAuthToken( + $httpHandler, + $this->applyTokenEndpointMetrics([], 'at') + ); } /** @@ -139,4 +145,9 @@ public function getLastReceivedToken() { return $this->sourceCredentials->getLastReceivedToken(); } + + protected function getCredType(): string + { + return self::CRED_TYPE; + } } diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index eba43cf9f..91238029d 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -65,6 +65,13 @@ class ServiceAccountCredentials extends CredentialsLoader implements { use ServiceAccountSignerTrait; + /** + * Used in observability metric headers + * + * @var string + */ + private const CRED_TYPE = 'sa'; + /** * The OAuth2 instance used to conduct authorization. * @@ -206,7 +213,9 @@ public function fetchAuthToken(callable $httpHandler = null) return $accessToken; } - return $this->auth->fetchAuthToken($httpHandler); + $authRequestType = empty($this->auth->getAdditionalClaims()['target_audience']) + ? 'at' : 'it'; + return $this->auth->fetchAuthToken($httpHandler, $this->applyTokenEndpointMetrics([], $authRequestType)); } /** @@ -344,6 +353,11 @@ public function getUniverseDomain(): string return $this->universeDomain; } + protected function getCredType(): string + { + return self::CRED_TYPE; + } + /** * @return bool */ diff --git a/src/Credentials/ServiceAccountJwtAccessCredentials.php b/src/Credentials/ServiceAccountJwtAccessCredentials.php index 8b2fb9454..87baa7500 100644 --- a/src/Credentials/ServiceAccountJwtAccessCredentials.php +++ b/src/Credentials/ServiceAccountJwtAccessCredentials.php @@ -40,6 +40,13 @@ class ServiceAccountJwtAccessCredentials extends CredentialsLoader implements { use ServiceAccountSignerTrait; + /** + * Used in observability metric headers + * + * @var string + */ + private const CRED_TYPE = 'jwt'; + /** * The OAuth2 instance used to conduct authorization. * @@ -209,4 +216,9 @@ public function getQuotaProject() { return $this->quotaProject; } + + protected function getCredType(): string + { + return self::CRED_TYPE; + } } diff --git a/src/Credentials/UserRefreshCredentials.php b/src/Credentials/UserRefreshCredentials.php index e2f32d87f..69778f7c8 100644 --- a/src/Credentials/UserRefreshCredentials.php +++ b/src/Credentials/UserRefreshCredentials.php @@ -34,6 +34,13 @@ */ class UserRefreshCredentials extends CredentialsLoader implements GetQuotaProjectInterface { + /** + * Used in observability metric headers + * + * @var string + */ + private const CRED_TYPE = 'u'; + /** * The OAuth2 instance used to conduct authorization. * @@ -98,6 +105,10 @@ public function __construct( /** * @param callable $httpHandler + * @param array $metricsHeader [optional] Metrics headers to be inserted + * into the token endpoint request present. + * This could be passed from ImersonatedServiceAccountCredentials as it uses + * UserRefreshCredentials as source credentials. * * @return array { * A set of auth related metadata, containing the following @@ -109,9 +120,13 @@ public function __construct( * @type string $id_token * } */ - public function fetchAuthToken(callable $httpHandler = null) + public function fetchAuthToken(callable $httpHandler = null, array $metricsHeader = []) { - return $this->auth->fetchAuthToken($httpHandler); + // We don't support id token endpoint requests as of now for User Cred + return $this->auth->fetchAuthToken( + $httpHandler, + $this->applyTokenEndpointMetrics($metricsHeader, 'at') + ); } /** @@ -149,4 +164,9 @@ public function getGrantedScope() { return $this->auth->getGrantedScope(); } + + protected function getCredType(): string + { + return self::CRED_TYPE; + } } diff --git a/src/MetricsTrait.php b/src/MetricsTrait.php new file mode 100644 index 000000000..8d5c03cf8 --- /dev/null +++ b/src/MetricsTrait.php @@ -0,0 +1,120 @@ + $metadata The metadata to update and return. + * @return array The updated metadata. + */ + protected function applyServiceApiUsageMetrics($metadata) + { + if ($credType = $this->getCredType()) { + // Add service api usage observability metrics info into metadata + // We expect upstream libries to have the metadata key populated already + $value = 'cred-type/' . $credType; + if (!isset($metadata[self::$metricMetadataKey])) { + // This case will happen only when someone invokes the updateMetadata + // method on the credentials fetcher themselves. + $metadata[self::$metricMetadataKey] = [$value]; + } elseif (is_array($metadata[self::$metricMetadataKey])) { + $metadata[self::$metricMetadataKey][0] .= ' ' . $value; + } else { + $metadata[self::$metricMetadataKey] .= ' ' . $value; + } + } + + return $metadata; + } + + /** + * @param array $metadata The metadata to update and return. + * @param string $authRequestType The auth request type. Possible values are + * `'at'`, `'it'`, `'mds'`. + * @return array The updated metadata. + */ + protected function applyTokenEndpointMetrics($metadata, $authRequestType) + { + $metricsHeader = self::getMetricsHeader($this->getCredType(), $authRequestType); + if (!isset($metadata[self::$metricMetadataKey])) { + $metadata[self::$metricMetadataKey] = $metricsHeader; + } + return $metadata; + } + + protected static function getVersion(): string + { + if (is_null(self::$version)) { + $versionFilePath = __DIR__ . '/../VERSION'; + self::$version = trim((string) file_get_contents($versionFilePath)); + } + return self::$version; + } + + protected function getCredType(): string + { + return ''; + } +} diff --git a/src/OAuth2.php b/src/OAuth2.php index 5fc3ba80c..b1f9ae26d 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -582,9 +582,11 @@ public function toJwt(array $config = []) * Generates a request for token credentials. * * @param callable $httpHandler callback which delivers psr7 request + * @param array $headers [optional] Additional headers to pass to + * the token endpoint request. * @return RequestInterface the authorization Url. */ - public function generateCredentialsRequest(callable $httpHandler = null) + public function generateCredentialsRequest(callable $httpHandler = null, $headers = []) { $uri = $this->getTokenCredentialUri(); if (is_null($uri)) { @@ -646,7 +648,7 @@ public function generateCredentialsRequest(callable $httpHandler = null) $headers = [ 'Cache-Control' => 'no-store', 'Content-Type' => 'application/x-www-form-urlencoded', - ]; + ] + $headers; return new Request( 'POST', @@ -660,15 +662,17 @@ public function generateCredentialsRequest(callable $httpHandler = null) * Fetches the auth tokens based on the current state. * * @param callable $httpHandler callback which delivers psr7 request + * @param array $headers [optional] If present, add these headers to the token + * endpoint request. * @return array the response */ - public function fetchAuthToken(callable $httpHandler = null) + public function fetchAuthToken(callable $httpHandler = null, $headers = []) { if (is_null($httpHandler)) { $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); } - $response = $httpHandler($this->generateCredentialsRequest($httpHandler)); + $response = $httpHandler($this->generateCredentialsRequest($httpHandler, $headers)); $credentials = $this->parseTokenResponse($response); $this->updateToken($credentials); if (isset($credentials['scope'])) { diff --git a/src/UpdateMetadataTrait.php b/src/UpdateMetadataTrait.php index fd33e0dca..30d4060cf 100644 --- a/src/UpdateMetadataTrait.php +++ b/src/UpdateMetadataTrait.php @@ -26,6 +26,8 @@ */ trait UpdateMetadataTrait { + use MetricsTrait; + /** * export a callback function which updates runtime metadata. * @@ -50,12 +52,18 @@ public function updateMetadata( $authUri = null, callable $httpHandler = null ) { - if (isset($metadata[self::AUTH_METADATA_KEY])) { + $metadata_copy = $metadata; + + // We do need to set the service api usage metrics irrespective even if + // the auth token is set because invoking this method with auth tokens + // would mean the intention is to just explicitly set the metrics metadata. + $metadata_copy = $this->applyServiceApiUsageMetrics($metadata_copy); + + if (isset($metadata_copy[self::AUTH_METADATA_KEY])) { // Auth metadata has already been set - return $metadata; + return $metadata_copy; } $result = $this->fetchAuthToken($httpHandler); - $metadata_copy = $metadata; if (isset($result['access_token'])) { $metadata_copy[self::AUTH_METADATA_KEY] = ['Bearer ' . $result['access_token']]; } elseif (isset($result['id_token'])) { diff --git a/tests/Credentials/GCECredentialsTest.php b/tests/Credentials/GCECredentialsTest.php index 5964b9a5c..cc1eb538f 100644 --- a/tests/Credentials/GCECredentialsTest.php +++ b/tests/Credentials/GCECredentialsTest.php @@ -52,6 +52,24 @@ public function testOnGceMetadataFlavorHeader() $this->assertTrue($onGce); } + public function testOnGceMetricsHeader() + { + $handerInvoked = false; + $dummyHandler = function ($request) use (&$handerInvoked) { + $header = $request->getHeaderLine('x-goog-api-client'); + $handerInvoked = true; + $this->assertStringMatchesFormat( + 'gl-php/%s auth/%s auth-request-type/mds', + $header + ); + + return new Psr7\Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']); + }; + + GCECredentials::onGce($dummyHandler); + $this->assertTrue($handerInvoked); + } + public function testOnGCEIsFalseOnClientErrorStatus() { // simulate retry attempts by returning multiple 400s diff --git a/tests/MetricsTraitTest.php b/tests/MetricsTraitTest.php new file mode 100644 index 000000000..7c54cf6ec --- /dev/null +++ b/tests/MetricsTraitTest.php @@ -0,0 +1,63 @@ +impl = new class() { + use MetricsTrait{ + getVersion as public; + getMetricsHeader as public; + } + }; + } + + public function testGetVersion() + { + $actualVersion = $this->impl::getVersion(); + $this->assertStringMatchesFormat('%d.%d.%d', $actualVersion); + } + + /** + * @dataProvider metricsHeaderCases + */ + public function testGetMetricsHeader($credType, $authRequestType, $expected) + { + $headerValue = $this->impl::getMetricsHeader($credType, $authRequestType); + $this->assertStringMatchesFormat('gl-php/%s auth/%s ' . $expected, $headerValue); + } + + public function metricsHeaderCases() + { + return [ + ['foo', '', 'cred-type/foo'], + ['', 'bar', 'auth-request-type/bar'], + ['foo', 'bar', 'auth-request-type/bar cred-type/foo'] + ]; + } +} diff --git a/tests/ObservabilityMetricsTest.php b/tests/ObservabilityMetricsTest.php new file mode 100644 index 000000000..450bfa125 --- /dev/null +++ b/tests/ObservabilityMetricsTest.php @@ -0,0 +1,203 @@ +langAndVersion = sprintf( + 'gl-php/%s auth/%s', + PHP_VERSION, + $updateMetadataTraitImpl::getVersion() + ); + $this->jsonTokens = json_encode(['access_token' => '1/abdef1234567890', 'expires_in' => '57']); + } + + /** + * @dataProvider tokenRequestType + */ + public function testGCECredentials($scope, $targetAudience, $requestTypeHeaderValue) + { + $handlerCalled = false; + $jsonTokens = $this->jsonTokens; + $handler = getHandler([ + new Response(200, [GCECredentials::FLAVOR_HEADER => 'Google']), + function ($request, $options) use ( + $jsonTokens, + &$handlerCalled, + $requestTypeHeaderValue + ) { + $handlerCalled = true; + // This confirms that token endpoint requests have proper observability metric headers + $this->assertStringContainsString( + sprintf('%s %s cred-type/mds', $this->langAndVersion, $requestTypeHeaderValue), + $request->getHeaderLine(self::$headerKey) + ); + return new Response(200, [], Utils::streamFor($jsonTokens)); + } + ]); + + $gceCred = new GCECredentials(null, $scope, $targetAudience); + $this->assertUpdateMetadata($gceCred, $handler, 'mds', $handlerCalled); + } + + /** + * @dataProvider tokenRequestType + */ + public function testServiceAccountCredentials($scope, $targetAudience, $requestTypeHeaderValue) + { + $keyFile = __DIR__ . '/fixtures3/service_account_credentials.json'; + $handlerCalled = false; + $handler = $this->getCustomHandler('sa', $requestTypeHeaderValue, $handlerCalled); + + $sa = new ServiceAccountCredentials( + $scope, + $keyFile, + null, + $targetAudience + ); + $this->assertUpdateMetadata($sa, $handler, 'sa', $handlerCalled); + } + + /** + * ServiceAccountJwtAccessCredentials creates the jwt token within library hence + * they don't have any observability metrics header check for token endpoint requests. + */ + public function testServiceAccountJwtAccessCredentials() + { + $keyFile = __DIR__ . '/fixtures3/service_account_credentials.json'; + $saJwt = new ServiceAccountJwtAccessCredentials($keyFile, 'exampleScope'); + $metadata = $saJwt->updateMetadata([self::$headerKey => ['foo']], null, null); + $this->assertArrayHasKey(self::$headerKey, $metadata); + + // This confirms that service usage requests have proper observability metric headers + $this->assertStringContainsString( + sprintf('foo cred-type/jwt'), + $metadata[self::$headerKey][0] + ); + } + + /** + * ImpersonatedServiceAccountCredentials haven't enabled identity token support hence + * they don't have 'auth-request-type/it' observability metric header check. + */ + public function testImpersonatedServiceAccountCredentials() + { + $keyFile = __DIR__ . '/fixtures5/.config/gcloud/application_default_credentials.json'; + $handlerCalled = false; + $handler = $this->getCustomHandler('imp', 'auth-request-type/at', $handlerCalled); + + $impersonatedCred = new ImpersonatedServiceAccountCredentials('exampleScope', $keyFile); + $this->assertUpdateMetadata($impersonatedCred, $handler, 'imp', $handlerCalled); + } + + /** + * UserRefreshCredentials haven't enabled identity token support hence + * they don't have 'auth-request-type/it' observability metric header check. + */ + public function testUserRefreshCredentials() + { + $keyFile = __DIR__ . '/fixtures2/gcloud.json'; + $handlerCalled = false; + $handler = $this->getCustomHandler('u', 'auth-request-type/at', $handlerCalled); + + $userRefreshCred = new UserRefreshCredentials('exampleScope', $keyFile); + $this->assertUpdateMetadata($userRefreshCred, $handler, 'u', $handlerCalled); + } + + /** + * Invokes the 'updateMetadata' method of cred fetcher with empty metadata argument + * and asserts for proper service api usage observability metrics header. + */ + private function assertUpdateMetadata($cred, $handler, $credShortform, &$handlerCalled) + { + $metadata = $cred->updateMetadata([self::$headerKey => ['foo']], null, $handler); + $this->assertArrayHasKey(self::$headerKey, $metadata); + + // This confirms that service usage requests have proper observability metric headers + $this->assertStringContainsString( + sprintf('foo cred-type/%s', $credShortform), + $metadata[self::$headerKey][0] + ); + + $this->assertTrue($handlerCalled); + } + + /** + * @param string $credShortform The short form of the credential type + * used in observability metric header value. + * @param string $requestTypeHeaderValue Expected header value of the form + * 'auth-request-type/<>' + * @param bool $handlerCalled Reference to the handlerCalled flag asserted later + * in the test. + * @return callable + */ + private function getCustomHandler($credShortform, $requestTypeHeaderValue, &$handlerCalled) + { + $jsonTokens = $this->jsonTokens; + return getHandler([ + function ($request, $options) use ( + $jsonTokens, + &$handlerCalled, + $requestTypeHeaderValue, + $credShortform + ) { + $handlerCalled = true; + // This confirms that token endpoint requests have proper observability metric headers + $this->assertStringContainsString( + sprintf('%s %s cred-type/%s', $this->langAndVersion, $requestTypeHeaderValue, $credShortform), + $request->getHeaderLine(self::$headerKey) + ); + return new Response(200, [], Utils::streamFor($jsonTokens)); + } + ]); + } + + public function tokenRequestType() + { + return [ + ['someScope', null, 'auth-request-type/at'], + [null, 'someTargetAudience', 'auth-request-type/it'], + ]; + } +} From 23e8e696d87f8d7dfefbd347ca1c99ce17ecb368 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Thu, 2 May 2024 16:03:51 +0000 Subject: [PATCH 05/17] chore(main): release 1.39.0 (#550) --- CHANGELOG.md | 7 +++++++ VERSION | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae7c3c9c7..269e8ab88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ * [feat]: add support for Firebase v6.0 (#391) +## [1.39.0](https://github.com/googleapis/google-auth-library-php/compare/v1.38.0...v1.39.0) (2024-05-02) + + +### Features + +* Enable auth observability metrics ([#509](https://github.com/googleapis/google-auth-library-php/issues/509)) ([6495f31](https://github.com/googleapis/google-auth-library-php/commit/6495f31061d2d51a173a968dbe65db8dfc6ac3cc)) + ## [1.38.0](https://github.com/googleapis/google-auth-library-php/compare/v1.37.1...v1.38.0) (2024-04-24) diff --git a/VERSION b/VERSION index 9cf86ad0f..5edffce6d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.37.1 +1.39.0 From ec13a53ddd625265b7a596817eb052c693ab89e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Mendoza?= Date: Fri, 31 May 2024 15:05:29 -0400 Subject: [PATCH 06/17] feat: add windows residency check (#553) --- .github/workflows/tests.yml | 13 +++-- src/Credentials/GCECredentials.php | 48 +++++++++++++++-- tests/Credentials/GCECredentialsTest.php | 66 +++++++++++++++++++++++- tests/phpstan-autoload.php | 20 +++++++ 4 files changed, 138 insertions(+), 9 deletions(-) create mode 100644 tests/phpstan-autoload.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8251dda2f..61c1cf40a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,17 +8,22 @@ permissions: contents: read jobs: test: - runs-on: ubuntu-latest strategy: matrix: php: [ "8.0", "8.1", "8.2", "8.3" ] - name: PHP ${{matrix.php }} Unit Test + os: [ ubuntu-latest ] + include: + - os: windows-latest + php: "8.1" + runs-on: ${{ matrix.os }} + name: PHP ${{ matrix.php }} Unit Test ${{ matrix.os == 'windows-latest' && 'on Windows' || '' }} steps: - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} + extensions: ${{ matrix.os == 'windows-latest' && 'gmp, php_com_dotnet' || '' }} - name: Install Dependencies uses: nick-invision/retry@v3 with: @@ -26,7 +31,7 @@ jobs: max_attempts: 3 command: composer install - name: Run Script - run: vendor/bin/phpunit + run: vendor/bin/phpunit ${{ matrix.os == 'windows-latest' && '--filter GCECredentialsTest' || '' }} test_lowest: runs-on: ubuntu-latest name: Test Prefer Lowest @@ -73,4 +78,4 @@ jobs: run: | composer install composer global require phpstan/phpstan:^1.8 - ~/.composer/vendor/bin/phpstan analyse + ~/.composer/vendor/bin/phpstan analyse --autoload-file tests/phpstan-autoload.php diff --git a/src/Credentials/GCECredentials.php b/src/Credentials/GCECredentials.php index 7ef8f7045..5fed54763 100644 --- a/src/Credentials/GCECredentials.php +++ b/src/Credentials/GCECredentials.php @@ -17,6 +17,8 @@ namespace Google\Auth\Credentials; +use COM; +use com_exception; use Google\Auth\CredentialsLoader; use Google\Auth\GetQuotaProjectInterface; use Google\Auth\HttpHandler\HttpClientCache; @@ -110,6 +112,21 @@ class GCECredentials extends CredentialsLoader implements */ private const GKE_PRODUCT_NAME_FILE = '/sys/class/dmi/id/product_name'; + /** + * The Windows Registry key path to the product name + */ + private const WINDOWS_REGISTRY_KEY_PATH = 'HKEY_LOCAL_MACHINE\\SYSTEM\\HardwareConfig\\Current\\'; + + /** + * The Windows registry key name for the product name + */ + private const WINDOWS_REGISTRY_KEY_NAME = 'SystemProductName'; + + /** + * The Name of the product expected from the windows registry + */ + private const PRODUCT_NAME = 'Google'; + private const CRED_TYPE = 'mds'; /** @@ -377,9 +394,10 @@ public static function onGce(callable $httpHandler = null) } } - if (PHP_OS === 'Windows') { - // @TODO: implement GCE residency detection on Windows - return false; + if (PHP_OS === 'Windows' || PHP_OS === 'WINNT') { + return self::detectResidencyWindows( + self::WINDOWS_REGISTRY_KEY_PATH . self::WINDOWS_REGISTRY_KEY_NAME + ); } // Detect GCE residency on Linux @@ -390,11 +408,33 @@ private static function detectResidencyLinux(string $productNameFile): bool { if (file_exists($productNameFile)) { $productName = trim((string) file_get_contents($productNameFile)); - return 0 === strpos($productName, 'Google'); + return 0 === strpos($productName, self::PRODUCT_NAME); } return false; } + private static function detectResidencyWindows(string $registryProductKey): bool + { + if (!class_exists(COM::class)) { + // the COM extension must be installed and enabled to detect Windows residency + // see https://www.php.net/manual/en/book.com.php + return false; + } + + $shell = new COM('WScript.Shell'); + $productName = null; + + try { + $productName = $shell->regRead($registryProductKey); + } catch(com_exception) { + // This means that we tried to read a key that doesn't exist on the registry + // which might mean that it is a windows instance that is not on GCE + return false; + } + + return 0 === strpos($productName, self::PRODUCT_NAME); + } + /** * Implements FetchAuthTokenInterface#fetchAuthToken. * diff --git a/tests/Credentials/GCECredentialsTest.php b/tests/Credentials/GCECredentialsTest.php index cc1eb538f..7aca40510 100644 --- a/tests/Credentials/GCECredentialsTest.php +++ b/tests/Credentials/GCECredentialsTest.php @@ -17,6 +17,7 @@ namespace Google\Auth\Tests\Credentials; +use COM; use Exception; use Google\Auth\Credentials\GCECredentials; use Google\Auth\HttpHandler\HttpClientCache; @@ -29,6 +30,7 @@ use InvalidArgumentException; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use ReflectionClass; /** * @group credentials @@ -96,7 +98,7 @@ public function testCheckProductNameFile() { $tmpFile = tempnam(sys_get_temp_dir(), 'gce-test-product-name'); - $method = (new \ReflectionClass(GCECredentials::class)) + $method = (new ReflectionClass(GCECredentials::class)) ->getMethod('detectResidencyLinux'); $method->setAccessible(true); @@ -124,6 +126,68 @@ public function testOnGceWithResidency() $this->assertTrue(GCECredentials::onGCE($httpHandler)); } + public function testOnWindowsGceWithResidencyWithNoCom() + { + if (PHP_OS !== 'Windows' && PHP_OS !== 'WINNT') { + $this->markTestSkipped('This test only works while running on Windows'); + } + + if (class_exists(COM::class)) { + throw $this->markTestSkipped('This test in meant to handle when the COM extension is not present'); + } + + $method = (new ReflectionClass(GCECredentials::class)) + ->getMethod('detectResidencyWindows'); + + $method->setAccessible(true); + + $this->assertFalse($method->invoke(null, 'thisShouldBeFalse')); + } + + public function testOnWindowsGceWithResidencyNotOnGCE() + { + if (!class_exists(COM::class)) { + throw $this->markTestSkipped('This test only works while running on windows COM extension enabled'); + } + + if (GCECredentials::onGce()) { + $this->markTestSkipped('This test runs only on non GCE machines'); + } + + $keyPathProperty = 'HKEY_LOCAL_MACHINE\\SYSTEM\\HardwareConfig\\Current\\'; + $keyName = 'SystemProductName'; + + $method = (new ReflectionClass(GCECredentials::class)) + ->getMethod('detectResidencyWindows'); + $method->setAccessible(true); + + $this->assertFalse($method->invoke(null, $keyPathProperty . $keyName)); + } + + public function testOnWindowsGceWithResidency() + { + if (PHP_OS !== 'Windows' && PHP_OS !== 'WINNT') { + $this->markTestSkipped('This test only works while running on Windows'); + } + + if (!class_exists(COM::class)) { + $this->markTestSkipped('This test only works with the COM extension enabled'); + } + + if (!GCECredentials::onGce()) { + $this->markTestSkipped('This test only works while running on GCE'); + } + + $keyPathProperty = 'HKEY_LOCAL_MACHINE\\SYSTEM\\HardwareConfig\\Current\\'; + $keyName = 'SystemProductName'; + + $method = (new ReflectionClass(GCECredentials::class)) + ->getMethod('detectResidencyWindows'); + $method->setAccessible(true); + + $this->assertTrue($method->invoke(null, $keyPathProperty . $keyName)); + } + public function testOnGCEIsFalseOnOkStatusWithoutExpectedHeader() { $httpHandler = getHandler([ diff --git a/tests/phpstan-autoload.php b/tests/phpstan-autoload.php new file mode 100644 index 000000000..22a38a245 --- /dev/null +++ b/tests/phpstan-autoload.php @@ -0,0 +1,20 @@ + Date: Fri, 31 May 2024 12:16:15 -0700 Subject: [PATCH 07/17] chore(main): release 1.40.0 (#555) --- CHANGELOG.md | 7 +++++++ VERSION | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 269e8ab88..a604c673a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ * [feat]: add support for Firebase v6.0 (#391) +## [1.40.0](https://github.com/googleapis/google-auth-library-php/compare/v1.39.0...v1.40.0) (2024-05-31) + + +### Features + +* Add windows residency check ([#553](https://github.com/googleapis/google-auth-library-php/issues/553)) ([ec13a53](https://github.com/googleapis/google-auth-library-php/commit/ec13a53ddd625265b7a596817eb052c693ab89e2)) + ## [1.39.0](https://github.com/googleapis/google-auth-library-php/compare/v1.38.0...v1.39.0) (2024-05-02) diff --git a/VERSION b/VERSION index 5edffce6d..32b7211cb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.39.0 +1.40.0 From 9ebf46ead4962812e35f9241d33a419b911d9ce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Mendoza?= Date: Wed, 26 Jun 2024 15:41:05 -0400 Subject: [PATCH 08/17] docs: better documentation for caching (#563) --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index eac25a236..7db408046 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,37 @@ $auth->verify($idToken, [ [google-id-tokens]: https://developers.google.com/identity/sign-in/web/backend-auth [iap-id-tokens]: https://cloud.google.com/iap/docs/signed-headers-howto +## Caching +Caching is enabled by passing a PSR-6 `CacheItemPoolInterface` +instance to the constructor when instantiating the credentials. + +We offer some caching classes out of the box under the `Google\Auth\Cache` namespace. + +```php +use Google\Auth\ApplicationDefaultCredentials; +use Google\Auth\Cache\MemoryCacheItemPool; + +// Cache Instance +$memoryCache = new MemoryCacheItemPool; + +// Get the credentials +// From here, the credentials will cache the access token +$middleware = ApplicationDefaultCredentials::getCredentials($scope, cache: $memoryCache); +``` + +### Integrating with a third party cache +You can use a third party that follows the `PSR-6` interface of your choice. + +```php +use Symphony\Component\Cache\Adapter\FileststenAdapter; + +// Create the cache instance +$filesystemCache = new FilesystemAdapter(); + +// Create Get the credentials +$credentials = ApplicationDefaultCredentials::getCredentials($targetAudience, cache: $filesystemCache); +``` + ## License This library is licensed under Apache 2.0. Full license text is From a35c4dbb52e01faedacd09d23634939ced4a8a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Mendoza?= Date: Wed, 10 Jul 2024 10:49:03 -0400 Subject: [PATCH 09/17] feat: Change getCacheKey implementation for more unique keys (#560) --- src/CredentialSource/AwsNativeSource.php | 15 +++++ src/CredentialSource/ExecutableSource.php | 12 ++++ src/CredentialSource/FileSource.php | 12 ++++ src/CredentialSource/UrlSource.php | 12 ++++ .../ExternalAccountCredentials.php | 26 +++++++-- src/Credentials/GCECredentials.php | 6 +- .../ImpersonatedServiceAccountCredentials.php | 3 + src/Credentials/ServiceAccountCredentials.php | 14 ++++- .../ServiceAccountJwtAccessCredentials.php | 12 +++- src/Credentials/UserRefreshCredentials.php | 12 +++- ...ternalAccountCredentialSourceInterface.php | 1 + src/OAuth2.php | 22 +++++++ .../ExternalAccountCredentialsTest.php | 57 +++++++++++++++++++ .../ServiceAccountCredentialsTest.php | 6 +- ...ServiceAccountJwtAccessCredentialsTest.php | 6 +- .../UserRefreshCredentialsTest.php | 2 +- 16 files changed, 202 insertions(+), 16 deletions(-) diff --git a/src/CredentialSource/AwsNativeSource.php b/src/CredentialSource/AwsNativeSource.php index 460d9e5ea..6d9244ba2 100644 --- a/src/CredentialSource/AwsNativeSource.php +++ b/src/CredentialSource/AwsNativeSource.php @@ -328,6 +328,21 @@ public static function getSigningVarsFromEnv(): ?array return null; } + /** + * Gets the unique key for caching + * For AwsNativeSource the values are: + * Imdsv2SessionTokenUrl.SecurityCredentialsUrl.RegionUrl.RegionalCredVerificationUrl + * + * @return string + */ + public function getCacheKey(): string + { + return ($this->imdsv2SessionTokenUrl ?? '') . + '.' . ($this->securityCredentialsUrl ?? '') . + '.' . $this->regionUrl . + '.' . $this->regionalCredVerificationUrl; + } + /** * Return HMAC hash in binary string */ diff --git a/src/CredentialSource/ExecutableSource.php b/src/CredentialSource/ExecutableSource.php index 7661fc9cc..ce3bd9fda 100644 --- a/src/CredentialSource/ExecutableSource.php +++ b/src/CredentialSource/ExecutableSource.php @@ -100,6 +100,18 @@ public function __construct( $this->executableHandler = $executableHandler ?: new ExecutableHandler(); } + /** + * Gets the unique key for caching + * The format for the cache key is: + * Command.OutputFile + * + * @return ?string + */ + public function getCacheKey(): ?string + { + return $this->command . '.' . $this->outputFile; + } + /** * @param callable $httpHandler unused. * @return string diff --git a/src/CredentialSource/FileSource.php b/src/CredentialSource/FileSource.php index e2afc6c58..00ac835a8 100644 --- a/src/CredentialSource/FileSource.php +++ b/src/CredentialSource/FileSource.php @@ -72,4 +72,16 @@ public function fetchSubjectToken(callable $httpHandler = null): string return $contents; } + + /** + * Gets the unique key for caching. + * The format for the cache key one of the following: + * Filename + * + * @return string + */ + public function getCacheKey(): ?string + { + return $this->file; + } } diff --git a/src/CredentialSource/UrlSource.php b/src/CredentialSource/UrlSource.php index 0acb3c6ef..6046d52fa 100644 --- a/src/CredentialSource/UrlSource.php +++ b/src/CredentialSource/UrlSource.php @@ -94,4 +94,16 @@ public function fetchSubjectToken(callable $httpHandler = null): string return $body; } + + /** + * Get the cache key for the credentials. + * The format for the cache key is: + * URL + * + * @return ?string + */ + public function getCacheKey(): ?string + { + return $this->url; + } } diff --git a/src/Credentials/ExternalAccountCredentials.php b/src/Credentials/ExternalAccountCredentials.php index 98f427a33..3614d24d0 100644 --- a/src/Credentials/ExternalAccountCredentials.php +++ b/src/Credentials/ExternalAccountCredentials.php @@ -98,9 +98,7 @@ public function __construct( ); } - if (array_key_exists('service_account_impersonation_url', $jsonKey)) { - $this->serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url']; - } + $this->serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url'] ?? null; $this->quotaProject = $jsonKey['quota_project_id'] ?? null; $this->workforcePoolUserProject = $jsonKey['workforce_pool_user_project'] ?? null; @@ -276,9 +274,27 @@ public function fetchAuthToken(callable $httpHandler = null) return $stsToken; } - public function getCacheKey() + /** + * Get the cache token key for the credentials. + * The cache token key format depends on the type of source + * The format for the cache key one of the following: + * FetcherCacheKey.Scope.[ServiceAccount].[TokenType].[WorkforcePoolUserProject] + * FetcherCacheKey.Audience.[ServiceAccount].[TokenType].[WorkforcePoolUserProject] + * + * @return ?string; + */ + public function getCacheKey(): ?string { - return $this->auth->getCacheKey(); + $scopeOrAudience = $this->auth->getAudience(); + if (!$scopeOrAudience) { + $scopeOrAudience = $this->auth->getScope(); + } + + return $this->auth->getSubjectTokenFetcher()->getCacheKey() . + '.' . $scopeOrAudience . + '.' . ($this->serviceAccountImpersonationUrl ?? '') . + '.' . ($this->auth->getSubjectTokenType() ?? '') . + '.' . ($this->workforcePoolUserProject ?? ''); } public function getLastReceivedToken() diff --git a/src/Credentials/GCECredentials.php b/src/Credentials/GCECredentials.php index 5fed54763..8b7547816 100644 --- a/src/Credentials/GCECredentials.php +++ b/src/Credentials/GCECredentials.php @@ -489,11 +489,15 @@ public function fetchAuthToken(callable $httpHandler = null) } /** + * Returns the Cache Key for the credential token. + * The format for the cache key is: + * TokenURI + * * @return string */ public function getCacheKey() { - return self::cacheKey; + return $this->tokenUri; } /** diff --git a/src/Credentials/ImpersonatedServiceAccountCredentials.php b/src/Credentials/ImpersonatedServiceAccountCredentials.php index 791fe985a..5d3522827 100644 --- a/src/Credentials/ImpersonatedServiceAccountCredentials.php +++ b/src/Credentials/ImpersonatedServiceAccountCredentials.php @@ -131,6 +131,9 @@ public function fetchAuthToken(callable $httpHandler = null) } /** + * Returns the Cache Key for the credentials + * The cache key is the same as the UserRefreshCredentials class + * * @return string */ public function getCacheKey() diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index 91238029d..4090b8931 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -219,13 +219,23 @@ public function fetchAuthToken(callable $httpHandler = null) } /** + * Return the Cache Key for the credentials. + * For the cache key format is one of the following: + * ClientEmail.Scope[.Sub] + * ClientEmail.Audience[.Sub] + * * @return string */ public function getCacheKey() { - $key = $this->auth->getIssuer() . ':' . $this->auth->getCacheKey(); + $scopeOrAudience = $this->auth->getScope(); + if (!$scopeOrAudience) { + $scopeOrAudience = $this->auth->getAudience(); + } + + $key = $this->auth->getIssuer() . '.' . $scopeOrAudience; if ($sub = $this->auth->getSub()) { - $key .= ':' . $sub; + $key .= '.' . $sub; } return $key; diff --git a/src/Credentials/ServiceAccountJwtAccessCredentials.php b/src/Credentials/ServiceAccountJwtAccessCredentials.php index 87baa7500..6c582a830 100644 --- a/src/Credentials/ServiceAccountJwtAccessCredentials.php +++ b/src/Credentials/ServiceAccountJwtAccessCredentials.php @@ -166,11 +166,21 @@ public function fetchAuthToken(callable $httpHandler = null) } /** + * Return the cache key for the credentials. + * The format for the Cache Key one of the following: + * ClientEmail.Scope + * ClientEmail.Audience + * * @return string */ public function getCacheKey() { - return $this->auth->getCacheKey(); + $scopeOrAudience = $this->auth->getScope(); + if (!$scopeOrAudience) { + $scopeOrAudience = $this->auth->getAudience(); + } + + return $this->auth->getIssuer() . '.' . $scopeOrAudience; } /** diff --git a/src/Credentials/UserRefreshCredentials.php b/src/Credentials/UserRefreshCredentials.php index 69778f7c8..d40055562 100644 --- a/src/Credentials/UserRefreshCredentials.php +++ b/src/Credentials/UserRefreshCredentials.php @@ -130,11 +130,21 @@ public function fetchAuthToken(callable $httpHandler = null, array $metricsHeade } /** + * Return the Cache Key for the credentials. + * The format for the Cache key is one of the following: + * ClientId.Scope + * ClientId.Audience + * * @return string */ public function getCacheKey() { - return $this->auth->getClientId() . ':' . $this->auth->getCacheKey(); + $scopeOrAudience = $this->auth->getScope(); + if (!$scopeOrAudience) { + $scopeOrAudience = $this->auth->getAudience(); + } + + return $this->auth->getClientId() . '.' . $scopeOrAudience; } /** diff --git a/src/ExternalAccountCredentialSourceInterface.php b/src/ExternalAccountCredentialSourceInterface.php index b4d00f8b4..041b18d51 100644 --- a/src/ExternalAccountCredentialSourceInterface.php +++ b/src/ExternalAccountCredentialSourceInterface.php @@ -20,4 +20,5 @@ interface ExternalAccountCredentialSourceInterface { public function fetchSubjectToken(callable $httpHandler = null): string; + public function getCacheKey(): ?string; } diff --git a/src/OAuth2.php b/src/OAuth2.php index b1f9ae26d..4019e258a 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -683,6 +683,8 @@ public function fetchAuthToken(callable $httpHandler = null, $headers = []) } /** + * @deprecated + * * Obtains a key that can used to cache the results of #fetchAuthToken. * * The key is derived from the scopes. @@ -703,6 +705,16 @@ public function getCacheKey() return null; } + /** + * Gets this instance's SubjectTokenFetcher + * + * @return null|ExternalAccountCredentialSourceInterface + */ + public function getSubjectTokenFetcher(): ?ExternalAccountCredentialSourceInterface + { + return $this->subjectTokenFetcher; + } + /** * Parses the fetched tokens. * @@ -1020,6 +1032,16 @@ public function getScope() return implode(' ', $this->scope); } + /** + * Gets the subject token type + * + * @return ?string + */ + public function getSubjectTokenType(): ?string + { + return $this->subjectTokenType; + } + /** * Sets the scope of the access request, expressed either as an Array or as * a space-delimited String. diff --git a/tests/Credentials/ExternalAccountCredentialsTest.php b/tests/Credentials/ExternalAccountCredentialsTest.php index c658054ec..09cac05db 100644 --- a/tests/Credentials/ExternalAccountCredentialsTest.php +++ b/tests/Credentials/ExternalAccountCredentialsTest.php @@ -521,6 +521,63 @@ public function testFetchAuthTokenWithWorkforcePoolCredentials() $this->assertEquals(strtotime($expiry), $authToken['expires_at']); } + public function testFileSourceCacheKey() + { + $this->baseCreds['credential_source'] = ['file' => 'fakeFile']; + $credentials = new ExternalAccountCredentials('scope1', $this->baseCreds); + $cacheKey = $credentials->getCacheKey(); + $expectedKey = 'fakeFile.scope1...'; + $this->assertEquals($expectedKey, $cacheKey); + } + + public function testAWSSourceCacheKey() + { + $this->baseCreds['credential_source'] = [ + 'environment_id' => 'aws1', + 'regional_cred_verification_url' => 'us-east', + 'region_url' => 'aws.us-east.com', + 'url' => 'aws.us-east.token.com', + 'imdsv2_session_token_url' => '12345' + ]; + $this->baseCreds['audience'] = 'audience1'; + $credentials = new ExternalAccountCredentials('scope1', $this->baseCreds); + $cacheKey = $credentials->getCacheKey(); + $expectedKey = '12345.aws.us-east.token.com.aws.us-east.com.us-east.audience1...'; + $this->assertEquals($expectedKey, $cacheKey); + } + + public function testUrlSourceCacheKey() + { + $this->baseCreds['credential_source'] = [ + 'url' => 'fakeUrl', + 'format' => [ + 'type' => 'json', + 'subject_token_field_name' => 'keyShouldBeHere' + ] + ]; + + $credentials = new ExternalAccountCredentials('scope1', $this->baseCreds); + $cacheKey = $credentials->getCacheKey(); + $expectedKey = 'fakeUrl.scope1...'; + $this->assertEquals($expectedKey, $cacheKey); + } + + public function testExecutableSourceCacheKey() + { + $this->baseCreds['credential_source'] = [ + 'executable' => [ + 'command' => 'ls -al', + 'output_file' => './output.txt' + ] + ]; + + $credentials = new ExternalAccountCredentials('scope1', $this->baseCreds); + $cacheKey = $credentials->getCacheKey(); + + $expectedCacheKey = 'ls -al../output.txt.scope1...'; + $this->assertEquals($cacheKey, $expectedCacheKey); + } + /** * @runInSeparateProcess */ diff --git a/tests/Credentials/ServiceAccountCredentialsTest.php b/tests/Credentials/ServiceAccountCredentialsTest.php index a53f55158..818f543ef 100644 --- a/tests/Credentials/ServiceAccountCredentialsTest.php +++ b/tests/Credentials/ServiceAccountCredentialsTest.php @@ -54,7 +54,7 @@ public function testShouldBeTheSameAsOAuth2WithTheSameScope() ); $o = new OAuth2(['scope' => $scope]); $this->assertSame( - $testJson['client_email'] . ':' . $o->getCacheKey(), + $testJson['client_email'] . '.' . implode(' ', $scope), $sa->getCacheKey() ); } @@ -71,7 +71,7 @@ public function testShouldBeTheSameAsOAuth2WithTheSameScopeWithSub() ); $o = new OAuth2(['scope' => $scope]); $this->assertSame( - $testJson['client_email'] . ':' . $o->getCacheKey() . ':' . $sub, + $testJson['client_email'] . '.' . implode(' ', $scope) . '.' . $sub, $sa->getCacheKey() ); } @@ -90,7 +90,7 @@ public function testShouldBeTheSameAsOAuth2WithTheSameScopeWithSubAddedLater() $o = new OAuth2(['scope' => $scope]); $this->assertSame( - $testJson['client_email'] . ':' . $o->getCacheKey() . ':' . $sub, + $testJson['client_email'] . '.' . implode(' ', $scope) . '.' . $sub, $sa->getCacheKey() ); } diff --git a/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php b/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php index 510225dd7..2cac3dac1 100644 --- a/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php +++ b/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php @@ -480,8 +480,10 @@ public function testShouldBeTheSameAsOAuth2WithTheSameScope() { $testJson = $this->createTestJson(); $scope = ['scope/1', 'scope/2']; - $sa = new ServiceAccountJwtAccessCredentials($testJson); - $this->assertNull($sa->getCacheKey()); + $sa = new ServiceAccountJwtAccessCredentials($testJson, $scope); + + $expectedKey = $testJson['client_email'] . '.' . implode(' ', $scope); + $this->assertEquals($expectedKey, $sa->getCacheKey()); } public function testReturnsClientEmail() diff --git a/tests/Credentials/UserRefreshCredentialsTest.php b/tests/Credentials/UserRefreshCredentialsTest.php index 420790a6f..b944dd40e 100644 --- a/tests/Credentials/UserRefreshCredentialsTest.php +++ b/tests/Credentials/UserRefreshCredentialsTest.php @@ -50,7 +50,7 @@ public function testShouldBeTheSameAsOAuth2WithTheSameScope() ); $o = new OAuth2(['scope' => $scope]); $this->assertSame( - $testJson['client_id'] . ':' . $o->getCacheKey(), + $testJson['client_id'] . '.' . implode(' ', $scope), $sa->getCacheKey() ); } From 1043ea18fe7f5dfbf5b208ce3ee6d6b6ab8cb038 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 10 Jul 2024 08:21:07 -0700 Subject: [PATCH 10/17] chore(main): release 1.41.0 (#564) --- CHANGELOG.md | 7 +++++++ VERSION | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a604c673a..9d3928a13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ * [feat]: add support for Firebase v6.0 (#391) +## [1.41.0](https://github.com/googleapis/google-auth-library-php/compare/v1.40.0...v1.41.0) (2024-07-10) + + +### Features + +* Change getCacheKey implementation for more unique keys ([#560](https://github.com/googleapis/google-auth-library-php/issues/560)) ([a35c4db](https://github.com/googleapis/google-auth-library-php/commit/a35c4dbb52e01faedacd09d23634939ced4a8a63)) + ## [1.40.0](https://github.com/googleapis/google-auth-library-php/compare/v1.39.0...v1.40.0) (2024-05-31) diff --git a/VERSION b/VERSION index 32b7211cb..7d47e5998 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.40.0 +1.41.0 From d2fa07b8a8edfa65c1bd732dac794c070e3451bc Mon Sep 17 00:00:00 2001 From: Razvan Grigore Date: Wed, 10 Jul 2024 20:41:35 +0300 Subject: [PATCH 11/17] feat: private key getters on service account credentials (google-wallet#112) (#557) --- src/Credentials/ServiceAccountCredentials.php | 12 ++++++++++++ .../ServiceAccountJwtAccessCredentials.php | 12 ++++++++++++ tests/Credentials/ServiceAccountCredentialsTest.php | 7 +++++++ .../ServiceAccountJwtAccessCredentialsTest.php | 8 ++++++++ 4 files changed, 39 insertions(+) diff --git a/src/Credentials/ServiceAccountCredentials.php b/src/Credentials/ServiceAccountCredentials.php index 4090b8931..5e7915333 100644 --- a/src/Credentials/ServiceAccountCredentials.php +++ b/src/Credentials/ServiceAccountCredentials.php @@ -343,6 +343,18 @@ public function getClientName(callable $httpHandler = null) return $this->auth->getIssuer(); } + /** + * Get the private key from the keyfile. + * + * In this case, it returns the keyfile's private_key key, needed for JWT signing. + * + * @return string + */ + public function getPrivateKey() + { + return $this->auth->getSigningKey(); + } + /** * Get the quota project used for this API request * diff --git a/src/Credentials/ServiceAccountJwtAccessCredentials.php b/src/Credentials/ServiceAccountJwtAccessCredentials.php index 6c582a830..7bdc21848 100644 --- a/src/Credentials/ServiceAccountJwtAccessCredentials.php +++ b/src/Credentials/ServiceAccountJwtAccessCredentials.php @@ -217,6 +217,18 @@ public function getClientName(callable $httpHandler = null) return $this->auth->getIssuer(); } + /** + * Get the private key from the keyfile. + * + * In this case, it returns the keyfile's private_key key, needed for JWT signing. + * + * @return string + */ + public function getPrivateKey() + { + return $this->auth->getSigningKey(); + } + /** * Get the quota project used for this API request * diff --git a/tests/Credentials/ServiceAccountCredentialsTest.php b/tests/Credentials/ServiceAccountCredentialsTest.php index 818f543ef..4352af154 100644 --- a/tests/Credentials/ServiceAccountCredentialsTest.php +++ b/tests/Credentials/ServiceAccountCredentialsTest.php @@ -369,6 +369,13 @@ public function testReturnsClientEmail() $this->assertEquals($testJson['client_email'], $sa->getClientName()); } + public function testReturnsPrivateKey() + { + $testJson = $this->createTestJson(); + $sa = new ServiceAccountCredentials('scope/1', $testJson); + $this->assertEquals($testJson['private_key'], $sa->getPrivateKey()); + } + public function testGetProjectId() { $testJson = $this->createTestJson(); diff --git a/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php b/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php index 2cac3dac1..47e2796ce 100644 --- a/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php +++ b/tests/Credentials/ServiceAccountJwtAccessCredentialsTest.php @@ -492,6 +492,14 @@ public function testReturnsClientEmail() $sa = new ServiceAccountJwtAccessCredentials($testJson); $this->assertEquals($testJson['client_email'], $sa->getClientName()); } + + public function testReturnsPrivateKey() + { + $testJson = $this->createTestJson(); + $sa = new ServiceAccountJwtAccessCredentials($testJson); + $this->assertEquals($testJson['private_key'], $sa->getPrivateKey()); + } + public function testGetProjectId() { $testJson = $this->createTestJson(); From 1277062739eedd04e7d0af86f6fe1853d2620706 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 16 Jul 2024 08:24:24 -0700 Subject: [PATCH 12/17] docs: fix import typo, add comment (#568) --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7db408046..63bcfeaa2 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,9 @@ $middleware = ApplicationDefaultCredentials::getCredentials($scope, cache: $memo You can use a third party that follows the `PSR-6` interface of your choice. ```php -use Symphony\Component\Cache\Adapter\FileststenAdapter; +// run "composer require symfony/cache" +use Google\Auth\ApplicationDefaultCredentials; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; // Create the cache instance $filesystemCache = new FilesystemAdapter(); From 8555cb063caa5571f80d9605969411b894ee6eb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Mendoza?= Date: Thu, 22 Aug 2024 17:45:48 -0400 Subject: [PATCH 13/17] feat: Add a file system cache class (#571) Co-authored-by: Brent Shaffer --- README.md | 19 ++ src/Cache/FileSystemCacheItemPool.php | 230 ++++++++++++++++++++ tests/Cache/FileSystemCacheItemPoolTest.php | 220 +++++++++++++++++++ 3 files changed, 469 insertions(+) create mode 100644 src/Cache/FileSystemCacheItemPool.php create mode 100644 tests/Cache/FileSystemCacheItemPoolTest.php diff --git a/README.md b/README.md index 63bcfeaa2..ce23622af 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,25 @@ $memoryCache = new MemoryCacheItemPool; $middleware = ApplicationDefaultCredentials::getCredentials($scope, cache: $memoryCache); ``` +### FileSystemCacheItemPool Cache +The `FileSystemCacheItemPool` class is a `PSR-6` compliant cache that stores its +serialized objects on disk, caching data between processes and making it possible +to use data between different requests. + +```php +use Google\Auth\Cache\FileSystemCacheItemPool; +use Google\Auth\ApplicationDefaultCredentials; + +// Create a Cache pool instance +$cache = new FileSystemCacheItemPool(__DIR__ . '/cache'); + +// Pass your Cache to the Auth Library +$credentials = ApplicationDefaultCredentials::getCredentials($scope, cache: $cache); + +// This token will be cached and be able to be used for the next request +$token = $credentials->fetchAuthToken(); +``` + ### Integrating with a third party cache You can use a third party that follows the `PSR-6` interface of your choice. diff --git a/src/Cache/FileSystemCacheItemPool.php b/src/Cache/FileSystemCacheItemPool.php new file mode 100644 index 000000000..ee0651a4e --- /dev/null +++ b/src/Cache/FileSystemCacheItemPool.php @@ -0,0 +1,230 @@ + + */ + private array $buffer = []; + + /** + * Creates a FileSystemCacheItemPool cache that stores values in local storage + * + * @param string $path The string representation of the path where the cache will store the serialized objects. + */ + public function __construct(string $path) + { + $this->cachePath = $path; + + if (is_dir($this->cachePath)) { + return; + } + + if (!mkdir($this->cachePath)) { + throw new ErrorException("Cache folder couldn't be created."); + } + } + + /** + * {@inheritdoc} + */ + public function getItem(string $key): CacheItemInterface + { + if (!$this->validKey($key)) { + throw new InvalidArgumentException("The key '$key' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|"); + } + + $item = new TypedItem($key); + + $itemPath = $this->cacheFilePath($key); + + if (!file_exists($itemPath)) { + return $item; + } + + $serializedItem = file_get_contents($itemPath); + + if ($serializedItem === false) { + return $item; + } + + $item->set(unserialize($serializedItem)); + + return $item; + } + + /** + * {@inheritdoc} + * + * @return iterable An iterable object containing all the + * A traversable collection of Cache Items keyed by the cache keys of + * each item. A Cache item will be returned for each key, even if that + * key is not found. However, if no keys are specified then an empty + * traversable MUST be returned instead. + */ + public function getItems(array $keys = []): iterable + { + $result = []; + + foreach ($keys as $key) { + $result[$key] = $this->getItem($key); + } + + return $result; + } + + /** + * {@inheritdoc} + */ + public function save(CacheItemInterface $item): bool + { + if (!$this->validKey($item->getKey())) { + return false; + } + + $itemPath = $this->cacheFilePath($item->getKey()); + $serializedItem = serialize($item->get()); + + $result = file_put_contents($itemPath, $serializedItem); + + // 0 bytes write is considered a successful operation + if ($result === false) { + return false; + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + /** + * {@inheritdoc} + */ + public function clear(): bool + { + $this->buffer = []; + + if (!is_dir($this->cachePath)) { + return false; + } + + $files = scandir($this->cachePath); + if (!$files) { + return false; + } + + foreach ($files as $fileName) { + if ($fileName === '.' || $fileName === '..') { + continue; + } + + if (!unlink($this->cachePath . '/' . $fileName)) { + return false; + } + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function deleteItem(string $key): bool + { + if (!$this->validKey($key)) { + throw new InvalidArgumentException("The key '$key' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|"); + } + + $itemPath = $this->cacheFilePath($key); + + if (!file_exists($itemPath)) { + return true; + } + + return unlink($itemPath); + } + + /** + * {@inheritdoc} + */ + public function deleteItems(array $keys): bool + { + $result = true; + + foreach ($keys as $key) { + if (!$this->deleteItem($key)) { + $result = false; + } + } + + return $result; + } + + /** + * {@inheritdoc} + */ + public function saveDeferred(CacheItemInterface $item): bool + { + array_push($this->buffer, $item); + + return true; + } + + /** + * {@inheritdoc} + */ + public function commit(): bool + { + $result = true; + + foreach ($this->buffer as $item) { + if (!$this->save($item)) { + $result = false; + } + } + + return $result; + } + + private function cacheFilePath(string $key): string + { + return $this->cachePath . '/' . $key; + } + + private function validKey(string $key): bool + { + return (bool) preg_match('|^[a-zA-Z0-9_\.]+$|', $key); + } +} diff --git a/tests/Cache/FileSystemCacheItemPoolTest.php b/tests/Cache/FileSystemCacheItemPoolTest.php new file mode 100644 index 000000000..a3214587a --- /dev/null +++ b/tests/Cache/FileSystemCacheItemPoolTest.php @@ -0,0 +1,220 @@ +', ',', '/', ' ', + ]; + + public function setUp(): void + { + $this->pool = new FileSystemCacheItemPool($this->defaultCacheDirectory); + } + + public function tearDown(): void + { + $files = scandir($this->defaultCacheDirectory); + + foreach($files as $fileName) { + if ($fileName === '.' || $fileName === '..') { + continue; + } + + unlink($this->defaultCacheDirectory . '/' . $fileName); + } + + rmdir($this->defaultCacheDirectory); + } + + public function testInstanceCreatesCacheFolder() + { + $this->assertTrue(file_exists($this->defaultCacheDirectory)); + $this->assertTrue(is_dir($this->defaultCacheDirectory)); + } + + public function testSaveAndGetItem() + { + $item = $this->getNewItem(); + $item->expiresAfter(60); + $this->pool->save($item); + $retrievedItem = $this->pool->getItem($item->getKey()); + + $this->assertTrue($retrievedItem->isHit()); + $this->assertEquals($retrievedItem->get(), $item->get()); + } + + public function testHasItem() + { + $item = $this->getNewItem(); + $this->assertFalse($this->pool->hasItem($item->getKey())); + $this->pool->save($item); + $this->assertTrue($this->pool->hasItem($item->getKey())); + } + + public function testDeleteItem() + { + $item = $this->getNewItem(); + $this->pool->save($item); + + $this->assertTrue($this->pool->deleteItem($item->getKey())); + $this->assertFalse($this->pool->hasItem($item->getKey())); + } + + public function testDeleteItems() + { + $items = [ + $this->getNewItem(), + $this->getNewItem('NewItem2'), + $this->getNewItem('NewItem3') + ]; + + foreach ($items as $item) { + $this->pool->save($item); + } + + $itemKeys = array_map(fn ($item) => $item->getKey(), $items); + + $result = $this->pool->deleteItems($itemKeys); + $this->assertTrue($result); + } + + public function testGetItems() + { + $items = [ + $this->getNewItem(), + $this->getNewItem('NewItem2'), + $this->getNewItem('NewItem3') + ]; + + foreach ($items as $item) { + $this->pool->save($item); + } + + $keys = array_map(fn ($item) => $item->getKey(), $items); + array_push($keys, 'NonExistant'); + + $retrievedItems = $this->pool->getItems($keys); + + foreach ($items as $item) { + $this->assertTrue($retrievedItems[$item->getKey()]->isHit()); + } + + $this->assertFalse($retrievedItems['NonExistant']->isHit()); + } + + public function testClear() + { + $item = $this->getNewItem(); + $this->pool->save($item); + $this->assertLessThan(scandir($this->defaultCacheDirectory), 2); + $this->pool->clear(); + // Clear removes all the files, but scandir returns `.` and `..` as files + $this->assertEquals(count(scandir($this->defaultCacheDirectory)), 2); + } + + public function testSaveDeferredAndCommit() + { + $item = $this->getNewItem(); + $this->pool->saveDeferred($item); + $this->assertFalse($this->pool->getItem($item->getKey())->isHit()); + + $this->pool->commit(); + $this->assertTrue($this->pool->getItem($item->getKey())->isHit()); + } + + /** + * @dataProvider provideInvalidChars + */ + public function testGetItemWithIncorrectKeyShouldThrowAnException($char) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("The key '$char' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|"); + $item = $this->getNewItem($char); + $this->pool->getItem($item->getKey()); + } + + /** + * @dataProvider provideInvalidChars + */ + public function testGetItemsWithIncorrectKeyShouldThrowAnException($char) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("The key '$char' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|"); + $item = $this->getNewItem($char); + $this->pool->getItems([$item->getKey()]); + } + + /** + * @dataProvider provideInvalidChars + */ + public function testHasItemWithIncorrectKeyShouldThrowAnException($char) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("The key '$char' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|"); + $item = $this->getNewItem($char); + $this->pool->hasItem($item->getKey()); + } + + /** + * @dataProvider provideInvalidChars + */ + public function testDeleteItemWithIncorrectKeyShouldThrowAnException($char) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("The key '$char' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|"); + $item = $this->getNewItem($char); + $this->pool->deleteItem($item->getKey()); + } + + /** + * @dataProvider provideInvalidChars + */ + public function testDeleteItemsWithIncorrectKeyShouldThrowAnException($char) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("The key '$char' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|"); + $item = $this->getNewItem($char); + $this->pool->deleteItems([$item->getKey()]); + } + + private function getNewItem(null|string $key = null): TypedItem + { + $item = new TypedItem($key ?? 'NewItem'); + $item->set('NewValue'); + + return $item; + } + + public function provideInvalidChars(): array + { + return array_map(fn ($char) => [$char], $this->invalidChars); + } +} From e0fa4ea774a65b0016735429f222ad5506f596af Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 26 Aug 2024 11:07:35 -0700 Subject: [PATCH 14/17] chore: replace cs and staticanalysis with reusable workflows (#573) --- .github/workflows/tests.yml | 28 ++++------------------------ .php-cs-fixer.dist.php | 25 ------------------------- 2 files changed, 4 insertions(+), 49 deletions(-) delete mode 100644 .php-cs-fixer.dist.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 61c1cf40a..2cbf61b44 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -51,31 +51,11 @@ jobs: run: vendor/bin/phpunit style: - runs-on: ubuntu-latest name: PHP Style Check - steps: - - uses: actions/checkout@v4 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - - name: Run Script - run: | - composer install - composer global require friendsofphp/php-cs-fixer:^3.0 - ~/.composer/vendor/bin/php-cs-fixer fix --dry-run --diff + uses: GoogleCloudPlatform/php-tools/.github/workflows/code-standards.yml@main staticanalysis: - runs-on: ubuntu-latest name: PHPStan Static Analysis - steps: - - uses: actions/checkout@v4 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - - name: Run Script - run: | - composer install - composer global require phpstan/phpstan:^1.8 - ~/.composer/vendor/bin/phpstan analyse --autoload-file tests/phpstan-autoload.php + uses: GoogleCloudPlatform/php-tools/.github/workflows/static-analysis.yml@main + with: + autoload-file: tests/phpstan-autoload.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php deleted file mode 100644 index 1e6dd1eff..000000000 --- a/.php-cs-fixer.dist.php +++ /dev/null @@ -1,25 +0,0 @@ -setRules([ - '@PSR2' => true, - 'array_syntax' => ['syntax' => 'short'], - 'concat_space' => ['spacing' => 'one'], - 'no_unused_imports' => true, - 'ordered_imports' => true, - 'new_with_braces' => true, - 'whitespace_after_comma_in_array' => true, - 'method_argument_space' => [ - 'keep_multiple_spaces_after_comma' => true, // for wordpress constants - 'on_multiline' => 'ignore', // consider removing this someday - ], - 'return_type_declaration' => [ - 'space_before' => 'none' - ], - 'single_quote' => true, - ]) - ->setFinder( - PhpCsFixer\Finder::create() - ->in(__DIR__) - ) -; From c94ee3a8635550dba7c46bdb508401b298928b46 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 26 Aug 2024 11:09:35 -0700 Subject: [PATCH 15/17] chore(docs): use shared doctum workflow (#574) --- .github/workflows/docs.yml | 40 +++++++++++++------------------------- 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 59674b926..e2741b146 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,32 +6,20 @@ on: tags: - "*" workflow_dispatch: + inputs: + tag: + description: 'Tag to release' + pull_request: + +permissions: + contents: write jobs: docs: - name: "Generate Project Documentation" - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: 8.1 - - name: Install Dependencies - uses: nick-invision/retry@v3 - with: - timeout_minutes: 10 - max_attempts: 3 - command: composer config repositories.sami vcs https://${{ secrets.GITHUB_TOKEN }}@github.com/jdpedrie/sami.git && composer require sami/sami:v4.2 && git reset --hard HEAD - - name: Generate Documentation - env: - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - run: .github/actions/docs/entrypoint.sh - - name: Deploy 🚀 - uses: JamesIves/github-pages-deploy-action@releases/v3 - with: - ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} - BRANCH: gh-pages - FOLDER: .docs + name: "Generate and Deploy Documentation" + uses: GoogleCloudPlatform/php-tools/.github/workflows/doctum.yml@main + with: + title: "Google Auth Library PHP Reference Documentation" + default_version: ${{ inputs.tag || github.head_ref || github.ref_name }} + dry_run: ${{ github.event_name == 'pull_request' }} + From 0c25599a91530b5847f129b271c536f75a7563f5 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 26 Aug 2024 11:33:48 -0700 Subject: [PATCH 16/17] chore(main): release 1.42.0 (#566) --- CHANGELOG.md | 8 ++++++++ VERSION | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d3928a13..f456a2107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ * [feat]: add support for Firebase v6.0 (#391) +## [1.42.0](https://github.com/googleapis/google-auth-library-php/compare/v1.41.0...v1.42.0) (2024-08-26) + + +### Features + +* Add a file system cache class ([#571](https://github.com/googleapis/google-auth-library-php/issues/571)) ([8555cb0](https://github.com/googleapis/google-auth-library-php/commit/8555cb063caa5571f80d9605969411b894ee6eb0)) +* Private key getters on service account credentials (https://github.com/googleapis/google-auth-library-php/pull/557) ([d2fa07b](https://github.com/googleapis/google-auth-library-php/commit/d2fa07b8a8edfa65c1bd732dac794c070e3451bc)) + ## [1.41.0](https://github.com/googleapis/google-auth-library-php/compare/v1.40.0...v1.41.0) (2024-07-10) diff --git a/VERSION b/VERSION index 7d47e5998..a50908ca3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.41.0 +1.42.0 From e10dc3fb43b6f76c717d10f553104431181a7686 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 26 Aug 2024 13:13:32 -0700 Subject: [PATCH 17/17] chore: cleanup docs job --- .github/actions/docs/entrypoint.sh | 11 ----------- .github/actions/docs/sami.php | 27 --------------------------- .github/workflows/docs.yml | 2 -- 3 files changed, 40 deletions(-) delete mode 100755 .github/actions/docs/entrypoint.sh delete mode 100644 .github/actions/docs/sami.php diff --git a/.github/actions/docs/entrypoint.sh b/.github/actions/docs/entrypoint.sh deleted file mode 100755 index 84f1a3967..000000000 --- a/.github/actions/docs/entrypoint.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -l - -apt-get update -apt-get install -y git -git fetch origin -git reset --hard HEAD - -mkdir .docs -mkdir .cache - -php vendor/bin/sami.php update .github/actions/docs/sami.php diff --git a/.github/actions/docs/sami.php b/.github/actions/docs/sami.php deleted file mode 100644 index df537ceef..000000000 --- a/.github/actions/docs/sami.php +++ /dev/null @@ -1,27 +0,0 @@ -files() - ->name('*.php') - ->exclude('vendor') - ->exclude('tests') - ->in($projectRoot); - -$versions = GitVersionCollection::create($projectRoot) - ->addFromTags('v1.*') - ->add('main', 'main branch'); - -return new Sami($iterator, [ - 'title' => 'Google Auth Library for PHP API Reference', - 'build_dir' => $projectRoot . '/.docs/%version%', - 'cache_dir' => $projectRoot . '/.cache/%version%', - 'remote_repository' => new GitHubRemoteRepository('googleapis/google-auth-library-php', $projectRoot), - 'versions' => $versions -]); diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e2741b146..84a1c6c5c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,8 +1,6 @@ name: Generate Documentation on: push: - branches: - - main tags: - "*" workflow_dispatch: