From 74f97678b8330803c0e40d3b87e9f1ebe4d224f2 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 4 Jun 2026 12:12:35 -0700 Subject: [PATCH 1/4] feat: add X509 credential source --- src/CredentialSource/X509Source.php | 136 ++++++++++++++++++ .../ExternalAccountCredentials.php | 8 ++ src/OAuth2.php | 25 +++- tests/CredentialSource/X509SourceTest.php | 92 ++++++++++++ tests/fixtures/fixtures8/cert_config.json | 8 ++ tests/fixtures/fixtures8/intermediate.crt | 3 + tests/fixtures/fixtures8/leaf.crt | 3 + tests/fixtures/fixtures8/leaf.key | 1 + 8 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 src/CredentialSource/X509Source.php create mode 100644 tests/CredentialSource/X509SourceTest.php create mode 100644 tests/fixtures/fixtures8/cert_config.json create mode 100644 tests/fixtures/fixtures8/intermediate.crt create mode 100644 tests/fixtures/fixtures8/leaf.crt create mode 100644 tests/fixtures/fixtures8/leaf.key diff --git a/src/CredentialSource/X509Source.php b/src/CredentialSource/X509Source.php new file mode 100644 index 0000000000..abc17aad91 --- /dev/null +++ b/src/CredentialSource/X509Source.php @@ -0,0 +1,136 @@ +keyPath = $config['cert_configs']['workload']['key_path']; + $this->certPath = $config['cert_configs']['workload']['cert_path']; + $this->trustChainPath = $trustChainPath; + + if (!file_exists($this->certPath)) { + throw new InvalidArgumentException('cert_path file does not exist: ' . $this->certPath); + } + if (!file_exists($this->keyPath)) { + throw new InvalidArgumentException('key_path file does not exist: ' . $this->keyPath); + } + if (!file_exists($this->trustChainPath)) { + // This is not a fatal error, the user may not have intermediate certs. + // The property will be an empty string. + $this->trustChainPath = ''; + } + } + + /** + * Implements the custom subject token generation from the user's script. + * + * The subject token is a JSON array containing the base64-encoded DER + * representation of the leaf and intermediate certs. + */ + public function fetchSubjectToken(?callable $httpHandler = null): string + { + $certsB64Der = []; + + $leafCert = file_get_contents($this->certPath); + if ($leafCert === false) { + throw new InvalidArgumentException('Unable to read leaf certificate file.'); + } + $certsB64Der[] = $this->pemToDerB64($leafCert); + + if ($this->trustChainPath && file_exists($this->trustChainPath)) { + $intermediates = file_get_contents($this->trustChainPath); + if ($intermediates === false) { + throw new InvalidArgumentException('Unable to read intermediate certificate file.'); + } + // The regex captures the full PEM blocks + preg_match_all( + '/' . self::BEGIN_CERT . '[\s\S]+?' . self::END_CERT . '/', + $intermediates, + $matches + ); + + foreach ($matches[0] as $pem) { + $certsB64Der[] = $this->pemToDerB64($pem); + } + } + + $jsonCertArray = json_encode($certsB64Der); + if ($jsonCertArray === false) { + throw new RuntimeException('Failed to encode certificate array to JSON.'); + } + return $jsonCertArray; + } + + public function getCacheKey(): ?string + { + return null; + } + + public function getCertPath(): string + { + return $this->certPath; + } + + public function getKeyPath(): string + { + return $this->keyPath; + } + + private function pemToDerB64(string $pem): string + { + $pattern = '/' . self::BEGIN_CERT . '\s*(.*?)\s*' . self::END_CERT . '/s'; + if (preg_match($pattern, $pem, $matches)) { + $base64 = str_replace(["\n", "\r"], '', $matches[1]); + $der = base64_decode($base64); + if ($der === false) { + throw new RuntimeException('Failed to base64-decode the certificate content.'); + } + return base64_encode($der); + } + throw new RuntimeException('Failed to parse PEM certificate.'); + } +} diff --git a/src/Credentials/ExternalAccountCredentials.php b/src/Credentials/ExternalAccountCredentials.php index afaf1ee3f8..edb677fa46 100644 --- a/src/Credentials/ExternalAccountCredentials.php +++ b/src/Credentials/ExternalAccountCredentials.php @@ -21,6 +21,7 @@ use Google\Auth\CredentialSource\ExecutableSource; use Google\Auth\CredentialSource\FileSource; use Google\Auth\CredentialSource\UrlSource; +use Google\Auth\CredentialSource\X509Source; use Google\Auth\ExecutableHandler\ExecutableHandler; use Google\Auth\ExternalAccountCredentialSourceInterface; use Google\Auth\FetchAuthTokenInterface; @@ -217,6 +218,13 @@ private static function buildCredentialSource(array $jsonKey): ExternalAccountCr ); } + if (isset($credentialSource['certificate'])) { + return new X509Source( + $credentialSource['certificate']['trust_chain_path'], + $credentialSource['certificate']['certificate_config_location'] + ); + } + throw new InvalidArgumentException('Unable to determine credential source from json key.'); } diff --git a/src/OAuth2.php b/src/OAuth2.php index ced3464ca6..5ae6369808 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -19,6 +19,7 @@ use Firebase\JWT\JWT; use Firebase\JWT\Key; +use Google\Auth\CredentialSource\X509Source; use Google\Auth\HttpHandler\HttpClientCache; use Google\Auth\HttpHandler\HttpHandlerFactory; use GuzzleHttp\Psr7\Query; @@ -42,7 +43,8 @@ class OAuth2 implements FetchAuthTokenInterface const DEFAULT_SKEW_SECONDS = 60; // 1 minute const JWT_URN = 'urn:ietf:params:oauth:grant-type:jwt-bearer'; const STS_URN = 'urn:ietf:params:oauth:grant-type:token-exchange'; - private const STS_REQUESTED_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'; + private const TOKEN_TYPE_ACCESS_TOKEN = 'urn:ietf:params:oauth:token-type:access_token'; + private const TOKEN_TYPE_MTLS = 'urn:ietf:params:oauth:token-type:mtls'; /** * TODO: determine known methods from the keys of JWT::methods. @@ -627,7 +629,7 @@ public function generateCredentialsRequest(?callable $httpHandler = null, array 'resource' => $this->resource, 'audience' => $this->audience, 'scope' => $this->getScope(), - 'requested_token_type' => self::STS_REQUESTED_TOKEN_TYPE, + 'requested_token_type' => self::TOKEN_TYPE_ACCESS_TOKEN, 'actor_token' => $this->actorToken, 'actor_token_type' => $this->actorTokenType, ]); @@ -661,6 +663,20 @@ public function generateCredentialsRequest(?callable $httpHandler = null, array ); } + private function generateCredentialsRequestOptions(): array + { + if ($this->subjectTokenType === self::TOKEN_TYPE_MTLS + && $this->subjectTokenFetcher instanceof X509Source + ) { + return [ + 'cert' => $this->subjectTokenFetcher->getCertPath(), + 'ssl_key' => $this->subjectTokenFetcher->getKeyPath(), + ]; + } + + return []; + } + /** * Fetches the auth tokens based on the current state. * @@ -675,7 +691,10 @@ public function fetchAuthToken(?callable $httpHandler = null, array $headers = [ $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); } - $response = $httpHandler($this->generateCredentialsRequest($httpHandler, $headers)); + $response = $httpHandler( + $this->generateCredentialsRequest($httpHandler, $headers), + $this->generateCredentialsRequestOptions() + ); $credentials = $this->parseTokenResponse($response); $this->updateToken($credentials); if (isset($credentials['scope'])) { diff --git a/tests/CredentialSource/X509SourceTest.php b/tests/CredentialSource/X509SourceTest.php new file mode 100644 index 0000000000..52a7e69f1e --- /dev/null +++ b/tests/CredentialSource/X509SourceTest.php @@ -0,0 +1,92 @@ +x509Json = [ + 'type' => 'external_account', + 'audience' => '//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/pool/providers/provider', + 'subject_token_type' => 'urn:ietf:params:oauth:token-type:mtls', + 'token_url' => 'https://sts.mtls.googleapis.com/v1/token', + 'credential_source' => [ + 'certificate' => [ + 'certificate_config_location' => __DIR__ . '/../fixtures/fixtures8/cert_config.json', + 'trust_chain_path' => __DIR__ . '/../fixtures/fixtures8/intermediate.crt', + ] + ] + ]; + } + + public function testPemToDerB64ConversionIsCorrect() + { + // Get the expected output. We know that for a PEM file, the content + // between the headers is the base64-encoded DER. Our pemToDerB64 function + // decodes this, then re-encodes it. So the output should be the same as the input. + $pemContent = file_get_contents(__DIR__ . '/../fixtures/fixtures8/leaf.crt'); + preg_match('/-----BEGIN CERTIFICATE-----\s*(.*?)\s*-----END CERTIFICATE-----/s', $pemContent, $matches); + $expectedDerB64 = base64_encode(base64_decode($matches[1])); + + // Get the actual output from our PHP function + $source = new X509Source( + $this->x509Json['credential_source']['certificate']['trust_chain_path'], + $this->x509Json['credential_source']['certificate']['certificate_config_location'] + ); + $pemToDerB64 = new ReflectionMethod(X509Source::class, 'pemToDerB64'); + $pemToDerB64->setAccessible(true); + $actualDerB64 = $pemToDerB64->invoke($source, $pemContent); + + // Assert they are identical + $this->assertEquals($expectedDerB64, $actualDerB64); + } + + public function testFetchSubjectTokenFormatIsCorrect() + { + // Calculate the expected base64(DER) strings + $leafPem = file_get_contents(__DIR__ . '/../fixtures/fixtures8/leaf.crt'); + preg_match('/-----BEGIN CERTIFICATE-----\s*(.*?)\s*-----END CERTIFICATE-----/s', $leafPem, $leafMatches); + $leafDerB64 = base64_encode(base64_decode($leafMatches[1])); + + $intermediatePem = file_get_contents(__DIR__ . '/../fixtures/fixtures8/intermediate.crt'); + preg_match('/-----BEGIN CERTIFICATE-----\s*(.*?)\s*-----END CERTIFICATE-----/s', $intermediatePem, $intermediateMatches); + $intermediateDerB64 = base64_encode(base64_decode($intermediateMatches[1])); + + // Construct the expected final JSON string + $expectedJson = json_encode([$leafDerB64, $intermediateDerB64]); + + // Get the actual subject token from our PHP code + $creds = new ExternalAccountCredentials('scope', $this->x509Json); + $reflection = new \ReflectionClass($creds); + $authProperty = $reflection->getProperty('auth'); + $authProperty->setAccessible(true); + $auth = $authProperty->getValue($creds); + $actualSubjectToken = $auth->getSubjectTokenFetcher()->fetchSubjectToken(); + + // Assert they are identical + $this->assertEquals($expectedJson, $actualSubjectToken); + } +} diff --git a/tests/fixtures/fixtures8/cert_config.json b/tests/fixtures/fixtures8/cert_config.json new file mode 100644 index 0000000000..c4a0ae02cc --- /dev/null +++ b/tests/fixtures/fixtures8/cert_config.json @@ -0,0 +1,8 @@ +{ + "cert_configs": { + "workload": { + "key_path": "tests/fixtures/fixtures8/leaf.key", + "cert_path": "tests/fixtures/fixtures8/leaf.crt" + } + } +} diff --git a/tests/fixtures/fixtures8/intermediate.crt b/tests/fixtures/fixtures8/intermediate.crt new file mode 100644 index 0000000000..d0adf6ad93 --- /dev/null +++ b/tests/fixtures/fixtures8/intermediate.crt @@ -0,0 +1,3 @@ +-----BEGIN CERTIFICATE----- +ZHVtbXkgaW50ZXJtZWRpYXRl +-----END CERTIFICATE----- diff --git a/tests/fixtures/fixtures8/leaf.crt b/tests/fixtures/fixtures8/leaf.crt new file mode 100644 index 0000000000..e7196eba66 --- /dev/null +++ b/tests/fixtures/fixtures8/leaf.crt @@ -0,0 +1,3 @@ +-----BEGIN CERTIFICATE----- +ZHVtbXkgbGVhZg== +-----END CERTIFICATE----- diff --git a/tests/fixtures/fixtures8/leaf.key b/tests/fixtures/fixtures8/leaf.key new file mode 100644 index 0000000000..1d6cb035b6 --- /dev/null +++ b/tests/fixtures/fixtures8/leaf.key @@ -0,0 +1 @@ +dummy key \ No newline at end of file From 937442e499b3d11ea4963f912dd934a2423704ff Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 4 Jun 2026 14:49:00 -0700 Subject: [PATCH 2/4] fix phpstan and cs --- src/CredentialSource/X509Source.php | 8 +++++--- src/Credentials/ExternalAccountCredentials.php | 7 ++++++- src/OAuth2.php | 3 +++ tests/CredentialSource/X509SourceTest.php | 2 +- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/CredentialSource/X509Source.php b/src/CredentialSource/X509Source.php index abc17aad91..9756168de7 100644 --- a/src/CredentialSource/X509Source.php +++ b/src/CredentialSource/X509Source.php @@ -38,12 +38,14 @@ class X509Source implements ExternalAccountCredentialSourceInterface private string $keyPath; private string $certPath; - public function __construct(string $trustChainPath, string $certificateConfigLocation) - { + public function __construct( + string $trustChainPath, + string $certificateConfigLocation + ) { if (!file_exists($certificateConfigLocation)) { throw new InvalidArgumentException('Certificate config file does not exist'); } - $config = json_decode(file_get_contents($certificateConfigLocation), true); + $config = json_decode((string) file_get_contents($certificateConfigLocation), true); if (!isset($config['cert_configs']['workload']['key_path']) || !isset($config['cert_configs']['workload']['cert_path'])) { throw new InvalidArgumentException('Certificate config is invalid'); } diff --git a/src/Credentials/ExternalAccountCredentials.php b/src/Credentials/ExternalAccountCredentials.php index edb677fa46..5862a3dbb1 100644 --- a/src/Credentials/ExternalAccountCredentials.php +++ b/src/Credentials/ExternalAccountCredentials.php @@ -219,8 +219,13 @@ private static function buildCredentialSource(array $jsonKey): ExternalAccountCr } if (isset($credentialSource['certificate'])) { + if (!array_key_exists('certificate_config_location', $credentialSource['certificate'])) { + throw new InvalidArgumentException( + 'x509 source requires a certificate_config_location to be set in the JSON file.' + ); + } return new X509Source( - $credentialSource['certificate']['trust_chain_path'], + $credentialSource['certificate']['trust_chain_path'] ?? '', $credentialSource['certificate']['certificate_config_location'] ); } diff --git a/src/OAuth2.php b/src/OAuth2.php index 5ae6369808..80d3a611da 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -663,6 +663,9 @@ public function generateCredentialsRequest(?callable $httpHandler = null, array ); } + /** + * @return array{cert?: string, ssl_key?: string} + */ private function generateCredentialsRequestOptions(): array { if ($this->subjectTokenType === self::TOKEN_TYPE_MTLS diff --git a/tests/CredentialSource/X509SourceTest.php b/tests/CredentialSource/X509SourceTest.php index 52a7e69f1e..fea782df05 100644 --- a/tests/CredentialSource/X509SourceTest.php +++ b/tests/CredentialSource/X509SourceTest.php @@ -17,8 +17,8 @@ namespace Google\Auth\Tests\CredentialSource; -use Google\Auth\CredentialSource\X509Source; use Google\Auth\Credentials\ExternalAccountCredentials; +use Google\Auth\CredentialSource\X509Source; use PHPUnit\Framework\TestCase; use ReflectionMethod; From e2a8c41bfb6a218058bdddd077a3e21f819f0f37 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 5 Jun 2026 15:51:01 -0700 Subject: [PATCH 3/4] misc cleanup and add tests --- src/CredentialSource/X509Source.php | 16 ++-- .../ExternalAccountCredentials.php | 4 +- tests/CredentialSource/X509SourceTest.php | 83 ++++++++++++++++++- 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/src/CredentialSource/X509Source.php b/src/CredentialSource/X509Source.php index 9756168de7..8e72b4d284 100644 --- a/src/CredentialSource/X509Source.php +++ b/src/CredentialSource/X509Source.php @@ -34,25 +34,25 @@ class X509Source implements ExternalAccountCredentialSourceInterface private const BEGIN_CERT = '-----BEGIN CERTIFICATE-----'; private const END_CERT = '-----END CERTIFICATE-----'; - private string $trustChainPath; private string $keyPath; private string $certPath; public function __construct( - string $trustChainPath, - string $certificateConfigLocation + string $certificateConfigLocation, + private string|null $trustChainPath, ) { if (!file_exists($certificateConfigLocation)) { throw new InvalidArgumentException('Certificate config file does not exist'); } $config = json_decode((string) file_get_contents($certificateConfigLocation), true); - if (!isset($config['cert_configs']['workload']['key_path']) || !isset($config['cert_configs']['workload']['cert_path'])) { + if (!isset($config['cert_configs']['workload']['key_path']) + || !isset($config['cert_configs']['workload']['cert_path']) + ) { throw new InvalidArgumentException('Certificate config is invalid'); } $this->keyPath = $config['cert_configs']['workload']['key_path']; $this->certPath = $config['cert_configs']['workload']['cert_path']; - $this->trustChainPath = $trustChainPath; if (!file_exists($this->certPath)) { throw new InvalidArgumentException('cert_path file does not exist: ' . $this->certPath); @@ -60,10 +60,8 @@ public function __construct( if (!file_exists($this->keyPath)) { throw new InvalidArgumentException('key_path file does not exist: ' . $this->keyPath); } - if (!file_exists($this->trustChainPath)) { - // This is not a fatal error, the user may not have intermediate certs. - // The property will be an empty string. - $this->trustChainPath = ''; + if ($this->trustChainPath && !file_exists($this->trustChainPath)) { + throw new InvalidArgumentException('Trust chain path is invalid'); } } diff --git a/src/Credentials/ExternalAccountCredentials.php b/src/Credentials/ExternalAccountCredentials.php index 5862a3dbb1..eb83d79fd8 100644 --- a/src/Credentials/ExternalAccountCredentials.php +++ b/src/Credentials/ExternalAccountCredentials.php @@ -225,8 +225,8 @@ private static function buildCredentialSource(array $jsonKey): ExternalAccountCr ); } return new X509Source( - $credentialSource['certificate']['trust_chain_path'] ?? '', - $credentialSource['certificate']['certificate_config_location'] + $credentialSource['certificate']['certificate_config_location'], + $credentialSource['certificate']['trust_chain_path'] ?? null, ); } diff --git a/tests/CredentialSource/X509SourceTest.php b/tests/CredentialSource/X509SourceTest.php index fea782df05..527d362acd 100644 --- a/tests/CredentialSource/X509SourceTest.php +++ b/tests/CredentialSource/X509SourceTest.php @@ -19,6 +19,7 @@ use Google\Auth\Credentials\ExternalAccountCredentials; use Google\Auth\CredentialSource\X509Source; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; use ReflectionMethod; @@ -53,8 +54,8 @@ public function testPemToDerB64ConversionIsCorrect() // Get the actual output from our PHP function $source = new X509Source( + $this->x509Json['credential_source']['certificate']['certificate_config_location'], $this->x509Json['credential_source']['certificate']['trust_chain_path'], - $this->x509Json['credential_source']['certificate']['certificate_config_location'] ); $pemToDerB64 = new ReflectionMethod(X509Source::class, 'pemToDerB64'); $pemToDerB64->setAccessible(true); @@ -89,4 +90,84 @@ public function testFetchSubjectTokenFormatIsCorrect() // Assert they are identical $this->assertEquals($expectedJson, $actualSubjectToken); } + + public function testConstructorThrowsExceptionWhenConfigDoesNotExist() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Certificate config file does not exist'); + + new X509Source('nonexistent_config_file.json', null); + } + + public function testConstructorThrowsExceptionWhenConfigIsInvalid() + { + $tmpFile = tempnam(sys_get_temp_dir(), 'gauth_'); + file_put_contents($tmpFile, json_encode(['foo' => 'bar'])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Certificate config is invalid'); + + try { + new X509Source($tmpFile, null); + } finally { + unlink($tmpFile); + } + } + + public function testConstructorThrowsExceptionWhenCertFileDoesNotExist() + { + $tmpFile = tempnam(sys_get_temp_dir(), 'gauth_'); + $config = [ + 'cert_configs' => [ + 'workload' => [ + 'key_path' => 'nonexistent_key_path.key', + 'cert_path' => 'nonexistent_cert_path.crt', + ] + ] + ]; + file_put_contents($tmpFile, json_encode($config)); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('cert_path file does not exist: nonexistent_cert_path.crt'); + + try { + new X509Source($tmpFile, null); + } finally { + unlink($tmpFile); + } + } + + public function testConstructorThrowsExceptionWhenKeyFileDoesNotExist() + { + $tmpFile = tempnam(sys_get_temp_dir(), 'gauth_'); + $config = [ + 'cert_configs' => [ + 'workload' => [ + 'key_path' => 'nonexistent_key_path.key', + 'cert_path' => __DIR__ . '/../fixtures/fixtures8/leaf.crt', + ] + ] + ]; + file_put_contents($tmpFile, json_encode($config)); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('key_path file does not exist: nonexistent_key_path.key'); + + try { + new X509Source($tmpFile, null); + } finally { + unlink($tmpFile); + } + } + + public function testConstructorThrowsExceptionWhenTrustChainPathIsInvalid() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Trust chain path is invalid'); + + new X509Source( + __DIR__ . '/../fixtures/fixtures8/cert_config.json', + 'nonexistent_trust_chain.crt' + ); + } } From 77dd36c44d50fbc87229f071ac189db1ced10ee0 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 9 Jun 2026 18:47:19 +0000 Subject: [PATCH 4/4] updating deps to fix prefer-lowest test --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 4f7c44a9b0..8d367b9f74 100644 --- a/composer.json +++ b/composer.json @@ -20,14 +20,14 @@ "require-dev": { "guzzlehttp/promises": "^2.0", "squizlabs/php_codesniffer": "^4.0", - "phpunit/phpunit": "^9.6", + "phpunit/phpunit": "^9.6.34", "phpspec/prophecy-phpunit": "^2.1", "sebastian/comparator": ">=1.2.3", "phpseclib/phpseclib": "^3.0.35", "kelvinmo/simplejwt": "^1.1.0", "webmozart/assert": "^1.11||^2.0", - "symfony/process": "^6.0||^7.0", - "symfony/filesystem": "^6.3||^7.3" + "symfony/process": "^6.4.41||^7.0", + "symfony/filesystem": "^6.4.39||^7.3" }, "suggest": { "phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2."