├── .gitattributes ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── roave-bc-check.yaml └── src ├── AbstractApi.php ├── AwsClientFactory.php ├── AwsError ├── AwsError.php ├── AwsErrorFactoryFromResponseTrait.php ├── AwsErrorFactoryInterface.php ├── ChainAwsErrorFactory.php ├── JsonRestAwsErrorFactory.php ├── JsonRpcAwsErrorFactory.php └── XmlAwsErrorFactory.php ├── Configuration.php ├── Credentials ├── CacheProvider.php ├── ChainProvider.php ├── ConfigurationProvider.php ├── ContainerProvider.php ├── CredentialProvider.php ├── Credentials.php ├── DateFromResult.php ├── IniFileLoader.php ├── IniFileProvider.php ├── InstanceProvider.php ├── NullProvider.php ├── PsrCacheProvider.php ├── SsoCacheFileLoader.php ├── SsoTokenProvider.php ├── SymfonyCacheProvider.php ├── TokenFileLoader.php └── WebIdentityProvider.php ├── EndpointDiscovery ├── EndpointCache.php └── EndpointInterface.php ├── EnvVar.php ├── Exception ├── Exception.php ├── Http │ ├── ClientException.php │ ├── HttpException.php │ ├── HttpExceptionTrait.php │ ├── NetworkException.php │ ├── RedirectionException.php │ └── ServerException.php ├── InvalidArgument.php ├── LogicException.php ├── MissingDependency.php ├── RuntimeException.php ├── UnexpectedValue.php ├── UnparsableResponse.php └── UnsupportedRegion.php ├── HttpClient ├── AwsHttpClientFactory.php └── AwsRetryStrategy.php ├── Input.php ├── Request.php ├── RequestContext.php ├── Response.php ├── Result.php ├── Signer ├── Signer.php ├── SignerV4.php └── SigningContext.php ├── Stream ├── CallableStream.php ├── FixedSizeStream.php ├── IterableStream.php ├── ReadOnceResultStream.php ├── RequestStream.php ├── ResourceStream.php ├── ResponseBodyResourceStream.php ├── ResponseBodyStream.php ├── ResultStream.php ├── RewindableStream.php ├── StreamFactory.php └── StringStream.php ├── Sts ├── Exception │ ├── ExpiredTokenException.php │ ├── IDPCommunicationErrorException.php │ ├── IDPRejectedClaimException.php │ ├── InvalidIdentityTokenException.php │ ├── MalformedPolicyDocumentException.php │ ├── PackedPolicyTooLargeException.php │ └── RegionDisabledException.php ├── Input │ ├── AssumeRoleRequest.php │ ├── AssumeRoleWithWebIdentityRequest.php │ └── GetCallerIdentityRequest.php ├── Result │ ├── AssumeRoleResponse.php │ ├── AssumeRoleWithWebIdentityResponse.php │ └── GetCallerIdentityResponse.php ├── StsClient.php └── ValueObject │ ├── AssumedRoleUser.php │ ├── Credentials.php │ ├── PolicyDescriptorType.php │ ├── ProvidedContext.php │ └── Tag.php ├── Test ├── Http │ └── SimpleMockedResponse.php ├── ResultMockFactory.php ├── SimpleResultStream.php └── TestCase.php └── Waiter.php /.gitattributes: -------------------------------------------------------------------------------- 1 | /.github export-ignore 2 | /tests export-ignore 3 | /.gitignore export-ignore 4 | /Makefile export-ignore 5 | /phpunit.xml.dist export-ignore 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Jérémy Derussé, Tobias Nyholm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AsyncAws Core 2 | 3 | ![](https://github.com/async-aws/core/workflows/Tests/badge.svg?branch=master) 4 | ![](https://github.com/async-aws/core/workflows/BC%20Check/badge.svg?branch=master) 5 | 6 | The repository contains shared classes between all AWS services. It also contains 7 | the STS client to handle authentication. 8 | 9 | ## Install 10 | 11 | ```cli 12 | composer require async-aws/core 13 | ``` 14 | 15 | ## Documentation 16 | 17 | See https://async-aws.com for documentation. 18 | 19 | ## Contribute 20 | 21 | Contributions are welcome and appreciated. Please read https://async-aws.com/contribute/ 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "async-aws/core", 3 | "description": "Core package to integrate with AWS. This is a lightweight AWS SDK provider by AsyncAws.", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": [ 7 | "aws", 8 | "amazon", 9 | "sdk", 10 | "async-aws", 11 | "sts" 12 | ], 13 | "require": { 14 | "php": "^7.2.5 || ^8.0", 15 | "ext-hash": "*", 16 | "ext-json": "*", 17 | "ext-simplexml": "*", 18 | "psr/cache": "^1.0 || ^2.0 || ^3.0", 19 | "psr/log": "^1.0 || ^2.0 || ^3.0", 20 | "symfony/deprecation-contracts": "^2.1 || ^3.0", 21 | "symfony/http-client": "^4.4.16 || ^5.1.7 || ^6.0 || ^7.0", 22 | "symfony/http-client-contracts": "^1.1.8 || ^2.0 || ^3.0", 23 | "symfony/service-contracts": "^1.0 || ^2.0 || ^3.0" 24 | }, 25 | "conflict": { 26 | "async-aws/s3": "<1.1", 27 | "symfony/http-client": "5.2.0" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "AsyncAws\\Core\\": "src" 32 | } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "AsyncAws\\Core\\Tests\\": "tests/" 37 | } 38 | }, 39 | "extra": { 40 | "branch-alias": { 41 | "dev-master": "1.26-dev" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /roave-bc-check.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - '#ReflectionClass "PHPUnit\\Framework\\TestCase" could not be found in the located source#' 4 | -------------------------------------------------------------------------------- /src/AwsError/AwsError.php: -------------------------------------------------------------------------------- 1 | code = $code; 30 | $this->message = $message; 31 | $this->type = $type; 32 | $this->detail = $detail; 33 | } 34 | 35 | public function getCode(): ?string 36 | { 37 | return $this->code; 38 | } 39 | 40 | public function getMessage(): ?string 41 | { 42 | return $this->message; 43 | } 44 | 45 | public function getType(): ?string 46 | { 47 | return $this->type; 48 | } 49 | 50 | public function getDetail(): ?string 51 | { 52 | return $this->detail; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/AwsError/AwsErrorFactoryFromResponseTrait.php: -------------------------------------------------------------------------------- 1 | getContent(false); 12 | $headers = $response->getHeaders(false); 13 | 14 | return $this->createFromContent($content, $headers); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/AwsError/AwsErrorFactoryInterface.php: -------------------------------------------------------------------------------- 1 | > $headers 13 | */ 14 | public function createFromContent(string $content, array $headers): AwsError; 15 | } 16 | -------------------------------------------------------------------------------- /src/AwsError/ChainAwsErrorFactory.php: -------------------------------------------------------------------------------- 1 | factories = $factories ?? [ 25 | new JsonRestAwsErrorFactory(), 26 | new JsonRpcAwsErrorFactory(), 27 | new XmlAwsErrorFactory(), 28 | ]; 29 | } 30 | 31 | public function createFromContent(string $content, array $headers): AwsError 32 | { 33 | $e = null; 34 | foreach ($this->factories as $factory) { 35 | try { 36 | return $factory->createFromContent($content, $headers); 37 | } catch (UnparsableResponse $e) { 38 | } 39 | } 40 | 41 | throw new UnparsableResponse('Failed to parse AWS error: ' . $content, 0, $e); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/AwsError/JsonRestAwsErrorFactory.php: -------------------------------------------------------------------------------- 1 | $body 25 | * @param array> $headers 26 | */ 27 | private static function parseJson(array $body, array $headers): AwsError 28 | { 29 | $code = null; 30 | $type = $body['type'] ?? $body['Type'] ?? null; 31 | if ($type) { 32 | $type = strtolower($type); 33 | } 34 | $message = $body['message'] ?? $body['Message'] ?? null; 35 | if (isset($headers['x-amzn-errortype'][0])) { 36 | $code = explode(':', $headers['x-amzn-errortype'][0], 2)[0]; 37 | } 38 | 39 | if (null !== $code) { 40 | return new AwsError($code, $message, $type, null); 41 | } 42 | 43 | throw new UnexpectedValue('JSON does not contains AWS Error'); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/AwsError/JsonRpcAwsErrorFactory.php: -------------------------------------------------------------------------------- 1 | $body 25 | * @param array> $headers 26 | */ 27 | private static function parseJson(array $body, array $headers): AwsError 28 | { 29 | $code = null; 30 | $message = $body['message'] ?? $body['Message'] ?? null; 31 | if (isset($body['__type'])) { 32 | $parts = explode('#', $body['__type'], 2); 33 | $code = $parts[1] ?? $parts[0]; 34 | } 35 | 36 | if (null !== $code || null !== $message) { 37 | return new AwsError($code, $message, null, null); 38 | } 39 | 40 | throw new UnexpectedValue('JSON does not contains AWS Error'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/AwsError/XmlAwsErrorFactory.php: -------------------------------------------------------------------------------- 1 | Error->count()) { 35 | return new AwsError( 36 | $xml->Error->Code->__toString(), 37 | $xml->Error->Message->__toString(), 38 | $xml->Error->Type->__toString(), 39 | $xml->Error->Detail->__toString() 40 | ); 41 | } 42 | 43 | if (1 === $xml->Code->count() && 1 === $xml->Message->count()) { 44 | return new AwsError( 45 | $xml->Code->__toString(), 46 | $xml->Message->__toString(), 47 | null, 48 | null 49 | ); 50 | } 51 | 52 | throw new UnexpectedValue('XML does not contains AWS Error'); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Configuration.php: -------------------------------------------------------------------------------- 1 | 14 | * @author Jérémy Derussé 15 | */ 16 | final class Configuration 17 | { 18 | public const DEFAULT_REGION = 'us-east-1'; 19 | 20 | public const OPTION_REGION = 'region'; 21 | public const OPTION_DEBUG = 'debug'; 22 | public const OPTION_PROFILE = 'profile'; 23 | public const OPTION_ACCESS_KEY_ID = 'accessKeyId'; 24 | public const OPTION_SECRET_ACCESS_KEY = 'accessKeySecret'; 25 | public const OPTION_SESSION_TOKEN = 'sessionToken'; 26 | public const OPTION_SHARED_CREDENTIALS_FILE = 'sharedCredentialsFile'; 27 | public const OPTION_SHARED_CONFIG_FILE = 'sharedConfigFile'; 28 | public const OPTION_ENDPOINT = 'endpoint'; 29 | public const OPTION_ROLE_ARN = 'roleArn'; 30 | public const OPTION_WEB_IDENTITY_TOKEN_FILE = 'webIdentityTokenFile'; 31 | public const OPTION_ROLE_SESSION_NAME = 'roleSessionName'; 32 | public const OPTION_CONTAINER_CREDENTIALS_RELATIVE_URI = 'containerCredentialsRelativeUri'; 33 | public const OPTION_ENDPOINT_DISCOVERY_ENABLED = 'endpointDiscoveryEnabled'; 34 | public const OPTION_POD_IDENTITY_CREDENTIALS_FULL_URI = 'podIdentityCredentialsFullUri'; 35 | public const OPTION_POD_IDENTITY_AUTHORIZATION_TOKEN_FILE = 'podIdentityAuthorizationTokenFile'; 36 | 37 | // S3 specific option 38 | public const OPTION_PATH_STYLE_ENDPOINT = 'pathStyleEndpoint'; 39 | public const OPTION_SEND_CHUNKED_BODY = 'sendChunkedBody'; 40 | 41 | private const AVAILABLE_OPTIONS = [ 42 | self::OPTION_REGION => true, 43 | self::OPTION_DEBUG => true, 44 | self::OPTION_PROFILE => true, 45 | self::OPTION_ACCESS_KEY_ID => true, 46 | self::OPTION_SECRET_ACCESS_KEY => true, 47 | self::OPTION_SESSION_TOKEN => true, 48 | self::OPTION_SHARED_CREDENTIALS_FILE => true, 49 | self::OPTION_SHARED_CONFIG_FILE => true, 50 | self::OPTION_ENDPOINT => true, 51 | self::OPTION_ROLE_ARN => true, 52 | self::OPTION_WEB_IDENTITY_TOKEN_FILE => true, 53 | self::OPTION_ROLE_SESSION_NAME => true, 54 | self::OPTION_CONTAINER_CREDENTIALS_RELATIVE_URI => true, 55 | self::OPTION_ENDPOINT_DISCOVERY_ENABLED => true, 56 | self::OPTION_PATH_STYLE_ENDPOINT => true, 57 | self::OPTION_SEND_CHUNKED_BODY => true, 58 | self::OPTION_POD_IDENTITY_CREDENTIALS_FULL_URI => true, 59 | self::OPTION_POD_IDENTITY_AUTHORIZATION_TOKEN_FILE => true, 60 | ]; 61 | 62 | // Put fallback options into groups to avoid mixing of provided config and environment variables 63 | private const FALLBACK_OPTIONS = [ 64 | [self::OPTION_REGION => ['AWS_REGION', 'AWS_DEFAULT_REGION']], 65 | [self::OPTION_PROFILE => ['AWS_PROFILE', 'AWS_DEFAULT_PROFILE']], 66 | [ 67 | self::OPTION_ACCESS_KEY_ID => ['AWS_ACCESS_KEY_ID', 'AWS_ACCESS_KEY'], 68 | self::OPTION_SECRET_ACCESS_KEY => ['AWS_SECRET_ACCESS_KEY', 'AWS_SECRET_KEY'], 69 | self::OPTION_SESSION_TOKEN => 'AWS_SESSION_TOKEN', 70 | ], 71 | [self::OPTION_SHARED_CREDENTIALS_FILE => 'AWS_SHARED_CREDENTIALS_FILE'], 72 | [self::OPTION_SHARED_CONFIG_FILE => 'AWS_CONFIG_FILE'], 73 | [self::OPTION_ENDPOINT => 'AWS_ENDPOINT_URL'], 74 | [ 75 | self::OPTION_ROLE_ARN => 'AWS_ROLE_ARN', 76 | self::OPTION_WEB_IDENTITY_TOKEN_FILE => 'AWS_WEB_IDENTITY_TOKEN_FILE', 77 | self::OPTION_ROLE_SESSION_NAME => 'AWS_ROLE_SESSION_NAME', 78 | ], 79 | [self::OPTION_CONTAINER_CREDENTIALS_RELATIVE_URI => 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'], 80 | [self::OPTION_ENDPOINT_DISCOVERY_ENABLED => ['AWS_ENDPOINT_DISCOVERY_ENABLED', 'AWS_ENABLE_ENDPOINT_DISCOVERY']], 81 | [self::OPTION_POD_IDENTITY_CREDENTIALS_FULL_URI => 'AWS_CONTAINER_CREDENTIALS_FULL_URI'], 82 | [self::OPTION_POD_IDENTITY_AUTHORIZATION_TOKEN_FILE => 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE'], 83 | ]; 84 | 85 | private const DEFAULT_OPTIONS = [ 86 | self::OPTION_REGION => self::DEFAULT_REGION, 87 | self::OPTION_DEBUG => 'false', 88 | self::OPTION_PROFILE => 'default', 89 | self::OPTION_SHARED_CREDENTIALS_FILE => '~/.aws/credentials', 90 | self::OPTION_SHARED_CONFIG_FILE => '~/.aws/config', 91 | // https://docs.aws.amazon.com/general/latest/gr/rande.html 92 | self::OPTION_ENDPOINT => 'https://%service%.%region%.amazonaws.com', 93 | self::OPTION_PATH_STYLE_ENDPOINT => 'false', 94 | self::OPTION_SEND_CHUNKED_BODY => 'false', 95 | self::OPTION_ENDPOINT_DISCOVERY_ENABLED => 'false', 96 | ]; 97 | 98 | /** 99 | * @var array 100 | */ 101 | private $data = []; 102 | 103 | /** 104 | * @var array 105 | */ 106 | private $userData = []; 107 | 108 | /** 109 | * @param array $options 110 | */ 111 | public static function create(array $options): self 112 | { 113 | if (0 < \count($invalidOptions = array_diff_key($options, self::AVAILABLE_OPTIONS))) { 114 | throw new InvalidArgument(\sprintf('Invalid option(s) "%s" passed to "%s::%s". ', implode('", "', array_keys($invalidOptions)), __CLASS__, __METHOD__)); 115 | } 116 | 117 | // Force each option to be string or null 118 | $options = array_map(static function ($value) { 119 | return null !== $value ? (string) $value : $value; 120 | }, $options); 121 | 122 | $configuration = new self(); 123 | $options = self::parseEnvironmentVariables($options); 124 | self::populateConfiguration($configuration, $options); 125 | $iniOptions = self::parseIniFiles($configuration); 126 | self::populateConfiguration($configuration, $iniOptions); 127 | 128 | return $configuration; 129 | } 130 | 131 | public static function optionExists(string $optionName): bool 132 | { 133 | return isset(self::AVAILABLE_OPTIONS[$optionName]); 134 | } 135 | 136 | /** 137 | * @param self::OPTION_* $name 138 | * 139 | * @psalm-return ( 140 | * $name is 141 | * self::OPTION_REGION 142 | * |self::OPTION_DEBUG 143 | * |self::OPTION_PROFILE 144 | * |self::OPTION_SHARED_CREDENTIALS_FILE 145 | * |self::OPTION_SHARED_CONFIG_FILE 146 | * |self::OPTION_ENDPOINT 147 | * |self::OPTION_PATH_STYLE_ENDPOINT 148 | * |self::OPTION_SEND_CHUNKED_BODY 149 | * ? string 150 | * : ?string 151 | * ) 152 | */ 153 | public function get(string $name): ?string 154 | { 155 | if (!isset(self::AVAILABLE_OPTIONS[$name])) { 156 | throw new InvalidArgument(\sprintf('Invalid option "%s" passed to "%s::%s". ', $name, __CLASS__, __METHOD__)); 157 | } 158 | 159 | return $this->data[$name] ?? null; 160 | } 161 | 162 | /** 163 | * @param self::OPTION_* $name 164 | */ 165 | public function has(string $name): bool 166 | { 167 | if (!isset(self::AVAILABLE_OPTIONS[$name])) { 168 | throw new InvalidArgument(\sprintf('Invalid option "%s" passed to "%s::%s". ', $name, __CLASS__, __METHOD__)); 169 | } 170 | 171 | return isset($this->data[$name]); 172 | } 173 | 174 | /** 175 | * @param self::OPTION_* $name 176 | */ 177 | public function isDefault(string $name): bool 178 | { 179 | if (!isset(self::AVAILABLE_OPTIONS[$name])) { 180 | throw new InvalidArgument(\sprintf('Invalid option "%s" passed to "%s::%s". ', $name, __CLASS__, __METHOD__)); 181 | } 182 | 183 | return empty($this->userData[$name]); 184 | } 185 | 186 | /** 187 | * @param array $options 188 | * 189 | * @return array 190 | */ 191 | private static function parseEnvironmentVariables(array $options): array 192 | { 193 | foreach (self::FALLBACK_OPTIONS as $fallbackGroup) { 194 | // prevent mixing env variables with config keys 195 | foreach ($fallbackGroup as $option => $envVariableNames) { 196 | if (isset($options[$option])) { 197 | continue 2; 198 | } 199 | } 200 | 201 | foreach ($fallbackGroup as $option => $envVariableNames) { 202 | // Read environment files 203 | $envVariableNames = (array) $envVariableNames; 204 | foreach ($envVariableNames as $envVariableName) { 205 | $envVariableValue = EnvVar::get($envVariableName); 206 | if (null !== $envVariableValue && '' !== $envVariableValue) { 207 | $options[$option] = $envVariableValue; 208 | 209 | break; 210 | } 211 | } 212 | } 213 | } 214 | 215 | return $options; 216 | } 217 | 218 | /** 219 | * Look for "region" in the configured ini files. 220 | * 221 | * @return array 222 | */ 223 | private static function parseIniFiles(Configuration $configuration): array 224 | { 225 | $options = []; 226 | if (!$configuration->isDefault(self::OPTION_REGION)) { 227 | return $options; 228 | } 229 | 230 | $profilesData = (new IniFileLoader())->loadProfiles([ 231 | $configuration->get(self::OPTION_SHARED_CREDENTIALS_FILE), 232 | $configuration->get(self::OPTION_SHARED_CONFIG_FILE), 233 | ]); 234 | 235 | if (empty($profilesData)) { 236 | return $options; 237 | } 238 | 239 | /** @var string $profile */ 240 | $profile = $configuration->get(Configuration::OPTION_PROFILE); 241 | if (isset($profilesData[$profile]['region'])) { 242 | $options[self::OPTION_REGION] = $profilesData[$profile]['region']; 243 | } 244 | 245 | return $options; 246 | } 247 | 248 | /** 249 | * Add array options to the configuration object. 250 | * 251 | * @param array $options 252 | */ 253 | private static function populateConfiguration(Configuration $configuration, array $options): void 254 | { 255 | foreach ($options as $key => $value) { 256 | if (null !== $value) { 257 | $configuration->userData[$key] = true; 258 | } 259 | } 260 | 261 | // If we have not applied default before 262 | if (empty($configuration->data)) { 263 | foreach (self::DEFAULT_OPTIONS as $optionTrigger => $defaultValue) { 264 | if (isset($options[$optionTrigger])) { 265 | continue; 266 | } 267 | 268 | $options[$optionTrigger] = $defaultValue; 269 | } 270 | } 271 | 272 | $configuration->data = array_merge($configuration->data, $options); 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/Credentials/CacheProvider.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class CacheProvider implements CredentialProvider, ResetInterface 18 | { 19 | /** 20 | * @var array 21 | */ 22 | private $cache = []; 23 | 24 | /** 25 | * @var CredentialProvider 26 | */ 27 | private $decorated; 28 | 29 | public function __construct(CredentialProvider $decorated) 30 | { 31 | $this->decorated = $decorated; 32 | } 33 | 34 | public function getCredentials(Configuration $configuration): ?Credentials 35 | { 36 | $key = sha1(serialize($configuration)); 37 | if (!\array_key_exists($key, $this->cache) || (null !== $this->cache[$key] && $this->cache[$key]->isExpired())) { 38 | $this->cache[$key] = $this->decorated->getCredentials($configuration); 39 | } 40 | 41 | return $this->cache[$key]; 42 | } 43 | 44 | public function reset(): void 45 | { 46 | $this->cache = []; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Credentials/ChainProvider.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final class ChainProvider implements CredentialProvider, ResetInterface 23 | { 24 | /** 25 | * @var iterable 26 | */ 27 | private $providers; 28 | 29 | /** 30 | * @var array 31 | */ 32 | private $lastSuccessfulProvider = []; 33 | 34 | /** 35 | * @param iterable $providers 36 | */ 37 | public function __construct(iterable $providers) 38 | { 39 | $this->providers = $providers; 40 | } 41 | 42 | public function getCredentials(Configuration $configuration): ?Credentials 43 | { 44 | $key = sha1(serialize($configuration)); 45 | if (\array_key_exists($key, $this->lastSuccessfulProvider)) { 46 | if (null === $provider = $this->lastSuccessfulProvider[$key]) { 47 | return null; 48 | } 49 | 50 | return $provider->getCredentials($configuration); 51 | } 52 | 53 | foreach ($this->providers as $provider) { 54 | if (null !== $credentials = $provider->getCredentials($configuration)) { 55 | $this->lastSuccessfulProvider[$key] = $provider; 56 | 57 | return $credentials; 58 | } 59 | } 60 | 61 | $this->lastSuccessfulProvider[$key] = null; 62 | 63 | return null; 64 | } 65 | 66 | public function reset(): void 67 | { 68 | $this->lastSuccessfulProvider = []; 69 | } 70 | 71 | public static function createDefaultChain(?HttpClientInterface $httpClient = null, ?LoggerInterface $logger = null): CredentialProvider 72 | { 73 | $httpClient = $httpClient ?? HttpClient::create(); 74 | $logger = $logger ?? new NullLogger(); 75 | 76 | return new ChainProvider([ 77 | new ConfigurationProvider(), 78 | new WebIdentityProvider($logger, null, $httpClient), 79 | new IniFileProvider($logger, null, $httpClient), 80 | new ContainerProvider($httpClient, $logger), 81 | new InstanceProvider($httpClient, $logger), 82 | ]); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Credentials/ConfigurationProvider.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class ConfigurationProvider implements CredentialProvider 20 | { 21 | use DateFromResult; 22 | 23 | /** 24 | * @var LoggerInterface 25 | */ 26 | private $logger; 27 | 28 | /** 29 | * @var HttpClientInterface|null 30 | */ 31 | private $httpClient; 32 | 33 | public function __construct(?HttpClientInterface $httpClient = null, ?LoggerInterface $logger = null) 34 | { 35 | $this->logger = $logger ?? new NullLogger(); 36 | $this->httpClient = $httpClient; 37 | } 38 | 39 | public function getCredentials(Configuration $configuration): ?Credentials 40 | { 41 | $accessKeyId = $configuration->get(Configuration::OPTION_ACCESS_KEY_ID); 42 | $secretAccessKeyId = $configuration->get(Configuration::OPTION_SECRET_ACCESS_KEY); 43 | 44 | if (null === $accessKeyId || null === $secretAccessKeyId) { 45 | return null; 46 | } 47 | 48 | $credentials = new Credentials( 49 | $accessKeyId, 50 | $secretAccessKeyId, 51 | $configuration->get(Configuration::OPTION_SESSION_TOKEN) 52 | ); 53 | 54 | $roleArn = $configuration->get(Configuration::OPTION_ROLE_ARN); 55 | if (null !== $roleArn) { 56 | $region = $configuration->get(Configuration::OPTION_REGION); 57 | $roleSessionName = $configuration->get(Configuration::OPTION_ROLE_SESSION_NAME); 58 | 59 | return $this->getCredentialsFromRole($credentials, $region, $roleArn, $roleSessionName); 60 | } 61 | 62 | /** @psalm-suppress PossiblyNullArgument */ 63 | return $credentials; 64 | } 65 | 66 | private function getCredentialsFromRole(Credentials $credentials, string $region, string $roleArn, ?string $roleSessionName = null): ?Credentials 67 | { 68 | $roleSessionName = $roleSessionName ?? uniqid('async-aws-', true); 69 | $stsClient = new StsClient(['region' => $region], $credentials, $this->httpClient); 70 | $result = $stsClient->assumeRole([ 71 | 'RoleArn' => $roleArn, 72 | 'RoleSessionName' => $roleSessionName, 73 | ]); 74 | 75 | try { 76 | if (null === $credentials = $result->getCredentials()) { 77 | throw new RuntimeException('The AsumeRole response does not contains credentials'); 78 | } 79 | } catch (\Exception $e) { 80 | $this->logger->warning('Failed to get credentials from assumed role: {exception}".', ['exception' => $e]); 81 | 82 | return null; 83 | } 84 | 85 | return new Credentials( 86 | $credentials->getAccessKeyId(), 87 | $credentials->getSecretAccessKey(), 88 | $credentials->getSessionToken(), 89 | Credentials::adjustExpireDate($credentials->getExpiration(), $this->getDateFromResult($result)) 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Credentials/ContainerProvider.php: -------------------------------------------------------------------------------- 1 | logger = $logger ?? new NullLogger(); 48 | $this->httpClient = $httpClient ?? HttpClient::create(); 49 | $this->timeout = $timeout; 50 | } 51 | 52 | public function getCredentials(Configuration $configuration): ?Credentials 53 | { 54 | $fullUri = $this->getFullUri($configuration); 55 | 56 | // introduces an early exit if the env variable is not set. 57 | if (empty($fullUri)) { 58 | return null; 59 | } 60 | 61 | if (!$this->isUriValid($fullUri)) { 62 | $this->logger->warning('Invalid URI "{uri}" provided.', ['uri' => $fullUri]); 63 | 64 | return null; 65 | } 66 | 67 | $tokenFile = $configuration->get(Configuration::OPTION_POD_IDENTITY_AUTHORIZATION_TOKEN_FILE); 68 | if (!empty($tokenFile)) { 69 | try { 70 | $tokenFileContent = $this->getTokenFileContent($tokenFile); 71 | } catch (\Exception $e) { 72 | $this->logger->warning('"Error reading PodIdentityTokenFile "{tokenFile}.', ['tokenFile' => $tokenFile, 'exception' => $e]); 73 | 74 | return null; 75 | } 76 | } 77 | 78 | // fetch credentials from ecs endpoint 79 | try { 80 | $response = $this->httpClient->request('GET', $fullUri, ['headers' => $this->getHeaders($tokenFileContent ?? null), 'timeout' => $this->timeout]); 81 | $result = $response->toArray(); 82 | } catch (DecodingExceptionInterface $e) { 83 | $this->logger->info('Failed to decode Credentials.', ['exception' => $e]); 84 | 85 | return null; 86 | } catch (TransportExceptionInterface|HttpExceptionInterface $e) { 87 | $this->logger->info('Failed to fetch Profile from Instance Metadata.', ['exception' => $e]); 88 | 89 | return null; 90 | } 91 | 92 | if (null !== $date = $response->getHeaders(false)['date'][0] ?? null) { 93 | $date = new \DateTimeImmutable($date); 94 | } 95 | 96 | return new Credentials( 97 | $result['AccessKeyId'], 98 | $result['SecretAccessKey'], 99 | $result['Token'], 100 | Credentials::adjustExpireDate(new \DateTimeImmutable($result['Expiration']), $date) 101 | ); 102 | } 103 | 104 | /** 105 | * Checks if the provided IP address is a loopback address. 106 | * 107 | * @param string $host the host address to check 108 | * 109 | * @return bool true if the IP is a loopback address, false otherwise 110 | */ 111 | private function isLoopBackAddress(string $host) 112 | { 113 | // Validate that the input is a valid IP address 114 | if (!filter_var($host, \FILTER_VALIDATE_IP)) { 115 | return false; 116 | } 117 | 118 | // Convert the IP address to binary format 119 | $packedIp = inet_pton($host); 120 | 121 | // Check if the IP is in the 127.0.0.0/8 range 122 | if (4 === \strlen($packedIp)) { 123 | return 127 === \ord($packedIp[0]); 124 | } 125 | 126 | // Check if the IP is ::1 127 | if (16 === \strlen($packedIp)) { 128 | return $packedIp === inet_pton('::1'); 129 | } 130 | 131 | // Unknown IP format 132 | return false; 133 | } 134 | 135 | private function getFullUri(Configuration $configuration): ?string 136 | { 137 | $relativeUri = $configuration->get(Configuration::OPTION_CONTAINER_CREDENTIALS_RELATIVE_URI); 138 | 139 | if (null !== $relativeUri) { 140 | return 'http://' . self::ECS_HOST . $relativeUri; 141 | } 142 | 143 | return $configuration->get(Configuration::OPTION_POD_IDENTITY_CREDENTIALS_FULL_URI); 144 | } 145 | 146 | private function getHeaders(?string $tokenFileContent): array 147 | { 148 | return $tokenFileContent ? ['Authorization' => $tokenFileContent] : []; 149 | } 150 | 151 | private function isUriValid(string $uri): bool 152 | { 153 | $parsedUri = parse_url($uri); 154 | if (false === $parsedUri) { 155 | return false; 156 | } 157 | 158 | if (!isset($parsedUri['scheme'])) { 159 | return false; 160 | } 161 | 162 | if ('https' !== $parsedUri['scheme']) { 163 | $host = trim($parsedUri['host'] ?? '', '[]'); 164 | if (self::EKS_HOST_IPV4 === $host || self::EKS_HOST_IPV6 === $host) { 165 | return true; 166 | } 167 | 168 | if (self::ECS_HOST === $host) { 169 | return true; 170 | } 171 | 172 | return $this->isLoopBackAddress($host); 173 | } 174 | 175 | return true; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/Credentials/CredentialProvider.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | interface CredentialProvider 15 | { 16 | /** 17 | * Return a Credential when possible. Return null otherwise. 18 | */ 19 | public function getCredentials(Configuration $configuration): ?Credentials; 20 | } 21 | -------------------------------------------------------------------------------- /src/Credentials/Credentials.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class Credentials implements CredentialProvider 15 | { 16 | private const EXPIRATION_DRIFT = 30; 17 | 18 | /** 19 | * @var string 20 | */ 21 | private $accessKeyId; 22 | 23 | /** 24 | * @var string 25 | */ 26 | private $secretKey; 27 | 28 | /** 29 | * @var string|null 30 | */ 31 | private $sessionToken; 32 | 33 | /** 34 | * @var \DateTimeImmutable|null 35 | */ 36 | private $expireDate; 37 | 38 | public function __construct( 39 | string $accessKeyId, 40 | string $secretKey, 41 | ?string $sessionToken = null, 42 | ?\DateTimeImmutable $expireDate = null 43 | ) { 44 | $this->accessKeyId = $accessKeyId; 45 | $this->secretKey = $secretKey; 46 | $this->sessionToken = $sessionToken; 47 | $this->expireDate = $expireDate; 48 | } 49 | 50 | public function getAccessKeyId(): string 51 | { 52 | return $this->accessKeyId; 53 | } 54 | 55 | public function getSecretKey(): string 56 | { 57 | return $this->secretKey; 58 | } 59 | 60 | public function getSessionToken(): ?string 61 | { 62 | return $this->sessionToken; 63 | } 64 | 65 | public function getExpireDate(): ?\DateTimeImmutable 66 | { 67 | return $this->expireDate; 68 | } 69 | 70 | public function isExpired(): bool 71 | { 72 | return null !== $this->expireDate && new \DateTimeImmutable() >= $this->expireDate; 73 | } 74 | 75 | public function getCredentials(Configuration $configuration): ?Credentials 76 | { 77 | return $this->isExpired() ? null : $this; 78 | } 79 | 80 | public static function adjustExpireDate(\DateTimeImmutable $expireDate, ?\DateTimeImmutable $reference = null): \DateTimeImmutable 81 | { 82 | if (null !== $reference) { 83 | $expireDate = (new \DateTimeImmutable())->add($reference->diff($expireDate)); 84 | } 85 | 86 | return $expireDate->sub(new \DateInterval(\sprintf('PT%dS', self::EXPIRATION_DRIFT))); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Credentials/DateFromResult.php: -------------------------------------------------------------------------------- 1 | info()['response']; 15 | if (null !== $date = $response->getHeaders(false)['date'][0] ?? null) { 16 | return new \DateTimeImmutable($date); 17 | } 18 | 19 | return null; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Credentials/IniFileLoader.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class IniFileLoader 17 | { 18 | public const KEY_REGION = 'region'; 19 | public const KEY_ACCESS_KEY_ID = 'aws_access_key_id'; 20 | public const KEY_SECRET_ACCESS_KEY = 'aws_secret_access_key'; 21 | public const KEY_SESSION_TOKEN = 'aws_session_token'; 22 | public const KEY_ROLE_ARN = 'role_arn'; 23 | public const KEY_ROLE_SESSION_NAME = 'role_session_name'; 24 | public const KEY_SOURCE_PROFILE = 'source_profile'; 25 | public const KEY_WEB_IDENTITY_TOKEN_FILE = 'web_identity_token_file'; 26 | public const KEY_SSO_SESSION = 'sso_session'; 27 | public const KEY_SSO_START_URL = 'sso_start_url'; 28 | public const KEY_SSO_REGION = 'sso_region'; 29 | public const KEY_SSO_ACCOUNT_ID = 'sso_account_id'; 30 | public const KEY_SSO_ROLE_NAME = 'sso_role_name'; 31 | 32 | /** 33 | * @var LoggerInterface 34 | */ 35 | private $logger; 36 | 37 | public function __construct(?LoggerInterface $logger = null) 38 | { 39 | $this->logger = $logger ?? new NullLogger(); 40 | } 41 | 42 | /** 43 | * @param string[] $filepaths 44 | * 45 | * @return array> 46 | */ 47 | public function loadProfiles(array $filepaths): array 48 | { 49 | $profilesData = []; 50 | $homeDir = null; 51 | foreach ($filepaths as $filepath) { 52 | if ('' === $filepath) { 53 | continue; 54 | } 55 | if ('~' === $filepath[0]) { 56 | $homeDir = $homeDir ?? $this->getHomeDir(); 57 | $filepath = $homeDir . substr($filepath, 1); 58 | } 59 | if (!is_readable($filepath) || !is_file($filepath)) { 60 | continue; 61 | } 62 | 63 | foreach ($this->parseIniFile($filepath) as $name => $profile) { 64 | $name = preg_replace('/^profile /', '', (string) $name); 65 | if (!isset($profilesData[$name])) { 66 | $profilesData[$name] = array_map('trim', $profile); 67 | } else { 68 | foreach ($profile as $k => $v) { 69 | if (!isset($profilesData[$name][$k])) { 70 | $profilesData[$name][$k] = trim($v); 71 | } 72 | } 73 | } 74 | } 75 | } 76 | 77 | return $profilesData; 78 | } 79 | 80 | private function getHomeDir(): string 81 | { 82 | // On Linux/Unix-like systems, use the HOME environment variable 83 | if (null !== $homeDir = EnvVar::get('HOME')) { 84 | return $homeDir; 85 | } 86 | 87 | // Get the HOMEDRIVE and HOMEPATH values for Windows hosts 88 | $homeDrive = EnvVar::get('HOMEDRIVE'); 89 | $homePath = EnvVar::get('HOMEPATH'); 90 | 91 | return ($homeDrive && $homePath) ? $homeDrive . $homePath : '/'; 92 | } 93 | 94 | /** 95 | * @return array 96 | */ 97 | private function parseIniFile(string $filepath): array 98 | { 99 | if (false === $data = parse_ini_string( 100 | preg_replace('/^#/m', ';', file_get_contents($filepath)), 101 | true, 102 | \INI_SCANNER_RAW 103 | )) { 104 | $this->logger->warning('The ini file {path} is invalid.', ['path' => $filepath]); 105 | 106 | return []; 107 | } 108 | 109 | return $data; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Credentials/IniFileProvider.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | final class IniFileProvider implements CredentialProvider 24 | { 25 | use DateFromResult; 26 | 27 | /** 28 | * @var IniFileLoader 29 | */ 30 | private $iniFileLoader; 31 | 32 | /** 33 | * @var LoggerInterface 34 | */ 35 | private $logger; 36 | 37 | /** 38 | * @var HttpClientInterface|null 39 | */ 40 | private $httpClient; 41 | 42 | public function __construct(?LoggerInterface $logger = null, ?IniFileLoader $iniFileLoader = null, ?HttpClientInterface $httpClient = null) 43 | { 44 | $this->logger = $logger ?? new NullLogger(); 45 | $this->iniFileLoader = $iniFileLoader ?? new IniFileLoader($this->logger); 46 | $this->httpClient = $httpClient; 47 | } 48 | 49 | public function getCredentials(Configuration $configuration): ?Credentials 50 | { 51 | $profilesData = $this->iniFileLoader->loadProfiles([ 52 | $configuration->get(Configuration::OPTION_SHARED_CREDENTIALS_FILE), 53 | $configuration->get(Configuration::OPTION_SHARED_CONFIG_FILE), 54 | ]); 55 | if (empty($profilesData)) { 56 | return null; 57 | } 58 | 59 | /** @var string $profile */ 60 | $profile = $configuration->get(Configuration::OPTION_PROFILE); 61 | 62 | return $this->getCredentialsFromProfile($profilesData, $profile); 63 | } 64 | 65 | /** 66 | * @param array> $profilesData 67 | * @param array $circularCollector 68 | */ 69 | private function getCredentialsFromProfile(array $profilesData, string $profile, array $circularCollector = []): ?Credentials 70 | { 71 | if (isset($circularCollector[$profile])) { 72 | $this->logger->warning('Circular reference detected when loading "{profile}". Already loaded {previous_profiles}', ['profile' => $profile, 'previous_profiles' => array_keys($circularCollector)]); 73 | 74 | return null; 75 | } 76 | $circularCollector[$profile] = true; 77 | 78 | if (!isset($profilesData[$profile])) { 79 | $this->logger->warning('Profile "{profile}" not found.', ['profile' => $profile]); 80 | 81 | return null; 82 | } 83 | 84 | $profileData = $profilesData[$profile]; 85 | if (isset($profileData[IniFileLoader::KEY_ACCESS_KEY_ID], $profileData[IniFileLoader::KEY_SECRET_ACCESS_KEY])) { 86 | return new Credentials( 87 | $profileData[IniFileLoader::KEY_ACCESS_KEY_ID], 88 | $profileData[IniFileLoader::KEY_SECRET_ACCESS_KEY], 89 | $profileData[IniFileLoader::KEY_SESSION_TOKEN] ?? null 90 | ); 91 | } 92 | 93 | if (isset($profileData[IniFileLoader::KEY_ROLE_ARN])) { 94 | return $this->getCredentialsFromRole($profilesData, $profileData, $profile, $circularCollector); 95 | } 96 | 97 | if (isset($profileData[IniFileLoader::KEY_SSO_SESSION])) { 98 | if (!class_exists(SsoClient::class) || !class_exists(SsoOidcClient::class)) { 99 | $this->logger->warning('The profile "{profile}" contains SSO session config but the required packages ("async-aws/sso" and "async-aws/sso-oidc") are not installed. Try running "composer require async-aws/sso async-aws/sso-oidc".', ['profile' => $profile]); 100 | 101 | return null; 102 | } 103 | 104 | return $this->getCredentialsFromSsoSession($profilesData, $profileData, $profile); 105 | } 106 | 107 | if (isset($profileData[IniFileLoader::KEY_SSO_START_URL])) { 108 | if (!class_exists(SsoClient::class)) { 109 | $this->logger->warning('The profile "{profile}" contains SSO (legacy) config but the "async-aws/sso" package is not installed. Try running "composer require async-aws/sso".', ['profile' => $profile]); 110 | 111 | return null; 112 | } 113 | 114 | return $this->getCredentialsFromLegacySso($profileData, $profile); 115 | } 116 | 117 | $this->logger->info('No credentials found for profile "{profile}".', ['profile' => $profile]); 118 | 119 | return null; 120 | } 121 | 122 | /** 123 | * @param array> $profilesData 124 | * @param array $profileData 125 | * @param array $circularCollector 126 | */ 127 | private function getCredentialsFromRole(array $profilesData, array $profileData, string $profile, array $circularCollector = []): ?Credentials 128 | { 129 | $roleArn = (string) ($profileData[IniFileLoader::KEY_ROLE_ARN] ?? ''); 130 | $roleSessionName = (string) ($profileData[IniFileLoader::KEY_ROLE_SESSION_NAME] ?? uniqid('async-aws-', true)); 131 | if (null === $sourceProfileName = $profileData[IniFileLoader::KEY_SOURCE_PROFILE] ?? null) { 132 | $this->logger->warning('The source profile is not defined in Role "{profile}".', ['profile' => $profile]); 133 | 134 | return null; 135 | } 136 | 137 | $sourceCredentials = $this->getCredentialsFromProfile($profilesData, $sourceProfileName, $circularCollector); 138 | if (null === $sourceCredentials) { 139 | $this->logger->warning('The source profile "{profile}" does not contains valid credentials.', ['profile' => $profile]); 140 | 141 | return null; 142 | } 143 | 144 | $stsClient = new StsClient( 145 | isset($profilesData[$sourceProfileName][IniFileLoader::KEY_REGION]) ? ['region' => $profilesData[$sourceProfileName][IniFileLoader::KEY_REGION]] : [], 146 | $sourceCredentials, 147 | $this->httpClient 148 | ); 149 | $result = $stsClient->assumeRole([ 150 | 'RoleArn' => $roleArn, 151 | 'RoleSessionName' => $roleSessionName, 152 | ]); 153 | 154 | try { 155 | if (null === $credentials = $result->getCredentials()) { 156 | throw new RuntimeException('The AssumeRole response does not contains credentials'); 157 | } 158 | } catch (\Exception $e) { 159 | $this->logger->warning('Failed to get credentials from assumed role in profile "{profile}: {exception}".', ['profile' => $profile, 'exception' => $e]); 160 | 161 | return null; 162 | } 163 | 164 | return new Credentials( 165 | $credentials->getAccessKeyId(), 166 | $credentials->getSecretAccessKey(), 167 | $credentials->getSessionToken(), 168 | Credentials::adjustExpireDate($credentials->getExpiration(), $this->getDateFromResult($result)) 169 | ); 170 | } 171 | 172 | /** 173 | * @param array> $profilesData 174 | * @param array $profileData 175 | */ 176 | private function getCredentialsFromSsoSession(array $profilesData, array $profileData, string $profile): ?Credentials 177 | { 178 | if (!isset($profileData[IniFileLoader::KEY_SSO_SESSION])) { 179 | $this->logger->warning('Profile "{profile}" does not contains required SSO session config.', ['profile' => $profile]); 180 | 181 | return null; 182 | } 183 | 184 | $sessionName = $profileData[IniFileLoader::KEY_SSO_SESSION]; 185 | if (!isset($profilesData['sso-session ' . $sessionName])) { 186 | $this->logger->warning('Profile "{profile}" refers to a the "{session}" sso-session that is not present in the configuration file.', ['profile' => $profile, 'session' => $sessionName]); 187 | 188 | return null; 189 | } 190 | 191 | $sessionData = $profilesData['sso-session ' . $sessionName]; 192 | if (!isset( 193 | $sessionData[IniFileLoader::KEY_SSO_START_URL], 194 | $sessionData[IniFileLoader::KEY_SSO_REGION] 195 | )) { 196 | $this->logger->warning('SSO Session "{session}" does not contains required SSO config.', ['session' => $sessionName]); 197 | 198 | return null; 199 | } 200 | 201 | $ssoTokenProvider = new SsoTokenProvider($this->httpClient, $this->logger); 202 | $token = $ssoTokenProvider->getToken($sessionName, $sessionData); 203 | if (null === $token) { 204 | return null; 205 | } 206 | 207 | return $this->getCredentialsFromSsoToken($profileData, $sessionData[IniFileLoader::KEY_SSO_REGION], $profile, $token); 208 | } 209 | 210 | /** 211 | * @param array $profileData 212 | */ 213 | private function getCredentialsFromLegacySso(array $profileData, string $profile): ?Credentials 214 | { 215 | if (!isset( 216 | $profileData[IniFileLoader::KEY_SSO_START_URL], 217 | $profileData[IniFileLoader::KEY_SSO_REGION], 218 | $profileData[IniFileLoader::KEY_SSO_ACCOUNT_ID], 219 | $profileData[IniFileLoader::KEY_SSO_ROLE_NAME] 220 | )) { 221 | $this->logger->warning('Profile "{profile}" does not contains required legacy SSO config.', ['profile' => $profile]); 222 | 223 | return null; 224 | } 225 | 226 | $ssoCacheFileLoader = new SsoCacheFileLoader($this->logger); 227 | $tokenData = $ssoCacheFileLoader->loadSsoCacheFile($profileData[IniFileLoader::KEY_SSO_START_URL]); 228 | 229 | if ([] === $tokenData) { 230 | return null; 231 | } 232 | 233 | return $this->getCredentialsFromSsoToken($profileData, $profileData[IniFileLoader::KEY_SSO_REGION], $profile, $tokenData[SsoCacheFileLoader::KEY_ACCESS_TOKEN]); 234 | } 235 | 236 | private function getCredentialsFromSsoToken(array $profileData, string $ssoRegion, string $profile, string $accessToken): ?Credentials 237 | { 238 | $ssoClient = new SsoClient( 239 | ['region' => $ssoRegion], 240 | new NullProvider(), // no credentials required as we provide an access token via the role credentials request 241 | $this->httpClient 242 | ); 243 | $result = $ssoClient->getRoleCredentials([ 244 | 'accessToken' => $accessToken, 245 | 'accountId' => $profileData[IniFileLoader::KEY_SSO_ACCOUNT_ID], 246 | 'roleName' => $profileData[IniFileLoader::KEY_SSO_ROLE_NAME], 247 | ]); 248 | 249 | try { 250 | if (null === $credentials = $result->getRoleCredentials()) { 251 | throw new RuntimeException('The RoleCredentials response does not contains credentials'); 252 | } 253 | if (null === $accessKeyId = $credentials->getAccessKeyId()) { 254 | throw new RuntimeException('The RoleCredentials response does not contain an accessKeyId'); 255 | } 256 | if (null === $secretAccessKey = $credentials->getSecretAccessKey()) { 257 | throw new RuntimeException('The RoleCredentials response does not contain a secretAccessKey'); 258 | } 259 | if (null === $sessionToken = $credentials->getSessionToken()) { 260 | throw new RuntimeException('The RoleCredentials response does not contain a sessionToken'); 261 | } 262 | if (null === $expiration = $credentials->getExpiration()) { 263 | throw new RuntimeException('The RoleCredentials response does not contain an expiration'); 264 | } 265 | } catch (\Exception $e) { 266 | $this->logger->warning('Failed to get credentials from role credentials in profile "{profile}: {exception}".', ['profile' => $profile, 'exception' => $e]); 267 | 268 | return null; 269 | } 270 | 271 | return new Credentials( 272 | $accessKeyId, 273 | $secretAccessKey, 274 | $sessionToken, 275 | (new \DateTimeImmutable())->setTimestamp($expiration) 276 | ); 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /src/Credentials/InstanceProvider.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | final class InstanceProvider implements CredentialProvider 27 | { 28 | private const TOKEN_ENDPOINT = 'http://169.254.169.254/latest/api/token'; 29 | private const METADATA_ENDPOINT = 'http://169.254.169.254/latest/meta-data/iam/security-credentials'; 30 | 31 | /** 32 | * @var LoggerInterface 33 | */ 34 | private $logger; 35 | 36 | /** 37 | * @var HttpClientInterface 38 | */ 39 | private $httpClient; 40 | 41 | /** 42 | * @var float 43 | */ 44 | private $timeout; 45 | 46 | /** 47 | * @var int 48 | */ 49 | private $tokenTtl; 50 | 51 | public function __construct(?HttpClientInterface $httpClient = null, ?LoggerInterface $logger = null, float $timeout = 1.0, int $tokenTtl = 21600) 52 | { 53 | $this->logger = $logger ?? new NullLogger(); 54 | $this->httpClient = $httpClient ?? HttpClient::create(); 55 | $this->timeout = $timeout; 56 | $this->tokenTtl = $tokenTtl; 57 | } 58 | 59 | public function getCredentials(Configuration $configuration): ?Credentials 60 | { 61 | $token = $this->getToken(); 62 | $headers = []; 63 | 64 | if (null !== $token) { 65 | $headers = ['X-aws-ec2-metadata-token' => $token]; 66 | } 67 | 68 | try { 69 | // Fetch current Profile 70 | $response = $this->httpClient->request('GET', self::METADATA_ENDPOINT, [ 71 | 'timeout' => $this->timeout, 72 | 'headers' => $headers, 73 | ]); 74 | $profile = $response->getContent(); 75 | 76 | // Fetch credentials from profile 77 | $response = $this->httpClient->request('GET', self::METADATA_ENDPOINT . '/' . $profile, [ 78 | 'timeout' => $this->timeout, 79 | 'headers' => $headers, 80 | ]); 81 | $result = $this->toArray($response); 82 | 83 | if ('Success' !== $result['Code']) { 84 | $this->logger->info('Unexpected instance profile.', ['response_code' => $result['Code']]); 85 | 86 | return null; 87 | } 88 | } catch (DecodingExceptionInterface $e) { 89 | $this->logger->info('Failed to decode Credentials.', ['exception' => $e]); 90 | 91 | return null; 92 | } catch (TransportExceptionInterface|HttpExceptionInterface $e) { 93 | $this->logger->info('Failed to fetch Profile from Instance Metadata.', ['exception' => $e]); 94 | 95 | return null; 96 | } 97 | 98 | if (null !== $date = $response->getHeaders(false)['date'][0] ?? null) { 99 | $date = new \DateTimeImmutable($date); 100 | } 101 | 102 | return new Credentials( 103 | $result['AccessKeyId'], 104 | $result['SecretAccessKey'], 105 | $result['Token'], 106 | Credentials::adjustExpireDate(new \DateTimeImmutable($result['Expiration']), $date) 107 | ); 108 | } 109 | 110 | /** 111 | * Copy of Symfony\Component\HttpClient\Response::toArray without assertion on Content-Type header. 112 | * 113 | * @return array 114 | */ 115 | private function toArray(ResponseInterface $response): array 116 | { 117 | if ('' === $content = $response->getContent(true)) { 118 | throw new TransportException('Response body is empty.'); 119 | } 120 | 121 | try { 122 | $content = json_decode($content, true, 512, \JSON_BIGINT_AS_STRING | (\PHP_VERSION_ID >= 70300 ? \JSON_THROW_ON_ERROR : 0)); 123 | } catch (\JsonException $e) { 124 | /** @psalm-suppress all */ 125 | throw new JsonException(\sprintf('%s for "%s".', $e->getMessage(), $response->getInfo('url')), $e->getCode()); 126 | } 127 | 128 | if (\PHP_VERSION_ID < 70300 && \JSON_ERROR_NONE !== json_last_error()) { 129 | /** @psalm-suppress InvalidArgument */ 130 | throw new JsonException(\sprintf('%s for "%s".', json_last_error_msg(), $response->getInfo('url')), json_last_error()); 131 | } 132 | 133 | if (!\is_array($content)) { 134 | /** @psalm-suppress InvalidArgument */ 135 | throw new JsonException(\sprintf('JSON content was expected to decode to an array, %s returned for "%s".', \gettype($content), $response->getInfo('url'))); 136 | } 137 | 138 | return $content; 139 | } 140 | 141 | private function getToken(): ?string 142 | { 143 | try { 144 | $response = $this->httpClient->request('PUT', self::TOKEN_ENDPOINT, 145 | [ 146 | 'timeout' => $this->timeout, 147 | 'headers' => ['X-aws-ec2-metadata-token-ttl-seconds' => $this->tokenTtl], 148 | ] 149 | ); 150 | 151 | return $response->getContent(); 152 | } catch (TransportExceptionInterface|HttpExceptionInterface $e) { 153 | $this->logger->info('Failed to fetch metadata token for IMDSv2, fallback to IMDSv1.', ['exception' => $e]); 154 | 155 | return null; 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/Credentials/NullProvider.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class NullProvider implements CredentialProvider 15 | { 16 | public function getCredentials(Configuration $configuration): ?Credentials 17 | { 18 | return null; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Credentials/PsrCacheProvider.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class PsrCacheProvider implements CredentialProvider 20 | { 21 | /** 22 | * @var CacheItemPoolInterface 23 | */ 24 | private $cache; 25 | 26 | /** 27 | * @var CredentialProvider 28 | */ 29 | private $decorated; 30 | 31 | /** 32 | * @var LoggerInterface|null 33 | */ 34 | private $logger; 35 | 36 | public function __construct(CredentialProvider $decorated, CacheItemPoolInterface $cache, ?LoggerInterface $logger = null) 37 | { 38 | $this->decorated = $decorated; 39 | $this->cache = $cache; 40 | $this->logger = $logger; 41 | } 42 | 43 | public function getCredentials(Configuration $configuration): ?Credentials 44 | { 45 | try { 46 | return $this->getFromCache($configuration); 47 | } catch (CacheException $e) { 48 | if (null !== $this->logger) { 49 | $this->logger->error('Failed to get AWS credentials from cache.', ['exception' => $e]); 50 | } 51 | 52 | return $this->decorated->getCredentials($configuration); 53 | } 54 | } 55 | 56 | /** 57 | * @throws CacheException 58 | */ 59 | private function getFromCache(Configuration $configuration): ?Credentials 60 | { 61 | $item = $this->cache->getItem('AsyncAws.Credentials.' . sha1(serialize([$configuration, \get_class($this->decorated)]))); 62 | if (!$item->isHit()) { 63 | $item->set($credential = $this->decorated->getCredentials($configuration)); 64 | 65 | if (null !== $credential && null !== $exp = $credential->getExpireDate()) { 66 | $item->expiresAt($exp); 67 | $this->cache->save($item); 68 | } 69 | } 70 | 71 | return $item->get(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Credentials/SsoCacheFileLoader.php: -------------------------------------------------------------------------------- 1 | logger = $logger ?? new NullLogger(); 29 | } 30 | 31 | /** 32 | * @return array 33 | */ 34 | public function loadSsoCacheFile(string $ssoStartUrl): array 35 | { 36 | $filepath = \sprintf('%s/.aws/sso/cache/%s.json', $this->getHomeDir(), sha1($ssoStartUrl)); 37 | 38 | if (false === ($contents = @file_get_contents($filepath))) { 39 | $this->logger->warning('The sso cache file {path} is not readable.', ['path' => $filepath]); 40 | 41 | return []; 42 | } 43 | 44 | $tokenData = json_decode($contents, true); 45 | if (!isset($tokenData[self::KEY_ACCESS_TOKEN], $tokenData[self::KEY_EXPIRES_AT])) { 46 | $this->logger->warning('Token file at {path} must contain an accessToken and an expiresAt.', ['path' => $filepath]); 47 | 48 | return []; 49 | } 50 | 51 | try { 52 | $expiration = (new \DateTimeImmutable($tokenData[self::KEY_EXPIRES_AT])); 53 | } catch (\Exception $e) { 54 | $this->logger->warning('Cached SSO credentials returned an invalid expiresAt value.'); 55 | 56 | return []; 57 | } 58 | 59 | if ($expiration < new \DateTimeImmutable()) { 60 | $this->logger->warning('Cached SSO credentials returned an invalid expiresAt value.'); 61 | 62 | return []; 63 | } 64 | 65 | return $tokenData; 66 | } 67 | 68 | private function getHomeDir(): string 69 | { 70 | // On Linux/Unix-like systems, use the HOME environment variable 71 | if (null !== $homeDir = EnvVar::get('HOME')) { 72 | return $homeDir; 73 | } 74 | 75 | // Get the HOMEDRIVE and HOMEPATH values for Windows hosts 76 | $homeDrive = EnvVar::get('HOMEDRIVE'); 77 | $homePath = EnvVar::get('HOMEPATH'); 78 | 79 | return ($homeDrive && $homePath) ? $homeDrive . $homePath : '/'; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Credentials/SsoTokenProvider.php: -------------------------------------------------------------------------------- 1 | httpClient = $httpClient; 41 | $this->logger = $logger ?? new NullLogger(); 42 | } 43 | 44 | /** 45 | * @param array $sessionData 46 | */ 47 | public function getToken(string $sessionName, array $sessionData): ?string 48 | { 49 | $tokenData = $this->loadSsoToken($sessionName); 50 | if (null === $tokenData) { 51 | return null; 52 | } 53 | 54 | $tokenData = $this->refreshTokenIfNeeded($sessionName, $sessionData, $tokenData); 55 | if (!isset($tokenData[self::KEY_ACCESS_TOKEN])) { 56 | $this->logger->warning('The token for SSO session "{session}" does not contains accessToken.', ['session' => $sessionName]); 57 | 58 | return null; 59 | } 60 | 61 | return $tokenData[self::KEY_ACCESS_TOKEN]; 62 | } 63 | 64 | /** 65 | * @param array $sessionData 66 | */ 67 | private function refreshTokenIfNeeded(string $sessionName, array $sessionData, array $tokenData): array 68 | { 69 | if (!isset($tokenData[self::KEY_EXPIRES_AT])) { 70 | $this->logger->warning('The token for SSO session "{session}" does not contains expiration date.', ['session' => $sessionName]); 71 | 72 | return $tokenData; 73 | } 74 | 75 | $tokenExpiresAt = new \DateTimeImmutable($tokenData[self::KEY_EXPIRES_AT]); 76 | $tokenRefreshAt = $tokenExpiresAt->sub(new \DateInterval(\sprintf('PT%dS', self::REFRESH_WINDOW))); 77 | 78 | // If token expiration is in the 5 minutes window 79 | if ($tokenRefreshAt > new \DateTimeImmutable()) { 80 | return $tokenData; 81 | } 82 | 83 | if (!isset( 84 | $tokenData[self::KEY_CLIENT_ID], 85 | $tokenData[self::KEY_CLIENT_SECRET], 86 | $tokenData[self::KEY_REFRESH_TOKEN] 87 | )) { 88 | $this->logger->warning('The token for SSO session "{session}" does not contains required properties and cannot be refreshed.', ['session' => $sessionName]); 89 | 90 | return $tokenData; 91 | } 92 | 93 | $ssoOidcClient = new SsoOidcClient( 94 | ['region' => $sessionData[IniFileLoader::KEY_SSO_REGION]], 95 | new NullProvider(), 96 | // no credentials required as we provide an access token via the role credentials request 97 | $this->httpClient 98 | ); 99 | 100 | $result = $ssoOidcClient->createToken([ 101 | 'clientId' => $tokenData[self::KEY_CLIENT_ID], 102 | 'clientSecret' => $tokenData[self::KEY_CLIENT_SECRET], 103 | 'grantType' => 'refresh_token', // REQUIRED 104 | 'refreshToken' => $tokenData[self::KEY_REFRESH_TOKEN], 105 | ]); 106 | 107 | $tokenData = [ 108 | self::KEY_ACCESS_TOKEN => $result->getAccessToken(), 109 | self::KEY_REFRESH_TOKEN => $result->getRefreshToken(), 110 | ] + $tokenData; 111 | 112 | if (null === $expiresIn = $result->getExpiresIn()) { 113 | $this->logger->warning('The token for SSO session "{session}" does not contains expiration time.', ['session' => $sessionName]); 114 | } else { 115 | $tokenData[self::KEY_EXPIRES_AT] = (new \DateTimeImmutable())->add(new \DateInterval(\sprintf('PT%dS', $expiresIn)))->format(\DateTime::ATOM); 116 | } 117 | 118 | $this->dumpSsoToken($sessionName, $tokenData); 119 | 120 | return $tokenData; 121 | } 122 | 123 | private function dumpSsoToken(string $sessionName, array $tokenData): void 124 | { 125 | $filepath = \sprintf('%s/.aws/sso/cache/%s.json', $this->getHomeDir(), sha1($sessionName)); 126 | 127 | file_put_contents($filepath, json_encode(array_filter($tokenData))); 128 | } 129 | 130 | /** 131 | * @return array|null 132 | */ 133 | private function loadSsoToken(string $sessionName): ?array 134 | { 135 | $filepath = \sprintf('%s/.aws/sso/cache/%s.json', $this->getHomeDir(), sha1($sessionName)); 136 | if (!is_readable($filepath)) { 137 | $this->logger->warning('The sso cache file {path} is not readable.', ['path' => $filepath]); 138 | 139 | return null; 140 | } 141 | 142 | if (false === ($content = @file_get_contents($filepath))) { 143 | $this->logger->warning('The sso cache file {path} is not readable.', ['path' => $filepath]); 144 | 145 | return null; 146 | } 147 | 148 | try { 149 | return json_decode( 150 | $content, 151 | true, 152 | 512, 153 | \JSON_BIGINT_AS_STRING | (\PHP_VERSION_ID >= 70300 ? \JSON_THROW_ON_ERROR : 0) 154 | ); 155 | } catch (\JsonException $e) { 156 | $this->logger->warning( 157 | 'The sso cache file {path} contains invalide JSON.', 158 | ['path' => $filepath, 'ecxeption' => $e] 159 | ); 160 | 161 | return null; 162 | } 163 | } 164 | 165 | private function getHomeDir(): string 166 | { 167 | // On Linux/Unix-like systems, use the HOME environment variable 168 | if (null !== $homeDir = EnvVar::get('HOME')) { 169 | return $homeDir; 170 | } 171 | 172 | // Get the HOMEDRIVE and HOMEPATH values for Windows hosts 173 | $homeDrive = EnvVar::get('HOMEDRIVE'); 174 | $homePath = EnvVar::get('HOMEPATH'); 175 | 176 | return ($homeDrive && $homePath) ? $homeDrive . $homePath : '/'; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/Credentials/SymfonyCacheProvider.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final class SymfonyCacheProvider implements CredentialProvider 23 | { 24 | /** 25 | * @var CacheInterface 26 | */ 27 | private $cache; 28 | 29 | /** 30 | * @var CredentialProvider 31 | */ 32 | private $decorated; 33 | 34 | /** 35 | * @var LoggerInterface|null 36 | */ 37 | private $logger; 38 | 39 | public function __construct(CredentialProvider $decorated, CacheInterface $cache, ?LoggerInterface $logger = null) 40 | { 41 | $this->decorated = $decorated; 42 | $this->cache = $cache; 43 | $this->logger = $logger; 44 | } 45 | 46 | public function getCredentials(Configuration $configuration): ?Credentials 47 | { 48 | $provider = $this->decorated; 49 | $closure = \Closure::fromCallable(static function (ItemInterface $item) use ($configuration, $provider) { 50 | $credential = $provider->getCredentials($configuration); 51 | 52 | if (null !== $credential && null !== $exp = $credential->getExpireDate()) { 53 | $item->expiresAt($exp); 54 | } else { 55 | $item->expiresAfter(0); 56 | } 57 | 58 | return $credential; 59 | }); 60 | 61 | try { 62 | return $this->cache->get('AsyncAws.Credentials.' . sha1(serialize([$configuration, \get_class($this->decorated)])), $closure); 63 | } catch (CacheException $e) { 64 | if (null !== $this->logger) { 65 | $this->logger->error('Failed to get AWS credentials from cache.', ['exception' => $e]); 66 | } 67 | 68 | return $provider->getCredentials($configuration); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Credentials/TokenFileLoader.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | final class WebIdentityProvider implements CredentialProvider 22 | { 23 | use DateFromResult; 24 | use TokenFileLoader; 25 | 26 | /** 27 | * @var IniFileLoader 28 | */ 29 | private $iniFileLoader; 30 | 31 | /** 32 | * @var LoggerInterface 33 | */ 34 | private $logger; 35 | 36 | /** 37 | * @var HttpClientInterface|null 38 | */ 39 | private $httpClient; 40 | 41 | public function __construct(?LoggerInterface $logger = null, ?IniFileLoader $iniFileLoader = null, ?HttpClientInterface $httpClient = null) 42 | { 43 | $this->logger = $logger ?? new NullLogger(); 44 | $this->iniFileLoader = $iniFileLoader ?? new IniFileLoader($this->logger); 45 | $this->httpClient = $httpClient; 46 | } 47 | 48 | public function getCredentials(Configuration $configuration): ?Credentials 49 | { 50 | $roleArn = $configuration->get(Configuration::OPTION_ROLE_ARN); 51 | $tokenFile = $configuration->get(Configuration::OPTION_WEB_IDENTITY_TOKEN_FILE); 52 | 53 | if ($tokenFile && $roleArn) { 54 | return $this->getCredentialsFromRole( 55 | $roleArn, 56 | $tokenFile, 57 | $configuration->get(Configuration::OPTION_ROLE_SESSION_NAME), 58 | $configuration->get(Configuration::OPTION_REGION) 59 | ); 60 | } 61 | 62 | $profilesData = $this->iniFileLoader->loadProfiles([ 63 | $configuration->get(Configuration::OPTION_SHARED_CREDENTIALS_FILE), 64 | $configuration->get(Configuration::OPTION_SHARED_CONFIG_FILE), 65 | ]); 66 | if (empty($profilesData)) { 67 | return null; 68 | } 69 | 70 | /** @var string $profile */ 71 | $profile = $configuration->get(Configuration::OPTION_PROFILE); 72 | if (!isset($profilesData[$profile])) { 73 | $this->logger->warning('Profile "{profile}" not found.', ['profile' => $profile]); 74 | 75 | return null; 76 | } 77 | 78 | $profileData = $profilesData[$profile]; 79 | $roleArn = $profileData[IniFileLoader::KEY_ROLE_ARN] ?? null; 80 | $tokenFile = $profileData[IniFileLoader::KEY_WEB_IDENTITY_TOKEN_FILE] ?? null; 81 | 82 | if (null !== $roleArn && null !== $tokenFile) { 83 | return $this->getCredentialsFromRole( 84 | $roleArn, 85 | $tokenFile, 86 | $profileData[IniFileLoader::KEY_ROLE_SESSION_NAME] ?? null, 87 | $profileData[IniFileLoader::KEY_REGION] ?? $configuration->get(Configuration::OPTION_REGION) 88 | ); 89 | } 90 | 91 | return null; 92 | } 93 | 94 | private function getCredentialsFromRole(string $roleArn, string $tokenFile, ?string $sessionName, ?string $region): ?Credentials 95 | { 96 | $sessionName = $sessionName ?? uniqid('async-aws-', true); 97 | if (!preg_match("/^\w\:|^\/|^\\\/", $tokenFile)) { 98 | $this->logger->warning('WebIdentityTokenFile "{tokenFile}" must be an absolute path.', ['tokenFile' => $tokenFile]); 99 | } 100 | 101 | try { 102 | $token = $this->getTokenFileContent($tokenFile); 103 | } catch (\Exception $e) { 104 | $this->logger->warning('"Error reading WebIdentityTokenFile "{tokenFile}.', ['tokenFile' => $tokenFile, 'exception' => $e]); 105 | 106 | return null; 107 | } 108 | 109 | $stsClient = new StsClient(['region' => $region], new NullProvider(), $this->httpClient); 110 | $result = $stsClient->assumeRoleWithWebIdentity([ 111 | 'RoleArn' => $roleArn, 112 | 'RoleSessionName' => $sessionName, 113 | 'WebIdentityToken' => $token, 114 | ]); 115 | 116 | try { 117 | if (null === $credentials = $result->getCredentials()) { 118 | throw new RuntimeException('The AssumeRoleWithWebIdentity response does not contains credentials'); 119 | } 120 | } catch (\Exception $e) { 121 | $this->logger->warning('Failed to get credentials from assumed role: {exception}".', ['exception' => $e]); 122 | 123 | return null; 124 | } 125 | 126 | return new Credentials( 127 | $credentials->getAccessKeyId(), 128 | $credentials->getSecretAccessKey(), 129 | $credentials->getSessionToken(), 130 | Credentials::adjustExpireDate($credentials->getExpiration(), $this->getDateFromResult($result)) 131 | ); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/EndpointDiscovery/EndpointCache.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * @internal 11 | */ 12 | class EndpointCache 13 | { 14 | /** 15 | * @var array> 16 | */ 17 | private $endpoints = []; 18 | 19 | /** 20 | * @var array> 21 | */ 22 | private $expired = []; 23 | 24 | /** 25 | * @param EndpointInterface[] $endpoints 26 | */ 27 | public function addEndpoints(?string $region, array $endpoints): void 28 | { 29 | $now = time(); 30 | 31 | if (null === $region) { 32 | $region = ''; 33 | } 34 | if (!isset($this->endpoints[$region])) { 35 | $this->endpoints[$region] = []; 36 | } 37 | 38 | foreach ($endpoints as $endpoint) { 39 | $this->endpoints[$region][$this->sanitizeEndpoint($endpoint->getAddress())] = $now + ($endpoint->getCachePeriodInMinutes() * 60); 40 | } 41 | arsort($this->endpoints[$region]); 42 | } 43 | 44 | public function removeEndpoint(string $endpoint): void 45 | { 46 | $endpoint = $this->sanitizeEndpoint($endpoint); 47 | foreach ($this->endpoints as &$endpoints) { 48 | unset($endpoints[$endpoint]); 49 | } 50 | unset($endpoints); 51 | foreach ($this->expired as &$endpoints) { 52 | unset($endpoints[$endpoint]); 53 | } 54 | 55 | unset($endpoints); 56 | } 57 | 58 | public function getActiveEndpoint(?string $region): ?string 59 | { 60 | if (null === $region) { 61 | $region = ''; 62 | } 63 | $now = time(); 64 | 65 | foreach ($this->endpoints[$region] ?? [] as $endpoint => $expiresAt) { 66 | if ($expiresAt < $now) { 67 | $this->expired[$region] = \array_slice($this->expired[$region] ?? [], -100); // keep only the last 100 items 68 | unset($this->endpoints[$region][$endpoint]); 69 | $this->expired[$region][$endpoint] = $expiresAt; 70 | 71 | continue; 72 | } 73 | 74 | return $endpoint; 75 | } 76 | 77 | return null; 78 | } 79 | 80 | public function getExpiredEndpoint(?string $region): ?string 81 | { 82 | if (null === $region) { 83 | $region = ''; 84 | } 85 | if (empty($this->expired[$region])) { 86 | return null; 87 | } 88 | 89 | return array_key_last($this->expired[$region]); 90 | } 91 | 92 | private function sanitizeEndpoint(string $address): string 93 | { 94 | $parsed = parse_url($address); 95 | 96 | // parse_url() will correctly parse full URIs with schemes 97 | if (isset($parsed['host'])) { 98 | return rtrim(\sprintf( 99 | '%s://%s/%s', 100 | $parsed['scheme'] ?? 'https', 101 | $parsed['host'], 102 | ltrim($parsed['path'] ?? '/', '/') 103 | ), '/'); 104 | } 105 | 106 | // parse_url() will put host & path in 'path' if scheme is not provided 107 | if (isset($parsed['path'])) { 108 | $split = explode('/', $parsed['path'], 2); 109 | $parsed['host'] = $split[0]; 110 | if (isset($split[1])) { 111 | $parsed['path'] = $split[1]; 112 | } else { 113 | $parsed['path'] = ''; 114 | } 115 | 116 | return rtrim(\sprintf( 117 | '%s://%s/%s', 118 | $parsed['scheme'] ?? 'https', 119 | $parsed['host'], 120 | ltrim($parsed['path'], '/') 121 | ), '/'); 122 | } 123 | 124 | throw new LogicException(\sprintf('The supplied endpoint "%s" is invalid.', $address)); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/EndpointDiscovery/EndpointInterface.php: -------------------------------------------------------------------------------- 1 | 11 | * 12 | * @internal 13 | */ 14 | final class EnvVar 15 | { 16 | public static function get(string $name): ?string 17 | { 18 | if (isset($_ENV[$name])) { 19 | // variable_order = *E*GPCS 20 | return (string) $_ENV[$name]; 21 | } elseif (isset($_SERVER[$name]) && !\is_array($_SERVER[$name]) && 0 !== strpos($name, 'HTTP_')) { 22 | // fastcgi_param, env var, ... 23 | return (string) $_SERVER[$name]; 24 | } elseif (false === $env = getenv($name)) { 25 | // getenv not thread safe 26 | return null; 27 | } 28 | 29 | return $env; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Exception/Exception.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | interface Exception extends \Throwable 13 | { 14 | } 15 | -------------------------------------------------------------------------------- /src/Exception/Http/ClientException.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class ClientException extends \RuntimeException implements ClientExceptionInterface, HttpException 15 | { 16 | use HttpExceptionTrait; 17 | } 18 | -------------------------------------------------------------------------------- /src/Exception/Http/HttpException.php: -------------------------------------------------------------------------------- 1 | 10 | * @author Tobias Nyholm 11 | * @author Jérémy Derussé 12 | * 13 | * @internal 14 | */ 15 | trait HttpExceptionTrait 16 | { 17 | /** 18 | * @var ResponseInterface 19 | */ 20 | private $response; 21 | 22 | /** 23 | * @var ?AwsError 24 | */ 25 | private $awsError; 26 | 27 | public function __construct(ResponseInterface $response, ?AwsError $awsError = null) 28 | { 29 | $this->response = $response; 30 | /** @var int $code */ 31 | $code = $response->getInfo('http_code'); 32 | /** @var string $url */ 33 | $url = $response->getInfo('url'); 34 | 35 | $message = \sprintf('HTTP %d returned for "%s".', $code, $url); 36 | if (null !== $this->awsError = $awsError) { 37 | $message .= <<awsError->getCode()} 41 | Message: {$this->awsError->getMessage()} 42 | Type: {$this->awsError->getType()} 43 | Detail: {$this->awsError->getDetail()} 44 | 45 | TEXT; 46 | } 47 | 48 | parent::__construct($message, $code); 49 | 50 | $this->populateResult($response); 51 | } 52 | 53 | public function getResponse(): ResponseInterface 54 | { 55 | return $this->response; 56 | } 57 | 58 | public function getAwsCode(): ?string 59 | { 60 | return $this->awsError ? $this->awsError->getCode() : null; 61 | } 62 | 63 | public function getAwsType(): ?string 64 | { 65 | return $this->awsError ? $this->awsError->getType() : null; 66 | } 67 | 68 | public function getAwsMessage(): ?string 69 | { 70 | return $this->awsError ? $this->awsError->getMessage() : null; 71 | } 72 | 73 | public function getAwsDetail(): ?string 74 | { 75 | return $this->awsError ? $this->awsError->getDetail() : null; 76 | } 77 | 78 | protected function populateResult(ResponseInterface $response): void 79 | { 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Exception/Http/NetworkException.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class NetworkException extends \RuntimeException implements Exception, TransportExceptionInterface 16 | { 17 | } 18 | -------------------------------------------------------------------------------- /src/Exception/Http/RedirectionException.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class RedirectionException extends \RuntimeException implements HttpException, RedirectionExceptionInterface 15 | { 16 | use HttpExceptionTrait; 17 | } 18 | -------------------------------------------------------------------------------- /src/Exception/Http/ServerException.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class ServerException extends \RuntimeException implements HttpException, ServerExceptionInterface 15 | { 16 | use HttpExceptionTrait; 17 | } 18 | -------------------------------------------------------------------------------- /src/Exception/InvalidArgument.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class AwsHttpClientFactory 14 | { 15 | public static function createRetryableClient(?HttpClientInterface $httpClient = null, ?LoggerInterface $logger = null): HttpClientInterface 16 | { 17 | if (null === $httpClient) { 18 | $httpClient = HttpClient::create(); 19 | } 20 | if (class_exists(RetryableHttpClient::class)) { 21 | /** @psalm-suppress MissingDependency */ 22 | $httpClient = new RetryableHttpClient( 23 | $httpClient, 24 | new AwsRetryStrategy(), 25 | 3, 26 | $logger 27 | ); 28 | } 29 | 30 | return $httpClient; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/HttpClient/AwsRetryStrategy.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class AwsRetryStrategy extends GenericRetryStrategy 16 | { 17 | // Override Symfony default options for a better integration of AWS servers. 18 | public const DEFAULT_RETRY_STATUS_CODES = [0, 423, 425, 429, 500, 502, 503, 504, 507, 510]; 19 | 20 | /** 21 | * @var AwsErrorFactoryInterface 22 | */ 23 | private $awsErrorFactory; 24 | 25 | /** 26 | * @param array $statusCodes 27 | */ 28 | public function __construct(array $statusCodes = self::DEFAULT_RETRY_STATUS_CODES, int $delayMs = 1000, float $multiplier = 2.0, int $maxDelayMs = 0, float $jitter = 0.1, ?AwsErrorFactoryInterface $awsErrorFactory = null) 29 | { 30 | parent::__construct($statusCodes, $delayMs, $multiplier, $maxDelayMs, $jitter); 31 | $this->awsErrorFactory = $awsErrorFactory ?? new ChainAwsErrorFactory(); 32 | } 33 | 34 | public function shouldRetry(AsyncContext $context, ?string $responseContent, ?TransportExceptionInterface $exception): ?bool 35 | { 36 | if (parent::shouldRetry($context, $responseContent, $exception)) { 37 | return true; 38 | } 39 | 40 | if (!\in_array($context->getStatusCode(), [400, 403], true)) { 41 | return false; 42 | } 43 | 44 | if (null === $responseContent) { 45 | return null; // null mean no decision taken and need to be called again with the body 46 | } 47 | 48 | try { 49 | $error = $this->awsErrorFactory->createFromContent($responseContent, $context->getHeaders()); 50 | } catch (UnparsableResponse $e) { 51 | return false; 52 | } 53 | 54 | return \in_array($error->getCode(), [ 55 | 'RequestLimitExceeded', 56 | 'Throttling', 57 | 'ThrottlingException', 58 | 'ThrottledException', 59 | 'LimitExceededException', 60 | 'PriorRequestNotComplete', 61 | 'ProvisionedThroughputExceededException', 62 | 'RequestThrottled', 63 | 'SlowDown', 64 | 'BandwidthLimitExceeded', 65 | 'RequestThrottledException', 66 | 'RetryableThrottlingException', 67 | 'TooManyRequestsException', 68 | 'IDPCommunicationError', 69 | 'EC2ThrottledException', 70 | 'TransactionInProgressException', 71 | ], true); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Input.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | abstract class Input 11 | { 12 | /** 13 | * @var string|null 14 | */ 15 | public $region; 16 | 17 | /** 18 | * @param array{'@region'?: ?string,...} $input 19 | */ 20 | protected function __construct(array $input) 21 | { 22 | $this->region = $input['@region'] ?? null; 23 | } 24 | 25 | public function setRegion(?string $region): void 26 | { 27 | $this->region = $region; 28 | } 29 | 30 | public function getRegion(): ?string 31 | { 32 | return $this->region; 33 | } 34 | 35 | abstract public function request(): Request; 36 | } 37 | -------------------------------------------------------------------------------- /src/Request.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class Request 15 | { 16 | /** 17 | * @var string 18 | */ 19 | private $method; 20 | 21 | /** 22 | * @var string 23 | */ 24 | private $uri; 25 | 26 | /** 27 | * @var array 28 | */ 29 | private $headers; 30 | 31 | /** 32 | * @var RequestStream 33 | */ 34 | private $body; 35 | 36 | /** 37 | * @var string|null 38 | */ 39 | private $queryString; 40 | 41 | /** 42 | * @var array 43 | */ 44 | private $query; 45 | 46 | /** 47 | * @var string 48 | */ 49 | private $endpoint; 50 | 51 | /** 52 | * @var string 53 | */ 54 | private $hostPrefix; 55 | 56 | /** 57 | * @var array{scheme: string, host: string, port: int|null}|null 58 | */ 59 | private $parsed; 60 | 61 | /** 62 | * @param array $query 63 | * @param array $headers 64 | */ 65 | public function __construct(string $method, string $uri, array $query, array $headers, RequestStream $body, string $hostPrefix = '') 66 | { 67 | $this->method = $method; 68 | $this->uri = $uri; 69 | $this->headers = []; 70 | foreach ($headers as $key => $value) { 71 | $this->headers[strtolower($key)] = (string) $value; 72 | } 73 | $this->body = $body; 74 | $this->query = $query; 75 | $this->hostPrefix = $hostPrefix; 76 | $this->endpoint = ''; 77 | } 78 | 79 | public function getMethod(): string 80 | { 81 | return $this->method; 82 | } 83 | 84 | public function setMethod(string $method): void 85 | { 86 | $this->method = $method; 87 | } 88 | 89 | public function getUri(): string 90 | { 91 | return $this->uri; 92 | } 93 | 94 | public function hasHeader(string $name): bool 95 | { 96 | return \array_key_exists(strtolower($name), $this->headers); 97 | } 98 | 99 | public function setHeader(string $name, string $value): void 100 | { 101 | $this->headers[strtolower($name)] = $value; 102 | } 103 | 104 | /** 105 | * @return array 106 | */ 107 | public function getHeaders(): array 108 | { 109 | return $this->headers; 110 | } 111 | 112 | public function getHeader(string $name): ?string 113 | { 114 | return $this->headers[strtolower($name)] ?? null; 115 | } 116 | 117 | public function removeHeader(string $name): void 118 | { 119 | unset($this->headers[strtolower($name)]); 120 | } 121 | 122 | public function getBody(): RequestStream 123 | { 124 | return $this->body; 125 | } 126 | 127 | public function setBody(RequestStream $body): void 128 | { 129 | $this->body = $body; 130 | } 131 | 132 | public function hasQueryAttribute(string $name): bool 133 | { 134 | return \array_key_exists($name, $this->query); 135 | } 136 | 137 | public function removeQueryAttribute(string $name): void 138 | { 139 | unset($this->query[$name]); 140 | $this->queryString = null; 141 | $this->endpoint = ''; 142 | } 143 | 144 | public function setQueryAttribute(string $name, string $value): void 145 | { 146 | $this->query[$name] = $value; 147 | $this->queryString = null; 148 | $this->endpoint = ''; 149 | } 150 | 151 | public function getQueryAttribute(string $name): ?string 152 | { 153 | return $this->query[$name] ?? null; 154 | } 155 | 156 | /** 157 | * @return array 158 | */ 159 | public function getQuery(): array 160 | { 161 | return $this->query; 162 | } 163 | 164 | public function getHostPrefix(): string 165 | { 166 | return $this->hostPrefix; 167 | } 168 | 169 | public function setHostPrefix(string $hostPrefix): void 170 | { 171 | $this->hostPrefix = $hostPrefix; 172 | $this->endpoint = ''; 173 | } 174 | 175 | public function getEndpoint(): string 176 | { 177 | if (empty($this->endpoint)) { 178 | if (null === $this->parsed) { 179 | throw new LogicException('Request::$endpoint must be set before using it.'); 180 | } 181 | 182 | $this->endpoint = $this->parsed['scheme'] . '://' . $this->hostPrefix . $this->parsed['host'] . (isset($this->parsed['port']) ? ':' . $this->parsed['port'] : '') . $this->uri . ($this->query ? (false === strpos($this->uri, '?') ? '?' : '&') . $this->getQueryString() : ''); 183 | } 184 | 185 | return $this->endpoint; 186 | } 187 | 188 | public function setEndpoint(string $endpoint): void 189 | { 190 | if (null !== $this->parsed) { 191 | throw new LogicException('Request::$endpoint cannot be changed after it has a value.'); 192 | } 193 | 194 | $parsed = parse_url($endpoint); 195 | 196 | if (false === $parsed || !isset($parsed['scheme'], $parsed['host'])) { 197 | throw new InvalidArgument(\sprintf('The endpoint "%s" is invalid.', $endpoint)); 198 | } 199 | 200 | $this->parsed = ['scheme' => $parsed['scheme'], 'host' => $parsed['host'], 'port' => $parsed['port'] ?? null]; 201 | 202 | $this->queryString = $parsed['query'] ?? ''; 203 | parse_str($parsed['query'] ?? '', $this->query); 204 | $this->uri = $parsed['path'] ?? '/'; 205 | } 206 | 207 | private function getQueryString(): string 208 | { 209 | if (null === $this->queryString) { 210 | $this->queryString = http_build_query($this->query, '', '&', \PHP_QUERY_RFC3986); 211 | } 212 | 213 | return $this->queryString; 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/RequestContext.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class RequestContext 14 | { 15 | public const AVAILABLE_OPTIONS = [ 16 | 'region' => true, 17 | 'operation' => true, 18 | 'expirationDate' => true, 19 | 'currentDate' => true, 20 | 'exceptionMapping' => true, 21 | 'usesEndpointDiscovery' => true, 22 | 'requiresEndpointDiscovery' => true, 23 | ]; 24 | 25 | /** 26 | * @var string|null 27 | */ 28 | private $operation; 29 | 30 | /** 31 | * @var bool 32 | */ 33 | private $usesEndpointDiscovery = false; 34 | 35 | /** 36 | * @var bool 37 | */ 38 | private $requiresEndpointDiscovery = false; 39 | 40 | /** 41 | * @var string|null 42 | */ 43 | private $region; 44 | 45 | /** 46 | * @var \DateTimeImmutable|null 47 | */ 48 | private $expirationDate; 49 | 50 | /** 51 | * @var \DateTimeImmutable|null 52 | */ 53 | private $currentDate; 54 | 55 | /** 56 | * @var array> 57 | */ 58 | private $exceptionMapping = []; 59 | 60 | /** 61 | * @param array{ 62 | * operation?: null|string, 63 | * region?: null|string, 64 | * expirationDate?: null|\DateTimeImmutable, 65 | * currentDate?: null|\DateTimeImmutable, 66 | * exceptionMapping?: array>, 67 | * usesEndpointDiscovery?: bool, 68 | * requiresEndpointDiscovery?: bool, 69 | * } $options 70 | */ 71 | public function __construct(array $options = []) 72 | { 73 | if (0 < \count($invalidOptions = array_diff_key($options, self::AVAILABLE_OPTIONS))) { 74 | throw new InvalidArgument(\sprintf('Invalid option(s) "%s" passed to "%s". ', implode('", "', array_keys($invalidOptions)), __METHOD__)); 75 | } 76 | 77 | foreach ($options as $property => $value) { 78 | $this->$property = $value; 79 | } 80 | } 81 | 82 | public function getOperation(): ?string 83 | { 84 | return $this->operation; 85 | } 86 | 87 | public function getRegion(): ?string 88 | { 89 | return $this->region; 90 | } 91 | 92 | public function getExpirationDate(): ?\DateTimeImmutable 93 | { 94 | return $this->expirationDate; 95 | } 96 | 97 | public function getCurrentDate(): ?\DateTimeImmutable 98 | { 99 | return $this->currentDate; 100 | } 101 | 102 | /** 103 | * @return array> 104 | */ 105 | public function getExceptionMapping(): array 106 | { 107 | return $this->exceptionMapping; 108 | } 109 | 110 | public function usesEndpointDiscovery(): bool 111 | { 112 | return $this->usesEndpointDiscovery; 113 | } 114 | 115 | public function requiresEndpointDiscovery(): bool 116 | { 117 | return $this->requiresEndpointDiscovery; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Result.php: -------------------------------------------------------------------------------- 1 | response = $response; 46 | $this->awsClient = $awsClient; 47 | $this->input = $request; 48 | } 49 | 50 | public function __destruct() 51 | { 52 | while (!empty($this->prefetchResults)) { 53 | array_shift($this->prefetchResults)->cancel(); 54 | } 55 | } 56 | 57 | /** 58 | * Make sure the actual request is executed. 59 | * 60 | * @param float|null $timeout Duration in seconds before aborting. When null wait until the end of execution. 61 | * 62 | * @return bool whether the request is executed or not 63 | * 64 | * @throws NetworkException 65 | * @throws HttpException 66 | */ 67 | final public function resolve(?float $timeout = null): bool 68 | { 69 | return $this->response->resolve($timeout); 70 | } 71 | 72 | /** 73 | * Make sure all provided requests are executed. 74 | * This only work if the http responses are produced by the same HTTP client. 75 | * See https://symfony.com/doc/current/components/http_client.html#multiplexing-responses. 76 | * 77 | * @param self[] $results 78 | * @param float|null $timeout Duration in seconds before aborting. When null wait 79 | * until the end of execution. Using 0 means non-blocking 80 | * @param bool $downloadBody Wait until receiving the entire response body or only the first bytes 81 | * 82 | * @return iterable 83 | * 84 | * @throws NetworkException 85 | * @throws HttpException 86 | */ 87 | final public static function wait(iterable $results, ?float $timeout = null, bool $downloadBody = false): iterable 88 | { 89 | $resultMap = []; 90 | $responses = []; 91 | foreach ($results as $index => $result) { 92 | $responses[$index] = $result->response; 93 | $resultMap[$index] = $result; 94 | } 95 | 96 | foreach (Response::wait($responses, $timeout, $downloadBody) as $index => $response) { 97 | yield $index => $resultMap[$index]; 98 | } 99 | } 100 | 101 | /** 102 | * Returns info on the current request. 103 | * 104 | * @return array{ 105 | * resolved: bool, 106 | * body_downloaded: bool, 107 | * response: \Symfony\Contracts\HttpClient\ResponseInterface, 108 | * status: int, 109 | * } 110 | */ 111 | final public function info(): array 112 | { 113 | return $this->response->info(); 114 | } 115 | 116 | final public function cancel(): void 117 | { 118 | $this->response->cancel(); 119 | } 120 | 121 | final protected function registerPrefetch(self $result): void 122 | { 123 | $this->prefetchResults[spl_object_id($result)] = $result; 124 | } 125 | 126 | final protected function unregisterPrefetch(self $result): void 127 | { 128 | unset($this->prefetchResults[spl_object_id($result)]); 129 | } 130 | 131 | final protected function initialize(): void 132 | { 133 | if ($this->initialized) { 134 | return; 135 | } 136 | 137 | $this->resolve(); 138 | $this->initialized = true; 139 | $this->populateResult($this->response); 140 | } 141 | 142 | protected function populateResult(Response $response): void 143 | { 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/Signer/Signer.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | interface Signer 15 | { 16 | public function sign(Request $request, Credentials $credentials, RequestContext $context): void; 17 | 18 | public function presign(Request $request, Credentials $credentials, RequestContext $context): void; 19 | } 20 | -------------------------------------------------------------------------------- /src/Signer/SignerV4.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class SignerV4 implements Signer 19 | { 20 | private const ALGORITHM_REQUEST = 'AWS4-HMAC-SHA256'; 21 | 22 | private const BLACKLIST_HEADERS = [ 23 | 'cache-control' => true, 24 | 'content-type' => true, 25 | 'content-length' => true, 26 | 'expect' => true, 27 | 'max-forwards' => true, 28 | 'pragma' => true, 29 | 'range' => true, 30 | 'te' => true, 31 | 'if-match' => true, 32 | 'if-none-match' => true, 33 | 'if-modified-since' => true, 34 | 'if-unmodified-since' => true, 35 | 'if-range' => true, 36 | 'accept' => true, 37 | 'authorization' => true, 38 | 'proxy-authorization' => true, 39 | 'from' => true, 40 | 'referer' => true, 41 | 'user-agent' => true, 42 | 'x-amzn-trace-id' => true, 43 | 'aws-sdk-invocation-id' => true, 44 | 'aws-sdk-retry' => true, 45 | ]; 46 | 47 | /** 48 | * @var string 49 | */ 50 | private $scopeName; 51 | 52 | /** 53 | * @var string 54 | */ 55 | private $region; 56 | 57 | public function __construct(string $scopeName, string $region) 58 | { 59 | $this->scopeName = $scopeName; 60 | $this->region = $region; 61 | } 62 | 63 | public function presign(Request $request, Credentials $credentials, RequestContext $context): void 64 | { 65 | $now = $context->getCurrentDate() ?? new \DateTimeImmutable(); 66 | 67 | // Signer date have to be UTC https://docs.aws.amazon.com/general/latest/gr/sigv4-date-handling.html 68 | $now = $now->setTimezone(new \DateTimeZone('UTC')); 69 | $expires = $context->getExpirationDate() ?? $now->add(new \DateInterval('PT1H')); 70 | 71 | $this->handleSignature($request, $credentials, $now, $expires, true); 72 | } 73 | 74 | public function sign(Request $request, Credentials $credentials, RequestContext $context): void 75 | { 76 | $now = $context->getCurrentDate() ?? new \DateTimeImmutable(); 77 | 78 | // Signer date have to be UTC https://docs.aws.amazon.com/general/latest/gr/sigv4-date-handling.html 79 | $now = $now->setTimezone(new \DateTimeZone('UTC')); 80 | 81 | $this->handleSignature($request, $credentials, $now, $now, false); 82 | } 83 | 84 | protected function buildBodyDigest(Request $request, bool $isPresign): string 85 | { 86 | if ($request->hasHeader('x-amz-content-sha256')) { 87 | /** @var string $hash */ 88 | $hash = $request->getHeader('x-amz-content-sha256'); 89 | } else { 90 | $body = $request->getBody(); 91 | if ($body instanceof ReadOnceResultStream) { 92 | $request->setBody($body = RewindableStream::create($body)); 93 | } 94 | 95 | $hash = $request->getBody()->hash(); 96 | } 97 | 98 | if ('UNSIGNED-PAYLOAD' === $hash) { 99 | $request->setHeader('x-amz-content-sha256', $hash); 100 | } 101 | 102 | return $hash; 103 | } 104 | 105 | protected function convertBodyToStream(SigningContext $context): void 106 | { 107 | $request = $context->getRequest(); 108 | $request->setBody(StringStream::create($request->getBody())); 109 | } 110 | 111 | protected function buildCanonicalPath(Request $request): string 112 | { 113 | $doubleEncoded = rawurlencode(ltrim($request->getUri(), '/')); 114 | 115 | return '/' . str_replace('%2F', '/', $doubleEncoded); 116 | } 117 | 118 | private function handleSignature(Request $request, Credentials $credentials, \DateTimeImmutable $now, \DateTimeImmutable $expires, bool $isPresign): void 119 | { 120 | $this->removePresign($request); 121 | $this->sanitizeHostForHeader($request); 122 | $this->assignAmzQueryValues($request, $credentials, $isPresign); 123 | 124 | $this->buildTime($request, $now, $expires, $isPresign); 125 | $credentialScope = $this->buildCredentialString($request, $credentials, $now, $isPresign); 126 | $context = new SigningContext( 127 | $request, 128 | $now, 129 | implode('/', $credentialScope), 130 | $this->buildSigningKey($credentials, $credentialScope) 131 | ); 132 | if ($isPresign) { 133 | // Should be called before `buildBodyDigest` because this method may alter the body 134 | $this->convertBodyToQuery($request); 135 | } else { 136 | $this->convertBodyToStream($context); 137 | } 138 | 139 | $bodyDigest = $this->buildBodyDigest($request, $isPresign); 140 | 141 | if ($isPresign) { 142 | // Should be called after `buildBodyDigest` because this method may remove the header `x-amz-content-sha256` 143 | $this->convertHeaderToQuery($request); 144 | } 145 | 146 | $canonicalHeaders = $this->buildCanonicalHeaders($request, $isPresign); 147 | $canonicalRequest = $this->buildCanonicalRequest($request, $canonicalHeaders, $bodyDigest); 148 | $stringToSign = $this->buildStringToSign($context->getNow(), $context->getCredentialString(), $canonicalRequest); 149 | $context->setSignature($signature = $this->buildSignature($stringToSign, $context->getSigningKey())); 150 | 151 | if ($isPresign) { 152 | $request->setQueryAttribute('X-Amz-Signature', $signature); 153 | } else { 154 | $request->setHeader('authorization', \sprintf( 155 | '%s Credential=%s/%s, SignedHeaders=%s, Signature=%s', 156 | self::ALGORITHM_REQUEST, 157 | $credentials->getAccessKeyId(), 158 | implode('/', $credentialScope), 159 | implode(';', array_keys($canonicalHeaders)), 160 | $signature 161 | )); 162 | } 163 | } 164 | 165 | private function removePresign(Request $request): void 166 | { 167 | $request->removeQueryAttribute('X-Amz-Algorithm'); 168 | $request->removeQueryAttribute('X-Amz-Signature'); 169 | $request->removeQueryAttribute('X-Amz-Security-Token'); 170 | $request->removeQueryAttribute('X-Amz-Date'); 171 | $request->removeQueryAttribute('X-Amz-Expires'); 172 | $request->removeQueryAttribute('X-Amz-Credential'); 173 | $request->removeQueryAttribute('X-Amz-SignedHeaders'); 174 | } 175 | 176 | private function sanitizeHostForHeader(Request $request): void 177 | { 178 | if (false === $parsedUrl = parse_url($request->getEndpoint())) { 179 | throw new InvalidArgument(\sprintf('The endpoint "%s" is invalid.', $request->getEndpoint())); 180 | } 181 | 182 | if (!isset($parsedUrl['host'])) { 183 | return; 184 | } 185 | 186 | $host = $parsedUrl['host']; 187 | if (isset($parsedUrl['port'])) { 188 | $host .= ':' . $parsedUrl['port']; 189 | } 190 | 191 | $request->setHeader('host', $host); 192 | } 193 | 194 | private function assignAmzQueryValues(Request $request, Credentials $credentials, bool $isPresign): void 195 | { 196 | if ($isPresign) { 197 | $request->setQueryAttribute('X-Amz-Algorithm', self::ALGORITHM_REQUEST); 198 | if (null !== $sessionToken = $credentials->getSessionToken()) { 199 | $request->setQueryAttribute('X-Amz-Security-Token', $sessionToken); 200 | } 201 | 202 | return; 203 | } 204 | 205 | if (null !== $sessionToken = $credentials->getSessionToken()) { 206 | $request->setHeader('x-amz-security-token', $sessionToken); 207 | } 208 | } 209 | 210 | private function buildTime(Request $request, \DateTimeImmutable $now, \DateTimeImmutable $expires, bool $isPresign): void 211 | { 212 | if ($isPresign) { 213 | $duration = $expires->getTimestamp() - $now->getTimestamp(); 214 | if ($duration > 604800) { 215 | throw new InvalidArgument('The expiration date of presigned URL must be less than one week'); 216 | } 217 | if ($duration < 0) { 218 | throw new InvalidArgument('The expiration date of presigned URL must be in the future'); 219 | } 220 | 221 | $request->setQueryAttribute('X-Amz-Date', $now->format('Ymd\THis\Z')); 222 | $request->setQueryAttribute('X-Amz-Expires', (string) $duration); 223 | } else { 224 | $request->setHeader('X-Amz-Date', $now->format('Ymd\THis\Z')); 225 | } 226 | } 227 | 228 | /** 229 | * @return string[] 230 | */ 231 | private function buildCredentialString(Request $request, Credentials $credentials, \DateTimeImmutable $now, bool $isPresign): array 232 | { 233 | $credentialScope = [$now->format('Ymd'), $this->region, $this->scopeName, 'aws4_request']; 234 | 235 | if ($isPresign) { 236 | $request->setQueryAttribute('X-Amz-Credential', $credentials->getAccessKeyId() . '/' . implode('/', $credentialScope)); 237 | } 238 | 239 | return $credentialScope; 240 | } 241 | 242 | private function convertHeaderToQuery(Request $request): void 243 | { 244 | foreach ($request->getHeaders() as $name => $value) { 245 | if ('x-amz' === substr($name, 0, 5)) { 246 | $request->setQueryAttribute($name, $value); 247 | } 248 | 249 | if (isset(self::BLACKLIST_HEADERS[$name])) { 250 | $request->removeHeader($name); 251 | } 252 | } 253 | $request->removeHeader('x-amz-content-sha256'); 254 | } 255 | 256 | private function convertBodyToQuery(Request $request): void 257 | { 258 | if ('POST' !== $request->getMethod()) { 259 | return; 260 | } 261 | 262 | $request->setMethod('GET'); 263 | if ('application/x-www-form-urlencoded' === $request->getHeader('Content-Type')) { 264 | parse_str($request->getBody()->stringify(), $params); 265 | foreach ($params as $name => $value) { 266 | $request->setQueryAttribute($name, $value); 267 | } 268 | } 269 | 270 | $request->removeHeader('content-type'); 271 | $request->removeHeader('content-length'); 272 | $request->setBody(StringStream::create('')); 273 | } 274 | 275 | /** 276 | * @return array 277 | */ 278 | private function buildCanonicalHeaders(Request $request, bool $isPresign): array 279 | { 280 | // Case-insensitively aggregate all of the headers. 281 | $canonicalHeaders = []; 282 | foreach ($request->getHeaders() as $key => $value) { 283 | $key = strtolower($key); 284 | if (isset(self::BLACKLIST_HEADERS[$key])) { 285 | continue; 286 | } 287 | 288 | $canonicalHeaders[$key] = $key . ':' . preg_replace('/\s+/', ' ', $value); 289 | } 290 | ksort($canonicalHeaders); 291 | 292 | if ($isPresign) { 293 | $request->setQueryAttribute('X-Amz-SignedHeaders', implode(';', array_keys($canonicalHeaders))); 294 | } 295 | 296 | return $canonicalHeaders; 297 | } 298 | 299 | /** 300 | * @param array $canonicalHeaders 301 | */ 302 | private function buildCanonicalRequest(Request $request, array $canonicalHeaders, string $bodyDigest): string 303 | { 304 | return implode("\n", [ 305 | $request->getMethod(), 306 | $this->buildCanonicalPath($request), 307 | $this->buildCanonicalQuery($request), 308 | implode("\n", array_values($canonicalHeaders)), 309 | '', // empty line after headers 310 | implode(';', array_keys($canonicalHeaders)), 311 | $bodyDigest, 312 | ]); 313 | } 314 | 315 | private function buildCanonicalQuery(Request $request): string 316 | { 317 | $query = $request->getQuery(); 318 | 319 | unset($query['X-Amz-Signature']); 320 | if (empty($query)) { 321 | return ''; 322 | } 323 | 324 | ksort($query); 325 | $encodedQuery = []; 326 | foreach ($query as $key => $values) { 327 | if (!\is_array($values)) { 328 | $encodedQuery[] = rawurlencode($key) . '=' . rawurlencode($values); 329 | 330 | continue; 331 | } 332 | 333 | // @phpstan-ignore argument.unresolvableType 334 | sort($values); 335 | foreach ($values as $value) { 336 | $encodedQuery[] = rawurlencode($key) . '=' . rawurlencode($value); 337 | } 338 | } 339 | 340 | return implode('&', $encodedQuery); 341 | } 342 | 343 | private function buildStringToSign(\DateTimeImmutable $now, string $credentialString, string $canonicalRequest): string 344 | { 345 | return implode("\n", [ 346 | self::ALGORITHM_REQUEST, 347 | $now->format('Ymd\THis\Z'), 348 | $credentialString, 349 | hash('sha256', $canonicalRequest), 350 | ]); 351 | } 352 | 353 | /** 354 | * @param string[] $credentialScope 355 | */ 356 | private function buildSigningKey(Credentials $credentials, array $credentialScope): string 357 | { 358 | $signingKey = 'AWS4' . $credentials->getSecretKey(); 359 | foreach ($credentialScope as $scopePart) { 360 | $signingKey = hash_hmac('sha256', $scopePart, $signingKey, true); 361 | } 362 | 363 | return $signingKey; 364 | } 365 | 366 | private function buildSignature(string $stringToSign, string $signingKey): string 367 | { 368 | return hash_hmac('sha256', $stringToSign, $signingKey); 369 | } 370 | } 371 | -------------------------------------------------------------------------------- /src/Signer/SigningContext.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class SigningContext 11 | { 12 | /** 13 | * @var Request 14 | */ 15 | private $request; 16 | 17 | /** 18 | * @var \DateTimeImmutable 19 | */ 20 | private $now; 21 | 22 | /** 23 | * @var string 24 | */ 25 | private $credentialString; 26 | 27 | /** 28 | * @var string 29 | */ 30 | private $signingKey; 31 | 32 | /** 33 | * @var string 34 | */ 35 | private $signature = ''; 36 | 37 | public function __construct( 38 | Request $request, 39 | \DateTimeImmutable $now, 40 | string $credentialString, 41 | string $signingKey 42 | ) { 43 | $this->request = $request; 44 | $this->now = $now; 45 | $this->credentialString = $credentialString; 46 | $this->signingKey = $signingKey; 47 | } 48 | 49 | public function getRequest(): Request 50 | { 51 | return $this->request; 52 | } 53 | 54 | public function getNow(): \DateTimeImmutable 55 | { 56 | return $this->now; 57 | } 58 | 59 | public function getCredentialString(): string 60 | { 61 | return $this->credentialString; 62 | } 63 | 64 | public function getSigningKey(): string 65 | { 66 | return $this->signingKey; 67 | } 68 | 69 | public function getSignature(): string 70 | { 71 | return $this->signature; 72 | } 73 | 74 | public function setSignature(string $signature): void 75 | { 76 | $this->signature = $signature; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Stream/CallableStream.php: -------------------------------------------------------------------------------- 1 | 12 | * 13 | * @internal 14 | */ 15 | final class CallableStream implements ReadOnceResultStream, RequestStream 16 | { 17 | /** 18 | * @var callable(int): string 19 | */ 20 | private $content; 21 | 22 | /** 23 | * @var int 24 | */ 25 | private $chunkSize; 26 | 27 | /** 28 | * @param callable(int): string $content 29 | */ 30 | private function __construct(callable $content, int $chunkSize = 64 * 1024) 31 | { 32 | $this->content = $content; 33 | $this->chunkSize = $chunkSize; 34 | } 35 | 36 | /** 37 | * @param self|callable(int): string $content 38 | */ 39 | public static function create($content, int $chunkSize = 64 * 1024): CallableStream 40 | { 41 | if ($content instanceof self) { 42 | return $content; 43 | } 44 | if (\is_callable($content)) { 45 | return new self($content, $chunkSize); 46 | } 47 | 48 | throw new InvalidArgument(\sprintf('Expect content to be a "callable". "%s" given.', \is_object($content) ? \get_class($content) : \gettype($content))); 49 | } 50 | 51 | public function length(): ?int 52 | { 53 | return null; 54 | } 55 | 56 | public function stringify(): string 57 | { 58 | return implode('', iterator_to_array($this)); 59 | } 60 | 61 | public function getIterator(): \Traversable 62 | { 63 | while (true) { 64 | if (!\is_string($data = ($this->content)($this->chunkSize))) { 65 | throw new InvalidArgument(\sprintf('The return value of content callback must be a string, %s returned.', \is_object($data) ? \get_class($data) : \gettype($data))); 66 | } 67 | if ('' === $data) { 68 | break; 69 | } 70 | 71 | yield $data; 72 | } 73 | } 74 | 75 | public function hash(string $algo = 'sha256', bool $raw = false): string 76 | { 77 | $ctx = hash_init($algo); 78 | foreach ($this as $chunk) { 79 | hash_update($ctx, $chunk); 80 | } 81 | 82 | return hash_final($ctx, $raw); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Stream/FixedSizeStream.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final class FixedSizeStream implements RequestStream 13 | { 14 | /** 15 | * @var RequestStream 16 | */ 17 | private $content; 18 | 19 | /** 20 | * @var int 21 | */ 22 | private $chunkSize; 23 | 24 | private function __construct(RequestStream $content, int $chunkSize = 64 * 1024) 25 | { 26 | $this->content = $content; 27 | $this->chunkSize = $chunkSize; 28 | } 29 | 30 | public static function create(RequestStream $content, int $chunkSize = 64 * 1024): FixedSizeStream 31 | { 32 | if ($content instanceof self) { 33 | if ($content->chunkSize === $chunkSize) { 34 | return $content; 35 | } 36 | 37 | return new self($content->content, $chunkSize); 38 | } 39 | 40 | return new self($content, $chunkSize); 41 | } 42 | 43 | public function length(): ?int 44 | { 45 | return $this->content->length(); 46 | } 47 | 48 | public function stringify(): string 49 | { 50 | return $this->content->stringify(); 51 | } 52 | 53 | public function getIterator(): \Traversable 54 | { 55 | // This algorithm do not use string concatenation nor substr, to reuse the same ZVAL et reduce memory footprint. 56 | $chunk = ''; 57 | foreach ($this->content as $buffer) { 58 | if (!\is_string($buffer)) { 59 | throw new InvalidArgument(\sprintf('The return value of content callback must be a string, %s returned.', \is_object($buffer) ? \get_class($buffer) : \gettype($buffer))); 60 | } 61 | 62 | $chunk .= $nextBytes = substr($buffer, 0, $this->chunkSize - \strlen($chunk)); 63 | $bufferPosition = \strlen($nextBytes); 64 | 65 | if (\strlen($chunk) < $this->chunkSize) { 66 | // The chunk does not have yet the expected size. Let's fetching new data 67 | continue; 68 | } 69 | 70 | yield $chunk; 71 | while (\strlen($buffer) - $bufferPosition >= $this->chunkSize) { 72 | // The buffer is bigger than the expected size. Let's flushing it. 73 | yield substr($buffer, $bufferPosition, $this->chunkSize); 74 | $bufferPosition += $this->chunkSize; 75 | } 76 | 77 | // Here we can substr the buffer because the remaining size is smaller that chunkSize 78 | $chunk = substr($buffer, $bufferPosition); 79 | } 80 | 81 | if ('' !== $chunk) { 82 | yield $chunk; 83 | } 84 | } 85 | 86 | public function hash(string $algo = 'sha256', bool $raw = false): string 87 | { 88 | return $this->content->hash($algo, $raw); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Stream/IterableStream.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final class IterableStream implements ReadOnceResultStream, RequestStream 13 | { 14 | /** 15 | * @var iterable 16 | */ 17 | private $content; 18 | 19 | /** 20 | * @param iterable $content 21 | */ 22 | private function __construct(iterable $content) 23 | { 24 | $this->content = $content; 25 | } 26 | 27 | /** 28 | * @param self|iterable $content 29 | */ 30 | public static function create($content): IterableStream 31 | { 32 | if ($content instanceof self) { 33 | return $content; 34 | } 35 | if (is_iterable($content)) { 36 | return new self($content); 37 | } 38 | 39 | throw new InvalidArgument(\sprintf('Expect content to be an iterable. "%s" given.', \is_object($content) ? \get_class($content) : \gettype($content))); 40 | } 41 | 42 | public function length(): ?int 43 | { 44 | return null; 45 | } 46 | 47 | public function stringify(): string 48 | { 49 | if ($this->content instanceof \Traversable) { 50 | return implode('', iterator_to_array($this->content)); 51 | } 52 | 53 | return implode('', iterator_to_array((function () {yield from $this->content; })())); 54 | } 55 | 56 | public function getIterator(): \Traversable 57 | { 58 | yield from $this->content; 59 | } 60 | 61 | public function hash(string $algo = 'sha256', bool $raw = false): string 62 | { 63 | $ctx = hash_init($algo); 64 | foreach ($this->content as $chunk) { 65 | hash_update($ctx, $chunk); 66 | } 67 | 68 | return hash_final($ctx, $raw); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Stream/ReadOnceResultStream.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * @extends \IteratorAggregate 11 | */ 12 | interface RequestStream extends \IteratorAggregate 13 | { 14 | /** 15 | * Length in bytes. 16 | */ 17 | public function length(): ?int; 18 | 19 | public function stringify(): string; 20 | 21 | public function hash(string $algo = 'sha256', bool $raw = false): string; 22 | } 23 | -------------------------------------------------------------------------------- /src/Stream/ResourceStream.php: -------------------------------------------------------------------------------- 1 | 11 | * 12 | * @internal 13 | */ 14 | final class ResourceStream implements RequestStream 15 | { 16 | /** 17 | * @var resource 18 | */ 19 | private $content; 20 | 21 | /** 22 | * @var int 23 | */ 24 | private $chunkSize; 25 | 26 | /** 27 | * @param resource $content 28 | */ 29 | private function __construct($content, int $chunkSize = 64 * 1024) 30 | { 31 | $this->content = $content; 32 | $this->chunkSize = $chunkSize; 33 | } 34 | 35 | /** 36 | * @param self|resource $content 37 | */ 38 | public static function create($content, int $chunkSize = 64 * 1024): ResourceStream 39 | { 40 | if ($content instanceof self) { 41 | return $content; 42 | } 43 | if (\is_resource($content)) { 44 | if (!stream_get_meta_data($content)['seekable']) { 45 | throw new InvalidArgument('The given body is not seekable.'); 46 | } 47 | 48 | return new self($content, $chunkSize); 49 | } 50 | 51 | throw new InvalidArgument(\sprintf('Expect content to be a "resource". "%s" given.', \is_object($content) ? \get_class($content) : \gettype($content))); 52 | } 53 | 54 | public function length(): ?int 55 | { 56 | return fstat($this->content)['size'] ?? null; 57 | } 58 | 59 | public function stringify(): string 60 | { 61 | if (-1 === fseek($this->content, 0)) { 62 | throw new InvalidArgument('Unable to seek the content.'); 63 | } 64 | 65 | return stream_get_contents($this->content); 66 | } 67 | 68 | public function getIterator(): \Traversable 69 | { 70 | if (-1 === fseek($this->content, 0)) { 71 | throw new InvalidArgument('Unable to seek the content.'); 72 | } 73 | 74 | while (!feof($this->content)) { 75 | yield fread($this->content, $this->chunkSize); 76 | } 77 | } 78 | 79 | /** 80 | * @return resource 81 | */ 82 | public function getResource() 83 | { 84 | return $this->content; 85 | } 86 | 87 | public function hash(string $algo = 'sha256', bool $raw = false): string 88 | { 89 | $pos = ftell($this->content); 90 | 91 | if ($pos > 0 && -1 === fseek($this->content, 0)) { 92 | throw new InvalidArgument('Unable to seek the content.'); 93 | } 94 | 95 | $ctx = hash_init($algo); 96 | hash_update_stream($ctx, $this->content); 97 | $out = hash_final($ctx, $raw); 98 | 99 | if (-1 === fseek($this->content, $pos)) { 100 | throw new InvalidArgument('Unable to seek the content.'); 101 | } 102 | 103 | return $out; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Stream/ResponseBodyResourceStream.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class ResponseBodyResourceStream implements ResultStream 15 | { 16 | /** 17 | * @var resource 18 | */ 19 | private $resource; 20 | 21 | /** 22 | * @param resource $resource 23 | */ 24 | public function __construct($resource) 25 | { 26 | $this->resource = $resource; 27 | } 28 | 29 | public function __toString(): string 30 | { 31 | return $this->getContentAsString(); 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function getChunks(): iterable 38 | { 39 | $pos = ftell($this->resource); 40 | if (0 !== $pos && !rewind($this->resource)) { 41 | throw new RuntimeException('The stream is not rewindable'); 42 | } 43 | 44 | try { 45 | while (!feof($this->resource)) { 46 | yield fread($this->resource, 64 * 1024); 47 | } 48 | } finally { 49 | fseek($this->resource, $pos); 50 | } 51 | } 52 | 53 | /** 54 | * {@inheritdoc} 55 | */ 56 | public function getContentAsString(): string 57 | { 58 | $pos = ftell($this->resource); 59 | 60 | try { 61 | if (!rewind($this->resource)) { 62 | throw new RuntimeException('Failed to rewind the stream'); 63 | } 64 | 65 | return stream_get_contents($this->resource); 66 | } finally { 67 | fseek($this->resource, $pos); 68 | } 69 | } 70 | 71 | /** 72 | * {@inheritdoc} 73 | */ 74 | public function getContentAsResource() 75 | { 76 | if (!rewind($this->resource)) { 77 | throw new RuntimeException('Failed to rewind the stream'); 78 | } 79 | 80 | return $this->resource; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Stream/ResponseBodyStream.php: -------------------------------------------------------------------------------- 1 | 17 | * @author Jérémy Derussé 18 | */ 19 | class ResponseBodyStream implements ResultStream 20 | { 21 | /** 22 | * @var ResponseStreamInterface 23 | */ 24 | private $responseStream; 25 | 26 | /** 27 | * @var ResponseBodyResourceStream|null 28 | */ 29 | private $fallback; 30 | 31 | /** 32 | * @var bool 33 | */ 34 | private $partialRead = false; 35 | 36 | public function __construct(ResponseStreamInterface $responseStream) 37 | { 38 | $this->responseStream = $responseStream; 39 | } 40 | 41 | public function __toString(): string 42 | { 43 | return $this->getContentAsString(); 44 | } 45 | 46 | /** 47 | * {@inheritdoc} 48 | */ 49 | public function getChunks(): iterable 50 | { 51 | if (null !== $this->fallback) { 52 | yield from $this->fallback->getChunks(); 53 | 54 | return; 55 | } 56 | if ($this->partialRead) { 57 | throw new LogicException(\sprintf('You can not call "%s". Another process doesn\'t reading "getChunks" till the end.', __METHOD__)); 58 | } 59 | 60 | $resource = fopen('php://temp', 'rb+'); 61 | foreach ($this->responseStream as $chunk) { 62 | $this->partialRead = true; 63 | $chunkContent = $chunk->getContent(); 64 | fwrite($resource, $chunkContent); 65 | yield $chunkContent; 66 | } 67 | 68 | $this->fallback = new ResponseBodyResourceStream($resource); 69 | $this->partialRead = false; 70 | } 71 | 72 | /** 73 | * {@inheritdoc} 74 | */ 75 | public function getContentAsString(): string 76 | { 77 | if (null === $this->fallback) { 78 | // Use getChunks() to read stream content to $this->fallback 79 | foreach ($this->getChunks() as $chunk) { 80 | } 81 | \assert(null !== $this->fallback); 82 | } 83 | 84 | return $this->fallback->getContentAsString(); 85 | } 86 | 87 | /** 88 | * {@inheritdoc} 89 | */ 90 | public function getContentAsResource() 91 | { 92 | if (null === $this->fallback) { 93 | // Use getChunks() to read stream content to $this->fallback 94 | foreach ($this->getChunks() as $chunk) { 95 | } 96 | \assert(null !== $this->fallback); 97 | } 98 | 99 | return $this->fallback->getContentAsResource(); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Stream/ResultStream.php: -------------------------------------------------------------------------------- 1 | getBody()->getChunks() as $chunk) { 17 | * fwrite($fileHandler, $chunk); 18 | * } 19 | * 20 | * @return iterable 21 | */ 22 | public function getChunks(): iterable; 23 | 24 | /** 25 | * Download content into a temporary resource and return a string. 26 | */ 27 | public function getContentAsString(): string; 28 | 29 | /** 30 | * Download content into a resource and then return that resource. 31 | * 32 | * @return resource 33 | */ 34 | public function getContentAsResource(); 35 | } 36 | -------------------------------------------------------------------------------- /src/Stream/RewindableStream.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class RewindableStream implements RequestStream 17 | { 18 | /** 19 | * @var RequestStream 20 | */ 21 | private $content; 22 | 23 | /** 24 | * @var RequestStream|null 25 | */ 26 | private $fallback; 27 | 28 | private function __construct(RequestStream $content) 29 | { 30 | $this->content = $content; 31 | } 32 | 33 | public static function create(RequestStream $content): RewindableStream 34 | { 35 | if ($content instanceof self) { 36 | return $content; 37 | } 38 | 39 | return new self($content); 40 | } 41 | 42 | public function length(): ?int 43 | { 44 | if (null !== $this->fallback) { 45 | return $this->fallback->length(); 46 | } 47 | 48 | return $this->content->length(); 49 | } 50 | 51 | public function stringify(): string 52 | { 53 | if (null !== $this->fallback) { 54 | return $this->fallback->stringify(); 55 | } 56 | 57 | return implode('', iterator_to_array($this)); 58 | } 59 | 60 | public function getIterator(): \Traversable 61 | { 62 | if (null !== $this->fallback) { 63 | yield from $this->fallback; 64 | 65 | return; 66 | } 67 | 68 | $resource = fopen('php://temp', 'r+b'); 69 | $this->fallback = ResourceStream::create($resource); 70 | 71 | foreach ($this->content as $chunk) { 72 | fwrite($resource, $chunk); 73 | yield $chunk; 74 | } 75 | } 76 | 77 | public function hash(string $algo = 'sha256', bool $raw = false): string 78 | { 79 | if (null !== $this->fallback) { 80 | return $this->fallback->hash($algo, $raw); 81 | } 82 | 83 | $ctx = hash_init($algo); 84 | foreach ($this as $chunk) { 85 | hash_update($ctx, $chunk); 86 | } 87 | 88 | return hash_final($ctx, $raw); 89 | } 90 | 91 | public function read(): void 92 | { 93 | // Use getIterator() to read stream content to $this->fallback 94 | foreach ($this as $chunk) { 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Stream/StreamFactory.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class StreamFactory 13 | { 14 | /** 15 | * @param string|resource|(callable(int): string)|iterable|null $content 16 | */ 17 | public static function create($content, int $preferredChunkSize = 64 * 1024): RequestStream 18 | { 19 | if (null === $content || \is_string($content)) { 20 | return StringStream::create($content ?? ''); 21 | } 22 | if (\is_callable($content)) { 23 | return CallableStream::create($content, $preferredChunkSize); 24 | } 25 | if (is_iterable($content)) { 26 | return IterableStream::create($content); 27 | } 28 | if (\is_resource($content)) { 29 | return ResourceStream::create($content, $preferredChunkSize); 30 | } 31 | 32 | throw new InvalidArgument(\sprintf('Unexpected content type "%s".', \is_object($content) ? \get_class($content) : \gettype($content))); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Stream/StringStream.php: -------------------------------------------------------------------------------- 1 | 11 | * 12 | * @internal 13 | */ 14 | final class StringStream implements RequestStream 15 | { 16 | /** 17 | * @var string 18 | */ 19 | private $content; 20 | 21 | /** 22 | * @var int|null 23 | */ 24 | private $lengthCache; 25 | 26 | private function __construct(string $content) 27 | { 28 | $this->content = $content; 29 | } 30 | 31 | /** 32 | * @param RequestStream|string $content 33 | */ 34 | public static function create($content): StringStream 35 | { 36 | if ($content instanceof self) { 37 | return $content; 38 | } 39 | if ($content instanceof RequestStream) { 40 | return new self($content->stringify()); 41 | } 42 | if (\is_string($content)) { 43 | return new self($content); 44 | } 45 | 46 | throw new InvalidArgument(\sprintf('Expect content to be a "%s" or as "string". "%s" given.', RequestStream::class, \is_object($content) ? \get_class($content) : \gettype($content))); 47 | } 48 | 49 | public function length(): int 50 | { 51 | return $this->lengthCache ?? $this->lengthCache = \strlen($this->content); 52 | } 53 | 54 | public function stringify(): string 55 | { 56 | return $this->content; 57 | } 58 | 59 | public function getIterator(): \Traversable 60 | { 61 | yield $this->content; 62 | } 63 | 64 | public function hash(string $algo = 'sha256', bool $raw = false): string 65 | { 66 | return hash($algo, $this->content, $raw); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Sts/Exception/ExpiredTokenException.php: -------------------------------------------------------------------------------- 1 | 'application/x-www-form-urlencoded']; 38 | 39 | // Prepare query 40 | $query = []; 41 | 42 | // Prepare URI 43 | $uriString = '/'; 44 | 45 | // Prepare Body 46 | $body = http_build_query(['Action' => 'GetCallerIdentity', 'Version' => '2011-06-15'] + $this->requestBody(), '', '&', \PHP_QUERY_RFC1738); 47 | 48 | // Return the Request 49 | return new Request('POST', $uriString, $query, $headers, StreamFactory::create($body)); 50 | } 51 | 52 | private function requestBody(): array 53 | { 54 | $payload = []; 55 | 56 | return $payload; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Sts/Result/AssumeRoleResponse.php: -------------------------------------------------------------------------------- 1 | The size of the security token that STS API operations return is not fixed. We strongly recommend that you make no 21 | * > assumptions about the maximum size. 22 | * 23 | * @var Credentials|null 24 | */ 25 | private $credentials; 26 | 27 | /** 28 | * The Amazon Resource Name (ARN) and the assumed role ID, which are identifiers that you can use to refer to the 29 | * resulting temporary security credentials. For example, you can reference these credentials as a principal in a 30 | * resource-based policy by using the ARN or assumed role ID. The ARN and ID include the `RoleSessionName` that you 31 | * specified when you called `AssumeRole`. 32 | * 33 | * @var AssumedRoleUser|null 34 | */ 35 | private $assumedRoleUser; 36 | 37 | /** 38 | * A percentage value that indicates the packed size of the session policies and session tags combined passed in the 39 | * request. The request fails if the packed size is greater than 100 percent, which means the policies and tags exceeded 40 | * the allowed space. 41 | * 42 | * @var int|null 43 | */ 44 | private $packedPolicySize; 45 | 46 | /** 47 | * The source identity specified by the principal that is calling the `AssumeRole` operation. 48 | * 49 | * You can require users to specify a source identity when they assume a role. You do this by using the 50 | * `sts:SourceIdentity` condition key in a role trust policy. You can use source identity information in CloudTrail logs 51 | * to determine who took actions with a role. You can use the `aws:SourceIdentity` condition key to further control 52 | * access to Amazon Web Services resources based on the value of source identity. For more information about using 53 | * source identity, see Monitor and control actions taken with assumed roles [^1] in the *IAM User Guide*. 54 | * 55 | * The regex used to validate this parameter is a string of characters consisting of upper- and lower-case alphanumeric 56 | * characters with no spaces. You can also include underscores or any of the following characters: =,.@- 57 | * 58 | * [^1]: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_control-access_monitor.html 59 | * 60 | * @var string|null 61 | */ 62 | private $sourceIdentity; 63 | 64 | public function getAssumedRoleUser(): ?AssumedRoleUser 65 | { 66 | $this->initialize(); 67 | 68 | return $this->assumedRoleUser; 69 | } 70 | 71 | public function getCredentials(): ?Credentials 72 | { 73 | $this->initialize(); 74 | 75 | return $this->credentials; 76 | } 77 | 78 | public function getPackedPolicySize(): ?int 79 | { 80 | $this->initialize(); 81 | 82 | return $this->packedPolicySize; 83 | } 84 | 85 | public function getSourceIdentity(): ?string 86 | { 87 | $this->initialize(); 88 | 89 | return $this->sourceIdentity; 90 | } 91 | 92 | protected function populateResult(Response $response): void 93 | { 94 | $data = new \SimpleXMLElement($response->getContent()); 95 | $data = $data->AssumeRoleResult; 96 | 97 | $this->credentials = 0 === $data->Credentials->count() ? null : $this->populateResultCredentials($data->Credentials); 98 | $this->assumedRoleUser = 0 === $data->AssumedRoleUser->count() ? null : $this->populateResultAssumedRoleUser($data->AssumedRoleUser); 99 | $this->packedPolicySize = (null !== $v = $data->PackedPolicySize[0]) ? (int) (string) $v : null; 100 | $this->sourceIdentity = (null !== $v = $data->SourceIdentity[0]) ? (string) $v : null; 101 | } 102 | 103 | private function populateResultAssumedRoleUser(\SimpleXMLElement $xml): AssumedRoleUser 104 | { 105 | return new AssumedRoleUser([ 106 | 'AssumedRoleId' => (string) $xml->AssumedRoleId, 107 | 'Arn' => (string) $xml->Arn, 108 | ]); 109 | } 110 | 111 | private function populateResultCredentials(\SimpleXMLElement $xml): Credentials 112 | { 113 | return new Credentials([ 114 | 'AccessKeyId' => (string) $xml->AccessKeyId, 115 | 'SecretAccessKey' => (string) $xml->SecretAccessKey, 116 | 'SessionToken' => (string) $xml->SessionToken, 117 | 'Expiration' => new \DateTimeImmutable((string) $xml->Expiration), 118 | ]); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Sts/Result/AssumeRoleWithWebIdentityResponse.php: -------------------------------------------------------------------------------- 1 | The size of the security token that STS API operations return is not fixed. We strongly recommend that you make no 20 | * > assumptions about the maximum size. 21 | * 22 | * @var Credentials|null 23 | */ 24 | private $credentials; 25 | 26 | /** 27 | * The unique user identifier that is returned by the identity provider. This identifier is associated with the 28 | * `WebIdentityToken` that was submitted with the `AssumeRoleWithWebIdentity` call. The identifier is typically unique 29 | * to the user and the application that acquired the `WebIdentityToken` (pairwise identifier). For OpenID Connect ID 30 | * tokens, this field contains the value returned by the identity provider as the token's `sub` (Subject) claim. 31 | * 32 | * @var string|null 33 | */ 34 | private $subjectFromWebIdentityToken; 35 | 36 | /** 37 | * The Amazon Resource Name (ARN) and the assumed role ID, which are identifiers that you can use to refer to the 38 | * resulting temporary security credentials. For example, you can reference these credentials as a principal in a 39 | * resource-based policy by using the ARN or assumed role ID. The ARN and ID include the `RoleSessionName` that you 40 | * specified when you called `AssumeRole`. 41 | * 42 | * @var AssumedRoleUser|null 43 | */ 44 | private $assumedRoleUser; 45 | 46 | /** 47 | * A percentage value that indicates the packed size of the session policies and session tags combined passed in the 48 | * request. The request fails if the packed size is greater than 100 percent, which means the policies and tags exceeded 49 | * the allowed space. 50 | * 51 | * @var int|null 52 | */ 53 | private $packedPolicySize; 54 | 55 | /** 56 | * The issuing authority of the web identity token presented. For OpenID Connect ID tokens, this contains the value of 57 | * the `iss` field. For OAuth 2.0 access tokens, this contains the value of the `ProviderId` parameter that was passed 58 | * in the `AssumeRoleWithWebIdentity` request. 59 | * 60 | * @var string|null 61 | */ 62 | private $provider; 63 | 64 | /** 65 | * The intended audience (also known as client ID) of the web identity token. This is traditionally the client 66 | * identifier issued to the application that requested the web identity token. 67 | * 68 | * @var string|null 69 | */ 70 | private $audience; 71 | 72 | /** 73 | * The value of the source identity that is returned in the JSON web token (JWT) from the identity provider. 74 | * 75 | * You can require users to set a source identity value when they assume a role. You do this by using the 76 | * `sts:SourceIdentity` condition key in a role trust policy. That way, actions that are taken with the role are 77 | * associated with that user. After the source identity is set, the value cannot be changed. It is present in the 78 | * request for all actions that are taken by the role and persists across chained role [^1] sessions. You can configure 79 | * your identity provider to use an attribute associated with your users, like user name or email, as the source 80 | * identity when calling `AssumeRoleWithWebIdentity`. You do this by adding a claim to the JSON web token. To learn more 81 | * about OIDC tokens and claims, see Using Tokens with User Pools [^2] in the *Amazon Cognito Developer Guide*. For more 82 | * information about using source identity, see Monitor and control actions taken with assumed roles [^3] in the *IAM 83 | * User Guide*. 84 | * 85 | * The regex used to validate this parameter is a string of characters consisting of upper- and lower-case alphanumeric 86 | * characters with no spaces. You can also include underscores or any of the following characters: =,.@- 87 | * 88 | * [^1]: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html#id_roles_terms-and-concepts 89 | * [^2]: https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-with-identity-providers.html 90 | * [^3]: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_control-access_monitor.html 91 | * 92 | * @var string|null 93 | */ 94 | private $sourceIdentity; 95 | 96 | public function getAssumedRoleUser(): ?AssumedRoleUser 97 | { 98 | $this->initialize(); 99 | 100 | return $this->assumedRoleUser; 101 | } 102 | 103 | public function getAudience(): ?string 104 | { 105 | $this->initialize(); 106 | 107 | return $this->audience; 108 | } 109 | 110 | public function getCredentials(): ?Credentials 111 | { 112 | $this->initialize(); 113 | 114 | return $this->credentials; 115 | } 116 | 117 | public function getPackedPolicySize(): ?int 118 | { 119 | $this->initialize(); 120 | 121 | return $this->packedPolicySize; 122 | } 123 | 124 | public function getProvider(): ?string 125 | { 126 | $this->initialize(); 127 | 128 | return $this->provider; 129 | } 130 | 131 | public function getSourceIdentity(): ?string 132 | { 133 | $this->initialize(); 134 | 135 | return $this->sourceIdentity; 136 | } 137 | 138 | public function getSubjectFromWebIdentityToken(): ?string 139 | { 140 | $this->initialize(); 141 | 142 | return $this->subjectFromWebIdentityToken; 143 | } 144 | 145 | protected function populateResult(Response $response): void 146 | { 147 | $data = new \SimpleXMLElement($response->getContent()); 148 | $data = $data->AssumeRoleWithWebIdentityResult; 149 | 150 | $this->credentials = 0 === $data->Credentials->count() ? null : $this->populateResultCredentials($data->Credentials); 151 | $this->subjectFromWebIdentityToken = (null !== $v = $data->SubjectFromWebIdentityToken[0]) ? (string) $v : null; 152 | $this->assumedRoleUser = 0 === $data->AssumedRoleUser->count() ? null : $this->populateResultAssumedRoleUser($data->AssumedRoleUser); 153 | $this->packedPolicySize = (null !== $v = $data->PackedPolicySize[0]) ? (int) (string) $v : null; 154 | $this->provider = (null !== $v = $data->Provider[0]) ? (string) $v : null; 155 | $this->audience = (null !== $v = $data->Audience[0]) ? (string) $v : null; 156 | $this->sourceIdentity = (null !== $v = $data->SourceIdentity[0]) ? (string) $v : null; 157 | } 158 | 159 | private function populateResultAssumedRoleUser(\SimpleXMLElement $xml): AssumedRoleUser 160 | { 161 | return new AssumedRoleUser([ 162 | 'AssumedRoleId' => (string) $xml->AssumedRoleId, 163 | 'Arn' => (string) $xml->Arn, 164 | ]); 165 | } 166 | 167 | private function populateResultCredentials(\SimpleXMLElement $xml): Credentials 168 | { 169 | return new Credentials([ 170 | 'AccessKeyId' => (string) $xml->AccessKeyId, 171 | 'SecretAccessKey' => (string) $xml->SecretAccessKey, 172 | 'SessionToken' => (string) $xml->SessionToken, 173 | 'Expiration' => new \DateTimeImmutable((string) $xml->Expiration), 174 | ]); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/Sts/Result/GetCallerIdentityResponse.php: -------------------------------------------------------------------------------- 1 | initialize(); 42 | 43 | return $this->account; 44 | } 45 | 46 | public function getArn(): ?string 47 | { 48 | $this->initialize(); 49 | 50 | return $this->arn; 51 | } 52 | 53 | public function getUserId(): ?string 54 | { 55 | $this->initialize(); 56 | 57 | return $this->userId; 58 | } 59 | 60 | protected function populateResult(Response $response): void 61 | { 62 | $data = new \SimpleXMLElement($response->getContent()); 63 | $data = $data->GetCallerIdentityResult; 64 | 65 | $this->userId = (null !== $v = $data->UserId[0]) ? (string) $v : null; 66 | $this->account = (null !== $v = $data->Account[0]) ? (string) $v : null; 67 | $this->arn = (null !== $v = $data->Arn[0]) ? (string) $v : null; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Sts/ValueObject/AssumedRoleUser.php: -------------------------------------------------------------------------------- 1 | assumedRoleId = $input['AssumedRoleId'] ?? $this->throwException(new InvalidArgument('Missing required field "AssumedRoleId".')); 39 | $this->arn = $input['Arn'] ?? $this->throwException(new InvalidArgument('Missing required field "Arn".')); 40 | } 41 | 42 | /** 43 | * @param array{ 44 | * AssumedRoleId: string, 45 | * Arn: string, 46 | * }|AssumedRoleUser $input 47 | */ 48 | public static function create($input): self 49 | { 50 | return $input instanceof self ? $input : new self($input); 51 | } 52 | 53 | public function getArn(): string 54 | { 55 | return $this->arn; 56 | } 57 | 58 | public function getAssumedRoleId(): string 59 | { 60 | return $this->assumedRoleId; 61 | } 62 | 63 | /** 64 | * @return never 65 | */ 66 | private function throwException(\Throwable $exception) 67 | { 68 | throw $exception; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Sts/ValueObject/Credentials.php: -------------------------------------------------------------------------------- 1 | accessKeyId = $input['AccessKeyId'] ?? $this->throwException(new InvalidArgument('Missing required field "AccessKeyId".')); 51 | $this->secretAccessKey = $input['SecretAccessKey'] ?? $this->throwException(new InvalidArgument('Missing required field "SecretAccessKey".')); 52 | $this->sessionToken = $input['SessionToken'] ?? $this->throwException(new InvalidArgument('Missing required field "SessionToken".')); 53 | $this->expiration = $input['Expiration'] ?? $this->throwException(new InvalidArgument('Missing required field "Expiration".')); 54 | } 55 | 56 | /** 57 | * @param array{ 58 | * AccessKeyId: string, 59 | * SecretAccessKey: string, 60 | * SessionToken: string, 61 | * Expiration: \DateTimeImmutable, 62 | * }|Credentials $input 63 | */ 64 | public static function create($input): self 65 | { 66 | return $input instanceof self ? $input : new self($input); 67 | } 68 | 69 | public function getAccessKeyId(): string 70 | { 71 | return $this->accessKeyId; 72 | } 73 | 74 | public function getExpiration(): \DateTimeImmutable 75 | { 76 | return $this->expiration; 77 | } 78 | 79 | public function getSecretAccessKey(): string 80 | { 81 | return $this->secretAccessKey; 82 | } 83 | 84 | public function getSessionToken(): string 85 | { 86 | return $this->sessionToken; 87 | } 88 | 89 | /** 90 | * @return never 91 | */ 92 | private function throwException(\Throwable $exception) 93 | { 94 | throw $exception; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Sts/ValueObject/PolicyDescriptorType.php: -------------------------------------------------------------------------------- 1 | arn = $input['arn'] ?? null; 30 | } 31 | 32 | /** 33 | * @param array{ 34 | * arn?: null|string, 35 | * }|PolicyDescriptorType $input 36 | */ 37 | public static function create($input): self 38 | { 39 | return $input instanceof self ? $input : new self($input); 40 | } 41 | 42 | public function getArn(): ?string 43 | { 44 | return $this->arn; 45 | } 46 | 47 | /** 48 | * @internal 49 | */ 50 | public function requestBody(): array 51 | { 52 | $payload = []; 53 | if (null !== $v = $this->arn) { 54 | $payload['arn'] = $v; 55 | } 56 | 57 | return $payload; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Sts/ValueObject/ProvidedContext.php: -------------------------------------------------------------------------------- 1 | providerArn = $input['ProviderArn'] ?? null; 35 | $this->contextAssertion = $input['ContextAssertion'] ?? null; 36 | } 37 | 38 | /** 39 | * @param array{ 40 | * ProviderArn?: null|string, 41 | * ContextAssertion?: null|string, 42 | * }|ProvidedContext $input 43 | */ 44 | public static function create($input): self 45 | { 46 | return $input instanceof self ? $input : new self($input); 47 | } 48 | 49 | public function getContextAssertion(): ?string 50 | { 51 | return $this->contextAssertion; 52 | } 53 | 54 | public function getProviderArn(): ?string 55 | { 56 | return $this->providerArn; 57 | } 58 | 59 | /** 60 | * @internal 61 | */ 62 | public function requestBody(): array 63 | { 64 | $payload = []; 65 | if (null !== $v = $this->providerArn) { 66 | $payload['ProviderArn'] = $v; 67 | } 68 | if (null !== $v = $this->contextAssertion) { 69 | $payload['ContextAssertion'] = $v; 70 | } 71 | 72 | return $payload; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Sts/ValueObject/Tag.php: -------------------------------------------------------------------------------- 1 | key = $input['Key'] ?? $this->throwException(new InvalidArgument('Missing required field "Key".')); 49 | $this->value = $input['Value'] ?? $this->throwException(new InvalidArgument('Missing required field "Value".')); 50 | } 51 | 52 | /** 53 | * @param array{ 54 | * Key: string, 55 | * Value: string, 56 | * }|Tag $input 57 | */ 58 | public static function create($input): self 59 | { 60 | return $input instanceof self ? $input : new self($input); 61 | } 62 | 63 | public function getKey(): string 64 | { 65 | return $this->key; 66 | } 67 | 68 | public function getValue(): string 69 | { 70 | return $this->value; 71 | } 72 | 73 | /** 74 | * @internal 75 | */ 76 | public function requestBody(): array 77 | { 78 | $payload = []; 79 | $v = $this->key; 80 | $payload['Key'] = $v; 81 | $v = $this->value; 82 | $payload['Value'] = $v; 83 | 84 | return $payload; 85 | } 86 | 87 | /** 88 | * @return never 89 | */ 90 | private function throwException(\Throwable $exception) 91 | { 92 | throw $exception; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Test/Http/SimpleMockedResponse.php: -------------------------------------------------------------------------------- 1 | > 17 | */ 18 | private $headers = []; 19 | 20 | /** 21 | * @var string 22 | */ 23 | private $content = ''; 24 | 25 | /** 26 | * @var int 27 | */ 28 | private $statusCode; 29 | 30 | /** 31 | * @param array> $headers ['name'=>'value'] OR ['name'=>['value']] 32 | */ 33 | public function __construct(string $content = '', array $headers = [], int $statusCode = 200) 34 | { 35 | $this->content = $content; 36 | $this->statusCode = $statusCode; 37 | $this->headers = []; 38 | foreach ($headers as $name => $value) { 39 | if (!\is_array($value)) { 40 | $value = [$value]; 41 | } 42 | $this->headers[$name] = $value; 43 | } 44 | 45 | parent::__construct($content, [ 46 | 'response_headers' => $this->getFlatHeaders(), 47 | 'http_code' => $statusCode, 48 | ]); 49 | } 50 | 51 | public function getStatusCode(): int 52 | { 53 | return $this->statusCode; 54 | } 55 | 56 | public function getHeaders(bool $throw = true): array 57 | { 58 | return $this->headers; 59 | } 60 | 61 | public function getContent(bool $throw = true): string 62 | { 63 | return $this->content; 64 | } 65 | 66 | /** 67 | * @return array 68 | */ 69 | public function toArray(bool $throw = true): array 70 | { 71 | return json_decode($this->getContent($throw), true); 72 | } 73 | 74 | public function cancel(): void 75 | { 76 | throw new LogicException('Not implemented'); 77 | } 78 | 79 | /** 80 | * @return list 81 | */ 82 | private function getFlatHeaders() 83 | { 84 | $flat = []; 85 | foreach ($this->headers as $name => $value) { 86 | $flat[] = \sprintf('%s: %s', $name, implode(';', $value)); 87 | } 88 | 89 | return $flat; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Test/ResultMockFactory.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class ResultMockFactory 21 | { 22 | /** 23 | * Instantiate a Result class that throws exception. 24 | * 25 | * 26 | * ResultMockFactory::createFailing(SendEmailResponse::class, 400, 'invalid value'); 27 | * 28 | * 29 | * @template T of Result 30 | * 31 | * @param class-string $class 32 | * @param array $additionalContent 33 | * 34 | * @return T 35 | */ 36 | public static function createFailing( 37 | string $class, 38 | int $code, 39 | ?string $message = null, 40 | array $additionalContent = [] 41 | ) { 42 | if (Result::class !== $class) { 43 | $parent = get_parent_class($class); 44 | if (false === $parent || Result::class !== $parent) { 45 | throw new LogicException(\sprintf('The "%s::%s" can only be used for classes that extend "%s"', __CLASS__, __METHOD__, Result::class)); 46 | } 47 | } 48 | 49 | $httpResponse = new SimpleMockedResponse(json_encode(array_merge(['message' => $message], $additionalContent)), ['content-type' => 'application/json'], $code); 50 | $client = new MockHttpClient($httpResponse); 51 | $response = new Response($client->request('POST', 'http://localhost'), $client, new NullLogger()); 52 | 53 | /** @psalm-var \ReflectionClass $reflectionClass */ 54 | $reflectionClass = new \ReflectionClass($class); 55 | 56 | return $reflectionClass->newInstance($response); 57 | } 58 | 59 | /** 60 | * Instantiate a Result class with some data. 61 | * 62 | * 63 | * ResultMockFactory::create(SendEmailResponse::class, ['MessageId'=>'foo123']); 64 | * 65 | * 66 | * @template T of Result 67 | * 68 | * @param class-string $class 69 | * @param array $data 70 | * 71 | * @return T 72 | */ 73 | public static function create(string $class, array $data = []) 74 | { 75 | if (Result::class !== $class) { 76 | $parent = get_parent_class($class); 77 | if (false === $parent || Result::class !== $parent) { 78 | throw new LogicException(\sprintf('The "%s::%s" can only be used for classes that extend "%s"', __CLASS__, __METHOD__, Result::class)); 79 | } 80 | } 81 | 82 | $response = self::getResponseObject(); 83 | 84 | // Make sure the Result is initialized 85 | $reflectionClass = new \ReflectionClass(Result::class); 86 | $initializedProperty = $reflectionClass->getProperty('initialized'); 87 | $initializedProperty->setAccessible(true); 88 | 89 | /** @psalm-var \ReflectionClass $reflectionClass */ 90 | $reflectionClass = new \ReflectionClass($class); 91 | $object = $reflectionClass->newInstance($response); 92 | if (Result::class !== $class) { 93 | self::addPropertiesOnResult($reflectionClass, $object, $class); 94 | } 95 | 96 | $initializedProperty->setValue($object, true); 97 | foreach ($data as $propertyName => $propertyValue) { 98 | if ($reflectionClass->hasProperty($propertyName)) { 99 | $property = $reflectionClass->getProperty($propertyName); 100 | } elseif ($reflectionClass->hasProperty(lcfirst($propertyName))) { 101 | // backward compatibility with `UpperCamelCase` naming (fast) 102 | $property = $reflectionClass->getProperty(lcfirst($propertyName)); 103 | } else { 104 | // compatibility with new `wordWithABREV` naming (slow) 105 | $lowerPropertyName = strtolower($propertyName); 106 | $property = null; 107 | foreach ($reflectionClass->getProperties() as $prop) { 108 | if (strtolower($prop->getName()) === $lowerPropertyName) { 109 | $property = $prop; 110 | 111 | break; 112 | } 113 | } 114 | if (null === $property) { 115 | // let bubble the original exception 116 | $property = $reflectionClass->getProperty($propertyName); 117 | } 118 | } 119 | $property->setAccessible(true); 120 | $property->setValue($object, $propertyValue); 121 | } 122 | 123 | self::addUndefinedProperties($reflectionClass, $object, $data); 124 | 125 | return $object; 126 | } 127 | 128 | /** 129 | * Instantiate a Waiter class with a final state. 130 | * 131 | * @template T of Waiter 132 | * 133 | * @psalm-param class-string $class 134 | * 135 | * @return T 136 | */ 137 | public static function waiter(string $class, string $finalState) 138 | { 139 | if (Waiter::class !== $class) { 140 | $parent = get_parent_class($class); 141 | if (false === $parent || Waiter::class !== $parent) { 142 | throw new LogicException(\sprintf('The "%s::%s" can only be used for classes that extend "%s"', __CLASS__, __METHOD__, Waiter::class)); 143 | } 144 | } 145 | 146 | if (Waiter::STATE_SUCCESS !== $finalState && Waiter::STATE_FAILURE !== $finalState) { 147 | throw new LogicException(\sprintf('The state passed to "%s::%s" must be "%s" or "%s".', __CLASS__, __METHOD__, Waiter::STATE_SUCCESS, Waiter::STATE_FAILURE)); 148 | } 149 | 150 | $response = self::getResponseObject(); 151 | 152 | $reflectionClass = new \ReflectionClass(Waiter::class); 153 | $propertyResponse = $reflectionClass->getProperty('response'); 154 | $propertyResponse->setAccessible(true); 155 | 156 | $propertyState = $reflectionClass->getProperty('finalState'); 157 | $propertyState->setAccessible(true); 158 | 159 | /** @psalm-var \ReflectionClass $reflectionClass */ 160 | $reflectionClass = new \ReflectionClass($class); 161 | $result = $reflectionClass->newInstanceWithoutConstructor(); 162 | $propertyResponse->setValue($result, $response); 163 | $propertyState->setValue($result, $finalState); 164 | 165 | return $result; 166 | } 167 | 168 | /** 169 | * Try to add some values to the properties not defined in $data. 170 | * 171 | * @param \ReflectionClass $reflectionClass 172 | * @param array $data 173 | * 174 | * @throws \ReflectionException 175 | */ 176 | private static function addUndefinedProperties(\ReflectionClass $reflectionClass, object $object, array $data): void 177 | { 178 | foreach ($reflectionClass->getProperties(\ReflectionProperty::IS_PRIVATE) as $property) { 179 | if (\array_key_exists($property->getName(), $data) || \array_key_exists(ucfirst($property->getName()), $data)) { 180 | continue; 181 | } 182 | 183 | if (!$reflectionClass->hasMethod('get' . $property->getName())) { 184 | continue; 185 | } 186 | 187 | $getter = $reflectionClass->getMethod('get' . $property->getName()); 188 | if (!$getter->hasReturnType() || (!($type = $getter->getReturnType()) instanceof \ReflectionNamedType) || $type->allowsNull()) { 189 | continue; 190 | } 191 | 192 | switch ($type->getName()) { 193 | case 'int': 194 | $propertyValue = 0; 195 | 196 | break; 197 | case 'string': 198 | $propertyValue = ''; 199 | 200 | break; 201 | case 'bool': 202 | $propertyValue = false; 203 | 204 | break; 205 | case 'float': 206 | $propertyValue = 0.0; 207 | 208 | break; 209 | case 'array': 210 | $propertyValue = []; 211 | 212 | break; 213 | default: 214 | $propertyValue = null; 215 | 216 | break; 217 | } 218 | 219 | if (null !== $propertyValue) { 220 | $property->setAccessible(true); 221 | $property->setValue($object, $propertyValue); 222 | } 223 | } 224 | } 225 | 226 | /** 227 | * Set input and aws client to handle pagination. 228 | * 229 | * @param \ReflectionClass $reflectionClass 230 | */ 231 | private static function addPropertiesOnResult(\ReflectionClass $reflectionClass, object $object, string $class): void 232 | { 233 | if (false === $pos = strrpos($class, '\\')) { 234 | throw new LogicException(\sprintf('Expected class "%s" to have a backslash. ', $class)); 235 | } 236 | 237 | $className = substr($class, $pos + 1); 238 | if ('Output' === substr($className, -6)) { 239 | $classNameWithoutSuffix = substr($className, 0, -6); 240 | } elseif ('Response' === substr($className, -8)) { 241 | $classNameWithoutSuffix = substr($className, 0, -8); 242 | } elseif ('Result' === substr($className, -6)) { 243 | $classNameWithoutSuffix = substr($className, 0, -6); 244 | } else { 245 | throw new LogicException(\sprintf('Unknown class suffix: "%s"', $className)); 246 | } 247 | 248 | if (false === $pos = strrpos($class, '\\', -2 - \strlen($className))) { 249 | throw new LogicException(\sprintf('Expected class "%s" to have more than one backslash. ', $class)); 250 | } 251 | 252 | $baseNamespace = substr($class, 0, $pos); 253 | if (false === $pos = strrpos($baseNamespace, '\\')) { 254 | throw new LogicException(\sprintf('Expected base namespace "%s" to have a backslash. ', $baseNamespace)); 255 | } 256 | 257 | $awsClientClass = $baseNamespace . substr($baseNamespace, $pos) . 'Client'; 258 | $inputClass = $baseNamespace . '\\Input\\' . $classNameWithoutSuffix . 'Request'; 259 | 260 | if (class_exists($awsClientClass)) { 261 | $awsClientMock = (new \ReflectionClass($awsClientClass))->newInstanceWithoutConstructor(); 262 | $property = $reflectionClass->getProperty('awsClient'); 263 | $property->setAccessible(true); 264 | $property->setValue($object, $awsClientMock); 265 | } 266 | 267 | if (class_exists($inputClass)) { 268 | $inputMock = (new \ReflectionClass($inputClass))->newInstanceWithoutConstructor(); 269 | $property = $reflectionClass->getProperty('input'); 270 | $property->setAccessible(true); 271 | $property->setValue($object, $inputMock); 272 | } 273 | } 274 | 275 | private static function getResponseObject(): Response 276 | { 277 | $reflectionClass = new \ReflectionClass(Response::class); 278 | $response = $reflectionClass->newInstanceWithoutConstructor(); 279 | 280 | $property = $reflectionClass->getProperty('resolveResult'); 281 | $property->setAccessible(true); 282 | $property->setValue($response, true); 283 | 284 | $property = $reflectionClass->getProperty('bodyDownloaded'); 285 | $property->setAccessible(true); 286 | $property->setValue($response, true); 287 | 288 | $property = $reflectionClass->getProperty('httpResponse'); 289 | $property->setAccessible(true); 290 | $property->setValue($response, new SimpleMockedResponse()); 291 | 292 | return $response; 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /src/Test/SimpleResultStream.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class SimpleResultStream implements ResultStream 15 | { 16 | /** 17 | * @var string 18 | */ 19 | private $data; 20 | 21 | public function __construct(string $data) 22 | { 23 | $this->data = $data; 24 | } 25 | 26 | public function getChunks(): iterable 27 | { 28 | yield $this->data; 29 | } 30 | 31 | public function getContentAsString(): string 32 | { 33 | return $this->data; 34 | } 35 | 36 | public function getContentAsResource() 37 | { 38 | $resource = fopen('php://temp', 'rw+'); 39 | 40 | try { 41 | fwrite($resource, $this->data); 42 | 43 | // Rewind 44 | fseek($resource, 0, \SEEK_SET); 45 | 46 | return $resource; 47 | } catch (\Throwable $e) { 48 | fclose($resource); 49 | 50 | throw $e; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Test/TestCase.php: -------------------------------------------------------------------------------- 1 | getMethod()); 57 | 58 | $actualUrl = $actual->getUri(); 59 | if ($actual->getQuery()) { 60 | $actualUrl .= false !== strpos($actual->getUri(), '?') ? '&' : '?'; 61 | $actualUrl .= http_build_query($actual->getQuery(), '', '&', \PHP_QUERY_RFC3986); 62 | } 63 | self::assertUrlEqualsUrl($url, $actualUrl); 64 | 65 | $expectedHeaders = []; 66 | foreach ($headers as $header) { 67 | $parts = explode(':', trim($header), 2); 68 | $key = $parts[0]; 69 | $value = $parts[1] ?? ''; 70 | $expectedHeaders[strtolower($key)] = trim($value); 71 | } 72 | self::assertEqualsIgnoringCase($expectedHeaders, $actual->getHeaders(), $message); 73 | 74 | switch ($expectedHeaders['content-type'] ?? null) { 75 | case 'application/x-www-form-urlencoded': 76 | self::assertHttpFormEqualsHttpForm(trim($body), $actual->getBody()->stringify(), $message); 77 | 78 | break; 79 | case 'application/json': 80 | case 'application/x-amz-json-1.0': 81 | case 'application/x-amz-json-1.1': 82 | if ('' === trim($body)) { 83 | self::assertSame($body, $actual->getBody()->stringify()); 84 | } else { 85 | self::assertJsonStringEqualsJsonString(trim($body), $actual->getBody()->stringify(), $message); 86 | } 87 | 88 | break; 89 | case 'application/xml': 90 | if ('' === trim($body)) { 91 | self::assertSame($body, $actual->getBody()->stringify()); 92 | } else { 93 | self::assertXmlStringEqualsXmlString(trim($body), $actual->getBody()->stringify(), $message); 94 | } 95 | 96 | break; 97 | default: 98 | self::assertSame(trim($body), $actual->getBody()->stringify()); 99 | 100 | break; 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Waiter.php: -------------------------------------------------------------------------------- 1 | response = $response; 60 | $this->awsClient = $awsClient; 61 | $this->input = $request; 62 | } 63 | 64 | public function __destruct() 65 | { 66 | if (!$this->resolved) { 67 | $this->resolve(); 68 | } 69 | } 70 | 71 | final public function isSuccess(): bool 72 | { 73 | return self::STATE_SUCCESS === $this->getState(); 74 | } 75 | 76 | final public function isFailure(): bool 77 | { 78 | return self::STATE_FAILURE === $this->getState(); 79 | } 80 | 81 | final public function isPending(): bool 82 | { 83 | return self::STATE_PENDING === $this->getState(); 84 | } 85 | 86 | final public function getState(): string 87 | { 88 | if (null !== $this->finalState) { 89 | return $this->finalState; 90 | } 91 | 92 | if ($this->needRefresh) { 93 | $this->stealResponse($this->refreshState()); 94 | } 95 | 96 | try { 97 | $this->response->resolve(); 98 | $exception = null; 99 | } catch (HttpException $exception) { 100 | // use $exception later 101 | } finally { 102 | $this->resolved = true; 103 | $this->needRefresh = true; 104 | } 105 | 106 | $state = $this->extractState($this->response, $exception); 107 | 108 | switch ($state) { 109 | case self::STATE_SUCCESS: 110 | case self::STATE_FAILURE: 111 | $this->finalState = $state; 112 | 113 | break; 114 | case self::STATE_PENDING: 115 | break; 116 | default: 117 | throw new LogicException(\sprintf('Unexpected state "%s" from Waiter "%s".', $state, __CLASS__)); 118 | } 119 | 120 | return $state; 121 | } 122 | 123 | /** 124 | * Make sure the actual request is executed. 125 | * 126 | * @param float|null $timeout Duration in seconds before aborting. When null wait until the end of execution. 127 | * 128 | * @return bool false on timeout. True if the response has returned with as status code. 129 | * 130 | * @throws NetworkException 131 | */ 132 | final public function resolve(?float $timeout = null): bool 133 | { 134 | try { 135 | return $this->response->resolve($timeout); 136 | } catch (HttpException $exception) { 137 | return true; 138 | } finally { 139 | $this->resolved = true; 140 | } 141 | } 142 | 143 | /** 144 | * Returns info on the current request. 145 | * 146 | * @return array{ 147 | * resolved: bool, 148 | * body_downloaded: bool, 149 | * response: \Symfony\Contracts\HttpClient\ResponseInterface, 150 | * status: int, 151 | * } 152 | */ 153 | final public function info(): array 154 | { 155 | return $this->response->info(); 156 | } 157 | 158 | final public function cancel(): void 159 | { 160 | $this->response->cancel(); 161 | $this->needRefresh = true; 162 | $this->resolved = true; 163 | } 164 | 165 | /** 166 | * Wait until the state is success. 167 | * Stopped when the state become Failure or the defined timeout is reached. 168 | * 169 | * @param float $timeout Duration in seconds before aborting 170 | * @param float $delay Duration in seconds between each check 171 | * 172 | * @return bool true if a final state was reached 173 | */ 174 | final public function wait(?float $timeout = null, ?float $delay = null): bool 175 | { 176 | if (null !== $this->finalState) { 177 | return true; 178 | } 179 | 180 | $timeout = $timeout ?? static::WAIT_TIMEOUT; 181 | $delay = $delay ?? static::WAIT_DELAY; 182 | 183 | $start = microtime(true); 184 | while (true) { 185 | if ($this->needRefresh) { 186 | $this->stealResponse($this->refreshState()); 187 | } 188 | 189 | // If request times out 190 | if (!$this->resolve($timeout - (microtime(true) - $start))) { 191 | break; 192 | } 193 | 194 | $this->getState(); 195 | // If we reached a final state 196 | if ($this->finalState) { 197 | return true; 198 | } 199 | 200 | // If the timeout will expire during our sleep, then exit early. 201 | if ($delay > $timeout - (microtime(true) - $start)) { 202 | break; 203 | } 204 | 205 | usleep((int) ceil($delay * 1000000)); 206 | } 207 | 208 | return false; 209 | } 210 | 211 | protected function extractState(Response $response, ?HttpException $exception): string 212 | { 213 | return self::STATE_PENDING; 214 | } 215 | 216 | protected function refreshState(): Waiter 217 | { 218 | return $this; 219 | } 220 | 221 | private function stealResponse(self $waiter): void 222 | { 223 | $this->response = $waiter->response; 224 | $this->resolved = $waiter->resolved; 225 | $waiter->resolved = true; 226 | $this->needRefresh = false; 227 | } 228 | } 229 | --------------------------------------------------------------------------------