├── VERSION ├── .repo-metadata.json ├── SECURITY.md ├── src ├── HttpHandler │ ├── Guzzle7HttpHandler.php │ ├── HttpClientCache.php │ ├── HttpHandlerFactory.php │ └── Guzzle6HttpHandler.php ├── Cache │ ├── InvalidArgumentException.php │ ├── TypedItem.php │ ├── MemoryCacheItemPool.php │ ├── FileSystemCacheItemPool.php │ └── SysVCacheItemPool.php ├── ExternalAccountCredentialSourceInterface.php ├── ExecutableHandler │ ├── ExecutableResponseError.php │ └── ExecutableHandler.php ├── GetQuotaProjectInterface.php ├── ProjectIdProviderInterface.php ├── GetUniverseDomainInterface.php ├── UpdateMetadataInterface.php ├── SignBlobInterface.php ├── FetchAuthTokenInterface.php ├── ServiceAccountSignerTrait.php ├── Credentials │ ├── InsecureCredentials.php │ ├── IAMCredentials.php │ ├── UserRefreshCredentials.php │ ├── AppIdentityCredentials.php │ ├── ServiceAccountJwtAccessCredentials.php │ └── ImpersonatedServiceAccountCredentials.php ├── Logging │ ├── StdOutLogger.php │ ├── RpcLogEvent.php │ └── LoggingTrait.php ├── GCECache.php ├── IamSignerTrait.php ├── UpdateMetadataTrait.php ├── CredentialSource │ ├── FileSource.php │ ├── UrlSource.php │ └── ExecutableSource.php ├── CacheTrait.php ├── Middleware │ ├── SimpleMiddleware.php │ ├── ProxyAuthTokenMiddleware.php │ ├── ScopedAccessTokenMiddleware.php │ └── AuthTokenMiddleware.php ├── MetricsTrait.php ├── Iam.php ├── FetchAuthTokenCache.php └── CredentialsLoader.php ├── composer.json ├── LICENSE └── COPYING /VERSION: -------------------------------------------------------------------------------- 1 | 1.49.0 2 | -------------------------------------------------------------------------------- /.repo-metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "php", 3 | "distribution_name": "google/auth", 4 | "release_level": "stable", 5 | "client_documentation": "https://cloud.google.com/php/docs/reference/auth/latest", 6 | "library_type": "CORE" 7 | } 8 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | To report a security issue, please use [g.co/vulnz](https://g.co/vulnz). 4 | 5 | The Google Security Team will respond within 5 working days of your report on g.co/vulnz. 6 | 7 | We use g.co/vulnz for our intake, and do coordination and disclosure here using GitHub Security Advisory to privately discuss and fix the issue. 8 | -------------------------------------------------------------------------------- /src/HttpHandler/Guzzle7HttpHandler.php: -------------------------------------------------------------------------------- 1 | $metadata metadata hashmap 32 | * @param string $authUri optional auth uri 33 | * @param callable|null $httpHandler callback which delivers psr7 request 34 | * @return array updated metadata hashmap 35 | */ 36 | public function updateMetadata( 37 | $metadata, 38 | $authUri = null, 39 | ?callable $httpHandler = null 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "google/auth", 3 | "type": "library", 4 | "description": "Google Auth Library for PHP", 5 | "keywords": ["google", "oauth2", "authentication"], 6 | "homepage": "https://github.com/google/google-auth-library-php", 7 | "license": "Apache-2.0", 8 | "support": { 9 | "docs": "https://cloud.google.com/php/docs/reference/auth/latest" 10 | }, 11 | "require": { 12 | "php": "^8.1", 13 | "firebase/php-jwt": "^6.0", 14 | "guzzlehttp/guzzle": "^7.4.5", 15 | "guzzlehttp/psr7": "^2.4.5", 16 | "psr/http-message": "^1.1||^2.0", 17 | "psr/cache": "^2.0||^3.0", 18 | "psr/log": "^3.0" 19 | }, 20 | "require-dev": { 21 | "guzzlehttp/promises": "^2.0", 22 | "squizlabs/php_codesniffer": "^4.0", 23 | "phpunit/phpunit": "^9.6", 24 | "phpspec/prophecy-phpunit": "^2.1", 25 | "sebastian/comparator": ">=1.2.3", 26 | "phpseclib/phpseclib": "^3.0.35", 27 | "kelvinmo/simplejwt": "0.7.1", 28 | "webmozart/assert": "^1.11", 29 | "symfony/process": "^6.0||^7.0", 30 | "symfony/filesystem": "^6.3||^7.3" 31 | }, 32 | "suggest": { 33 | "phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2." 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "Google\\Auth\\": "src" 38 | } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "Google\\Auth\\Tests\\": "tests" 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/HttpHandler/HttpClientCache.php: -------------------------------------------------------------------------------- 1 | a hash of auth tokens 30 | */ 31 | public function fetchAuthToken(?callable $httpHandler = null); 32 | 33 | /** 34 | * Obtains a key that can used to cache the results of #fetchAuthToken. 35 | * 36 | * If the value is empty, the auth token is not cached. 37 | * 38 | * @return string a key that may be used to cache the auth token. 39 | */ 40 | public function getCacheKey(); 41 | 42 | /** 43 | * Returns an associative array with the token and 44 | * expiration time. 45 | * 46 | * @return null|array { 47 | * The last received access token. 48 | * 49 | * @type string $access_token The access token string. 50 | * @type int $expires_at The time the token expires as a UNIX timestamp. 51 | * } 52 | */ 53 | public function getLastReceivedToken(); 54 | } 55 | -------------------------------------------------------------------------------- /src/ServiceAccountSignerTrait.php: -------------------------------------------------------------------------------- 1 | auth->getSigningKey(); 39 | 40 | $signedString = ''; 41 | if (class_exists(phpseclib3\Crypt\RSA::class) && !$forceOpenssl) { 42 | $key = PublicKeyLoader::load($privateKey); 43 | $rsa = $key->withHash('sha256')->withPadding(RSA::SIGNATURE_PKCS1); 44 | 45 | $signedString = $rsa->sign($stringToSign); 46 | } elseif (extension_loaded('openssl')) { 47 | openssl_sign($stringToSign, $signedString, $privateKey, 'sha256WithRSAEncryption'); 48 | } else { 49 | // @codeCoverageIgnoreStart 50 | throw new \RuntimeException('OpenSSL is not installed.'); 51 | } 52 | // @codeCoverageIgnoreEnd 53 | 54 | return base64_encode($signedString); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Credentials/InsecureCredentials.php: -------------------------------------------------------------------------------- 1 | '' 34 | ]; 35 | 36 | /** 37 | * Fetches the auth token. In this case it returns an empty string. 38 | * 39 | * @param callable|null $httpHandler 40 | * @return array{access_token:string} A set of auth related metadata 41 | */ 42 | public function fetchAuthToken(?callable $httpHandler = null) 43 | { 44 | return $this->token; 45 | } 46 | 47 | /** 48 | * Returns the cache key. In this case it returns a null value, disabling 49 | * caching. 50 | * 51 | * @return string|null 52 | */ 53 | public function getCacheKey() 54 | { 55 | return null; 56 | } 57 | 58 | /** 59 | * Fetches the last received token. In this case, it returns the same empty string 60 | * auth token. 61 | * 62 | * @return array{access_token:string} 63 | */ 64 | public function getLastReceivedToken() 65 | { 66 | return $this->token; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Logging/StdOutLogger.php: -------------------------------------------------------------------------------- 1 | 37 | */ 38 | private array $levelMapping = [ 39 | LogLevel::EMERGENCY => 7, 40 | LogLevel::ALERT => 6, 41 | LogLevel::CRITICAL => 5, 42 | LogLevel::ERROR => 4, 43 | LogLevel::WARNING => 3, 44 | LogLevel::NOTICE => 2, 45 | LogLevel::INFO => 1, 46 | LogLevel::DEBUG => 0, 47 | ]; 48 | private int $level; 49 | 50 | /** 51 | * Constructs a basic PSR-3 logger class that logs into StdOut for GCP Logging 52 | * 53 | * @param string $level The level of the logger instance. 54 | */ 55 | public function __construct(string $level = LogLevel::DEBUG) 56 | { 57 | $this->level = $this->getLevelFromName($level); 58 | } 59 | 60 | /** 61 | * {@inheritdoc} 62 | */ 63 | public function log($level, string|Stringable $message, array $context = []): void 64 | { 65 | if ($this->getLevelFromName($level) < $this->level) { 66 | return; 67 | } 68 | 69 | print($message . "\n"); 70 | } 71 | 72 | /** 73 | * @param string $levelName 74 | * @return int 75 | * @throws InvalidArgumentException 76 | */ 77 | private function getLevelFromName(string $levelName): int 78 | { 79 | if (!array_key_exists($levelName, $this->levelMapping)) { 80 | throw new InvalidArgumentException('The level supplied to the Logger is not valid'); 81 | } 82 | 83 | return $this->levelMapping[$levelName]; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/ExecutableHandler/ExecutableHandler.php: -------------------------------------------------------------------------------- 1 | */ 30 | private array $env = []; 31 | 32 | private ?string $output = null; 33 | 34 | /** 35 | * @param array $env 36 | */ 37 | public function __construct( 38 | array $env = [], 39 | int $timeoutMs = self::DEFAULT_EXECUTABLE_TIMEOUT_MILLIS, 40 | ) { 41 | if (!class_exists(Process::class)) { 42 | throw new RuntimeException(sprintf( 43 | 'The "symfony/process" package is required to use %s.', 44 | self::class 45 | )); 46 | } 47 | $this->env = $env; 48 | $this->timeoutMs = $timeoutMs; 49 | } 50 | 51 | /** 52 | * @param string $command 53 | * @return int 54 | */ 55 | public function __invoke(string $command): int 56 | { 57 | $process = Process::fromShellCommandline( 58 | $command, 59 | null, 60 | $this->env, 61 | null, 62 | ($this->timeoutMs / 1000) 63 | ); 64 | 65 | try { 66 | $process->run(); 67 | } catch (ProcessTimedOutException $e) { 68 | throw new ExecutableResponseError( 69 | 'The executable failed to finish within the timeout specified.', 70 | 'TIMEOUT_EXCEEDED' 71 | ); 72 | } 73 | 74 | $this->output = $process->getOutput() . $process->getErrorOutput(); 75 | 76 | return $process->getExitCode(); 77 | } 78 | 79 | public function getOutput(): ?string 80 | { 81 | return $this->output; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/GCECache.php: -------------------------------------------------------------------------------- 1 | $cacheConfig Configuration for the cache 46 | * @param CacheItemPoolInterface $cache 47 | */ 48 | public function __construct( 49 | ?array $cacheConfig = null, 50 | ?CacheItemPoolInterface $cache = null 51 | ) { 52 | $this->cache = $cache; 53 | $this->cacheConfig = array_merge([ 54 | 'lifetime' => 1500, 55 | 'prefix' => '', 56 | ], (array) $cacheConfig); 57 | } 58 | 59 | /** 60 | * Caches the result of onGce so the metadata server is not called multiple 61 | * times. 62 | * 63 | * @param callable|null $httpHandler callback which delivers psr7 request 64 | * @return bool True if this a GCEInstance, false otherwise 65 | */ 66 | public function onGce(?callable $httpHandler = null) 67 | { 68 | if (is_null($this->cache)) { 69 | return GCECredentials::onGce($httpHandler); 70 | } 71 | 72 | $cacheKey = self::GCE_CACHE_KEY; 73 | $onGce = $this->getCachedValue($cacheKey); 74 | 75 | if (is_null($onGce)) { 76 | $onGce = GCECredentials::onGce($httpHandler); 77 | $this->setCachedValue($cacheKey, $onGce); 78 | } 79 | 80 | return $onGce; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/IamSignerTrait.php: -------------------------------------------------------------------------------- 1 | iam; 55 | if (!$signer) { 56 | $signer = $this instanceof GetUniverseDomainInterface 57 | ? new Iam($httpHandler, $this->getUniverseDomain()) 58 | : new Iam($httpHandler); 59 | } 60 | 61 | $email = $this->getClientName($httpHandler); 62 | 63 | if (is_null($accessToken)) { 64 | $previousToken = $this->getLastReceivedToken(); 65 | $accessToken = $previousToken 66 | ? $previousToken['access_token'] 67 | : $this->fetchAuthToken($httpHandler)['access_token']; 68 | } 69 | 70 | return $signer->signBlob($email, $accessToken, $stringToSign); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/UpdateMetadataTrait.php: -------------------------------------------------------------------------------- 1 | $metadata metadata hashmap 46 | * @param string $authUri optional auth uri 47 | * @param callable|null $httpHandler callback which delivers psr7 request 48 | * @return array updated metadata hashmap 49 | */ 50 | public function updateMetadata( 51 | $metadata, 52 | $authUri = null, 53 | ?callable $httpHandler = null 54 | ) { 55 | $metadata_copy = $metadata; 56 | 57 | // We do need to set the service api usage metrics irrespective even if 58 | // the auth token is set because invoking this method with auth tokens 59 | // would mean the intention is to just explicitly set the metrics metadata. 60 | $metadata_copy = $this->applyServiceApiUsageMetrics($metadata_copy); 61 | 62 | if (isset($metadata_copy[self::AUTH_METADATA_KEY])) { 63 | // Auth metadata has already been set 64 | return $metadata_copy; 65 | } 66 | $result = $this->fetchAuthToken($httpHandler); 67 | if (isset($result['access_token'])) { 68 | $metadata_copy[self::AUTH_METADATA_KEY] = ['Bearer ' . $result['access_token']]; 69 | } elseif (isset($result['id_token'])) { 70 | $metadata_copy[self::AUTH_METADATA_KEY] = ['Bearer ' . $result['id_token']]; 71 | } 72 | return $metadata_copy; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/HttpHandler/HttpHandlerFactory.php: -------------------------------------------------------------------------------- 1 | remove('http_errors'); 48 | $stack->unshift(Middleware::httpErrors($bodySummarizer), 'http_errors'); 49 | } 50 | $client = new Client(['handler' => $stack]); 51 | } 52 | 53 | $logger = ($logger === false) 54 | ? null 55 | : $logger ?? ApplicationDefaultCredentials::getDefaultLogger(); 56 | 57 | $version = null; 58 | if (defined('GuzzleHttp\ClientInterface::MAJOR_VERSION')) { 59 | $version = ClientInterface::MAJOR_VERSION; 60 | } elseif (defined('GuzzleHttp\ClientInterface::VERSION')) { 61 | $version = (int) substr(ClientInterface::VERSION, 0, 1); 62 | } 63 | 64 | switch ($version) { 65 | case 6: 66 | return new Guzzle6HttpHandler($client, $logger); 67 | case 7: 68 | return new Guzzle7HttpHandler($client, $logger); 69 | default: 70 | throw new \Exception('Version not supported'); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Credentials/IAMCredentials.php: -------------------------------------------------------------------------------- 1 | selector = $selector; 56 | $this->token = $token; 57 | } 58 | 59 | /** 60 | * export a callback function which updates runtime metadata. 61 | * 62 | * @return callable updateMetadata function 63 | */ 64 | public function getUpdateMetadataFunc() 65 | { 66 | return [$this, 'updateMetadata']; 67 | } 68 | 69 | /** 70 | * Updates metadata with the appropriate header metadata. 71 | * 72 | * @param array $metadata metadata hashmap 73 | * @param string $unusedAuthUri optional auth uri 74 | * @param callable|null $httpHandler callback which delivers psr7 request 75 | * Note: this param is unused here, only included here for 76 | * consistency with other credentials class 77 | * 78 | * @return array updated metadata hashmap 79 | */ 80 | public function updateMetadata( 81 | $metadata, 82 | $unusedAuthUri = null, 83 | ?callable $httpHandler = null 84 | ) { 85 | $metadata_copy = $metadata; 86 | $metadata_copy[self::SELECTOR_KEY] = $this->selector; 87 | $metadata_copy[self::TOKEN_KEY] = $this->token; 88 | 89 | return $metadata_copy; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/CredentialSource/FileSource.php: -------------------------------------------------------------------------------- 1 | file = $file; 45 | 46 | if ($format === 'json' && is_null($subjectTokenFieldName)) { 47 | throw new InvalidArgumentException( 48 | 'subject_token_field_name must be set when format is JSON' 49 | ); 50 | } 51 | 52 | $this->format = $format; 53 | $this->subjectTokenFieldName = $subjectTokenFieldName; 54 | } 55 | 56 | public function fetchSubjectToken(?callable $httpHandler = null): string 57 | { 58 | $contents = file_get_contents($this->file); 59 | if ($this->format === 'json') { 60 | if (!$json = json_decode((string) $contents, true)) { 61 | throw new UnexpectedValueException( 62 | 'Unable to decode JSON file' 63 | ); 64 | } 65 | if (!isset($json[$this->subjectTokenFieldName])) { 66 | throw new UnexpectedValueException( 67 | 'subject_token_field_name not found in JSON file' 68 | ); 69 | } 70 | $contents = $json[$this->subjectTokenFieldName]; 71 | } 72 | 73 | return $contents; 74 | } 75 | 76 | /** 77 | * Gets the unique key for caching. 78 | * The format for the cache key one of the following: 79 | * Filename 80 | * 81 | * @return string 82 | */ 83 | public function getCacheKey(): ?string 84 | { 85 | return $this->file; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/CacheTrait.php: -------------------------------------------------------------------------------- 1 | 31 | */ 32 | private $cacheConfig; 33 | 34 | /** 35 | * @var ?CacheItemPoolInterface 36 | */ 37 | private $cache; 38 | 39 | /** 40 | * Gets the cached value if it is present in the cache when that is 41 | * available. 42 | * 43 | * @param mixed $k 44 | * 45 | * @return mixed 46 | */ 47 | private function getCachedValue($k) 48 | { 49 | if (is_null($this->cache)) { 50 | return null; 51 | } 52 | 53 | $key = $this->getFullCacheKey($k); 54 | if (is_null($key)) { 55 | return null; 56 | } 57 | 58 | $cacheItem = $this->cache->getItem($key); 59 | if ($cacheItem->isHit()) { 60 | return $cacheItem->get(); 61 | } 62 | } 63 | 64 | /** 65 | * Saves the value in the cache when that is available. 66 | * 67 | * @param mixed $k 68 | * @param mixed $v 69 | * @return mixed 70 | */ 71 | private function setCachedValue($k, $v) 72 | { 73 | if (is_null($this->cache)) { 74 | return null; 75 | } 76 | 77 | $key = $this->getFullCacheKey($k); 78 | if (is_null($key)) { 79 | return null; 80 | } 81 | 82 | $cacheItem = $this->cache->getItem($key); 83 | $cacheItem->set($v); 84 | $cacheItem->expiresAfter($this->cacheConfig['lifetime']); 85 | return $this->cache->save($cacheItem); 86 | } 87 | 88 | /** 89 | * @param null|string $key 90 | * @return null|string 91 | */ 92 | private function getFullCacheKey($key) 93 | { 94 | if (is_null($key)) { 95 | return null; 96 | } 97 | 98 | $key = ($this->cacheConfig['prefix'] ?? '') . $key; 99 | 100 | // ensure we do not have illegal characters 101 | $key = preg_replace('|[^a-zA-Z0-9_\.!]|', '', $key); 102 | 103 | // Hash keys if they exceed $maxKeyLength (defaults to 64) 104 | if ($this->maxKeyLength && strlen($key) > $this->maxKeyLength) { 105 | $key = substr(hash('sha256', $key), 0, $this->maxKeyLength); 106 | } 107 | 108 | return $key; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Middleware/SimpleMiddleware.php: -------------------------------------------------------------------------------- 1 | 33 | */ 34 | private $config; 35 | 36 | /** 37 | * Create a new Simple plugin. 38 | * 39 | * The configuration array expects one option 40 | * - key: required, otherwise InvalidArgumentException is thrown 41 | * 42 | * @param array $config Configuration array 43 | */ 44 | public function __construct(array $config) 45 | { 46 | if (!isset($config['key'])) { 47 | throw new \InvalidArgumentException('requires a key to have been set'); 48 | } 49 | 50 | $this->config = array_merge(['key' => null], $config); 51 | } 52 | 53 | /** 54 | * Updates the request query with the developer key if auth is set to simple. 55 | * 56 | * use Google\Auth\Middleware\SimpleMiddleware; 57 | * use GuzzleHttp\Client; 58 | * use GuzzleHttp\HandlerStack; 59 | * 60 | * $my_key = 'is not the same as yours'; 61 | * $middleware = new SimpleMiddleware(['key' => $my_key]); 62 | * $stack = HandlerStack::create(); 63 | * $stack->push($middleware); 64 | * 65 | * $client = new Client([ 66 | * 'handler' => $stack, 67 | * 'base_uri' => 'https://www.googleapis.com/discovery/v1/', 68 | * 'auth' => 'simple' 69 | * ]); 70 | * 71 | * $res = $client->get('drive/v2/rest'); 72 | * 73 | * @param callable $handler 74 | * @return \Closure 75 | */ 76 | public function __invoke(callable $handler) 77 | { 78 | return function (RequestInterface $request, array $options) use ($handler) { 79 | // Requests using "auth"="scoped" will be authorized. 80 | if (!isset($options['auth']) || $options['auth'] !== 'simple') { 81 | return $handler($request, $options); 82 | } 83 | 84 | $query = Query::parse($request->getUri()->getQuery()); 85 | $params = array_merge($query, $this->config); 86 | $uri = $request->getUri()->withQuery(Query::build($params)); 87 | $request = $request->withUri($uri); 88 | 89 | return $handler($request, $options); 90 | }; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Logging/RpcLogEvent.php: -------------------------------------------------------------------------------- 1 | 59 | */ 60 | public null|array $headers = null; 61 | 62 | /** 63 | * An array representation of JSON for the response or request 64 | * 65 | * @var null|string 66 | */ 67 | public null|string $payload = null; 68 | 69 | /** 70 | * Status code for REST or gRPC methods 71 | * 72 | * @var null|int|string 73 | */ 74 | public null|int|string $status = null; 75 | 76 | /** 77 | * The latency in milliseconds 78 | * 79 | * @var null|int 80 | */ 81 | public null|int $latency = null; 82 | 83 | /** 84 | * The retry attempt number 85 | * 86 | * @var null|int 87 | */ 88 | public null|int $retryAttempt = null; 89 | 90 | /** 91 | * The name of the gRPC method being called 92 | * 93 | * @var null|string 94 | */ 95 | public null|string $rpcName = null; 96 | 97 | /** 98 | * The Service Name of the gRPC 99 | * 100 | * @var null|string $serviceName 101 | */ 102 | public null|string $serviceName = null; 103 | 104 | /** 105 | * The Process ID for tracing logs 106 | * 107 | * @var null|int $processId 108 | */ 109 | public null|int $processId = null; 110 | 111 | /** 112 | * The Request id for tracing logs 113 | * 114 | * @var null|int $requestId; 115 | */ 116 | public null|int $requestId = null; 117 | 118 | /** 119 | * Creates an object with all the fields required for logging 120 | * Passing a string representation of a timestamp calculates the difference between 121 | * these two times and sets the latency field with the result. 122 | * 123 | * @param null|float $startTime (Optional) Parameter to calculate the latency 124 | */ 125 | public function __construct(null|float $startTime = null) 126 | { 127 | $this->timestamp = date(DATE_RFC3339); 128 | 129 | // Takes the micro time and convets it to millis 130 | $this->milliseconds = round(microtime(true) * 1000); 131 | 132 | if ($startTime) { 133 | $this->latency = (int) round($this->milliseconds - $startTime); 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/CredentialSource/UrlSource.php: -------------------------------------------------------------------------------- 1 | 38 | */ 39 | private ?array $headers; 40 | 41 | /** 42 | * @param string $url The URL to fetch the subject token from. 43 | * @param string|null $format The format of the token in the response. Can be null or "json". 44 | * @param string|null $subjectTokenFieldName The name of the field containing the token in the response. This is required 45 | * when format is "json". 46 | * @param array|null $headers Request headers to send in with the request to the URL. 47 | */ 48 | public function __construct( 49 | string $url, 50 | ?string $format = null, 51 | ?string $subjectTokenFieldName = null, 52 | ?array $headers = null 53 | ) { 54 | $this->url = $url; 55 | 56 | if ($format === 'json' && is_null($subjectTokenFieldName)) { 57 | throw new InvalidArgumentException( 58 | 'subject_token_field_name must be set when format is JSON' 59 | ); 60 | } 61 | 62 | $this->format = $format; 63 | $this->subjectTokenFieldName = $subjectTokenFieldName; 64 | $this->headers = $headers; 65 | } 66 | 67 | public function fetchSubjectToken(?callable $httpHandler = null): string 68 | { 69 | if (is_null($httpHandler)) { 70 | $httpHandler = HttpHandlerFactory::build(HttpClientCache::getHttpClient()); 71 | } 72 | 73 | $request = new Request( 74 | 'GET', 75 | $this->url, 76 | $this->headers ?: [] 77 | ); 78 | 79 | $response = $httpHandler($request); 80 | $body = (string) $response->getBody(); 81 | if ($this->format === 'json') { 82 | if (!$json = json_decode((string) $body, true)) { 83 | throw new UnexpectedValueException( 84 | 'Unable to decode JSON response' 85 | ); 86 | } 87 | if (!isset($json[$this->subjectTokenFieldName])) { 88 | throw new UnexpectedValueException( 89 | 'subject_token_field_name not found in JSON file' 90 | ); 91 | } 92 | $body = $json[$this->subjectTokenFieldName]; 93 | } 94 | 95 | return $body; 96 | } 97 | 98 | /** 99 | * Get the cache key for the credentials. 100 | * The format for the cache key is: 101 | * URL 102 | * 103 | * @return ?string 104 | */ 105 | public function getCacheKey(): ?string 106 | { 107 | return $this->url; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/MetricsTrait.php: -------------------------------------------------------------------------------- 1 | $metadata The metadata to update and return. 70 | * @return array The updated metadata. 71 | */ 72 | protected function applyServiceApiUsageMetrics($metadata) 73 | { 74 | if ($credType = $this->getCredType()) { 75 | // Add service api usage observability metrics info into metadata 76 | // We expect upstream libries to have the metadata key populated already 77 | $value = 'cred-type/' . $credType; 78 | if (!isset($metadata[self::$metricMetadataKey])) { 79 | // This case will happen only when someone invokes the updateMetadata 80 | // method on the credentials fetcher themselves. 81 | $metadata[self::$metricMetadataKey] = [$value]; 82 | } elseif (is_array($metadata[self::$metricMetadataKey])) { 83 | $metadata[self::$metricMetadataKey][0] .= ' ' . $value; 84 | } else { 85 | $metadata[self::$metricMetadataKey] .= ' ' . $value; 86 | } 87 | } 88 | 89 | return $metadata; 90 | } 91 | 92 | /** 93 | * @param array $metadata The metadata to update and return. 94 | * @param string $authRequestType The auth request type. Possible values are 95 | * `'at'`, `'it'`, `'mds'`. 96 | * @return array The updated metadata. 97 | */ 98 | protected function applyTokenEndpointMetrics($metadata, $authRequestType) 99 | { 100 | $metricsHeader = self::getMetricsHeader($this->getCredType(), $authRequestType); 101 | if (!isset($metadata[self::$metricMetadataKey])) { 102 | $metadata[self::$metricMetadataKey] = $metricsHeader; 103 | } 104 | return $metadata; 105 | } 106 | 107 | protected static function getVersion(): string 108 | { 109 | if (is_null(self::$version)) { 110 | $versionFilePath = __DIR__ . '/../VERSION'; 111 | self::$version = trim((string) file_get_contents($versionFilePath)); 112 | } 113 | return self::$version; 114 | } 115 | 116 | protected function getCredType(): string 117 | { 118 | return ''; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/HttpHandler/Guzzle6HttpHandler.php: -------------------------------------------------------------------------------- 1 | client = $client; 47 | $this->logger = $logger; 48 | } 49 | 50 | /** 51 | * Accepts a PSR-7 request and an array of options and returns a PSR-7 response. 52 | * 53 | * @param RequestInterface $request 54 | * @param array $options 55 | * @return ResponseInterface 56 | */ 57 | public function __invoke(RequestInterface $request, array $options = []) 58 | { 59 | $requestEvent = null; 60 | 61 | if ($this->logger) { 62 | $requestEvent = $this->requestLog($request, $options); 63 | } 64 | 65 | $response = $this->client->send($request, $options); 66 | 67 | if ($this->logger) { 68 | $this->responseLog($response, $requestEvent); 69 | } 70 | 71 | return $response; 72 | } 73 | 74 | /** 75 | * Accepts a PSR-7 request and an array of options and returns a PromiseInterface 76 | * 77 | * @param RequestInterface $request 78 | * @param array $options 79 | * 80 | * @return \GuzzleHttp\Promise\PromiseInterface 81 | */ 82 | public function async(RequestInterface $request, array $options = []) 83 | { 84 | $requestEvent = null; 85 | 86 | if ($this->logger) { 87 | $requestEvent = $this->requestLog($request, $options); 88 | } 89 | 90 | $promise = $this->client->sendAsync($request, $options); 91 | 92 | if ($this->logger) { 93 | $promise->then(function (ResponseInterface $response) use ($requestEvent) { 94 | $this->responseLog($response, $requestEvent); 95 | return $response; 96 | }); 97 | } 98 | 99 | return $promise; 100 | } 101 | 102 | /** 103 | * @internal 104 | * @param RequestInterface $request 105 | * @param array $options 106 | */ 107 | public function requestLog(RequestInterface $request, array $options): RpcLogEvent 108 | { 109 | $requestEvent = new RpcLogEvent(); 110 | 111 | $requestEvent->method = $request->getMethod(); 112 | $requestEvent->url = (string) $request->getUri(); 113 | $requestEvent->headers = $request->getHeaders(); 114 | $requestEvent->payload = $request->getBody()->getContents(); 115 | $requestEvent->retryAttempt = $options['retryAttempt'] ?? null; 116 | $requestEvent->serviceName = $options['serviceName'] ?? null; 117 | $requestEvent->processId = (int) getmypid(); 118 | $requestEvent->requestId = $options['requestId'] ?? crc32((string) spl_object_id($request) . getmypid()); 119 | 120 | $this->logRequest($requestEvent); 121 | 122 | return $requestEvent; 123 | } 124 | 125 | /** 126 | * @internal 127 | */ 128 | public function responseLog(ResponseInterface $response, RpcLogEvent $requestEvent): void 129 | { 130 | $responseEvent = new RpcLogEvent($requestEvent->milliseconds); 131 | 132 | $responseEvent->headers = $response->getHeaders(); 133 | $responseEvent->payload = $response->getBody()->getContents(); 134 | $responseEvent->status = $response->getStatusCode(); 135 | $responseEvent->processId = $requestEvent->processId; 136 | $responseEvent->requestId = $requestEvent->requestId; 137 | 138 | $this->logResponse($responseEvent); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Logging/LoggingTrait.php: -------------------------------------------------------------------------------- 1 | $event->timestamp, 36 | 'severity' => strtoupper(LogLevel::DEBUG), 37 | 'processId' => $event->processId ?? null, 38 | 'requestId' => $event->requestId ?? null, 39 | 'rpcName' => $event->rpcName ?? null, 40 | ]; 41 | 42 | $debugEvent = array_filter($debugEvent, fn ($value) => !is_null($value)); 43 | 44 | $jsonPayload = [ 45 | 'request.method' => $event->method, 46 | 'request.url' => $event->url, 47 | 'request.headers' => $event->headers, 48 | 'request.payload' => $this->truncatePayload($event->payload), 49 | 'request.jwt' => $this->getJwtToken($event->headers ?? []), 50 | 'retryAttempt' => $event->retryAttempt 51 | ]; 52 | 53 | // Remove null values 54 | $debugEvent['jsonPayload'] = array_filter($jsonPayload, fn ($value) => !is_null($value)); 55 | 56 | $stringifiedEvent = json_encode($debugEvent, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); 57 | 58 | // There was an error stringifying the event, return to not break execution 59 | if ($stringifiedEvent === false) { 60 | return; 61 | } 62 | 63 | $this->logger->debug($stringifiedEvent); 64 | } 65 | 66 | /** 67 | * @param RpcLogEvent $event 68 | */ 69 | private function logResponse(RpcLogEvent $event): void 70 | { 71 | $debugEvent = [ 72 | 'timestamp' => $event->timestamp, 73 | 'severity' => strtoupper(LogLevel::DEBUG), 74 | 'processId' => $event->processId ?? null, 75 | 'requestId' => $event->requestId ?? null, 76 | 'jsonPayload' => [ 77 | 'response.status' => $event->status, 78 | 'response.headers' => $event->headers, 79 | 'response.payload' => $this->truncatePayload($event->payload), 80 | 'latencyMillis' => $event->latency, 81 | ] 82 | ]; 83 | 84 | // Remove null values 85 | $debugEvent = array_filter($debugEvent, fn ($value) => !is_null($value)); 86 | $debugEvent['jsonPayload'] = array_filter( 87 | $debugEvent['jsonPayload'], 88 | fn ($value) => !is_null($value) 89 | ); 90 | 91 | $stringifiedEvent = json_encode($debugEvent, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); 92 | 93 | // There was an error stringifying the event, return to not break execution 94 | if ($stringifiedEvent !== false) { 95 | $this->logger->debug($stringifiedEvent); 96 | } 97 | } 98 | 99 | /** 100 | * @param array $headers 101 | * @return null|array 102 | */ 103 | private function getJwtToken(array $headers): null|array 104 | { 105 | if (empty($headers)) { 106 | return null; 107 | } 108 | 109 | $tokenHeader = $headers['Authorization'] ?? ''; 110 | $token = str_replace('Bearer ', '', $tokenHeader); 111 | 112 | if (substr_count($token, '.') !== 2) { 113 | return null; 114 | } 115 | 116 | [$header, $token, $_] = explode('.', $token); 117 | 118 | return [ 119 | 'header' => base64_decode($header), 120 | 'token' => base64_decode($token) 121 | ]; 122 | } 123 | 124 | /** 125 | * @param null|string $payload 126 | * @return string 127 | */ 128 | private function truncatePayload(null|string $payload): null|string 129 | { 130 | $maxLength = 500; 131 | 132 | if (is_null($payload) || strlen($payload) <= $maxLength) { 133 | return $payload; 134 | } 135 | 136 | return substr($payload, 0, $maxLength) . '...'; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/Cache/TypedItem.php: -------------------------------------------------------------------------------- 1 | key = $key; 53 | $this->expiration = null; 54 | } 55 | 56 | /** 57 | * {@inheritdoc} 58 | */ 59 | public function getKey(): string 60 | { 61 | return $this->key; 62 | } 63 | 64 | /** 65 | * {@inheritdoc} 66 | */ 67 | public function get(): mixed 68 | { 69 | return $this->isHit() ? $this->value : null; 70 | } 71 | 72 | /** 73 | * {@inheritdoc} 74 | */ 75 | public function isHit(): bool 76 | { 77 | if (!$this->isHit) { 78 | return false; 79 | } 80 | 81 | if ($this->expiration === null) { 82 | return true; 83 | } 84 | 85 | return $this->currentTime()->getTimestamp() < $this->expiration->getTimestamp(); 86 | } 87 | 88 | /** 89 | * {@inheritdoc} 90 | */ 91 | public function set(mixed $value): static 92 | { 93 | $this->isHit = true; 94 | $this->value = $value; 95 | 96 | return $this; 97 | } 98 | 99 | /** 100 | * {@inheritdoc} 101 | */ 102 | public function expiresAt($expiration): static 103 | { 104 | if ($this->isValidExpiration($expiration)) { 105 | $this->expiration = $expiration; 106 | 107 | return $this; 108 | } 109 | 110 | $error = sprintf( 111 | 'Argument 1 passed to %s::expiresAt() must implement interface DateTimeInterface, %s given', 112 | get_class($this), 113 | gettype($expiration) 114 | ); 115 | 116 | throw new \TypeError($error); 117 | } 118 | 119 | /** 120 | * {@inheritdoc} 121 | */ 122 | public function expiresAfter($time): static 123 | { 124 | if (is_int($time)) { 125 | $this->expiration = $this->currentTime()->add(new \DateInterval("PT{$time}S")); 126 | } elseif ($time instanceof \DateInterval) { 127 | $this->expiration = $this->currentTime()->add($time); 128 | } elseif ($time === null) { 129 | $this->expiration = $time; 130 | } else { 131 | $message = 'Argument 1 passed to %s::expiresAfter() must be an ' . 132 | 'instance of DateInterval or of the type integer, %s given'; 133 | $error = sprintf($message, get_class($this), gettype($time)); 134 | 135 | throw new \TypeError($error); 136 | } 137 | 138 | return $this; 139 | } 140 | 141 | /** 142 | * Determines if an expiration is valid based on the rules defined by PSR6. 143 | * 144 | * @param mixed $expiration 145 | * @return bool 146 | */ 147 | private function isValidExpiration($expiration) 148 | { 149 | if ($expiration === null) { 150 | return true; 151 | } 152 | 153 | // We test for two types here due to the fact the DateTimeInterface 154 | // was not introduced until PHP 5.5. Checking for the DateTime type as 155 | // well allows us to support 5.4. 156 | if ($expiration instanceof \DateTimeInterface) { 157 | return true; 158 | } 159 | 160 | return false; 161 | } 162 | 163 | /** 164 | * @return \DateTime 165 | */ 166 | protected function currentTime() 167 | { 168 | return new \DateTime('now', new \DateTimeZone('UTC')); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/Cache/MemoryCacheItemPool.php: -------------------------------------------------------------------------------- 1 | getItems([$key])); // @phpstan-ignore-line 46 | } 47 | 48 | /** 49 | * {@inheritdoc} 50 | * 51 | * @return iterable 52 | * A traversable collection of Cache Items keyed by the cache keys of 53 | * each item. A Cache item will be returned for each key, even if that 54 | * key is not found. However, if no keys are specified then an empty 55 | * traversable MUST be returned instead. 56 | */ 57 | public function getItems(array $keys = []): iterable 58 | { 59 | $items = []; 60 | foreach ($keys as $key) { 61 | $items[$key] = $this->hasItem($key) ? clone $this->items[$key] : new TypedItem($key); 62 | } 63 | 64 | return $items; 65 | } 66 | 67 | /** 68 | * {@inheritdoc} 69 | * 70 | * @return bool 71 | * True if item exists in the cache, false otherwise. 72 | */ 73 | public function hasItem($key): bool 74 | { 75 | $this->isValidKey($key); 76 | 77 | return isset($this->items[$key]) && $this->items[$key]->isHit(); 78 | } 79 | 80 | /** 81 | * {@inheritdoc} 82 | * 83 | * @return bool 84 | * True if the pool was successfully cleared. False if there was an error. 85 | */ 86 | public function clear(): bool 87 | { 88 | $this->items = []; 89 | $this->deferredItems = []; 90 | 91 | return true; 92 | } 93 | 94 | /** 95 | * {@inheritdoc} 96 | * 97 | * @return bool 98 | * True if the item was successfully removed. False if there was an error. 99 | */ 100 | public function deleteItem($key): bool 101 | { 102 | return $this->deleteItems([$key]); 103 | } 104 | 105 | /** 106 | * {@inheritdoc} 107 | * 108 | * @return bool 109 | * True if the items were successfully removed. False if there was an error. 110 | */ 111 | public function deleteItems(array $keys): bool 112 | { 113 | array_walk($keys, [$this, 'isValidKey']); 114 | 115 | foreach ($keys as $key) { 116 | unset($this->items[$key]); 117 | } 118 | 119 | return true; 120 | } 121 | 122 | /** 123 | * {@inheritdoc} 124 | * 125 | * @return bool 126 | * True if the item was successfully persisted. False if there was an error. 127 | */ 128 | public function save(CacheItemInterface $item): bool 129 | { 130 | $this->items[$item->getKey()] = $item; 131 | 132 | return true; 133 | } 134 | 135 | /** 136 | * {@inheritdoc} 137 | * 138 | * @return bool 139 | * False if the item could not be queued or if a commit was attempted and failed. True otherwise. 140 | */ 141 | public function saveDeferred(CacheItemInterface $item): bool 142 | { 143 | $this->deferredItems[$item->getKey()] = $item; 144 | 145 | return true; 146 | } 147 | 148 | /** 149 | * {@inheritdoc} 150 | * 151 | * @return bool 152 | * True if all not-yet-saved items were successfully saved or there were none. False otherwise. 153 | */ 154 | public function commit(): bool 155 | { 156 | foreach ($this->deferredItems as $item) { 157 | $this->save($item); 158 | } 159 | 160 | $this->deferredItems = []; 161 | 162 | return true; 163 | } 164 | 165 | /** 166 | * Determines if the provided key is valid. 167 | * 168 | * @param string $key 169 | * @return bool 170 | * @throws InvalidArgumentException 171 | */ 172 | private function isValidKey($key) 173 | { 174 | $invalidCharacters = '{}()/\\\\@:'; 175 | 176 | if (!is_string($key) || preg_match("#[$invalidCharacters]#", $key)) { 177 | throw new InvalidArgumentException('The provided key is not valid: ' . var_export($key, true)); 178 | } 179 | 180 | return true; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/Middleware/ProxyAuthTokenMiddleware.php: -------------------------------------------------------------------------------- 1 | ' 34 | */ 35 | class ProxyAuthTokenMiddleware 36 | { 37 | /** 38 | * @var callable 39 | */ 40 | private $httpHandler; 41 | 42 | /** 43 | * @var FetchAuthTokenInterface 44 | */ 45 | private $fetcher; 46 | 47 | /** 48 | * @var ?callable 49 | */ 50 | private $tokenCallback; 51 | 52 | /** 53 | * Creates a new ProxyAuthTokenMiddleware. 54 | * 55 | * @param FetchAuthTokenInterface $fetcher is used to fetch the auth token 56 | * @param callable|null $httpHandler (optional) callback which delivers psr7 request 57 | * @param callable|null $tokenCallback (optional) function to be called when a new token is fetched. 58 | */ 59 | public function __construct( 60 | FetchAuthTokenInterface $fetcher, 61 | ?callable $httpHandler = null, 62 | ?callable $tokenCallback = null 63 | ) { 64 | $this->fetcher = $fetcher; 65 | $this->httpHandler = $httpHandler; 66 | $this->tokenCallback = $tokenCallback; 67 | } 68 | 69 | /** 70 | * Updates the request with an Authorization header when auth is 'google_auth'. 71 | * 72 | * use Google\Auth\Middleware\ProxyAuthTokenMiddleware; 73 | * use Google\Auth\OAuth2; 74 | * use GuzzleHttp\Client; 75 | * use GuzzleHttp\HandlerStack; 76 | * 77 | * $config = [...]; 78 | * $oauth2 = new OAuth2($config) 79 | * $middleware = new ProxyAuthTokenMiddleware($oauth2); 80 | * $stack = HandlerStack::create(); 81 | * $stack->push($middleware); 82 | * 83 | * $client = new Client([ 84 | * 'handler' => $stack, 85 | * 'base_uri' => 'https://www.googleapis.com/taskqueue/v1beta2/projects/', 86 | * 'proxy_auth' => 'google_auth' // authorize all requests 87 | * ]); 88 | * 89 | * $res = $client->get('myproject/taskqueues/myqueue'); 90 | * 91 | * @param callable $handler 92 | * @return \Closure 93 | */ 94 | public function __invoke(callable $handler) 95 | { 96 | return function (RequestInterface $request, array $options) use ($handler) { 97 | // Requests using "proxy_auth"="google_auth" will be authorized. 98 | if (!isset($options['proxy_auth']) || $options['proxy_auth'] !== 'google_auth') { 99 | return $handler($request, $options); 100 | } 101 | 102 | $request = $request->withHeader('proxy-authorization', 'Bearer ' . $this->fetchToken()); 103 | 104 | if ($quotaProject = $this->getQuotaProject()) { 105 | $request = $request->withHeader( 106 | GetQuotaProjectInterface::X_GOOG_USER_PROJECT_HEADER, 107 | $quotaProject 108 | ); 109 | } 110 | 111 | return $handler($request, $options); 112 | }; 113 | } 114 | 115 | /** 116 | * Call fetcher to fetch the token. 117 | * 118 | * @return string|null 119 | */ 120 | private function fetchToken() 121 | { 122 | $auth_tokens = $this->fetcher->fetchAuthToken($this->httpHandler); 123 | 124 | if (array_key_exists('access_token', $auth_tokens)) { 125 | // notify the callback if applicable 126 | if ($this->tokenCallback) { 127 | call_user_func( 128 | $this->tokenCallback, 129 | $this->fetcher->getCacheKey(), 130 | $auth_tokens['access_token'] 131 | ); 132 | } 133 | 134 | return $auth_tokens['access_token']; 135 | } 136 | 137 | if (array_key_exists('id_token', $auth_tokens)) { 138 | return $auth_tokens['id_token']; 139 | } 140 | 141 | return null; 142 | } 143 | 144 | /** 145 | * @return string|null; 146 | */ 147 | private function getQuotaProject() 148 | { 149 | if ($this->fetcher instanceof GetQuotaProjectInterface) { 150 | return $this->fetcher->getQuotaProject(); 151 | } 152 | 153 | return null; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/Middleware/ScopedAccessTokenMiddleware.php: -------------------------------------------------------------------------------- 1 | ' 35 | */ 36 | class ScopedAccessTokenMiddleware 37 | { 38 | use CacheTrait; 39 | 40 | const DEFAULT_CACHE_LIFETIME = 1500; 41 | 42 | /** 43 | * @var callable 44 | */ 45 | private $tokenFunc; 46 | 47 | /** 48 | * @var array|string 49 | */ 50 | private $scopes; 51 | 52 | /** 53 | * Creates a new ScopedAccessTokenMiddleware. 54 | * 55 | * @param callable $tokenFunc a token generator function 56 | * @param array|string $scopes the token authentication scopes 57 | * @param array|null $cacheConfig configuration for the cache when it's present 58 | * @param CacheItemPoolInterface|null $cache an implementation of CacheItemPoolInterface 59 | */ 60 | public function __construct( 61 | callable $tokenFunc, 62 | $scopes, 63 | ?array $cacheConfig = null, 64 | ?CacheItemPoolInterface $cache = null 65 | ) { 66 | $this->tokenFunc = $tokenFunc; 67 | if (!(is_string($scopes) || is_array($scopes))) { 68 | throw new \InvalidArgumentException( 69 | 'wants scope should be string or array' 70 | ); 71 | } 72 | $this->scopes = $scopes; 73 | 74 | if (!is_null($cache)) { 75 | $this->cache = $cache; 76 | $this->cacheConfig = array_merge([ 77 | 'lifetime' => self::DEFAULT_CACHE_LIFETIME, 78 | 'prefix' => '', 79 | ], $cacheConfig); 80 | } 81 | } 82 | 83 | /** 84 | * Updates the request with an Authorization header when auth is 'scoped'. 85 | * 86 | * E.g this could be used to authenticate using the AppEngine 87 | * AppIdentityService. 88 | * 89 | * use google\appengine\api\app_identity\AppIdentityService; 90 | * use Google\Auth\Middleware\ScopedAccessTokenMiddleware; 91 | * use GuzzleHttp\Client; 92 | * use GuzzleHttp\HandlerStack; 93 | * 94 | * $scope = 'https://www.googleapis.com/auth/taskqueue' 95 | * $middleware = new ScopedAccessTokenMiddleware( 96 | * 'AppIdentityService::getAccessToken', 97 | * $scope, 98 | * [ 'prefix' => 'Google\Auth\ScopedAccessToken::' ], 99 | * $cache = new Memcache() 100 | * ); 101 | * $stack = HandlerStack::create(); 102 | * $stack->push($middleware); 103 | * 104 | * $client = new Client([ 105 | * 'handler' => $stack, 106 | * 'base_url' => 'https://www.googleapis.com/taskqueue/v1beta2/projects/', 107 | * 'auth' => 'scoped' // authorize all requests 108 | * ]); 109 | * 110 | * $res = $client->get('myproject/taskqueues/myqueue'); 111 | * 112 | * @param callable $handler 113 | * @return \Closure 114 | */ 115 | public function __invoke(callable $handler) 116 | { 117 | return function (RequestInterface $request, array $options) use ($handler) { 118 | // Requests using "auth"="scoped" will be authorized. 119 | if (!isset($options['auth']) || $options['auth'] !== 'scoped') { 120 | return $handler($request, $options); 121 | } 122 | 123 | $request = $request->withHeader('authorization', 'Bearer ' . $this->fetchToken()); 124 | 125 | return $handler($request, $options); 126 | }; 127 | } 128 | 129 | /** 130 | * @return string 131 | */ 132 | private function getCacheKey() 133 | { 134 | $key = null; 135 | 136 | if (is_string($this->scopes)) { 137 | $key .= $this->scopes; 138 | } elseif (is_array($this->scopes)) { 139 | $key .= implode(':', $this->scopes); 140 | } 141 | 142 | return $key; 143 | } 144 | 145 | /** 146 | * Determine if token is available in the cache, if not call tokenFunc to 147 | * fetch it. 148 | * 149 | * @return string 150 | */ 151 | private function fetchToken() 152 | { 153 | $cacheKey = $this->getCacheKey(); 154 | $cached = $this->getCachedValue($cacheKey); 155 | 156 | if (!empty($cached)) { 157 | return $cached; 158 | } 159 | 160 | $token = call_user_func($this->tokenFunc, $this->scopes); 161 | $this->setCachedValue($cacheKey, $token); 162 | 163 | return $token; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/Iam.php: -------------------------------------------------------------------------------- 1 | httpHandler = $httpHandler 56 | ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient()); 57 | $this->universeDomain = $universeDomain; 58 | } 59 | 60 | /** 61 | * Sign a string using the IAM signBlob API. 62 | * 63 | * Note that signing using IAM requires your service account to have the 64 | * `iam.serviceAccounts.signBlob` permission, part of the "Service Account 65 | * Token Creator" IAM role. 66 | * 67 | * @param string $email The service account email. 68 | * @param string $accessToken An access token from the service account. 69 | * @param string $stringToSign The string to be signed. 70 | * @param array $delegates [optional] A list of service account emails to 71 | * add to the delegate chain. If omitted, the value of `$email` will 72 | * be used. 73 | * @return string The signed string, base64-encoded. 74 | */ 75 | public function signBlob($email, $accessToken, $stringToSign, array $delegates = []) 76 | { 77 | $name = sprintf(self::SERVICE_ACCOUNT_NAME, $email); 78 | $apiRoot = str_replace('UNIVERSE_DOMAIN', $this->universeDomain, self::IAM_API_ROOT_TEMPLATE); 79 | $uri = $apiRoot . '/' . sprintf(self::SIGN_BLOB_PATH, $name); 80 | 81 | if ($delegates) { 82 | foreach ($delegates as &$delegate) { 83 | $delegate = sprintf(self::SERVICE_ACCOUNT_NAME, $delegate); 84 | } 85 | } else { 86 | $delegates = [$name]; 87 | } 88 | 89 | $body = [ 90 | 'delegates' => $delegates, 91 | 'payload' => base64_encode($stringToSign), 92 | ]; 93 | 94 | $headers = [ 95 | 'Authorization' => 'Bearer ' . $accessToken 96 | ]; 97 | 98 | $request = new Psr7\Request( 99 | 'POST', 100 | $uri, 101 | $headers, 102 | Utils::streamFor(json_encode($body)) 103 | ); 104 | 105 | $res = ($this->httpHandler)($request); 106 | $body = json_decode((string) $res->getBody(), true); 107 | 108 | return $body['signedBlob']; 109 | } 110 | 111 | /** 112 | * Sign a string using the IAM signBlob API. 113 | * 114 | * Note that signing using IAM requires your service account to have the 115 | * `iam.serviceAccounts.signBlob` permission, part of the "Service Account 116 | * Token Creator" IAM role. 117 | * 118 | * @param string $clientEmail The service account email. 119 | * @param string $targetAudience The audience for the ID token. 120 | * @param string $bearerToken The token to authenticate the IAM request. 121 | * @param array $headers [optional] Additional headers to send with the request. 122 | * 123 | * @return string The signed string, base64-encoded. 124 | */ 125 | public function generateIdToken( 126 | string $clientEmail, 127 | string $targetAudience, 128 | string $bearerToken, 129 | array $headers = [] 130 | ): string { 131 | $name = sprintf(self::SERVICE_ACCOUNT_NAME, $clientEmail); 132 | $apiRoot = str_replace('UNIVERSE_DOMAIN', $this->universeDomain, self::IAM_API_ROOT_TEMPLATE); 133 | $uri = $apiRoot . '/' . sprintf(self::GENERATE_ID_TOKEN_PATH, $name); 134 | 135 | $headers['Authorization'] = 'Bearer ' . $bearerToken; 136 | 137 | $body = [ 138 | 'audience' => $targetAudience, 139 | 'includeEmail' => true, 140 | 'useEmailAzp' => true, 141 | ]; 142 | 143 | $request = new Psr7\Request( 144 | 'POST', 145 | $uri, 146 | $headers, 147 | Utils::streamFor(json_encode($body)) 148 | ); 149 | 150 | $res = ($this->httpHandler)($request); 151 | $body = json_decode((string) $res->getBody(), true); 152 | 153 | return $body['token']; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/Middleware/AuthTokenMiddleware.php: -------------------------------------------------------------------------------- 1 | ' 37 | */ 38 | class AuthTokenMiddleware 39 | { 40 | /** 41 | * @var callable 42 | */ 43 | private $httpHandler; 44 | 45 | /** 46 | * It must be an implementation of FetchAuthTokenInterface. 47 | * It may also implement UpdateMetadataInterface allowing direct 48 | * retrieval of auth related headers 49 | * @var FetchAuthTokenInterface 50 | */ 51 | private $fetcher; 52 | 53 | /** 54 | * @var ?callable 55 | */ 56 | private $tokenCallback; 57 | 58 | /** 59 | * Creates a new AuthTokenMiddleware. 60 | * 61 | * @param FetchAuthTokenInterface $fetcher is used to fetch the auth token 62 | * @param callable|null $httpHandler (optional) callback which delivers psr7 request 63 | * @param callable|null $tokenCallback (optional) function to be called when a new token is fetched. 64 | */ 65 | public function __construct( 66 | FetchAuthTokenInterface $fetcher, 67 | ?callable $httpHandler = null, 68 | ?callable $tokenCallback = null 69 | ) { 70 | $this->fetcher = $fetcher; 71 | $this->httpHandler = $httpHandler; 72 | $this->tokenCallback = $tokenCallback; 73 | } 74 | 75 | /** 76 | * Updates the request with an Authorization header when auth is 'google_auth'. 77 | * 78 | * use Google\Auth\Middleware\AuthTokenMiddleware; 79 | * use Google\Auth\OAuth2; 80 | * use GuzzleHttp\Client; 81 | * use GuzzleHttp\HandlerStack; 82 | * 83 | * $config = [...]; 84 | * $oauth2 = new OAuth2($config) 85 | * $middleware = new AuthTokenMiddleware($oauth2); 86 | * $stack = HandlerStack::create(); 87 | * $stack->push($middleware); 88 | * 89 | * $client = new Client([ 90 | * 'handler' => $stack, 91 | * 'base_uri' => 'https://www.googleapis.com/taskqueue/v1beta2/projects/', 92 | * 'auth' => 'google_auth' // authorize all requests 93 | * ]); 94 | * 95 | * $res = $client->get('myproject/taskqueues/myqueue'); 96 | * 97 | * @param callable $handler 98 | * @return \Closure 99 | */ 100 | public function __invoke(callable $handler) 101 | { 102 | return function (RequestInterface $request, array $options) use ($handler) { 103 | // Requests using "auth"="google_auth" will be authorized. 104 | if (!isset($options['auth']) || $options['auth'] !== 'google_auth') { 105 | return $handler($request, $options); 106 | } 107 | 108 | $request = $this->addAuthHeaders($request); 109 | 110 | if ($quotaProject = $this->getQuotaProject()) { 111 | $request = $request->withHeader( 112 | GetQuotaProjectInterface::X_GOOG_USER_PROJECT_HEADER, 113 | $quotaProject 114 | ); 115 | } 116 | 117 | return $handler($request, $options); 118 | }; 119 | } 120 | 121 | /** 122 | * Adds auth related headers to the request. 123 | * 124 | * @param RequestInterface $request 125 | * @return RequestInterface 126 | */ 127 | private function addAuthHeaders(RequestInterface $request) 128 | { 129 | if (!$this->fetcher instanceof UpdateMetadataInterface || 130 | ($this->fetcher instanceof FetchAuthTokenCache && 131 | !$this->fetcher->getFetcher() instanceof UpdateMetadataInterface) 132 | ) { 133 | $token = $this->fetcher->fetchAuthToken(); 134 | $request = $request->withHeader( 135 | 'authorization', 136 | 'Bearer ' . ($token['access_token'] ?? $token['id_token'] ?? '') 137 | ); 138 | } else { 139 | $headers = $this->fetcher->updateMetadata($request->getHeaders(), null, $this->httpHandler); 140 | $request = Utils::modifyRequest($request, ['set_headers' => $headers]); 141 | } 142 | 143 | if ($this->tokenCallback && ($token = $this->fetcher->getLastReceivedToken())) { 144 | if (array_key_exists('access_token', $token)) { 145 | call_user_func($this->tokenCallback, $this->fetcher->getCacheKey(), $token['access_token']); 146 | } 147 | } 148 | 149 | return $request; 150 | } 151 | 152 | /** 153 | * @return string|null 154 | */ 155 | private function getQuotaProject() 156 | { 157 | if ($this->fetcher instanceof GetQuotaProjectInterface) { 158 | return $this->fetcher->getQuotaProject(); 159 | } 160 | 161 | return null; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Cache/FileSystemCacheItemPool.php: -------------------------------------------------------------------------------- 1 | 33 | */ 34 | private array $buffer = []; 35 | 36 | /** 37 | * Creates a FileSystemCacheItemPool cache that stores values in local storage 38 | * 39 | * @param string $path The string representation of the path where the cache will store the serialized objects. 40 | */ 41 | public function __construct(string $path) 42 | { 43 | $this->cachePath = $path; 44 | 45 | if (is_dir($this->cachePath)) { 46 | return; 47 | } 48 | 49 | // Suppress the error for when the directory already exists because of a 50 | // race condition 51 | if (!@mkdir($this->cachePath, 0777, true) && !is_dir($this->cachePath)) { 52 | throw new ErrorException("Cache folder couldn't be created."); 53 | } 54 | } 55 | 56 | /** 57 | * {@inheritdoc} 58 | */ 59 | public function getItem(string $key): CacheItemInterface 60 | { 61 | if (!$this->validKey($key)) { 62 | throw new InvalidArgumentException("The key '$key' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|"); 63 | } 64 | 65 | $item = new TypedItem($key); 66 | 67 | $itemPath = $this->cacheFilePath($key); 68 | 69 | if (!file_exists($itemPath)) { 70 | return $item; 71 | } 72 | 73 | $serializedItem = file_get_contents($itemPath); 74 | 75 | if ($serializedItem === false) { 76 | return $item; 77 | } 78 | 79 | $item->set(unserialize($serializedItem)); 80 | 81 | return $item; 82 | } 83 | 84 | /** 85 | * {@inheritdoc} 86 | * 87 | * @return iterable An iterable object containing all the 88 | * A traversable collection of Cache Items keyed by the cache keys of 89 | * each item. A Cache item will be returned for each key, even if that 90 | * key is not found. However, if no keys are specified then an empty 91 | * traversable MUST be returned instead. 92 | */ 93 | public function getItems(array $keys = []): iterable 94 | { 95 | $result = []; 96 | 97 | foreach ($keys as $key) { 98 | $result[$key] = $this->getItem($key); 99 | } 100 | 101 | return $result; 102 | } 103 | 104 | /** 105 | * {@inheritdoc} 106 | */ 107 | public function save(CacheItemInterface $item): bool 108 | { 109 | if (!$this->validKey($item->getKey())) { 110 | return false; 111 | } 112 | 113 | $itemPath = $this->cacheFilePath($item->getKey()); 114 | $serializedItem = serialize($item->get()); 115 | 116 | $result = file_put_contents($itemPath, $serializedItem, LOCK_EX); 117 | 118 | // 0 bytes write is considered a successful operation 119 | if ($result === false) { 120 | return false; 121 | } 122 | 123 | return true; 124 | } 125 | 126 | /** 127 | * {@inheritdoc} 128 | */ 129 | public function hasItem(string $key): bool 130 | { 131 | return $this->getItem($key)->isHit(); 132 | } 133 | 134 | /** 135 | * {@inheritdoc} 136 | */ 137 | public function clear(): bool 138 | { 139 | $this->buffer = []; 140 | 141 | if (!is_dir($this->cachePath)) { 142 | return false; 143 | } 144 | 145 | $files = scandir($this->cachePath); 146 | if (!$files) { 147 | return false; 148 | } 149 | 150 | foreach ($files as $fileName) { 151 | if ($fileName === '.' || $fileName === '..') { 152 | continue; 153 | } 154 | 155 | if (!unlink($this->cachePath . '/' . $fileName)) { 156 | return false; 157 | } 158 | } 159 | 160 | return true; 161 | } 162 | 163 | /** 164 | * {@inheritdoc} 165 | */ 166 | public function deleteItem(string $key): bool 167 | { 168 | if (!$this->validKey($key)) { 169 | throw new InvalidArgumentException("The key '$key' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|"); 170 | } 171 | 172 | $itemPath = $this->cacheFilePath($key); 173 | 174 | if (!file_exists($itemPath)) { 175 | return true; 176 | } 177 | 178 | return unlink($itemPath); 179 | } 180 | 181 | /** 182 | * {@inheritdoc} 183 | */ 184 | public function deleteItems(array $keys): bool 185 | { 186 | $result = true; 187 | 188 | foreach ($keys as $key) { 189 | if (!$this->deleteItem($key)) { 190 | $result = false; 191 | } 192 | } 193 | 194 | return $result; 195 | } 196 | 197 | /** 198 | * {@inheritdoc} 199 | */ 200 | public function saveDeferred(CacheItemInterface $item): bool 201 | { 202 | array_push($this->buffer, $item); 203 | 204 | return true; 205 | } 206 | 207 | /** 208 | * {@inheritdoc} 209 | */ 210 | public function commit(): bool 211 | { 212 | $result = true; 213 | 214 | foreach ($this->buffer as $item) { 215 | if (!$this->save($item)) { 216 | $result = false; 217 | } 218 | } 219 | 220 | return $result; 221 | } 222 | 223 | private function cacheFilePath(string $key): string 224 | { 225 | return $this->cachePath . '/' . $key; 226 | } 227 | 228 | private function validKey(string $key): bool 229 | { 230 | return (bool) preg_match('|^[a-zA-Z0-9_\.]+$|', $key); 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/Credentials/UserRefreshCredentials.php: -------------------------------------------------------------------------------- 1 | $jsonKey JSON credential file path or JSON credentials 72 | * as an associative array 73 | * @param string|null $targetAudience The audience for the ID token. 74 | */ 75 | public function __construct( 76 | $scope, 77 | $jsonKey, 78 | ?string $targetAudience = null 79 | ) { 80 | if (is_string($jsonKey)) { 81 | if (!file_exists($jsonKey)) { 82 | throw new InvalidArgumentException('file does not exist or is unreadable'); 83 | } 84 | $json = file_get_contents($jsonKey); 85 | if (!$jsonKey = json_decode((string) $json, true)) { 86 | throw new LogicException('invalid json for auth config'); 87 | } 88 | } 89 | if (!array_key_exists('client_id', $jsonKey)) { 90 | throw new InvalidArgumentException( 91 | 'json key is missing the client_id field' 92 | ); 93 | } 94 | if (!array_key_exists('client_secret', $jsonKey)) { 95 | throw new InvalidArgumentException( 96 | 'json key is missing the client_secret field' 97 | ); 98 | } 99 | if (!array_key_exists('refresh_token', $jsonKey)) { 100 | throw new InvalidArgumentException( 101 | 'json key is missing the refresh_token field' 102 | ); 103 | } 104 | if ($scope && $targetAudience) { 105 | throw new InvalidArgumentException( 106 | 'Scope and targetAudience cannot both be supplied' 107 | ); 108 | } 109 | $additionalClaims = []; 110 | if ($targetAudience) { 111 | $additionalClaims = ['target_audience' => $targetAudience]; 112 | $this->isIdTokenRequest = true; 113 | } 114 | $this->auth = new OAuth2([ 115 | 'clientId' => $jsonKey['client_id'], 116 | 'clientSecret' => $jsonKey['client_secret'], 117 | 'refresh_token' => $jsonKey['refresh_token'], 118 | 'scope' => $scope, 119 | 'tokenCredentialUri' => self::TOKEN_CREDENTIAL_URI, 120 | 'additionalClaims' => $additionalClaims, 121 | ]); 122 | if (array_key_exists('quota_project_id', $jsonKey)) { 123 | $this->quotaProject = (string) $jsonKey['quota_project_id']; 124 | } 125 | } 126 | 127 | /** 128 | * @param callable|null $httpHandler 129 | * @param array $headers [optional] Metrics headers to be inserted 130 | * into the token endpoint request present. 131 | * This could be passed from ImersonatedServiceAccountCredentials as it uses 132 | * UserRefreshCredentials as source credentials. 133 | * 134 | * @return array { 135 | * A set of auth related metadata, containing the following 136 | * 137 | * @type string $access_token 138 | * @type int $expires_in 139 | * @type string $scope 140 | * @type string $token_type 141 | * @type string $id_token 142 | * } 143 | */ 144 | public function fetchAuthToken(?callable $httpHandler = null, array $headers = []) 145 | { 146 | return $this->auth->fetchAuthToken( 147 | $httpHandler, 148 | $this->applyTokenEndpointMetrics($headers, $this->isIdTokenRequest ? 'it' : 'at') 149 | ); 150 | } 151 | 152 | /** 153 | * Return the Cache Key for the credentials. 154 | * The format for the Cache key is one of the following: 155 | * ClientId.Scope 156 | * ClientId.Audience 157 | * 158 | * @return string 159 | */ 160 | public function getCacheKey() 161 | { 162 | $scopeOrAudience = $this->auth->getScope(); 163 | if (!$scopeOrAudience) { 164 | $scopeOrAudience = $this->auth->getAudience(); 165 | } 166 | 167 | return $this->auth->getClientId() . '.' . $scopeOrAudience; 168 | } 169 | 170 | /** 171 | * @return array 172 | */ 173 | public function getLastReceivedToken() 174 | { 175 | return $this->auth->getLastReceivedToken(); 176 | } 177 | 178 | /** 179 | * Get the quota project used for this API request 180 | * 181 | * @return string|null 182 | */ 183 | public function getQuotaProject() 184 | { 185 | return $this->quotaProject; 186 | } 187 | 188 | /** 189 | * Get the granted scopes (if they exist) for the last fetched token. 190 | * 191 | * @return string|null 192 | */ 193 | public function getGrantedScope() 194 | { 195 | return $this->auth->getGrantedScope(); 196 | } 197 | 198 | protected function getCredType(): string 199 | { 200 | return self::CRED_TYPE; 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/Credentials/AppIdentityCredentials.php: -------------------------------------------------------------------------------- 1 | push($middleware); 49 | * 50 | * $client = new Client([ 51 | * 'handler' => $stack, 52 | * 'base_uri' => 'https://www.googleapis.com/books/v1', 53 | * 'auth' => 'google_auth' 54 | * ]); 55 | * 56 | * $res = $client->get('volumes?q=Henry+David+Thoreau&country=US'); 57 | * ``` 58 | */ 59 | class AppIdentityCredentials extends CredentialsLoader implements 60 | SignBlobInterface, 61 | ProjectIdProviderInterface 62 | { 63 | /** 64 | * Result of fetchAuthToken. 65 | * 66 | * @var array 67 | */ 68 | protected $lastReceivedToken; 69 | 70 | /** 71 | * Array of OAuth2 scopes to be requested. 72 | * 73 | * @var string[] 74 | */ 75 | private $scope; 76 | 77 | /** 78 | * @var string 79 | */ 80 | private $clientName; 81 | 82 | /** 83 | * @param string|string[] $scope One or more scopes. 84 | */ 85 | public function __construct($scope = []) 86 | { 87 | $this->scope = is_array($scope) ? $scope : explode(' ', (string) $scope); 88 | } 89 | 90 | /** 91 | * Determines if this an App Engine instance, by accessing the 92 | * SERVER_SOFTWARE environment variable (prod) or the APPENGINE_RUNTIME 93 | * environment variable (dev). 94 | * 95 | * @return bool true if this an App Engine Instance, false otherwise 96 | */ 97 | public static function onAppEngine() 98 | { 99 | $appEngineProduction = isset($_SERVER['SERVER_SOFTWARE']) && 100 | 0 === strpos($_SERVER['SERVER_SOFTWARE'], 'Google App Engine'); 101 | if ($appEngineProduction) { 102 | return true; 103 | } 104 | $appEngineDevAppServer = isset($_SERVER['APPENGINE_RUNTIME']) && 105 | $_SERVER['APPENGINE_RUNTIME'] == 'php'; 106 | if ($appEngineDevAppServer) { 107 | return true; 108 | } 109 | return false; 110 | } 111 | 112 | /** 113 | * Implements FetchAuthTokenInterface#fetchAuthToken. 114 | * 115 | * Fetches the auth tokens using the AppIdentityService if available. 116 | * As the AppIdentityService uses protobufs to fetch the access token, 117 | * the GuzzleHttp\ClientInterface instance passed in will not be used. 118 | * 119 | * @param callable|null $httpHandler callback which delivers psr7 request 120 | * @return array { 121 | * A set of auth related metadata, containing the following 122 | * 123 | * @type string $access_token 124 | * @type string $expiration_time 125 | * } 126 | */ 127 | public function fetchAuthToken(?callable $httpHandler = null) 128 | { 129 | try { 130 | $this->checkAppEngineContext(); 131 | } catch (\Exception $e) { 132 | return []; 133 | } 134 | 135 | /** @phpstan-ignore-next-line */ 136 | $token = AppIdentityService::getAccessToken($this->scope); 137 | $this->lastReceivedToken = $token; 138 | 139 | return $token; 140 | } 141 | 142 | /** 143 | * Sign a string using AppIdentityService. 144 | * 145 | * @param string $stringToSign The string to sign. 146 | * @param bool $forceOpenSsl [optional] Does not apply to this credentials 147 | * type. 148 | * @return string The signature, base64-encoded. 149 | * @throws \Exception If AppEngine SDK or mock is not available. 150 | */ 151 | public function signBlob($stringToSign, $forceOpenSsl = false) 152 | { 153 | $this->checkAppEngineContext(); 154 | 155 | /** @phpstan-ignore-next-line */ 156 | return base64_encode(AppIdentityService::signForApp($stringToSign)['signature']); 157 | } 158 | 159 | /** 160 | * Get the project ID from AppIdentityService. 161 | * 162 | * Returns null if AppIdentityService is unavailable. 163 | * 164 | * @param callable|null $httpHandler Not used by this type. 165 | * @return string|null 166 | */ 167 | public function getProjectId(?callable $httpHandler = null) 168 | { 169 | try { 170 | $this->checkAppEngineContext(); 171 | } catch (\Exception $e) { 172 | return null; 173 | } 174 | 175 | /** @phpstan-ignore-next-line */ 176 | return AppIdentityService::getApplicationId(); 177 | } 178 | 179 | /** 180 | * Get the client name from AppIdentityService. 181 | * 182 | * Subsequent calls to this method will return a cached value. 183 | * 184 | * @param callable|null $httpHandler Not used in this implementation. 185 | * @return string 186 | * @throws \Exception If AppEngine SDK or mock is not available. 187 | */ 188 | public function getClientName(?callable $httpHandler = null) 189 | { 190 | $this->checkAppEngineContext(); 191 | 192 | if (!$this->clientName) { 193 | /** @phpstan-ignore-next-line */ 194 | $this->clientName = AppIdentityService::getServiceAccountName(); 195 | } 196 | 197 | return $this->clientName; 198 | } 199 | 200 | /** 201 | * @return array{access_token:string,expires_at:int}|null 202 | */ 203 | public function getLastReceivedToken() 204 | { 205 | if ($this->lastReceivedToken) { 206 | return [ 207 | 'access_token' => $this->lastReceivedToken['access_token'], 208 | 'expires_at' => $this->lastReceivedToken['expiration_time'], 209 | ]; 210 | } 211 | 212 | return null; 213 | } 214 | 215 | /** 216 | * Caching is handled by the underlying AppIdentityService, return empty string 217 | * to prevent caching. 218 | * 219 | * @return string 220 | */ 221 | public function getCacheKey() 222 | { 223 | return ''; 224 | } 225 | 226 | /** 227 | * @return void 228 | */ 229 | private function checkAppEngineContext() 230 | { 231 | if (!self::onAppEngine() || !class_exists('google\appengine\api\app_identity\AppIdentityService')) { 232 | throw new \Exception( 233 | 'This class must be run in App Engine, or you must include the AppIdentityService ' 234 | . 'mock class defined in tests/mocks/AppIdentityService.php' 235 | ); 236 | } 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/Credentials/ServiceAccountJwtAccessCredentials.php: -------------------------------------------------------------------------------- 1 | $jsonKey JSON credential file path or JSON credentials 73 | * as an associative array 74 | * @param string|string[] $scope the scope of the access request, expressed 75 | * either as an Array or as a space-delimited String. 76 | */ 77 | public function __construct($jsonKey, $scope = null) 78 | { 79 | if (is_string($jsonKey)) { 80 | if (!file_exists($jsonKey)) { 81 | throw new \InvalidArgumentException('file does not exist'); 82 | } 83 | $jsonKeyStream = file_get_contents($jsonKey); 84 | if (!$jsonKey = json_decode((string) $jsonKeyStream, true)) { 85 | throw new \LogicException('invalid json for auth config'); 86 | } 87 | } 88 | if (!array_key_exists('client_email', $jsonKey)) { 89 | throw new \InvalidArgumentException( 90 | 'json key is missing the client_email field' 91 | ); 92 | } 93 | if (!array_key_exists('private_key', $jsonKey)) { 94 | throw new \InvalidArgumentException( 95 | 'json key is missing the private_key field' 96 | ); 97 | } 98 | if (array_key_exists('quota_project_id', $jsonKey)) { 99 | $this->quotaProject = (string) $jsonKey['quota_project_id']; 100 | } 101 | $this->auth = new OAuth2([ 102 | 'issuer' => $jsonKey['client_email'], 103 | 'sub' => $jsonKey['client_email'], 104 | 'signingAlgorithm' => 'RS256', 105 | 'signingKey' => $jsonKey['private_key'], 106 | 'scope' => $scope, 107 | ]); 108 | 109 | $this->projectId = $jsonKey['project_id'] ?? null; 110 | } 111 | 112 | /** 113 | * Updates metadata with the authorization token. 114 | * 115 | * @param array $metadata metadata hashmap 116 | * @param string $authUri optional auth uri 117 | * @param callable|null $httpHandler callback which delivers psr7 request 118 | * @return array updated metadata hashmap 119 | */ 120 | public function updateMetadata( 121 | $metadata, 122 | $authUri = null, 123 | ?callable $httpHandler = null 124 | ) { 125 | $scope = $this->auth->getScope(); 126 | if (empty($authUri) && empty($scope)) { 127 | return $metadata; 128 | } 129 | 130 | $this->auth->setAudience($authUri); 131 | 132 | return parent::updateMetadata($metadata, $authUri, $httpHandler); 133 | } 134 | 135 | /** 136 | * Implements FetchAuthTokenInterface#fetchAuthToken. 137 | * 138 | * @param callable|null $httpHandler 139 | * 140 | * @return null|array{access_token:string} A set of auth related metadata 141 | */ 142 | public function fetchAuthToken(?callable $httpHandler = null) 143 | { 144 | $audience = $this->auth->getAudience(); 145 | $scope = $this->auth->getScope(); 146 | if (empty($audience) && empty($scope)) { 147 | return null; 148 | } 149 | 150 | if (!empty($audience) && !empty($scope)) { 151 | throw new \UnexpectedValueException( 152 | 'Cannot sign both audience and scope in JwtAccess' 153 | ); 154 | } 155 | 156 | $access_token = $this->auth->toJwt(); 157 | 158 | // Set the self-signed access token in OAuth2 for getLastReceivedToken 159 | $this->auth->setAccessToken($access_token); 160 | 161 | return [ 162 | 'access_token' => $access_token, 163 | 'expires_in' => $this->auth->getExpiry(), 164 | 'token_type' => 'Bearer' 165 | ]; 166 | } 167 | 168 | /** 169 | * Return the cache key for the credentials. 170 | * The format for the Cache Key one of the following: 171 | * ClientEmail.Scope 172 | * ClientEmail.Audience 173 | * 174 | * @return string 175 | */ 176 | public function getCacheKey() 177 | { 178 | $scopeOrAudience = $this->auth->getScope(); 179 | if (!$scopeOrAudience) { 180 | $scopeOrAudience = $this->auth->getAudience(); 181 | } 182 | 183 | return $this->auth->getIssuer() . '.' . $scopeOrAudience; 184 | } 185 | 186 | /** 187 | * @return array 188 | */ 189 | public function getLastReceivedToken() 190 | { 191 | return $this->auth->getLastReceivedToken(); 192 | } 193 | 194 | /** 195 | * Get the project ID from the service account keyfile. 196 | * 197 | * Returns null if the project ID does not exist in the keyfile. 198 | * 199 | * @param callable|null $httpHandler Not used by this credentials type. 200 | * @return string|null 201 | */ 202 | public function getProjectId(?callable $httpHandler = null) 203 | { 204 | return $this->projectId; 205 | } 206 | 207 | /** 208 | * Get the client name from the keyfile. 209 | * 210 | * In this case, it returns the keyfile's client_email key. 211 | * 212 | * @param callable|null $httpHandler Not used by this credentials type. 213 | * @return string 214 | */ 215 | public function getClientName(?callable $httpHandler = null) 216 | { 217 | return $this->auth->getIssuer(); 218 | } 219 | 220 | /** 221 | * Get the private key from the keyfile. 222 | * 223 | * In this case, it returns the keyfile's private_key key, needed for JWT signing. 224 | * 225 | * @return string 226 | */ 227 | public function getPrivateKey() 228 | { 229 | return $this->auth->getSigningKey(); 230 | } 231 | 232 | /** 233 | * Get the quota project used for this API request 234 | * 235 | * @return string|null 236 | */ 237 | public function getQuotaProject() 238 | { 239 | return $this->quotaProject; 240 | } 241 | 242 | protected function getCredType(): string 243 | { 244 | return self::CRED_TYPE; 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/Cache/SysVCacheItemPool.php: -------------------------------------------------------------------------------- 1 | 60 | */ 61 | private $options; 62 | 63 | /** 64 | * @var bool 65 | */ 66 | private $hasLoadedItems = false; 67 | 68 | /** 69 | * @var SysvSemaphore|false 70 | */ 71 | private SysvSemaphore|false $semId = false; 72 | 73 | /** 74 | * Maintain the process which is currently holding the semaphore to prevent deadlock. 75 | * 76 | * @var int|null 77 | */ 78 | private ?int $lockOwnerPid = null; 79 | 80 | /** 81 | * Create a SystemV shared memory based CacheItemPool. 82 | * 83 | * @param array $options { 84 | * [optional] Configuration options. 85 | * 86 | * @type int $variableKey The variable key for getting the data from the shared memory. **Defaults to** 1. 87 | * @type string $proj The project identifier for ftok. This needs to be a one character string. 88 | * **Defaults to** 'A'. 89 | * @type string $semProj The project identifier for ftok to provide to `sem_get`. This needs to be a one 90 | * character string. 91 | * **Defaults to** 'B'. 92 | * @type int $memsize The memory size in bytes for shm_attach. **Defaults to** 10000. 93 | * @type int $perm The permission for shm_attach. **Defaults to** 0600. 94 | * } 95 | */ 96 | public function __construct($options = []) 97 | { 98 | if (!extension_loaded('sysvshm')) { 99 | throw new \RuntimeException( 100 | 'sysvshm extension is required to use this ItemPool' 101 | ); 102 | } 103 | $this->options = $options + [ 104 | 'variableKey' => self::VAR_KEY, 105 | 'proj' => self::DEFAULT_PROJ, 106 | 'semProj' => self::DEFAULT_SEM_PROJ, 107 | 'memsize' => self::DEFAULT_MEMSIZE, 108 | 'perm' => self::DEFAULT_PERM 109 | ]; 110 | $this->items = []; 111 | $this->deferredItems = []; 112 | $this->sysvKey = ftok(__FILE__, $this->options['proj']); 113 | 114 | // gracefully handle when `sysvsem` isn't loaded 115 | // @TODO(v2): throw an exception when the extension isn't loaded 116 | if (extension_loaded('sysvsem')) { 117 | $semKey = ftok(__FILE__, $this->options['semProj']); 118 | $this->semId = sem_get($semKey, 1, $this->options['perm'], true); 119 | } 120 | } 121 | 122 | /** 123 | * @param mixed $key 124 | * @return CacheItemInterface 125 | */ 126 | public function getItem($key): CacheItemInterface 127 | { 128 | $this->loadItems(); 129 | return current($this->getItems([$key])); // @phpstan-ignore-line 130 | } 131 | 132 | /** 133 | * @param array $keys 134 | * @return iterable 135 | */ 136 | public function getItems(array $keys = []): iterable 137 | { 138 | $this->loadItems(); 139 | $items = []; 140 | foreach ($keys as $key) { 141 | $items[$key] = $this->hasItem($key) ? 142 | clone $this->items[$key] : 143 | new TypedItem($key); 144 | } 145 | return $items; 146 | } 147 | 148 | /** 149 | * {@inheritdoc} 150 | */ 151 | public function hasItem($key): bool 152 | { 153 | $this->loadItems(); 154 | return isset($this->items[$key]) && $this->items[$key]->isHit(); 155 | } 156 | 157 | /** 158 | * {@inheritdoc} 159 | */ 160 | public function clear(): bool 161 | { 162 | if (!$this->acquireLock()) { 163 | return false; 164 | } 165 | 166 | $this->items = []; 167 | $this->deferredItems = []; 168 | $ret = $this->saveCurrentItems(); 169 | 170 | $this->resetShm(); 171 | $this->releaseLock(); 172 | return $ret; 173 | } 174 | 175 | /** 176 | * {@inheritdoc} 177 | */ 178 | public function deleteItem($key): bool 179 | { 180 | return $this->deleteItems([$key]); 181 | } 182 | 183 | /** 184 | * {@inheritdoc} 185 | */ 186 | public function deleteItems(array $keys): bool 187 | { 188 | if (!$this->acquireLock()) { 189 | return false; 190 | } 191 | 192 | if (!$this->hasLoadedItems) { 193 | $this->loadItems(); 194 | } 195 | 196 | foreach ($keys as $key) { 197 | unset($this->items[$key]); 198 | } 199 | $ret = $this->saveCurrentItems(); 200 | 201 | $this->resetShm(); 202 | $this->releaseLock(); 203 | return $ret; 204 | } 205 | 206 | /** 207 | * {@inheritdoc} 208 | */ 209 | public function save(CacheItemInterface $item): bool 210 | { 211 | if (!$this->acquireLock()) { 212 | return false; 213 | } 214 | 215 | if (!$this->hasLoadedItems) { 216 | $this->loadItems(); 217 | } 218 | 219 | $this->items[$item->getKey()] = $item; 220 | $ret = $this->saveCurrentItems(); 221 | $this->releaseLock(); 222 | return $ret; 223 | } 224 | 225 | /** 226 | * {@inheritdoc} 227 | */ 228 | public function saveDeferred(CacheItemInterface $item): bool 229 | { 230 | $this->deferredItems[$item->getKey()] = $item; 231 | return true; 232 | } 233 | 234 | /** 235 | * {@inheritdoc} 236 | */ 237 | public function commit(): bool 238 | { 239 | if (!$this->acquireLock()) { 240 | return false; 241 | } 242 | 243 | foreach ($this->deferredItems as $item) { 244 | if ($this->save($item) === false) { 245 | $this->releaseLock(); 246 | return false; 247 | } 248 | } 249 | $this->deferredItems = []; 250 | $this->releaseLock(); 251 | return true; 252 | } 253 | 254 | /** 255 | * Save the current items. 256 | * 257 | * @return bool true when success, false upon failure 258 | */ 259 | private function saveCurrentItems() 260 | { 261 | if (!$this->acquireLock()) { 262 | return false; 263 | } 264 | 265 | if (false !== $shmid = $this->attachShm()) { 266 | $success = shm_put_var( 267 | $shmid, 268 | $this->options['variableKey'], 269 | $this->items 270 | ); 271 | shm_detach($shmid); 272 | $this->releaseLock(); 273 | return $success; 274 | } 275 | $this->releaseLock(); 276 | return false; 277 | } 278 | 279 | /** 280 | * Load the items from the shared memory. 281 | * 282 | * @return bool true when success, false upon failure 283 | */ 284 | private function loadItems() 285 | { 286 | if (!$this->acquireLock()) { 287 | return false; 288 | } 289 | 290 | if (false !== $shmid = $this->attachShm()) { 291 | $data = @shm_get_var($shmid, $this->options['variableKey']); 292 | $this->items = $data ?: []; 293 | shm_detach($shmid); 294 | $this->hasLoadedItems = true; 295 | $this->releaseLock(); 296 | return true; 297 | } 298 | $this->releaseLock(); 299 | return false; 300 | } 301 | 302 | private function acquireLock(): bool 303 | { 304 | if ($this->semId === false) { 305 | // if `sysvsem` isn't loaded, or if `sem_get` fails, return true 306 | // this ensures BC with previous versions of the auth library. 307 | // @TODO consider better handling when `sem_get` fails. 308 | return true; 309 | } 310 | 311 | $currentPid = getmypid(); 312 | if ($this->lockOwnerPid === $currentPid) { 313 | // We already have the lock 314 | return true; 315 | } 316 | 317 | if (sem_acquire($this->semId)) { 318 | $this->lockOwnerPid = (int) $currentPid; 319 | return true; 320 | } 321 | return false; 322 | } 323 | 324 | private function releaseLock(): bool 325 | { 326 | if ($this->semId === false || $this->lockOwnerPid !== getmypid()) { 327 | return true; 328 | } 329 | 330 | $this->lockOwnerPid = null; 331 | return sem_release($this->semId); 332 | } 333 | 334 | private function resetShm(): void 335 | { 336 | // Remove the shared memory segment and semaphore when clearing the cache 337 | $shmid = @shm_attach($this->sysvKey); 338 | if ($shmid !== false) { 339 | @shm_remove($shmid); 340 | @shm_detach($shmid); 341 | } 342 | } 343 | 344 | private function attachShm(): SysvSharedMemory|false 345 | { 346 | return shm_attach( 347 | $this->sysvKey, 348 | $this->options['memsize'], 349 | $this->options['perm'] 350 | ); 351 | } 352 | } 353 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | 204 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2015 Google Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /src/CredentialSource/ExecutableSource.php: -------------------------------------------------------------------------------- 1 | 43 | * OIDC response sample: 44 | * { 45 | * "version": 1, 46 | * "success": true, 47 | * "token_type": "urn:ietf:params:oauth:token-type:id_token", 48 | * "id_token": "HEADER.PAYLOAD.SIGNATURE", 49 | * "expiration_time": 1620433341 50 | * } 51 | * 52 | * SAML2 response sample: 53 | * { 54 | * "version": 1, 55 | * "success": true, 56 | * "token_type": "urn:ietf:params:oauth:token-type:saml2", 57 | * "saml_response": "...", 58 | * "expiration_time": 1620433341 59 | * } 60 | * 61 | * Error response sample: 62 | * { 63 | * "version": 1, 64 | * "success": false, 65 | * "code": "401", 66 | * "message": "Error message." 67 | * } 68 | * 69 | * 70 | * The "expiration_time" field in the JSON response is only required for successful 71 | * responses when an output file was specified in the credential configuration 72 | * 73 | * The auth libraries will populate certain environment variables that will be accessible by the 74 | * executable, such as: GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE, GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE, 75 | * GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE, GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL, and 76 | * GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE. 77 | */ 78 | class ExecutableSource implements ExternalAccountCredentialSourceInterface 79 | { 80 | private const GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES = 'GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES'; 81 | private const SAML_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:saml2'; 82 | private const OIDC_SUBJECT_TOKEN_TYPE1 = 'urn:ietf:params:oauth:token-type:id_token'; 83 | private const OIDC_SUBJECT_TOKEN_TYPE2 = 'urn:ietf:params:oauth:token-type:jwt'; 84 | 85 | private string $command; 86 | private ExecutableHandler $executableHandler; 87 | private ?string $outputFile; 88 | 89 | /** 90 | * @param string $command The string command to run to get the subject token. 91 | * @param string|null $outputFile 92 | */ 93 | public function __construct( 94 | string $command, 95 | ?string $outputFile, 96 | ?ExecutableHandler $executableHandler = null, 97 | ) { 98 | $this->command = $command; 99 | $this->outputFile = $outputFile; 100 | $this->executableHandler = $executableHandler ?: new ExecutableHandler(); 101 | } 102 | 103 | /** 104 | * Gets the unique key for caching 105 | * The format for the cache key is: 106 | * Command.OutputFile 107 | * 108 | * @return ?string 109 | */ 110 | public function getCacheKey(): ?string 111 | { 112 | return $this->command . '.' . $this->outputFile; 113 | } 114 | 115 | /** 116 | * @param callable|null $httpHandler unused. 117 | * @return string 118 | * @throws RuntimeException if the executable is not allowed to run. 119 | * @throws ExecutableResponseError if the executable response is invalid. 120 | */ 121 | public function fetchSubjectToken(?callable $httpHandler = null): string 122 | { 123 | // Check if the executable is allowed to run. 124 | if (getenv(self::GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES) !== '1') { 125 | throw new RuntimeException( 126 | 'Pluggable Auth executables need to be explicitly allowed to run by ' 127 | . 'setting the GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment ' 128 | . 'Variable to 1.' 129 | ); 130 | } 131 | 132 | if (!$executableResponse = $this->getCachedExecutableResponse()) { 133 | // Run the executable. 134 | $exitCode = ($this->executableHandler)($this->command); 135 | $output = $this->executableHandler->getOutput(); 136 | 137 | // If the exit code is not 0, throw an exception with the output as the error details 138 | if ($exitCode !== 0) { 139 | throw new ExecutableResponseError( 140 | 'The executable failed to run' 141 | . ($output ? ' with the following error: ' . $output : '.'), 142 | (string) $exitCode 143 | ); 144 | } 145 | 146 | $executableResponse = $this->parseExecutableResponse($output); 147 | 148 | // Validate expiration. 149 | if (isset($executableResponse['expiration_time']) && time() >= $executableResponse['expiration_time']) { 150 | throw new ExecutableResponseError('Executable response is expired.'); 151 | } 152 | } 153 | 154 | // Throw error when the request was unsuccessful 155 | if ($executableResponse['success'] === false) { 156 | throw new ExecutableResponseError($executableResponse['message'], (string) $executableResponse['code']); 157 | } 158 | 159 | // Return subject token field based on the token type 160 | return $executableResponse['token_type'] === self::SAML_SUBJECT_TOKEN_TYPE 161 | ? $executableResponse['saml_response'] 162 | : $executableResponse['id_token']; 163 | } 164 | 165 | /** 166 | * @return array|null 167 | */ 168 | private function getCachedExecutableResponse(): ?array 169 | { 170 | if ( 171 | $this->outputFile 172 | && file_exists($this->outputFile) 173 | && !empty(trim($outputFileContents = (string) file_get_contents($this->outputFile))) 174 | ) { 175 | try { 176 | $executableResponse = $this->parseExecutableResponse($outputFileContents); 177 | } catch (ExecutableResponseError $e) { 178 | throw new ExecutableResponseError( 179 | 'Error in output file: ' . $e->getMessage(), 180 | 'INVALID_OUTPUT_FILE' 181 | ); 182 | } 183 | 184 | if ($executableResponse['success'] === false) { 185 | // If the cached token was unsuccessful, run the executable to get a new one. 186 | return null; 187 | } 188 | 189 | if (isset($executableResponse['expiration_time']) && time() >= $executableResponse['expiration_time']) { 190 | // If the cached token is expired, run the executable to get a new one. 191 | return null; 192 | } 193 | 194 | return $executableResponse; 195 | } 196 | 197 | return null; 198 | } 199 | 200 | /** 201 | * @return array 202 | */ 203 | private function parseExecutableResponse(string $response): array 204 | { 205 | $executableResponse = json_decode($response, true); 206 | if (json_last_error() !== JSON_ERROR_NONE) { 207 | throw new ExecutableResponseError( 208 | 'The executable returned an invalid response: ' . $response, 209 | 'INVALID_RESPONSE' 210 | ); 211 | } 212 | if (!array_key_exists('version', $executableResponse)) { 213 | throw new ExecutableResponseError('Executable response must contain a "version" field.'); 214 | } 215 | if (!array_key_exists('success', $executableResponse)) { 216 | throw new ExecutableResponseError('Executable response must contain a "success" field.'); 217 | } 218 | 219 | // Validate required fields for a successful response. 220 | if ($executableResponse['success']) { 221 | // Validate token type field. 222 | $tokenTypes = [self::SAML_SUBJECT_TOKEN_TYPE, self::OIDC_SUBJECT_TOKEN_TYPE1, self::OIDC_SUBJECT_TOKEN_TYPE2]; 223 | if (!isset($executableResponse['token_type'])) { 224 | throw new ExecutableResponseError( 225 | 'Executable response must contain a "token_type" field when successful' 226 | ); 227 | } 228 | if (!in_array($executableResponse['token_type'], $tokenTypes)) { 229 | throw new ExecutableResponseError(sprintf( 230 | 'Executable response "token_type" field must be one of %s.', 231 | implode(', ', $tokenTypes) 232 | )); 233 | } 234 | 235 | // Validate subject token for SAML and OIDC. 236 | if ($executableResponse['token_type'] === self::SAML_SUBJECT_TOKEN_TYPE) { 237 | if (empty($executableResponse['saml_response'])) { 238 | throw new ExecutableResponseError(sprintf( 239 | 'Executable response must contain a "saml_response" field when token_type=%s.', 240 | self::SAML_SUBJECT_TOKEN_TYPE 241 | )); 242 | } 243 | } elseif (empty($executableResponse['id_token'])) { 244 | throw new ExecutableResponseError(sprintf( 245 | 'Executable response must contain a "id_token" field when ' 246 | . 'token_type=%s.', 247 | $executableResponse['token_type'] 248 | )); 249 | } 250 | 251 | // Validate expiration exists when an output file is specified. 252 | if ($this->outputFile) { 253 | if (!isset($executableResponse['expiration_time'])) { 254 | throw new ExecutableResponseError( 255 | 'The executable response must contain a "expiration_time" field for successful responses ' . 256 | 'when an output_file has been specified in the configuration.' 257 | ); 258 | } 259 | } 260 | } else { 261 | // Both code and message must be provided for unsuccessful responses. 262 | if (!array_key_exists('code', $executableResponse)) { 263 | throw new ExecutableResponseError('Executable response must contain a "code" field when unsuccessful.'); 264 | } 265 | if (empty($executableResponse['message'])) { 266 | throw new ExecutableResponseError('Executable response must contain a "message" field when unsuccessful.'); 267 | } 268 | } 269 | 270 | return $executableResponse; 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /src/FetchAuthTokenCache.php: -------------------------------------------------------------------------------- 1 | |null $cacheConfig Configuration for the cache 49 | * @param CacheItemPoolInterface $cache 50 | */ 51 | public function __construct( 52 | FetchAuthTokenInterface $fetcher, 53 | ?array $cacheConfig = null, 54 | ?CacheItemPoolInterface $cache = null 55 | ) { 56 | $this->fetcher = $fetcher; 57 | $this->cache = $cache; 58 | $this->cacheConfig = array_merge([ 59 | 'lifetime' => 1500, 60 | 'prefix' => '', 61 | 'cacheUniverseDomain' => $fetcher instanceof Credentials\GCECredentials, 62 | ], (array) $cacheConfig); 63 | } 64 | 65 | /** 66 | * @return FetchAuthTokenInterface 67 | */ 68 | public function getFetcher() 69 | { 70 | return $this->fetcher; 71 | } 72 | 73 | /** 74 | * Implements FetchAuthTokenInterface#fetchAuthToken. 75 | * 76 | * Checks the cache for a valid auth token and fetches the auth tokens 77 | * from the supplied fetcher. 78 | * 79 | * @param callable|null $httpHandler callback which delivers psr7 request 80 | * @return array the response 81 | * @throws \Exception 82 | */ 83 | public function fetchAuthToken(?callable $httpHandler = null) 84 | { 85 | if ($cached = $this->fetchAuthTokenFromCache()) { 86 | return $cached; 87 | } 88 | 89 | $auth_token = $this->fetcher->fetchAuthToken($httpHandler); 90 | 91 | $this->saveAuthTokenInCache($auth_token); 92 | 93 | return $auth_token; 94 | } 95 | 96 | /** 97 | * @return string 98 | */ 99 | public function getCacheKey() 100 | { 101 | return $this->getFullCacheKey($this->fetcher->getCacheKey()); 102 | } 103 | 104 | /** 105 | * @return array|null 106 | */ 107 | public function getLastReceivedToken() 108 | { 109 | return $this->fetcher->getLastReceivedToken(); 110 | } 111 | 112 | /** 113 | * Get the client name from the fetcher. 114 | * 115 | * @param callable|null $httpHandler An HTTP handler to deliver PSR7 requests. 116 | * @return string 117 | */ 118 | public function getClientName(?callable $httpHandler = null) 119 | { 120 | if (!$this->fetcher instanceof SignBlobInterface) { 121 | throw new \RuntimeException( 122 | 'Credentials fetcher does not implement ' . 123 | 'Google\Auth\SignBlobInterface' 124 | ); 125 | } 126 | 127 | return $this->fetcher->getClientName($httpHandler); 128 | } 129 | 130 | /** 131 | * Sign a blob using the fetcher. 132 | * 133 | * @param string $stringToSign The string to sign. 134 | * @param bool $forceOpenSsl Require use of OpenSSL for local signing. Does 135 | * not apply to signing done using external services. **Defaults to** 136 | * `false`. 137 | * @return string The resulting signature. 138 | * @throws \RuntimeException If the fetcher does not implement 139 | * `Google\Auth\SignBlobInterface`. 140 | */ 141 | public function signBlob($stringToSign, $forceOpenSsl = false) 142 | { 143 | if (!$this->fetcher instanceof SignBlobInterface) { 144 | throw new \RuntimeException( 145 | 'Credentials fetcher does not implement ' . 146 | 'Google\Auth\SignBlobInterface' 147 | ); 148 | } 149 | 150 | // Pass the access token from cache for credentials that sign blobs 151 | // using the IAM API. This saves a call to fetch an access token when a 152 | // cached token exists. 153 | if ($this->fetcher instanceof Credentials\GCECredentials 154 | || $this->fetcher instanceof Credentials\ImpersonatedServiceAccountCredentials 155 | ) { 156 | $cached = $this->fetchAuthTokenFromCache(); 157 | $accessToken = $cached['access_token'] ?? null; 158 | return $this->fetcher->signBlob($stringToSign, $forceOpenSsl, $accessToken); 159 | } 160 | 161 | return $this->fetcher->signBlob($stringToSign, $forceOpenSsl); 162 | } 163 | 164 | /** 165 | * Get the quota project used for this API request from the credentials 166 | * fetcher. 167 | * 168 | * @return string|null 169 | */ 170 | public function getQuotaProject() 171 | { 172 | if ($this->fetcher instanceof GetQuotaProjectInterface) { 173 | return $this->fetcher->getQuotaProject(); 174 | } 175 | 176 | return null; 177 | } 178 | 179 | /** 180 | * Get the Project ID from the fetcher. 181 | * 182 | * @param callable|null $httpHandler Callback which delivers psr7 request 183 | * @return string|null 184 | * @throws \RuntimeException If the fetcher does not implement 185 | * `Google\Auth\ProvidesProjectIdInterface`. 186 | */ 187 | public function getProjectId(?callable $httpHandler = null) 188 | { 189 | if (!$this->fetcher instanceof ProjectIdProviderInterface) { 190 | throw new \RuntimeException( 191 | 'Credentials fetcher does not implement ' . 192 | 'Google\Auth\ProvidesProjectIdInterface' 193 | ); 194 | } 195 | 196 | // Pass the access token from cache for credentials that require an 197 | // access token to fetch the project ID. This saves a call to fetch an 198 | // access token when a cached token exists. 199 | if ($this->fetcher instanceof Credentials\ExternalAccountCredentials) { 200 | $cached = $this->fetchAuthTokenFromCache(); 201 | $accessToken = $cached['access_token'] ?? null; 202 | return $this->fetcher->getProjectId($httpHandler, $accessToken); 203 | } 204 | 205 | return $this->fetcher->getProjectId($httpHandler); 206 | } 207 | 208 | /* 209 | * Get the Universe Domain from the fetcher. 210 | * 211 | * @return string 212 | */ 213 | public function getUniverseDomain(): string 214 | { 215 | if ($this->fetcher instanceof GetUniverseDomainInterface) { 216 | if ($this->cacheConfig['cacheUniverseDomain']) { 217 | return $this->getCachedUniverseDomain($this->fetcher); 218 | } 219 | return $this->fetcher->getUniverseDomain(); 220 | } 221 | 222 | return GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN; 223 | } 224 | 225 | /** 226 | * Updates metadata with the authorization token. 227 | * 228 | * @param array $metadata metadata hashmap 229 | * @param string $authUri optional auth uri 230 | * @param callable|null $httpHandler callback which delivers psr7 request 231 | * @return array updated metadata hashmap 232 | * @throws \RuntimeException If the fetcher does not implement 233 | * `Google\Auth\UpdateMetadataInterface`. 234 | */ 235 | public function updateMetadata( 236 | $metadata, 237 | $authUri = null, 238 | ?callable $httpHandler = null 239 | ) { 240 | if (!$this->fetcher instanceof UpdateMetadataInterface) { 241 | throw new \RuntimeException( 242 | 'Credentials fetcher does not implement ' . 243 | 'Google\Auth\UpdateMetadataInterface' 244 | ); 245 | } 246 | 247 | $cached = $this->fetchAuthTokenFromCache($authUri); 248 | if ($cached) { 249 | // Set the access token in the `Authorization` metadata header so 250 | // the downstream call to updateMetadata know they don't need to 251 | // fetch another token. 252 | if (isset($cached['access_token'])) { 253 | $metadata[self::AUTH_METADATA_KEY] = [ 254 | 'Bearer ' . $cached['access_token'] 255 | ]; 256 | } elseif (isset($cached['id_token'])) { 257 | $metadata[self::AUTH_METADATA_KEY] = [ 258 | 'Bearer ' . $cached['id_token'] 259 | ]; 260 | } 261 | } 262 | 263 | $newMetadata = $this->fetcher->updateMetadata( 264 | $metadata, 265 | $authUri, 266 | $httpHandler 267 | ); 268 | 269 | if (!$cached && $token = $this->fetcher->getLastReceivedToken()) { 270 | $this->saveAuthTokenInCache($token, $authUri); 271 | } 272 | 273 | return $newMetadata; 274 | } 275 | 276 | /** 277 | * @param string|null $authUri 278 | * @return array|null 279 | */ 280 | private function fetchAuthTokenFromCache($authUri = null) 281 | { 282 | // Use the cached value if its available. 283 | // 284 | // TODO: correct caching; update the call to setCachedValue to set the expiry 285 | // to the value returned with the auth token. 286 | // 287 | // TODO: correct caching; enable the cache to be cleared. 288 | 289 | // if $authUri is set, use it as the cache key 290 | $cacheKey = $authUri 291 | ? $this->getFullCacheKey($authUri) 292 | : $this->fetcher->getCacheKey(); 293 | 294 | $cached = $this->getCachedValue($cacheKey); 295 | if (is_array($cached)) { 296 | if (empty($cached['expires_at'])) { 297 | // If there is no expiration data, assume token is not expired. 298 | // (for JwtAccess and ID tokens) 299 | return $cached; 300 | } 301 | if ((time() + $this->eagerRefreshThresholdSeconds) < $cached['expires_at']) { 302 | // access token is not expired 303 | return $cached; 304 | } 305 | } 306 | 307 | return null; 308 | } 309 | 310 | /** 311 | * @param array $authToken 312 | * @param string|null $authUri 313 | * @return void 314 | */ 315 | private function saveAuthTokenInCache($authToken, $authUri = null) 316 | { 317 | if (isset($authToken['access_token']) || 318 | isset($authToken['id_token'])) { 319 | // if $authUri is set, use it as the cache key 320 | $cacheKey = $authUri 321 | ? $this->getFullCacheKey($authUri) 322 | : $this->fetcher->getCacheKey(); 323 | 324 | $this->setCachedValue($cacheKey, $authToken); 325 | } 326 | } 327 | 328 | private function getCachedUniverseDomain(GetUniverseDomainInterface $fetcher): string 329 | { 330 | $cacheKey = $this->getFullCacheKey($fetcher->getCacheKey() . 'universe_domain'); // @phpstan-ignore-line 331 | if ($universeDomain = $this->getCachedValue($cacheKey)) { 332 | return $universeDomain; 333 | } 334 | 335 | $universeDomain = $fetcher->getUniverseDomain(); 336 | $this->setCachedValue($cacheKey, $universeDomain); 337 | return $universeDomain; 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /src/CredentialsLoader.php: -------------------------------------------------------------------------------- 1 | |null JSON key | null 76 | */ 77 | public static function fromEnv() 78 | { 79 | $path = self::getEnv(self::ENV_VAR); 80 | if (empty($path)) { 81 | return null; 82 | } 83 | if (!file_exists($path)) { 84 | $cause = 'file ' . $path . ' does not exist'; 85 | throw new \DomainException(self::unableToReadEnv($cause)); 86 | } 87 | $jsonKey = file_get_contents($path); 88 | 89 | return json_decode((string) $jsonKey, true); 90 | } 91 | 92 | /** 93 | * Load a JSON key from a well known path. 94 | * 95 | * The well known path is OS dependent: 96 | * 97 | * * windows: %APPDATA%/gcloud/application_default_credentials.json 98 | * * others: $HOME/.config/gcloud/application_default_credentials.json 99 | * 100 | * If the file does not exist, this returns null. 101 | * 102 | * @return array|null JSON key | null 103 | */ 104 | public static function fromWellKnownFile() 105 | { 106 | $rootEnv = self::isOnWindows() ? 'APPDATA' : 'HOME'; 107 | $path = [self::getEnv($rootEnv)]; 108 | if (!self::isOnWindows()) { 109 | $path[] = self::NON_WINDOWS_WELL_KNOWN_PATH_BASE; 110 | } 111 | $path[] = self::WELL_KNOWN_PATH; 112 | $path = implode(DIRECTORY_SEPARATOR, $path); 113 | if (!file_exists($path)) { 114 | return null; 115 | } 116 | $jsonKey = file_get_contents($path); 117 | return json_decode((string) $jsonKey, true); 118 | } 119 | 120 | /** 121 | * Create a new Credentials instance. 122 | * 123 | * @deprecated This method is being deprecated because of a potential security risk. 124 | * 125 | * This method does not validate the credential configuration. The security 126 | * risk occurs when a credential configuration is accepted from a source 127 | * that is not under your control and used without validation on your side. 128 | * 129 | * If you know that you will be loading credential configurations of a 130 | * specific type, it is recommended to use a credential-type-specific 131 | * method. 132 | * This will ensure that an unexpected credential type with potential for 133 | * malicious intent is not loaded unintentionally. You might still have to do 134 | * validation for certain credential types. Please follow the recommendation 135 | * for that method. For example, if you want to load only service accounts, 136 | * you can create the {@see ServiceAccountCredentials} explicitly: 137 | * 138 | * ``` 139 | * use Google\Auth\Credentials\ServiceAccountCredentials; 140 | * $creds = new ServiceAccountCredentials($scopes, $json); 141 | * ``` 142 | * 143 | * If you are loading your credential configuration from an untrusted source and have 144 | * not mitigated the risks (e.g. by validating the configuration yourself), make 145 | * these changes as soon as possible to prevent security risks to your environment. 146 | * 147 | * Regardless of the method used, it is always your responsibility to validate 148 | * configurations received from external sources. 149 | * 150 | * @see https://cloud.google.com/docs/authentication/external/externally-sourced-credentials 151 | * 152 | * @param string|string[] $scope 153 | * @param array $jsonKey 154 | * @param string|string[] $defaultScope 155 | * @return ServiceAccountCredentials|UserRefreshCredentials|ImpersonatedServiceAccountCredentials|ExternalAccountCredentials 156 | */ 157 | public static function makeCredentials( 158 | $scope, 159 | array $jsonKey, 160 | $defaultScope = null 161 | ) { 162 | if (!array_key_exists('type', $jsonKey)) { 163 | throw new \InvalidArgumentException('json key is missing the type field'); 164 | } 165 | 166 | if ($jsonKey['type'] == 'service_account') { 167 | // Do not pass $defaultScope to ServiceAccountCredentials 168 | return new ServiceAccountCredentials($scope, $jsonKey); 169 | } 170 | 171 | if ($jsonKey['type'] == 'authorized_user') { 172 | $anyScope = $scope ?: $defaultScope; 173 | return new UserRefreshCredentials($anyScope, $jsonKey); 174 | } 175 | 176 | if ($jsonKey['type'] == 'impersonated_service_account') { 177 | return new ImpersonatedServiceAccountCredentials($scope, $jsonKey, null, $defaultScope); 178 | } 179 | 180 | if ($jsonKey['type'] == 'external_account') { 181 | $anyScope = $scope ?: $defaultScope; 182 | return new ExternalAccountCredentials($anyScope, $jsonKey); 183 | } 184 | 185 | throw new \InvalidArgumentException('invalid value in the type field'); 186 | } 187 | 188 | /** 189 | * Create an authorized HTTP Client from an instance of FetchAuthTokenInterface. 190 | * 191 | * @param FetchAuthTokenInterface $fetcher is used to fetch the auth token 192 | * @param array $httpClientOptions (optional) Array of request options to apply. 193 | * @param callable|null $httpHandler (optional) http client to fetch the token. 194 | * @param callable|null $tokenCallback (optional) function to be called when a new token is fetched. 195 | * @return \GuzzleHttp\Client 196 | */ 197 | public static function makeHttpClient( 198 | FetchAuthTokenInterface $fetcher, 199 | array $httpClientOptions = [], 200 | ?callable $httpHandler = null, 201 | ?callable $tokenCallback = null 202 | ) { 203 | $middleware = new Middleware\AuthTokenMiddleware( 204 | $fetcher, 205 | $httpHandler, 206 | $tokenCallback 207 | ); 208 | $stack = \GuzzleHttp\HandlerStack::create(); 209 | $stack->push($middleware); 210 | 211 | return new \GuzzleHttp\Client([ 212 | 'handler' => $stack, 213 | 'auth' => 'google_auth', 214 | ] + $httpClientOptions); 215 | } 216 | 217 | /** 218 | * Create a new instance of InsecureCredentials. 219 | * 220 | * @return InsecureCredentials 221 | */ 222 | public static function makeInsecureCredentials() 223 | { 224 | return new InsecureCredentials(); 225 | } 226 | 227 | /** 228 | * Fetch a quota project from the environment variable 229 | * GOOGLE_CLOUD_QUOTA_PROJECT. Return null if 230 | * GOOGLE_CLOUD_QUOTA_PROJECT is not specified. 231 | * 232 | * @return string|null 233 | */ 234 | public static function quotaProjectFromEnv() 235 | { 236 | return self::getEnv(self::QUOTA_PROJECT_ENV_VAR) ?: null; 237 | } 238 | 239 | /** 240 | * Gets a callable which returns the default device certification. 241 | * 242 | * @throws UnexpectedValueException 243 | * @return callable|null 244 | */ 245 | public static function getDefaultClientCertSource() 246 | { 247 | if (!$clientCertSourceJson = self::loadDefaultClientCertSourceFile()) { 248 | return null; 249 | } 250 | $clientCertSourceCmd = $clientCertSourceJson['cert_provider_command']; 251 | 252 | return function () use ($clientCertSourceCmd) { 253 | $cmd = array_map('escapeshellarg', $clientCertSourceCmd); 254 | exec(implode(' ', $cmd), $output, $returnVar); 255 | 256 | if (0 === $returnVar) { 257 | return implode(PHP_EOL, $output); 258 | } 259 | throw new RuntimeException( 260 | '"cert_provider_command" failed with a nonzero exit code' 261 | ); 262 | }; 263 | } 264 | 265 | /** 266 | * Determines whether or not the default device certificate should be loaded. 267 | * 268 | * @return bool 269 | */ 270 | public static function shouldLoadClientCertSource() 271 | { 272 | return filter_var(self::getEnv(self::MTLS_CERT_ENV_VAR), FILTER_VALIDATE_BOOLEAN); 273 | } 274 | 275 | /** 276 | * @return array{cert_provider_command:string[]}|null 277 | */ 278 | private static function loadDefaultClientCertSourceFile() 279 | { 280 | $rootEnv = self::isOnWindows() ? 'APPDATA' : 'HOME'; 281 | $path = sprintf('%s/%s', self::getEnv($rootEnv), self::MTLS_WELL_KNOWN_PATH); 282 | if (!file_exists($path)) { 283 | return null; 284 | } 285 | $jsonKey = file_get_contents($path); 286 | $clientCertSourceJson = json_decode((string) $jsonKey, true); 287 | if (!$clientCertSourceJson) { 288 | throw new UnexpectedValueException('Invalid client cert source JSON'); 289 | } 290 | if (!isset($clientCertSourceJson['cert_provider_command'])) { 291 | throw new UnexpectedValueException( 292 | 'cert source requires "cert_provider_command"' 293 | ); 294 | } 295 | if (!is_array($clientCertSourceJson['cert_provider_command'])) { 296 | throw new UnexpectedValueException( 297 | 'cert source expects "cert_provider_command" to be an array' 298 | ); 299 | } 300 | return $clientCertSourceJson; 301 | } 302 | 303 | /** 304 | * Get the universe domain from the credential. Defaults to "googleapis.com" 305 | * for all credential types which do not support universe domain. 306 | * 307 | * @return string 308 | */ 309 | public function getUniverseDomain(): string 310 | { 311 | return self::DEFAULT_UNIVERSE_DOMAIN; 312 | } 313 | 314 | private static function getEnv(string $env): mixed 315 | { 316 | return getenv($env) ?: $_ENV[$env] ?? null; 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /src/Credentials/ImpersonatedServiceAccountCredentials.php: -------------------------------------------------------------------------------- 1 | $jsonKey JSON credential file path or JSON array credentials { 82 | * JSON credentials as an associative array. 83 | * 84 | * @type string $service_account_impersonation_url The URL to the service account 85 | * @type string|FetchAuthTokenInterface $source_credentials The source credentials to impersonate 86 | * @type int $lifetime The lifetime of the impersonated credentials 87 | * @type string[] $delegates The delegates to impersonate 88 | * } 89 | * @param string|null $targetAudience The audience to request an ID token. 90 | * @param string|string[]|null $defaultScope The scopes to be used if no "scopes" field exists 91 | * in the `$jsonKey`. 92 | */ 93 | public function __construct( 94 | string|array|null $scope, 95 | string|array $jsonKey, 96 | private ?string $targetAudience = null, 97 | string|array|null $defaultScope = null, 98 | ) { 99 | if (is_string($jsonKey)) { 100 | if (!file_exists($jsonKey)) { 101 | throw new InvalidArgumentException('file does not exist'); 102 | } 103 | $json = file_get_contents($jsonKey); 104 | if (!$jsonKey = json_decode((string) $json, true)) { 105 | throw new LogicException('invalid json for auth config'); 106 | } 107 | } 108 | if (!array_key_exists('service_account_impersonation_url', $jsonKey)) { 109 | throw new LogicException( 110 | 'json key is missing the service_account_impersonation_url field' 111 | ); 112 | } 113 | if (!array_key_exists('source_credentials', $jsonKey)) { 114 | throw new LogicException('json key is missing the source_credentials field'); 115 | } 116 | 117 | $jsonKeyScope = $jsonKey['scopes'] ?? null; 118 | $scope = $scope ?: $jsonKeyScope ?: $defaultScope; 119 | if ($scope && $targetAudience) { 120 | throw new InvalidArgumentException( 121 | 'Scope and targetAudience cannot both be supplied' 122 | ); 123 | } 124 | if (is_array($jsonKey['source_credentials'])) { 125 | if (!array_key_exists('type', $jsonKey['source_credentials'])) { 126 | throw new InvalidArgumentException('json key source credentials are missing the type field'); 127 | } 128 | if ( 129 | $targetAudience !== null 130 | && $jsonKey['source_credentials']['type'] === 'service_account' 131 | ) { 132 | // Service account tokens MUST request a scope, and as this token is only used to impersonate 133 | // an ID token, the narrowest scope we can request is `iam`. 134 | $scope = self::IAM_SCOPE; 135 | } 136 | $jsonKey['source_credentials'] = match ($jsonKey['source_credentials']['type'] ?? null) { 137 | // Do not pass $defaultScope to ServiceAccountCredentials 138 | 'service_account' => new ServiceAccountCredentials($scope, $jsonKey['source_credentials']), 139 | 'authorized_user' => new UserRefreshCredentials($scope, $jsonKey['source_credentials']), 140 | 'external_account' => new ExternalAccountCredentials($scope, $jsonKey['source_credentials']), 141 | default => throw new \InvalidArgumentException('invalid value in the type field'), 142 | }; 143 | } 144 | 145 | $this->targetScope = $scope ?? []; 146 | $this->lifetime = $jsonKey['lifetime'] ?? 3600; 147 | $this->delegates = $jsonKey['delegates'] ?? []; 148 | 149 | $this->serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url']; 150 | $this->impersonatedServiceAccountName = $this->getImpersonatedServiceAccountNameFromUrl( 151 | $this->serviceAccountImpersonationUrl 152 | ); 153 | 154 | $this->sourceCredentials = $jsonKey['source_credentials']; 155 | } 156 | 157 | /** 158 | * Helper function for extracting the Server Account Name from the URL saved in the account 159 | * credentials file. 160 | * 161 | * @param $serviceAccountImpersonationUrl string URL from "service_account_impersonation_url" 162 | * @return string Service account email or ID. 163 | */ 164 | private function getImpersonatedServiceAccountNameFromUrl( 165 | string $serviceAccountImpersonationUrl 166 | ): string { 167 | $fields = explode('/', $serviceAccountImpersonationUrl); 168 | $lastField = end($fields); 169 | $splitter = explode(':', $lastField); 170 | return $splitter[0]; 171 | } 172 | 173 | /** 174 | * Get the client name from the keyfile 175 | * 176 | * In this implementation, it will return the issuers email from the oauth token. 177 | * 178 | * @param callable|null $unusedHttpHandler not used by this credentials type. 179 | * @return string Token issuer email 180 | */ 181 | public function getClientName(?callable $unusedHttpHandler = null) 182 | { 183 | return $this->impersonatedServiceAccountName; 184 | } 185 | 186 | /** 187 | * @param callable|null $httpHandler 188 | * 189 | * @return array { 190 | * A set of auth related metadata, containing the following 191 | * 192 | * @type string $access_token 193 | * @type int $expires_in 194 | * @type string $scope 195 | * @type string $token_type 196 | * @type string $id_token 197 | * } 198 | */ 199 | public function fetchAuthToken(?callable $httpHandler = null) 200 | { 201 | $httpHandler = $httpHandler ?? HttpHandlerFactory::build(HttpClientCache::getHttpClient()); 202 | 203 | // The FetchAuthTokenInterface technically does not have a "headers" argument, but all of 204 | // the implementations do. Additionally, passing in more parameters than the function has 205 | // defined is allowed in PHP. So we'll just ignore the phpstan error here. 206 | // @phpstan-ignore-next-line 207 | $authToken = $this->sourceCredentials->fetchAuthToken( 208 | $httpHandler, 209 | $this->applyTokenEndpointMetrics([], 'at') 210 | ); 211 | 212 | $headers = $this->applyTokenEndpointMetrics([ 213 | 'Content-Type' => 'application/json', 214 | 'Cache-Control' => 'no-store', 215 | 'Authorization' => sprintf('Bearer %s', $authToken['access_token'] ?? $authToken['id_token']), 216 | ], $this->isIdTokenRequest() ? 'it' : 'at'); 217 | 218 | $body = match ($this->isIdTokenRequest()) { 219 | true => [ 220 | 'audience' => $this->targetAudience, 221 | 'includeEmail' => true, 222 | ], 223 | false => [ 224 | 'scope' => $this->targetScope, 225 | 'delegates' => $this->delegates, 226 | 'lifetime' => sprintf('%ss', $this->lifetime), 227 | ] 228 | }; 229 | 230 | $url = $this->serviceAccountImpersonationUrl; 231 | if ($this->isIdTokenRequest()) { 232 | $regex = '/serviceAccounts\/(?[^:]+):generateAccessToken$/'; 233 | if (!preg_match($regex, $url, $matches)) { 234 | throw new InvalidArgumentException( 235 | 'Invalid service account impersonation URL - unable to parse service account email' 236 | ); 237 | } 238 | $url = str_replace( 239 | 'UNIVERSE_DOMAIN', 240 | $this->getUniverseDomain(), 241 | sprintf(self::ID_TOKEN_IMPERSONATION_URL, $matches['email']) 242 | ); 243 | } 244 | 245 | $request = new Request( 246 | 'POST', 247 | $url, 248 | $headers, 249 | (string) json_encode($body) 250 | ); 251 | 252 | $response = $httpHandler($request); 253 | $body = json_decode((string) $response->getBody(), true); 254 | 255 | return match ($this->isIdTokenRequest()) { 256 | true => ['id_token' => $body['token']], 257 | false => [ 258 | 'access_token' => $body['accessToken'], 259 | 'expires_at' => strtotime($body['expireTime']), 260 | ] 261 | }; 262 | } 263 | 264 | /** 265 | * Returns the Cache Key for the credentials 266 | * The cache key is the same as the UserRefreshCredentials class 267 | * 268 | * @return string 269 | */ 270 | public function getCacheKey() 271 | { 272 | return $this->getFullCacheKey( 273 | $this->serviceAccountImpersonationUrl . $this->sourceCredentials->getCacheKey() 274 | ); 275 | } 276 | 277 | /** 278 | * @return array 279 | */ 280 | public function getLastReceivedToken() 281 | { 282 | return $this->sourceCredentials->getLastReceivedToken(); 283 | } 284 | 285 | protected function getCredType(): string 286 | { 287 | return self::CRED_TYPE; 288 | } 289 | 290 | private function isIdTokenRequest(): bool 291 | { 292 | return !is_null($this->targetAudience); 293 | } 294 | 295 | public function getUniverseDomain(): string 296 | { 297 | return $this->sourceCredentials instanceof GetUniverseDomainInterface 298 | ? $this->sourceCredentials->getUniverseDomain() 299 | : self::DEFAULT_UNIVERSE_DOMAIN; 300 | } 301 | } 302 | --------------------------------------------------------------------------------