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." diff --git a/src/CredentialSource/X509Source.php b/src/CredentialSource/X509Source.php new file mode 100644 index 0000000000..8e72b4d284 --- /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']; + + 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 ($this->trustChainPath && !file_exists($this->trustChainPath)) { + throw new InvalidArgumentException('Trust chain path is invalid'); + } + } + + /** + * 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..eb83d79fd8 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,18 @@ 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']['certificate_config_location'], + $credentialSource['certificate']['trust_chain_path'] ?? null, + ); + } + throw new InvalidArgumentException('Unable to determine credential source from json key.'); } diff --git a/src/OAuth2.php b/src/OAuth2.php index ced3464ca6..80d3a611da 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,23 @@ 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 + && $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 +694,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..527d362acd --- /dev/null +++ b/tests/CredentialSource/X509SourceTest.php @@ -0,0 +1,173 @@ +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']['certificate_config_location'], + $this->x509Json['credential_source']['certificate']['trust_chain_path'], + ); + $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); + } + + 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' + ); + } +} 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