From a767ea52a4ee425e82c2a5253b224ec8281ed855 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 29 Jun 2024 16:17:13 +0200 Subject: [PATCH 1/9] Implement credential providers See #7 --- .../amazon/aws/CredentialProvider.class.php | 53 ++++ .../aws/credentials/FromConfig.class.php | 56 ++++ .../amazon/aws/credentials/FromEcs.class.php | 66 ++++ .../aws/credentials/FromEnvironment.class.php | 26 ++ .../aws/credentials/FromGiven.class.php | 16 + .../amazon/aws/credentials/Provider.class.php | 8 + .../unittest/CredentialProviderTest.class.php | 287 ++++++++++++++++++ .../amazon/aws/unittest/Exported.class.php | 21 ++ 8 files changed, 533 insertions(+) create mode 100755 src/main/php/com/amazon/aws/CredentialProvider.class.php create mode 100755 src/main/php/com/amazon/aws/credentials/FromConfig.class.php create mode 100755 src/main/php/com/amazon/aws/credentials/FromEcs.class.php create mode 100755 src/main/php/com/amazon/aws/credentials/FromEnvironment.class.php create mode 100755 src/main/php/com/amazon/aws/credentials/FromGiven.class.php create mode 100755 src/main/php/com/amazon/aws/credentials/Provider.class.php create mode 100755 src/test/php/com/amazon/aws/unittest/CredentialProviderTest.class.php create mode 100755 src/test/php/com/amazon/aws/unittest/Exported.class.php diff --git a/src/main/php/com/amazon/aws/CredentialProvider.class.php b/src/main/php/com/amazon/aws/CredentialProvider.class.php new file mode 100755 index 0000000..a8bdfbc --- /dev/null +++ b/src/main/php/com/amazon/aws/CredentialProvider.class.php @@ -0,0 +1,53 @@ +delegates= $delegates; + } + + /** @return ?com.amazon.aws.Credentials */ + public function credentials() { + foreach ($this->delegates as $delegate) { + if (null !== ($credentials= $delegate->credentials())) return $credentials; + } + return null; + } + + /** + * Returns default credential provider chain, checking, in the following order: + * + * 1. Environment variables + * 2. Shared credentials and config files + * 3. Amazon ECS container credentials + * + * If none of the above provide credentials, an exception is raised when invoking + * the `credentials()` method. + * + * @see https://docs.aws.amazon.com/sdk-for-kotlin/latest/developer-guide/credential-providers.html + * @return com.amazon.aws.credentials.Provider + */ + public static function default() { + return new self( + new FromEnvironment(), + new FromConfig(), + new FromEcs(), + self::$raise + ); + } +} \ No newline at end of file diff --git a/src/main/php/com/amazon/aws/credentials/FromConfig.class.php b/src/main/php/com/amazon/aws/credentials/FromConfig.class.php new file mode 100755 index 0000000..48df247 --- /dev/null +++ b/src/main/php/com/amazon/aws/credentials/FromConfig.class.php @@ -0,0 +1,56 @@ +file= new File(Environment::variable('AWS_SHARED_CREDENTIALS_FILE', null) + ?? new Path(Environment::homeDir(), '.aws', 'credentials') + ); + } else if ($file instanceof File) { + $this->file= $file; + } else { + $this->file= new File($file); + } + + $this->profile= $profile ?? Environment::variable('AWS_PROFILE', 'default'); + } + + /** @return ?com.amazon.aws.Credentials */ + public function credentials() { + if (!$this->file->exists()) return null; + + // Either check "profile [...]" or the default section + $config= parse_ini_file($this->file->getURI(), true, INI_SCANNER_RAW); + $section= $config['default' === $this->profile ? 'default' : "profile {$this->profile}"] ?? null; + if (null === $section) return null; + + return new Credentials( + $section['aws_access_key_id'], + new Secret($section['aws_secret_access_key']), + $section['aws_session_token'] ?? null + ); + } +} \ No newline at end of file diff --git a/src/main/php/com/amazon/aws/credentials/FromEcs.class.php b/src/main/php/com/amazon/aws/credentials/FromEcs.class.php new file mode 100755 index 0000000..1cdc787 --- /dev/null +++ b/src/main/php/com/amazon/aws/credentials/FromEcs.class.php @@ -0,0 +1,66 @@ +conn= $conn ?? new HttpConnection(self::DEFAULT_HOST); + } + + /** @return ?com.amazon.aws.Credentials */ + public function credentials() { + $req= $this->conn->create(new HttpRequest()); + + // Check AWS_CONTAINER_CREDENTIALS_* + if (null !== ($relative= Environment::variable('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI', null))) { + $req->setTarget($relative); + } else if (null !== ($uri= Environment::variable('AWS_CONTAINER_CREDENTIALS_FULL_URI', null))) { + $req->setUrl(new URL($uri)); + } else { + return null; + } + + // Append authorizatio from AWS_CONTAINER_AUTHORIZATION_TOKEN_*, if existant + if (null !== ($file= Environment::variable('AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE', null))) { + $req->setHeader('Authorization', rtrim(file_get_contents($file), "\r\n")); + } else if (null !== ($token= Environment::variable('AWS_CONTAINER_AUTHORIZATION_TOKEN', null))) { + $req->setHeader('Authorization', $token); + } + + try { + $res= $this->conn->send($req); + } catch (Throwable $t) { + throw new IllegalStateException("Container credential provider {$req->getUrl()->getURL()} failed", $t); + } + + if (200 !== $res->statusCode()) { + throw new IllegalStateException("Container credential provider {$req->getUrl()->getURL()} returned unexpected {$res->toString()}"); + } + + $credentials= Json::read(new StreamInput($res->in())); + return new Credentials( + $credentials['AccessKeyId'], + $credentials['SecretAccessKey'], + $credentials['Token'] + ); + } +} \ No newline at end of file diff --git a/src/main/php/com/amazon/aws/credentials/FromEnvironment.class.php b/src/main/php/com/amazon/aws/credentials/FromEnvironment.class.php new file mode 100755 index 0000000..d699e96 --- /dev/null +++ b/src/main/php/com/amazon/aws/credentials/FromEnvironment.class.php @@ -0,0 +1,26 @@ +credentials= $credentials; + } + + /** @return ?com.amazon.aws.Credentials */ + public function credentials() { return $this->credentials; } + +} \ No newline at end of file diff --git a/src/main/php/com/amazon/aws/credentials/Provider.class.php b/src/main/php/com/amazon/aws/credentials/Provider.class.php new file mode 100755 index 0000000..4815cc6 --- /dev/null +++ b/src/main/php/com/amazon/aws/credentials/Provider.class.php @@ -0,0 +1,8 @@ +credentials()); + } + + #[Test, Values([[[], null], [['AWS_SESSION_TOKEN' => 'token'], 'token']])] + public function in_environment_with($session, $token) { + $env= ['AWS_ACCESS_KEY_ID' => 'key', 'AWS_SECRET_ACCESS_KEY' => 'secret'] + $session; + with (new Exported($env), function() use($token) { + $credentials= (new FromEnvironment())->credentials(); + + Assert::equals('key', $credentials->accessKey()); + Assert::equals('secret', $credentials->secretKey()->reveal()); + Assert::equals($token, $credentials->sessionToken()); + }); + } + + #[Test] + public function not_in_environment() { + with (new Exported(['AWS_ACCESS_KEY_ID' => null]), function() { + Assert::null((new FromEnvironment())->credentials()); + }); + } + + #[Test, Values([['', null], ['aws_session_token = token', 'token']])] + public function from_config_with($session, $token) { + $file= (new TempFile())->containing( + "[default]\n". + "aws_access_key_id = key\n". + "aws_secret_access_key = secret\n". + "{$session}\n" + ); + $credentials= (new FromConfig($file))->credentials(); + + Assert::equals('key', $credentials->accessKey()); + Assert::equals('secret', $credentials->secretKey()->reveal()); + Assert::equals($token, $credentials->sessionToken()); + } + + #[Test, Values(['default', 'test'])] + public function from_config_using_profile($profile) { + $file= (new TempFile())->containing( + "[default]\n". + "aws_access_key_id = default\n". + "aws_secret_access_key = default-secret\n". + "[profile test]\n". + "aws_access_key_id = test\n". + "aws_secret_access_key = test-secret\n" + ); + $credentials= (new FromConfig($file, $profile))->credentials(); + + Assert::equals($profile, $credentials->accessKey()); + Assert::equals($profile.'-secret', $credentials->secretKey()->reveal()); + } + + #[Test] + public function from_config_uses_shared_credentials_file() { + $file= (new TempFile())->containing( + "[default]\n". + "aws_access_key_id = key\n". + "aws_secret_access_key = secret\n" + ); + with (new Exported(['AWS_SHARED_CREDENTIALS_FILE' => $file->getURI()]), function() { + $credentials= (new FromConfig())->credentials(); + + Assert::equals('key', $credentials->accessKey()); + Assert::equals('secret', $credentials->secretKey()->reveal()); + }); + } + + #[Test] + public function from_config_uses_environment_if_profile_omitted() { + with (new Exported(['AWS_PROFILE' => 'test']), function() { + $file= (new TempFile())->containing( + "[profile test]\n". + "aws_access_key_id = test\n". + "aws_secret_access_key = test-secret\n" + ); + $credentials= (new FromConfig($file))->credentials(); + + Assert::equals('test', $credentials->accessKey()); + Assert::equals('test-secret', $credentials->secretKey()->reveal()); + }); + } + + #[Test] + public function from_config_ignored_environment_if_profile_passed() { + with (new Exported(['AWS_PROFILE' => 'default']), function() { + $file= (new TempFile())->containing( + "[profile test]\n". + "aws_access_key_id = test\n". + "aws_secret_access_key = test-secret\n" + ); + $credentials= (new FromConfig($file, 'test'))->credentials(); + + Assert::equals('test', $credentials->accessKey()); + Assert::equals('test-secret', $credentials->secretKey()->reveal()); + }); + } + + #[Test] + public function from_config_using_non_existant_profile() { + $file= (new TempFile())->containing( + "[default]\n". + "aws_access_key_id = default\n". + "aws_secret_access_key = default\n" + ); + Assert::null((new FromConfig($file, 'test'))->credentials()); + } + + #[Test] + public function from_non_existant_config() { + Assert::null((new FromConfig('/file-does-not-exist'))->credentials()); + } + + #[Test, Values([['/get-credentials', null], [null, 'http://localhost/get-credentials']])] + public function ecs_api($relative, $full) { + $env= [ + 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI' => $relative, + 'AWS_CONTAINER_CREDENTIALS_FULL_URI' => $full, + ]; + with (new Exported($env), function() { + $conn= new TestConnection(['/get-credentials' => self::ECS_CREDENTIALS_RESPONSE]); + $credentials= (new FromEcs($conn))->credentials(); + + Assert::equals('key', $credentials->accessKey()); + Assert::equals('secret', $credentials->secretKey()->reveal()); + }); + } + + #[Test, Values(['Basic Test', "Basic Test\n", "Basic Test\r", "Basic Test\r\n"])] + public function ecs_api_with_authorization_file($contents) { + $file= (new TempFile())->containing($contents); + $env= [ + 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI' => '/get-credentials', + 'AWS_CONTAINER_CREDENTIALS_FULL_URI' => null, + 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE' => $file->getURI(), + 'AWS_CONTAINER_AUTHORIZATION_TOKEN' => null, + ]; + with (new Exported($env), function() { + $conn= new TestConnection([ + '/get-credentials' => function($req) { + return in_array('Basic Test', $req->headers['Authorization'] ?? []) + ? self::ECS_CREDENTIALS_RESPONSE + : ['HTTP/1.1 403', ''] + ; + } + ]); + $credentials= (new FromEcs($conn))->credentials(); + + Assert::equals('key', $credentials->accessKey()); + Assert::equals('secret', $credentials->secretKey()->reveal()); + }); + } + + #[Test] + public function ecs_api_with_authorization() { + $env= [ + 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI' => '/get-credentials', + 'AWS_CONTAINER_CREDENTIALS_FULL_URI' => null, + 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE' => null, + 'AWS_CONTAINER_AUTHORIZATION_TOKEN' => 'Basic Test', + ]; + with (new Exported($env), function() { + $conn= new TestConnection([ + '/get-credentials' => function($req) { + return in_array('Basic Test', $req->headers['Authorization'] ?? []) + ? self::ECS_CREDENTIALS_RESPONSE + : ['HTTP/1.1 403', ''] + ; + } + ]); + $credentials= (new FromEcs($conn))->credentials(); + + Assert::equals('key', $credentials->accessKey()); + Assert::equals('secret', $credentials->secretKey()->reveal()); + }); + } + + #[Test] + public function ecs_environment_variables_not_present() { + $env= ['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI' => null, 'AWS_CONTAINER_CREDENTIALS_FULL_URI' => null]; + with (new Exported($env), function() { + Assert::null((new FromEcs())->credentials()); + }); + } + + #[Test, Expect(class: IllegalStateException::class, message: '/returned unexpected/')] + public function ecs_api_error_raises_exception() { + with (new Exported(['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI' => '/get-credentials']), function() { + $conn= new TestConnection(['/get-credentials' => ['HTTP/1.1 403', '']]); + (new FromEcs($conn))->credentials(); + }); + } + + #[Test, Expect(class: IllegalStateException::class, message: '/failed/')] + public function ecs_api_failing_raises_exception() { + with (new Exported(['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI' => '/get-credentials']), function() { + $conn= new TestConnection(['/get-credentials' => function($req) { + throw new IOException('Connection failed'); + }]); + (new FromEcs($conn))->credentials(); + }); + } + + #[Test] + public function all_provider() { + $env= ['AWS_ACCESS_KEY_ID' => 'key', 'AWS_SECRET_ACCESS_KEY' => 'secret']; + with (new Exported($env), function() { + $credentials= (new CredentialProvider(new FromEnvironment()))->credentials(); + + Assert::equals('key', $credentials->accessKey()); + Assert::equals('secret', $credentials->secretKey()->reveal()); + }); + } + + #[Test] + public function chain_returns_null_when_empty() { + Assert::null((new CredentialProvider())->credentials()); + } + + #[Test] + public function chain_returns_given() { + $credentials= new Credentials('key', 'secret'); + Assert::equals($credentials, (new CredentialProvider(new FromGiven($credentials)))->credentials()); + } + + #[Test] + public function chain_returns_first_non_null() { + $env= ['AWS_ACCESS_KEY_ID' => null, 'AWS_SECRET_ACCESS_KEY' => null]; + with (new Exported($env), function() { + $credentials= new Credentials('key', 'secret'); + $chain= new CredentialProvider(new FromEnvironment(), new FromGiven($credentials)); + Assert::equals($credentials, $chain->credentials()); + }); + } + + #[Test] + public function default_provider_chain() { + $env= ['AWS_ACCESS_KEY_ID' => 'key', 'AWS_SECRET_ACCESS_KEY' => 'secret']; + with (new Exported($env), function() { + $credentials= CredentialProvider::default()->credentials(); + + Assert::equals('key', $credentials->accessKey()); + Assert::equals('secret', $credentials->secretKey()->reveal()); + }); + } + + #[Test, Expect(NoSuchElementException::class)] + public function default_provider_chain_raises() { + $env= [ + 'AWS_ACCESS_KEY_ID' => null, + 'AWS_SECRET_ACCESS_KEY' => null, + 'AWS_SHARED_CREDENTIALS_FILE' => '/file-does-not-exist', + 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI' => null, + 'AWS_CONTAINER_CREDENTIALS_FULL_URI' => null, + 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE' => null, + 'AWS_CONTAINER_AUTHORIZATION_TOKEN' => null, + ]; + with (new Exported($env), function() { + CredentialProvider::default()->credentials(); + }); + } +} \ No newline at end of file diff --git a/src/test/php/com/amazon/aws/unittest/Exported.class.php b/src/test/php/com/amazon/aws/unittest/Exported.class.php new file mode 100755 index 0000000..98ce238 --- /dev/null +++ b/src/test/php/com/amazon/aws/unittest/Exported.class.php @@ -0,0 +1,21 @@ + $value) { + $this->restore[$name]= Environment::variable($name, null); + } + Environment::export($variables); + } + + /** @return void */ + public function close() { + Environment::export($this->restore); + $this->restore= []; + } +} \ No newline at end of file From 3ec7c2789ef41a02ad4010ff9ed29da4a93390b3 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 29 Jun 2024 16:31:46 +0200 Subject: [PATCH 2/9] QA: WS --- src/main/php/com/amazon/aws/CredentialProvider.class.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/php/com/amazon/aws/CredentialProvider.class.php b/src/main/php/com/amazon/aws/CredentialProvider.class.php index a8bdfbc..c858ff4 100755 --- a/src/main/php/com/amazon/aws/CredentialProvider.class.php +++ b/src/main/php/com/amazon/aws/CredentialProvider.class.php @@ -31,11 +31,11 @@ public function credentials() { /** * Returns default credential provider chain, checking, in the following order: - * + * * 1. Environment variables * 2. Shared credentials and config files * 3. Amazon ECS container credentials - * + * * If none of the above provide credentials, an exception is raised when invoking * the `credentials()` method. * From 240bff0585e4e99f58d122190b7877807f56bb9b Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 29 Jun 2024 16:47:16 +0200 Subject: [PATCH 3/9] Add expiryTime to credentials --- .../php/com/amazon/aws/Credentials.class.php | 18 +++++++++++-- .../aws/unittest/CredentialsTest.class.php | 27 ++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/main/php/com/amazon/aws/Credentials.class.php b/src/main/php/com/amazon/aws/Credentials.class.php index 369080c..f74dd67 100755 --- a/src/main/php/com/amazon/aws/Credentials.class.php +++ b/src/main/php/com/amazon/aws/Credentials.class.php @@ -5,7 +5,7 @@ /** @test com.amazon.aws.unittest.CredentialsTest */ class Credentials implements Value { - private $accessKey, $secretKey, $sessionToken; + private $accessKey, $secretKey, $sessionToken, $expiryTime; /** * Creates a new instance @@ -13,11 +13,13 @@ class Credentials implements Value { * @param string $accessKey * @param string|util.Secret $secretKey * @param ?string $sessionToken + * @param ?int $expiryTime */ - public function __construct($accessKey, $secretKey, $sessionToken= null) { + public function __construct($accessKey, $secretKey, $sessionToken= null, $expiryTime= null) { $this->accessKey= $accessKey; $this->secretKey= $secretKey instanceof Secret ? $secretKey : new Secret($secretKey); $this->sessionToken= $sessionToken; + $this->expiryTime= $expiryTime; } /** @return string */ @@ -29,11 +31,23 @@ public function secretKey() { return $this->secretKey; } /** @return ?string */ public function sessionToken() { return $this->sessionToken; } + /** @return ?int */ + public function expiryTime() { return $this->expiryTime; } + /** @return string */ public function hashCode() { return 'C'.sha1($this->accessKey.$this->secretKey->reveal().$this->sessionToken); } + /** + * Check whether these credentials have expired + * + * @return bool + */ + public function expired() { + return null === $this->expiryTime ? false : $this->expiryTime <= time(); + } + /** @return string */ public function toString() { return sprintf( diff --git a/src/test/php/com/amazon/aws/unittest/CredentialsTest.class.php b/src/test/php/com/amazon/aws/unittest/CredentialsTest.class.php index 2d60321..7d9d925 100755 --- a/src/test/php/com/amazon/aws/unittest/CredentialsTest.class.php +++ b/src/test/php/com/amazon/aws/unittest/CredentialsTest.class.php @@ -1,7 +1,7 @@ compareTo($this)); } + + #[Test, Values([null, -1, 0, 1])] + public function expiry_time($time) { + Assert::equals($time, (new Credentials('key', 'secret', null, $time))->expiryTime()); + } + + #[Test] + public function without_expiry() { + Assert::false((new Credentials('key', 'secret'))->expired()); + } + + #[Test] + public function not_expired() { + Assert::false((new Credentials('key', 'secret', null, time() + 1))->expired()); + } + + #[Test] + public function expired_now() { + Assert::true((new Credentials('key', 'secret', null, time()))->expired()); + } + + #[Test] + public function expired_one_second_ago() { + Assert::true((new Credentials('key', 'secret', null, time() - 1))->expired()); + } } \ No newline at end of file From ee850a2cf5ff990b26af3d609dacb8bca533369f Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 29 Jun 2024 16:52:17 +0200 Subject: [PATCH 4/9] Use `Expiration` field from ECS API --- src/main/php/com/amazon/aws/Credentials.class.php | 12 ++++++------ .../php/com/amazon/aws/credentials/FromEcs.class.php | 3 ++- .../amazon/aws/unittest/CredentialsTest.class.php | 6 +++--- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/main/php/com/amazon/aws/Credentials.class.php b/src/main/php/com/amazon/aws/Credentials.class.php index f74dd67..d4e6915 100755 --- a/src/main/php/com/amazon/aws/Credentials.class.php +++ b/src/main/php/com/amazon/aws/Credentials.class.php @@ -5,7 +5,7 @@ /** @test com.amazon.aws.unittest.CredentialsTest */ class Credentials implements Value { - private $accessKey, $secretKey, $sessionToken, $expiryTime; + private $accessKey, $secretKey, $sessionToken, $expiration; /** * Creates a new instance @@ -13,13 +13,13 @@ class Credentials implements Value { * @param string $accessKey * @param string|util.Secret $secretKey * @param ?string $sessionToken - * @param ?int $expiryTime + * @param ?int|string $expiration */ - public function __construct($accessKey, $secretKey, $sessionToken= null, $expiryTime= null) { + public function __construct($accessKey, $secretKey, $sessionToken= null, $expiration= null) { $this->accessKey= $accessKey; $this->secretKey= $secretKey instanceof Secret ? $secretKey : new Secret($secretKey); $this->sessionToken= $sessionToken; - $this->expiryTime= $expiryTime; + $this->expiration= null === $expiration || is_int($expiration) ? $expiration : strtotime($expiration); } /** @return string */ @@ -32,7 +32,7 @@ public function secretKey() { return $this->secretKey; } public function sessionToken() { return $this->sessionToken; } /** @return ?int */ - public function expiryTime() { return $this->expiryTime; } + public function expiration() { return $this->expiration; } /** @return string */ public function hashCode() { @@ -45,7 +45,7 @@ public function hashCode() { * @return bool */ public function expired() { - return null === $this->expiryTime ? false : $this->expiryTime <= time(); + return null === $this->expiration ? false : $this->expiration <= time(); } /** @return string */ diff --git a/src/main/php/com/amazon/aws/credentials/FromEcs.class.php b/src/main/php/com/amazon/aws/credentials/FromEcs.class.php index 1cdc787..0df9a52 100755 --- a/src/main/php/com/amazon/aws/credentials/FromEcs.class.php +++ b/src/main/php/com/amazon/aws/credentials/FromEcs.class.php @@ -60,7 +60,8 @@ public function credentials() { return new Credentials( $credentials['AccessKeyId'], $credentials['SecretAccessKey'], - $credentials['Token'] + $credentials['Token'], + $credentials['Expiration'] ?? null ); } } \ No newline at end of file diff --git a/src/test/php/com/amazon/aws/unittest/CredentialsTest.class.php b/src/test/php/com/amazon/aws/unittest/CredentialsTest.class.php index 7d9d925..9a87aa1 100755 --- a/src/test/php/com/amazon/aws/unittest/CredentialsTest.class.php +++ b/src/test/php/com/amazon/aws/unittest/CredentialsTest.class.php @@ -46,12 +46,12 @@ public function compare_to_another_object() { } #[Test, Values([null, -1, 0, 1])] - public function expiry_time($time) { - Assert::equals($time, (new Credentials('key', 'secret', null, $time))->expiryTime()); + public function expiration($time) { + Assert::equals($time, (new Credentials('key', 'secret', null, $time))->expiration()); } #[Test] - public function without_expiry() { + public function without_expiration() { Assert::false((new Credentials('key', 'secret'))->expired()); } From 728ba42c6b27a8dd3a98a045e4aa476c120a2c44 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 29 Jun 2024 21:45:20 +0200 Subject: [PATCH 5/9] Add credentials caching for ECS & config providers --- .../php/com/amazon/aws/Credentials.class.php | 2 +- .../aws/credentials/FromConfig.class.php | 32 +++-- .../amazon/aws/credentials/FromEcs.class.php | 14 +- .../unittest/CredentialProviderTest.class.php | 136 +++++++++++++----- 4 files changed, 132 insertions(+), 52 deletions(-) diff --git a/src/main/php/com/amazon/aws/Credentials.class.php b/src/main/php/com/amazon/aws/Credentials.class.php index d4e6915..4b76a3f 100755 --- a/src/main/php/com/amazon/aws/Credentials.class.php +++ b/src/main/php/com/amazon/aws/Credentials.class.php @@ -45,7 +45,7 @@ public function hashCode() { * @return bool */ public function expired() { - return null === $this->expiration ? false : $this->expiration <= time(); + return null !== $this->expiration && $this->expiration <= time(); } /** @return string */ diff --git a/src/main/php/com/amazon/aws/credentials/FromConfig.class.php b/src/main/php/com/amazon/aws/credentials/FromConfig.class.php index 48df247..5934eab 100755 --- a/src/main/php/com/amazon/aws/credentials/FromConfig.class.php +++ b/src/main/php/com/amazon/aws/credentials/FromConfig.class.php @@ -14,6 +14,8 @@ */ class FromConfig implements Provider { private $file, $profile; + private $modified= null; + private $credentials; /** * Creates a new configuration source. Checks the `AWS_SHARED_CREDENTIALS_FILE` @@ -40,17 +42,23 @@ public function __construct($file= null, $profile= null) { /** @return ?com.amazon.aws.Credentials */ public function credentials() { - if (!$this->file->exists()) return null; - - // Either check "profile [...]" or the default section - $config= parse_ini_file($this->file->getURI(), true, INI_SCANNER_RAW); - $section= $config['default' === $this->profile ? 'default' : "profile {$this->profile}"] ?? null; - if (null === $section) return null; - - return new Credentials( - $section['aws_access_key_id'], - new Secret($section['aws_secret_access_key']), - $section['aws_session_token'] ?? null - ); + if (!$this->file->exists()) return $this->credentials= null; + + // Only read the underlying file if its modification time has changed + $modified= $this->file->lastModified(); + if ($modified >= $this->modified) { + $this->modified= $modified; + + // Either check "profile [...]" or the default section + $config= parse_ini_file($this->file->getURI(), true, INI_SCANNER_RAW); + $section= $config['default' === $this->profile ? 'default' : "profile {$this->profile}"] ?? null; + + $this->credentials= null === $section ? null : new Credentials( + $section['aws_access_key_id'], + new Secret($section['aws_secret_access_key']), + $section['aws_session_token'] ?? null + ); + } + return $this->credentials; } } \ No newline at end of file diff --git a/src/main/php/com/amazon/aws/credentials/FromEcs.class.php b/src/main/php/com/amazon/aws/credentials/FromEcs.class.php index 0df9a52..6304cba 100755 --- a/src/main/php/com/amazon/aws/credentials/FromEcs.class.php +++ b/src/main/php/com/amazon/aws/credentials/FromEcs.class.php @@ -20,6 +20,7 @@ class FromEcs implements Provider { const DEFAULT_HOST= 'http://169.254.170.2'; private $conn; + private $credentials= null; /** @param ?peer.HttpConnection $conn */ public function __construct($conn= null) { @@ -28,18 +29,19 @@ public function __construct($conn= null) { /** @return ?com.amazon.aws.Credentials */ public function credentials() { - $req= $this->conn->create(new HttpRequest()); + if (null !== $this->credentials && !$this->credentials->expired()) return $this->credentials; // Check AWS_CONTAINER_CREDENTIALS_* if (null !== ($relative= Environment::variable('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI', null))) { + $req= $this->conn->create(new HttpRequest()); $req->setTarget($relative); } else if (null !== ($uri= Environment::variable('AWS_CONTAINER_CREDENTIALS_FULL_URI', null))) { - $req->setUrl(new URL($uri)); + $req= new HttpRequest(new URL($uri)); } else { - return null; + return $this->credentials= null; } - // Append authorizatio from AWS_CONTAINER_AUTHORIZATION_TOKEN_*, if existant + // Append authorization from AWS_CONTAINER_AUTHORIZATION_TOKEN_*, if existant if (null !== ($file= Environment::variable('AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE', null))) { $req->setHeader('Authorization', rtrim(file_get_contents($file), "\r\n")); } else if (null !== ($token= Environment::variable('AWS_CONTAINER_AUTHORIZATION_TOKEN', null))) { @@ -57,10 +59,10 @@ public function credentials() { } $credentials= Json::read(new StreamInput($res->in())); - return new Credentials( + return $this->credentials= new Credentials( $credentials['AccessKeyId'], $credentials['SecretAccessKey'], - $credentials['Token'], + $credentials['Token'] ?? null, $credentials['Expiration'] ?? null ); } diff --git a/src/test/php/com/amazon/aws/unittest/CredentialProviderTest.class.php b/src/test/php/com/amazon/aws/unittest/CredentialProviderTest.class.php index 286ac33..75295ae 100755 --- a/src/test/php/com/amazon/aws/unittest/CredentialProviderTest.class.php +++ b/src/test/php/com/amazon/aws/unittest/CredentialProviderTest.class.php @@ -1,6 +1,6 @@ credentials()); } + #[Test] + public function from_config_cached() { + $file= (new TempFile())->containing( + "[default]\n". + "aws_access_key_id = key\n". + "aws_secret_access_key = secret\n" + ); + $provider= new FromConfig($file); + $first= $provider->credentials(); + $second= $provider->credentials(); + + Assert::equals($first, $second); + } + + #[Test] + public function from_config_cache_checks_for_modifications() { + $file= (new TempFile())->containing( + "[default]\n". + "aws_access_key_id = key\n". + "aws_secret_access_key = secret\n" + ); + $provider= new FromConfig($file); + $first= $provider->credentials(); + + $file->containing( + "[default]\n". + "aws_access_key_id = modifed\n". + "aws_secret_access_key = secret\n" + ); + $second= $provider->credentials(); + + Assert::notEquals($first, $second); + } + #[Test, Values([['/get-credentials', null], [null, 'http://localhost/get-credentials']])] public function ecs_api($relative, $full) { $env= [ @@ -143,11 +187,25 @@ public function ecs_api($relative, $full) { 'AWS_CONTAINER_CREDENTIALS_FULL_URI' => $full, ]; with (new Exported($env), function() { - $conn= new TestConnection(['/get-credentials' => self::ECS_CREDENTIALS_RESPONSE]); + $conn= new TestConnection(['/get-credentials' => $this->ecsCredentials()]); $credentials= (new FromEcs($conn))->credentials(); Assert::equals('key', $credentials->accessKey()); Assert::equals('secret', $credentials->secretKey()->reveal()); + Assert::equals('session', $credentials->sessionToken()); + }); + } + + #[Test, Values([['null', null], ['"session"', 'session']])] + public function ecs_api_with($token, $session) { + $env= ['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI' => '/get-credentials']; + with (new Exported($env), function() use($token, $session) { + $conn= new TestConnection(['/get-credentials' => $this->ecsCredentials(0, $token)]); + $credentials= (new FromEcs($conn))->credentials(); + + Assert::equals('key', $credentials->accessKey()); + Assert::equals('secret', $credentials->secretKey()->reveal()); + Assert::equals($session, $credentials->sessionToken()); }); } @@ -164,15 +222,12 @@ public function ecs_api_with_authorization_file($contents) { $conn= new TestConnection([ '/get-credentials' => function($req) { return in_array('Basic Test', $req->headers['Authorization'] ?? []) - ? self::ECS_CREDENTIALS_RESPONSE + ? $this->ecsCredentials() : ['HTTP/1.1 403', ''] ; } ]); - $credentials= (new FromEcs($conn))->credentials(); - - Assert::equals('key', $credentials->accessKey()); - Assert::equals('secret', $credentials->secretKey()->reveal()); + Assert::equals('key', (new FromEcs($conn))->credentials()->accessKey()); }); } @@ -188,15 +243,13 @@ public function ecs_api_with_authorization() { $conn= new TestConnection([ '/get-credentials' => function($req) { return in_array('Basic Test', $req->headers['Authorization'] ?? []) - ? self::ECS_CREDENTIALS_RESPONSE + ? $this->ecsCredentials() : ['HTTP/1.1 403', ''] ; } ]); - $credentials= (new FromEcs($conn))->credentials(); - Assert::equals('key', $credentials->accessKey()); - Assert::equals('secret', $credentials->secretKey()->reveal()); + Assert::equals('key', (new FromEcs($conn))->credentials()->accessKey()); }); } @@ -227,13 +280,33 @@ public function ecs_api_failing_raises_exception() { } #[Test] - public function all_provider() { - $env= ['AWS_ACCESS_KEY_ID' => 'key', 'AWS_SECRET_ACCESS_KEY' => 'secret']; - with (new Exported($env), function() { - $credentials= (new CredentialProvider(new FromEnvironment()))->credentials(); + public function ecs_api_cached() { + with (new Exported(['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI' => '/get-credentials']), function() { + $conn= new TestConnection(['/get-credentials' => function() { + static $times= 0; + return 0 === $times++ ? $this->ecsCredentials() : ['HTTP/1.1 500', '']; + }]); + $provider= new FromEcs($conn); + $first= $provider->credentials(); + $second= $provider->credentials(); - Assert::equals('key', $credentials->accessKey()); - Assert::equals('secret', $credentials->secretKey()->reveal()); + Assert::equals($first, $second); + }); + } + + #[Test] + public function ecs_api_cache_checks_for_expiration() { + with (new Exported(['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI' => '/get-credentials']), function() { + $conn= new TestConnection(['/get-credentials' => function() { + static $times= 0; + return 0 === $times++ ? $this->ecsCredentials(-1) : ['HTTP/1.1 500', '']; + }]); + $provider= new FromEcs($conn); + $provider->credentials(); + + Assert::throws(IllegalStateException::class, function() use($provider) { + $provider->credentials(); + }); }); } @@ -262,15 +335,12 @@ public function chain_returns_first_non_null() { public function default_provider_chain() { $env= ['AWS_ACCESS_KEY_ID' => 'key', 'AWS_SECRET_ACCESS_KEY' => 'secret']; with (new Exported($env), function() { - $credentials= CredentialProvider::default()->credentials(); - - Assert::equals('key', $credentials->accessKey()); - Assert::equals('secret', $credentials->secretKey()->reveal()); + Assert::equals('key', CredentialProvider::default()->credentials()->accessKey()); }); } #[Test, Expect(NoSuchElementException::class)] - public function default_provider_chain_raises() { + public function default_provider_chain_raises_when_no_credentials_are_provided() { $env= [ 'AWS_ACCESS_KEY_ID' => null, 'AWS_SECRET_ACCESS_KEY' => null, From 2624d2a65e9dec2f0469060d760e3401b48207bd Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 29 Jun 2024 21:46:37 +0200 Subject: [PATCH 6/9] Add type hint to CredentialProvider::default() --- src/main/php/com/amazon/aws/CredentialProvider.class.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/php/com/amazon/aws/CredentialProvider.class.php b/src/main/php/com/amazon/aws/CredentialProvider.class.php index c858ff4..b9d499e 100755 --- a/src/main/php/com/amazon/aws/CredentialProvider.class.php +++ b/src/main/php/com/amazon/aws/CredentialProvider.class.php @@ -40,9 +40,8 @@ public function credentials() { * the `credentials()` method. * * @see https://docs.aws.amazon.com/sdk-for-kotlin/latest/developer-guide/credential-providers.html - * @return com.amazon.aws.credentials.Provider */ - public static function default() { + public static function default(): Provider { return new self( new FromEnvironment(), new FromConfig(), From 9501ec842d5741d250f4de97c067f8bbd5f565f5 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 29 Jun 2024 21:50:27 +0200 Subject: [PATCH 7/9] Adjust size to reality (before: 51kB, after: 80kB = a little more than 2%) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b43b019..854c518 100755 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ AWS Core for the XP Framework [![Supports PHP 8.0+](https://raw.githubusercontent.com/xp-framework/web/master/static/php-8_0plus.svg)](http://php.net/) [![Latest Stable Version](https://poser.pugx.org/xp-forge/aws/version.png)](https://packagist.org/packages/xp-forge/aws) -Provides common AWS functionality in a low-level and therefore lightweight library (*less than 1% of the size of the official PHP SDK!*) +Provides common AWS functionality in a low-level and therefore lightweight library (*less than 3% of the size of the official PHP SDK!*) Invoking a lambda ----------------- From 415b07adad41b1c0c7313e4e0e83121223db69e9 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 29 Jun 2024 22:31:28 +0200 Subject: [PATCH 8/9] Pass user agent to container credentials service --- .../php/com/amazon/aws/credentials/FromEcs.class.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/php/com/amazon/aws/credentials/FromEcs.class.php b/src/main/php/com/amazon/aws/credentials/FromEcs.class.php index 6304cba..e424ca8 100755 --- a/src/main/php/com/amazon/aws/credentials/FromEcs.class.php +++ b/src/main/php/com/amazon/aws/credentials/FromEcs.class.php @@ -19,12 +19,18 @@ class FromEcs implements Provider { const DEFAULT_HOST= 'http://169.254.170.2'; - private $conn; + private $conn, $userAgent; private $credentials= null; /** @param ?peer.HttpConnection $conn */ public function __construct($conn= null) { $this->conn= $conn ?? new HttpConnection(self::DEFAULT_HOST); + $this->userAgent= sprintf( + 'xp-aws/1.0.0 OS/%s/%s lang/php/%s', + php_uname('s'), + php_uname('r'), + PHP_VERSION + ); } /** @return ?com.amazon.aws.Credentials */ @@ -48,6 +54,7 @@ public function credentials() { $req->setHeader('Authorization', $token); } + $req->setHeader('User-Agent', $this->userAgent); try { $res= $this->conn->send($req); } catch (Throwable $t) { From 7da226a6640343d3b055dc44c9026f2496f8580b Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 29 Jun 2024 22:51:10 +0200 Subject: [PATCH 9/9] QA: Simplify chain_returns_first_non_null test --- .../aws/unittest/CredentialProviderTest.class.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/test/php/com/amazon/aws/unittest/CredentialProviderTest.class.php b/src/test/php/com/amazon/aws/unittest/CredentialProviderTest.class.php index 75295ae..7bb1633 100755 --- a/src/test/php/com/amazon/aws/unittest/CredentialProviderTest.class.php +++ b/src/test/php/com/amazon/aws/unittest/CredentialProviderTest.class.php @@ -323,12 +323,13 @@ public function chain_returns_given() { #[Test] public function chain_returns_first_non_null() { - $env= ['AWS_ACCESS_KEY_ID' => null, 'AWS_SECRET_ACCESS_KEY' => null]; - with (new Exported($env), function() { - $credentials= new Credentials('key', 'secret'); - $chain= new CredentialProvider(new FromEnvironment(), new FromGiven($credentials)); - Assert::equals($credentials, $chain->credentials()); - }); + $credentials= new Credentials('key', 'secret'); + $chain= new CredentialProvider( + new class() implements Provider { public function credentials() { return null; } }, + new FromGiven($credentials) + ); + + Assert::equals($credentials, $chain->credentials()); } #[Test]