From 39b12529e76625bcde7b32241fa0d3baaf15392c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20M=C3=B6nch?= Date: Mon, 10 Jul 2023 14:08:34 +0200 Subject: [PATCH] adds support for sso credentials --- README.md | 1 + composer.json | 2 + couscous.yml | 3 + docs/clients/index.md | 1 + docs/clients/sso.md | 29 ++++ manifest.json | 9 ++ src/Core/CHANGELOG.md | 1 + src/Core/src/AwsClientFactory.php | 10 ++ src/Core/src/Credentials/IniFileLoader.php | 4 + src/Core/src/Credentials/IniFileProvider.php | 74 ++++++++++ .../src/Credentials/SsoCacheFileLoader.php | 79 ++++++++++ .../Input/GetRoleCredentialsRequestTest.php | 32 ++++ .../Result/GetRoleCredentialsResponseTest.php | 28 ++++ .../AwsPackagesProvider.php | 4 + src/Service/Sso/.gitattributes | 5 + src/Service/Sso/.github/FUNDING.yml | 3 + .../Sso/.github/workflows/.editorconfig | 2 + .../Sso/.github/workflows/branch_alias.yml | 76 ++++++++++ src/Service/Sso/.github/workflows/checks.yml | 38 +++++ src/Service/Sso/.github/workflows/ci.yml | 38 +++++ src/Service/Sso/.gitignore | 3 + src/Service/Sso/CHANGELOG.md | 7 + src/Service/Sso/LICENSE | 21 +++ src/Service/Sso/Makefile | 12 ++ src/Service/Sso/README.md | 20 +++ src/Service/Sso/composer.json | 32 ++++ src/Service/Sso/phpunit.xml.dist | 23 +++ .../src/Exception/InvalidRequestException.php | 13 ++ .../Exception/ResourceNotFoundException.php | 12 ++ .../Exception/TooManyRequestsException.php | 12 ++ .../src/Exception/UnauthorizedException.php | 12 ++ .../src/Input/GetRoleCredentialsRequest.php | 139 ++++++++++++++++++ .../src/Result/GetRoleCredentialsResponse.php | 41 ++++++ src/Service/Sso/src/SsoClient.php | 97 ++++++++++++ .../Sso/src/ValueObject/RoleCredentials.php | 95 ++++++++++++ src/Service/Sso/tests/.gitignore | 0 .../Sso/tests/Integration/SsoClientTest.php | 36 +++++ .../Input/GetRoleCredentialsRequestTest.php | 32 ++++ .../Result/GetRoleCredentialsResponseTest.php | 28 ++++ src/Service/Sso/tests/Unit/SsoClientTest.php | 28 ++++ 40 files changed, 1102 insertions(+) create mode 100644 docs/clients/sso.md create mode 100644 src/Core/src/Credentials/SsoCacheFileLoader.php create mode 100644 src/Core/tests/Unit/Input/GetRoleCredentialsRequestTest.php create mode 100644 src/Core/tests/Unit/Result/GetRoleCredentialsResponseTest.php create mode 100644 src/Service/Sso/.gitattributes create mode 100644 src/Service/Sso/.github/FUNDING.yml create mode 100644 src/Service/Sso/.github/workflows/.editorconfig create mode 100644 src/Service/Sso/.github/workflows/branch_alias.yml create mode 100644 src/Service/Sso/.github/workflows/checks.yml create mode 100644 src/Service/Sso/.github/workflows/ci.yml create mode 100644 src/Service/Sso/.gitignore create mode 100644 src/Service/Sso/CHANGELOG.md create mode 100644 src/Service/Sso/LICENSE create mode 100644 src/Service/Sso/Makefile create mode 100644 src/Service/Sso/README.md create mode 100644 src/Service/Sso/composer.json create mode 100644 src/Service/Sso/phpunit.xml.dist create mode 100644 src/Service/Sso/src/Exception/InvalidRequestException.php create mode 100644 src/Service/Sso/src/Exception/ResourceNotFoundException.php create mode 100644 src/Service/Sso/src/Exception/TooManyRequestsException.php create mode 100644 src/Service/Sso/src/Exception/UnauthorizedException.php create mode 100644 src/Service/Sso/src/Input/GetRoleCredentialsRequest.php create mode 100644 src/Service/Sso/src/Result/GetRoleCredentialsResponse.php create mode 100644 src/Service/Sso/src/SsoClient.php create mode 100644 src/Service/Sso/src/ValueObject/RoleCredentials.php create mode 100644 src/Service/Sso/tests/.gitignore create mode 100644 src/Service/Sso/tests/Integration/SsoClientTest.php create mode 100644 src/Service/Sso/tests/Unit/Input/GetRoleCredentialsRequestTest.php create mode 100644 src/Service/Sso/tests/Unit/Result/GetRoleCredentialsResponseTest.php create mode 100644 src/Service/Sso/tests/Unit/SsoClientTest.php diff --git a/README.md b/README.md index 737366093..551472949 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ See full documentation on [https://async-aws.com](https://async-aws.com). | [async-aws/sns](https://github.com/async-aws/sns) | [![Latest Stable Version](https://poser.pugx.org/async-aws/sns/v/stable)](https://packagist.org/packages/async-aws/sns) [![Total Downloads](https://poser.pugx.org/async-aws/sns/downloads)](https://packagist.org/packages/async-aws/sns) | [![](https://github.com/async-aws/sns/workflows/BC%20Check/badge.svg?branch=master)](https://github.com/async-aws/sns/actions) | [![](https://async-aws-pr.github.io/commits-since-release-counter/sns.svg)](https://github.com/async-aws/sns/releases) | | [async-aws/sqs](https://github.com/async-aws/sqs) | [![Latest Stable Version](https://poser.pugx.org/async-aws/sqs/v/stable)](https://packagist.org/packages/async-aws/sqs) [![Total Downloads](https://poser.pugx.org/async-aws/sqs/downloads)](https://packagist.org/packages/async-aws/sqs) | [![](https://github.com/async-aws/sqs/workflows/BC%20Check/badge.svg?branch=master)](https://github.com/async-aws/sqs/actions) | [![](https://async-aws-pr.github.io/commits-since-release-counter/sqs.svg)](https://github.com/async-aws/sqs/releases) | | [async-aws/ssm](https://github.com/async-aws/ssm) | [![Latest Stable Version](https://poser.pugx.org/async-aws/ssm/v/stable)](https://packagist.org/packages/async-aws/ssm) [![Total Downloads](https://poser.pugx.org/async-aws/ssm/downloads)](https://packagist.org/packages/async-aws/ssm) | [![](https://github.com/async-aws/ssm/workflows/BC%20Check/badge.svg?branch=master)](https://github.com/async-aws/ssm/actions) | [![](https://async-aws-pr.github.io/commits-since-release-counter/ssm.svg)](https://github.com/async-aws/ssm/releases) | +| [async-aws/sso](https://github.com/async-aws/sso) | [![Latest Stable Version](https://poser.pugx.org/async-aws/sso/v/stable)](https://packagist.org/packages/async-aws/sso) [![Total Downloads](https://poser.pugx.org/async-aws/sso/downloads)](https://packagist.org/packages/async-aws/sso) | [![](https://github.com/async-aws/sso/workflows/BC%20Check/badge.svg?branch=master)](https://github.com/async-aws/sso/actions) | [![](https://async-aws-pr.github.io/commits-since-release-counter/sso.svg)](https://github.com/async-aws/sso/releases) | | [async-aws/step-functions](https://github.com/async-aws/step-functions) | [![Latest Stable Version](https://poser.pugx.org/async-aws/step-functions/v/stable)](https://packagist.org/packages/async-aws/step-functions) [![Total Downloads](https://poser.pugx.org/async-aws/step-functions/downloads)](https://packagist.org/packages/async-aws/step-functions) | [![](https://github.com/async-aws/step-functions/workflows/BC%20Check/badge.svg?branch=master)](https://github.com/async-aws/step-functions/actions) | [![](https://async-aws-pr.github.io/commits-since-release-counter/step-functions.svg)](https://github.com/async-aws/step-functions/releases) | | [async-aws/timestream-query](https://github.com/async-aws/timestream-query) | [![Latest Stable Version](https://poser.pugx.org/async-aws/timestream-query/v/stable)](https://packagist.org/packages/async-aws/timestream-query) [![Total Downloads](https://poser.pugx.org/async-aws/timestream-query/downloads)](https://packagist.org/packages/async-aws/timestream-query) | [![](https://github.com/async-aws/timestream-query/workflows/BC%20Check/badge.svg?branch=master)](https://github.com/async-aws/timestream-query/actions) | [![](https://async-aws-pr.github.io/commits-since-release-counter/timestream-query.svg)](https://github.com/async-aws/timestream-query/releases) | | [async-aws/timestream-write](https://github.com/async-aws/timestream-write) | [![Latest Stable Version](https://poser.pugx.org/async-aws/timestream-write/v/stable)](https://packagist.org/packages/async-aws/timestream-write) [![Total Downloads](https://poser.pugx.org/async-aws/timestream-write/downloads)](https://packagist.org/packages/async-aws/timestream-write) | [![](https://github.com/async-aws/timestream-write/workflows/BC%20Check/badge.svg?branch=master)](https://github.com/async-aws/timestream-write/actions) | [![](https://async-aws-pr.github.io/commits-since-release-counter/timestream-write.svg)](https://github.com/async-aws/timestream-write/releases) | diff --git a/composer.json b/composer.json index 7177664f3..87c68be98 100644 --- a/composer.json +++ b/composer.json @@ -94,6 +94,7 @@ "AsyncAws\\Sns\\": "src/Service/Sns/src", "AsyncAws\\Sqs\\": "src/Service/Sqs/src", "AsyncAws\\Ssm\\": "src/Service/Ssm/src", + "AsyncAws\\Sso\\": "src/Service/Sso/src", "AsyncAws\\StepFunctions\\": "src/Service/StepFunctions/src", "AsyncAws\\Symfony\\Bundle\\": "src/Integration/Symfony/Bundle/src", "AsyncAws\\TimestreamQuery\\": "src/Service/TimestreamQuery/src", @@ -147,6 +148,7 @@ "AsyncAws\\Sns\\Tests\\": "src/Service/Sns/tests", "AsyncAws\\Sqs\\Tests\\": "src/Service/Sqs/tests", "AsyncAws\\Ssm\\Tests\\": "src/Service/Ssm/tests", + "AsyncAws\\Sso\\Tests\\": "src/Service/Sso/tests", "AsyncAws\\StepFunctions\\Tests\\": "src/Service/StepFunctions/tests", "AsyncAws\\Symfony\\Bundle\\Tests\\": "src/Integration/Symfony/Bundle/tests", "AsyncAws\\Test\\": "tests", diff --git a/couscous.yml b/couscous.yml index 5c84f0099..1b8866e17 100644 --- a/couscous.yml +++ b/couscous.yml @@ -133,6 +133,9 @@ menu: ssm: text: SSM url: /clients/ssm.html + sso: + text: SSO + url: /clients/sso.html sts: text: STS url: /clients/sts.html diff --git a/docs/clients/index.md b/docs/clients/index.md index 1ddb38129..cf8d8629e 100644 --- a/docs/clients/index.md +++ b/docs/clients/index.md @@ -171,6 +171,7 @@ for more information. | [SNS](./sns.md) | [async-aws/sns](https://packagist.org/packages/async-aws/sns) | | [SQS](./sqs.md) | [async-aws/sqs](https://packagist.org/packages/async-aws/sqs) | | [SSM](./ssm.md) | [async-aws/ssm](https://packagist.org/packages/async-aws/ssm) | +| [SSO](./sso.md) | [async-aws/sso](https://packagist.org/packages/async-aws/sso) | | [STS](./sts.md) | [async-aws/core](https://packagist.org/packages/async-aws/core) | | [StepFunctions](./step-functions.md) | [async-aws/step-functions](https://packagist.org/packages/async-aws/step-functions) | | [TimestreamQuery](./timestream-query.md) | [async-aws/timestream-query](https://packagist.org/packages/async-aws/timestream-query) | diff --git a/docs/clients/sso.md b/docs/clients/sso.md new file mode 100644 index 000000000..e43d8de10 --- /dev/null +++ b/docs/clients/sso.md @@ -0,0 +1,29 @@ +--- +layout: client +category: clients +name: SSO +package: async-aws/sso +fqcn: AsyncAws\Sso\SsoClient +--- + +## Usage + +### Retrieve role credentials + +```php +use AsyncAws\Sso\Input\GetRoleCredentialsRequest; +use AsyncAws\Sso\SsoClient; + +$client = new SsoClient(); + +$result = $client->getRoleCredentials(new GetRoleCredentialsRequest([ + 'roleName' => 'YourRoleName', + 'accountId' => 'YourAccountId', + 'accessToken' => 'YourAccessToken', +])); + +echo 'AccessKeyId:' . $result->getRoleCredentials()->getAccessKeyId().PHP_EOL; +echo 'Expiration:' . $result->getRoleCredentials()->getExpiration().PHP_EOL; +echo 'SecretAccessKey:' . $result->getRoleCredentials()->getSecretAccessKey().PHP_EOL; +echo 'SessionToken:' . $result->getRoleCredentials()->getSessionToken(); +``` diff --git a/manifest.json b/manifest.json index cebd429ad..56634b79a 100644 --- a/manifest.json +++ b/manifest.json @@ -713,6 +713,15 @@ "PutParameter" ] }, + "Sso": { + "source": "https://raw.githubusercontent.com/aws/aws-sdk-php/${LATEST}/src/data/sso/2019-06-10/api-2.json", + "documentation": "https://raw.githubusercontent.com/aws/aws-sdk-php/${LATEST}/src/data/sso/2019-06-10/docs-2.json", + "pagination": "https://raw.githubusercontent.com/aws/aws-sdk-php/${LATEST}/src/data/sso/2019-06-10/paginators-1.json", + "api-reference": "https://docs.aws.amazon.com/singlesignon/latest/PortalAPIReference", + "methods": [ + "GetRoleCredentials" + ] + }, "Sts": { "source": "https://raw.githubusercontent.com/aws/aws-sdk-php/${LATEST}/src/data/sts/2011-06-15/api-2.json", "documentation": "https://raw.githubusercontent.com/aws/aws-sdk-php/${LATEST}/src/data/sts/2011-06-15/docs-2.json", diff --git a/src/Core/CHANGELOG.md b/src/Core/CHANGELOG.md index ff6bd0ef5..f162ad422 100644 --- a/src/Core/CHANGELOG.md +++ b/src/Core/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Support for LocationService +- Support for SSO credentials ## 1.19.0 diff --git a/src/Core/src/AwsClientFactory.php b/src/Core/src/AwsClientFactory.php index d0c7a3ab3..5ff2b0981 100644 --- a/src/Core/src/AwsClientFactory.php +++ b/src/Core/src/AwsClientFactory.php @@ -44,6 +44,7 @@ use AsyncAws\Sns\SnsClient; use AsyncAws\Sqs\SqsClient; use AsyncAws\Ssm\SsmClient; +use AsyncAws\Sso\SsoClient; use AsyncAws\StepFunctions\StepFunctionsClient; use AsyncAws\TimestreamQuery\TimestreamQueryClient; use AsyncAws\TimestreamWrite\TimestreamWriteClient; @@ -519,6 +520,15 @@ public function ssm(): SsmClient return $this->serviceCache[__METHOD__]; } + public function sso(): SsoClient + { + if (!isset($this->serviceCache[__METHOD__])) { + $this->serviceCache[__METHOD__] = new SsoClient($this->configuration, $this->credentialProvider, $this->httpClient, $this->logger); + } + + return $this->serviceCache[__METHOD__]; + } + public function sts(): StsClient { if (!isset($this->serviceCache[__METHOD__])) { diff --git a/src/Core/src/Credentials/IniFileLoader.php b/src/Core/src/Credentials/IniFileLoader.php index 0148ad98a..df45ed73b 100644 --- a/src/Core/src/Credentials/IniFileLoader.php +++ b/src/Core/src/Credentials/IniFileLoader.php @@ -23,6 +23,10 @@ final class IniFileLoader public const KEY_ROLE_SESSION_NAME = 'role_session_name'; public const KEY_SOURCE_PROFILE = 'source_profile'; public const KEY_WEB_IDENTITY_TOKEN_FILE = 'web_identity_token_file'; + public const KEY_SSO_START_URL = 'sso_start_url'; + public const KEY_SSO_REGION = 'sso_region'; + public const KEY_SSO_ACCOUNT_ID = 'sso_account_id'; + public const KEY_SSO_ROLE_NAME = 'sso_role_name'; /** * @var LoggerInterface diff --git a/src/Core/src/Credentials/IniFileProvider.php b/src/Core/src/Credentials/IniFileProvider.php index 5f9a84f7d..9941a5b18 100644 --- a/src/Core/src/Credentials/IniFileProvider.php +++ b/src/Core/src/Credentials/IniFileProvider.php @@ -7,6 +7,7 @@ use AsyncAws\Core\Configuration; use AsyncAws\Core\Exception\RuntimeException; use AsyncAws\Core\Sts\StsClient; +use AsyncAws\Sso\SsoClient; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -92,6 +93,15 @@ private function getCredentialsFromProfile(array $profilesData, string $profile, return $this->getCredentialsFromRole($profilesData, $profileData, $profile, $circularCollector); } + $existsSsoClient = class_exists(SsoClient::class); + $hasProfileSsoLegacyConfig = isset($profileData[IniFileLoader::KEY_SSO_START_URL]); + + if (!$existsSsoClient && $hasProfileSsoLegacyConfig) { + $this->logger->warning('Profile "{profile}" contains SSO (legacy) config but "async-aws/sso" client is not installed.', ['profile' => $profile]); + } elseif ($existsSsoClient && $hasProfileSsoLegacyConfig) { + return $this->getCredentialsFromLegacySso($profileData, $profile); + } + $this->logger->info('No credentials found for profile "{profile}".', ['profile' => $profile]); return null; @@ -146,4 +156,68 @@ private function getCredentialsFromRole(array $profilesData, array $profileData, Credentials::adjustExpireDate($credentials->getExpiration(), $this->getDateFromResult($result)) ); } + + /** + * @param array $profileData + */ + private function getCredentialsFromLegacySso(array $profileData, string $profile): ?Credentials + { + if (!isset( + $profileData[IniFileLoader::KEY_SSO_START_URL], + $profileData[IniFileLoader::KEY_SSO_REGION], + $profileData[IniFileLoader::KEY_SSO_ACCOUNT_ID], + $profileData[IniFileLoader::KEY_SSO_ROLE_NAME] + )) { + $this->logger->warning('Profile "{profile}" does not contains require legacy SSO config.', ['profile' => $profile]); + + return null; + } + + $ssoCacheFileLoader = new SsoCacheFileLoader($this->logger); + $tokenData = $ssoCacheFileLoader->loadSsoCacheFile($profileData[IniFileLoader::KEY_SSO_START_URL]); + + if ([] === $tokenData) { + return null; + } + + $ssoClient = new SsoClient( + ['region' => $profileData[IniFileLoader::KEY_SSO_REGION]], + null, + $this->httpClient + ); + $result = $ssoClient->getRoleCredentials([ + 'accessToken' => $tokenData[SsoCacheFileLoader::KEY_ACCESS_TOKEN], + 'accountId' => $profileData[IniFileLoader::KEY_SSO_ACCOUNT_ID], + 'roleName' => $profileData[IniFileLoader::KEY_SSO_ROLE_NAME], + ]); + + try { + if (null === $credentials = $result->getRoleCredentials()) { + throw new RuntimeException('The RoleCredentials response does not contains credentials'); + } + if (null === $accessKeyId = $credentials->getAccessKeyId()) { + throw new RuntimeException('The RoleCredentials response does not contain an accessKeyId'); + } + if (null === $secretAccessKey = $credentials->getSecretAccessKey()) { + throw new RuntimeException('The RoleCredentials response does not contain a secretAccessKey'); + } + if (null === $sessionToken = $credentials->getSessionToken()) { + throw new RuntimeException('The RoleCredentials response does not contain a sessionToken'); + } + if (null === $expiration = $credentials->getExpiration()) { + throw new RuntimeException('The RoleCredentials response does not contain an expiration'); + } + } catch (\Exception $e) { + $this->logger->warning('Failed to get credentials from role credentials in profile "{profile}: {exception}".', ['profile' => $profile, 'exception' => $e]); + + return null; + } + + return new Credentials( + $accessKeyId, + $secretAccessKey, + $sessionToken, + (new \DateTimeImmutable())->setTimestamp($expiration) + ); + } } diff --git a/src/Core/src/Credentials/SsoCacheFileLoader.php b/src/Core/src/Credentials/SsoCacheFileLoader.php new file mode 100644 index 000000000..df2d00ccb --- /dev/null +++ b/src/Core/src/Credentials/SsoCacheFileLoader.php @@ -0,0 +1,79 @@ +logger = $logger ?? new NullLogger(); + } + + /** + * @return array + */ + public function loadSsoCacheFile(string $ssoStartUrl): array + { + $filepath = sprintf('%s/.aws/sso/cache/%s.json', $this->getHomeDir(), sha1($ssoStartUrl)); + + if (!@is_readable($filepath)) { + $this->logger->warning('The sso cache file {path} is not readable.', ['path' => $filepath]); + + return []; + } + + $tokenData = json_decode(file_get_contents($filepath), true); + if (!isset($tokenData[self::KEY_ACCESS_TOKEN], $tokenData[self::KEY_EXPIRES_AT])) { + $this->logger->warning('Token file at {path} must contain an accessToken and an expiresAt.', ['path' => $filepath]); + + return []; + } + + try { + $expiration = (new \DateTimeImmutable($tokenData[self::KEY_EXPIRES_AT])); + } catch (\Exception $e) { + $this->logger->warning('Cached SSO credentials returned an invalid expiresAt value.'); + + return []; + } + + if ($expiration < new \DateTimeImmutable()) { + $this->logger->warning('Cached SSO credentials returned an invalid expiresAt value.'); + + return []; + } + + return $tokenData; + } + + private function getHomeDir(): string + { + // On Linux/Unix-like systems, use the HOME environment variable + if (null !== $homeDir = EnvVar::get('HOME')) { + return $homeDir; + } + + // Get the HOMEDRIVE and HOMEPATH values for Windows hosts + $homeDrive = EnvVar::get('HOMEDRIVE'); + $homePath = EnvVar::get('HOMEPATH'); + + return ($homeDrive && $homePath) ? $homeDrive . $homePath : '/'; + } +} diff --git a/src/Core/tests/Unit/Input/GetRoleCredentialsRequestTest.php b/src/Core/tests/Unit/Input/GetRoleCredentialsRequestTest.php new file mode 100644 index 000000000..b8fd748d9 --- /dev/null +++ b/src/Core/tests/Unit/Input/GetRoleCredentialsRequestTest.php @@ -0,0 +1,32 @@ + 'change me', + 'accountId' => 'change me', + 'accessToken' => 'change me', + ]); + + // see https://docs.aws.amazon.com/singlesignon/latest/PortalAPIReference/API_GetRoleCredentials.html + $expected = ' + GET / HTTP/1.0 + Content-Type: application/json + + { + "change": "it" + } + '; + + self::assertRequestEqualsHttpRequest($expected, $input->request()); + } +} diff --git a/src/Core/tests/Unit/Result/GetRoleCredentialsResponseTest.php b/src/Core/tests/Unit/Result/GetRoleCredentialsResponseTest.php new file mode 100644 index 000000000..ad7653f87 --- /dev/null +++ b/src/Core/tests/Unit/Result/GetRoleCredentialsResponseTest.php @@ -0,0 +1,28 @@ +request('POST', 'http://localhost'), $client, new NullLogger())); + + // self::assertTODO(expected, $result->getRoleCredentials()); + } +} diff --git a/src/Integration/Symfony/Bundle/src/DependencyInjection/AwsPackagesProvider.php b/src/Integration/Symfony/Bundle/src/DependencyInjection/AwsPackagesProvider.php index a43a6d2c7..0c51090e3 100644 --- a/src/Integration/Symfony/Bundle/src/DependencyInjection/AwsPackagesProvider.php +++ b/src/Integration/Symfony/Bundle/src/DependencyInjection/AwsPackagesProvider.php @@ -154,6 +154,10 @@ public static function getAllServices(): array 'class' => \AsyncAws\Ssm\SsmClient::class, 'package' => 'async-aws/ssm', ], + 'sso' => [ + 'class' => \AsyncAws\Core\Sso\SsoClient::class, + 'package' => 'async-aws/core', + ], 'sts' => [ 'class' => \AsyncAws\Core\Sts\StsClient::class, 'package' => 'async-aws/core', diff --git a/src/Service/Sso/.gitattributes b/src/Service/Sso/.gitattributes new file mode 100644 index 000000000..410d4a1a6 --- /dev/null +++ b/src/Service/Sso/.gitattributes @@ -0,0 +1,5 @@ +/.github export-ignore +/tests export-ignore +/.gitignore export-ignore +/Makefile export-ignore +/phpunit.xml.dist export-ignore diff --git a/src/Service/Sso/.github/FUNDING.yml b/src/Service/Sso/.github/FUNDING.yml new file mode 100644 index 000000000..ef7eb6190 --- /dev/null +++ b/src/Service/Sso/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: [nyholm, jderusse] diff --git a/src/Service/Sso/.github/workflows/.editorconfig b/src/Service/Sso/.github/workflows/.editorconfig new file mode 100644 index 000000000..7bd3346f2 --- /dev/null +++ b/src/Service/Sso/.github/workflows/.editorconfig @@ -0,0 +1,2 @@ +[*.yml] +indent_size = 2 diff --git a/src/Service/Sso/.github/workflows/branch_alias.yml b/src/Service/Sso/.github/workflows/branch_alias.yml new file mode 100644 index 000000000..29b6cd3c6 --- /dev/null +++ b/src/Service/Sso/.github/workflows/branch_alias.yml @@ -0,0 +1,76 @@ +name: Update branch alias + +on: + push: + tags: ['*'] + +jobs: + branch-alias: + name: Update branch alias + runs-on: ubuntu-latest + + steps: + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + coverage: none + + - name: Find branch alias + id: find_alias + run: | + TAG=$(echo $GITHUB_REF | cut -d'/' -f 3) + echo "Last tag was $TAG" + ARR=(${TAG//./ }) + ARR[1]=$((${ARR[1]}+1)) + echo "alias=${ARR[0]}.${ARR[1]}" >> $GITHUB_OUTPUT + + - name: Checkout main repo + run: | + git clone --branch master https://${{ secrets.BOT_GITHUB_TOKEN }}:x-oauth-basic@github.com/async-aws/aws aws + + - name: Update branch alias + run: | + cd aws/src/Service/Sso + CURRENT_ALIAS=$(composer config extra.branch-alias.dev-master | cut -d'-' -f 1) + + # If there is a current value on the branch alias + if [ ! -z $CURRENT_ALIAS ]; then + NEW_ALIAS=${{ steps.find_alias.outputs.alias }} + CURRENT_ARR=(${CURRENT_ALIAS//./ }) + NEW_ARR=(${NEW_ALIAS//./ }) + + if [ ${CURRENT_ARR[0]} -gt ${NEW_ARR[0]} ]; then + echo "The current value for major version is larger" + exit 1; + fi + + if [ ${CURRENT_ARR[0]} -eq ${NEW_ARR[0]} ] && [ ${CURRENT_ARR[1]} -gt ${NEW_ARR[1]} ]; then + echo "The current value for minor version is larger" + exit 1; + fi + fi + + composer config extra.branch-alias.dev-master ${{ steps.find_alias.outputs.alias }}-dev + + - name: Commit & push the new files + run: | + echo "::group::git status" + cd aws + git status + echo "::endgroup::" + + git add -N . + if [[ $(git diff --numstat | wc -l) -eq 0 ]]; then + echo "No changes found. Exiting." + exit 0; + fi + + git config --local user.email "github@async-aws.com" + git config --local user.name "AsyncAws Bot" + + echo "::group::git push" + git add . + git commit -m "Update branch alias" + git push + echo "::endgroup::" diff --git a/src/Service/Sso/.github/workflows/checks.yml b/src/Service/Sso/.github/workflows/checks.yml new file mode 100644 index 000000000..c73d351f1 --- /dev/null +++ b/src/Service/Sso/.github/workflows/checks.yml @@ -0,0 +1,38 @@ +name: BC Check + +on: + push: + branches: + - master + +jobs: + roave-bc-check: + name: Roave BC Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Modify composer.json + run: | + sed -i -re 's/"require": \{/"minimum-stability": "dev","prefer-stable": true,"require": \{/' composer.json + cat composer.json + + git config --local user.email "github@async-aws.com" + git config --local user.name "AsyncAws Bot" + git commit -am "Allow unstable dependencies" + + - name: Install PHP with extensions + uses: shivammathur/setup-php@v2 + with: + php-version: 8.2 + tools: composer:v2 + + - name: Install roave/backward-compatibility-check + run: composer require --dev roave/backward-compatibility-check + + - name: Roave BC Check + run: vendor/bin/roave-backward-compatibility-check diff --git a/src/Service/Sso/.github/workflows/ci.yml b/src/Service/Sso/.github/workflows/ci.yml new file mode 100644 index 000000000..78d4ac812 --- /dev/null +++ b/src/Service/Sso/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: Tests + +on: + push: + branches: + - master + +jobs: + + build: + name: Build + runs-on: ubuntu-latest + strategy: + max-parallel: 10 + matrix: + php: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2'] + + steps: + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Checkout code + uses: actions/checkout@v3 + + - name: Initialize tests + run: make initialize + + - name: Download dependencies + run: | + composer config minimum-stability dev + composer req symfony/phpunit-bridge --no-update + composer update --no-interaction --prefer-dist --optimize-autoloader --prefer-stable + + - name: Run tests + run: ./vendor/bin/simple-phpunit diff --git a/src/Service/Sso/.gitignore b/src/Service/Sso/.gitignore new file mode 100644 index 000000000..4ef8091e0 --- /dev/null +++ b/src/Service/Sso/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +*.cache +composer.lock diff --git a/src/Service/Sso/CHANGELOG.md b/src/Service/Sso/CHANGELOG.md new file mode 100644 index 000000000..457d4177c --- /dev/null +++ b/src/Service/Sso/CHANGELOG.md @@ -0,0 +1,7 @@ +# Change Log + +## NOT RELEASED + +## 0.1.0 + +First version diff --git a/src/Service/Sso/LICENSE b/src/Service/Sso/LICENSE new file mode 100644 index 000000000..c924ee5c6 --- /dev/null +++ b/src/Service/Sso/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 Jérémy Derussé, Tobias Nyholm + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Service/Sso/Makefile b/src/Service/Sso/Makefile new file mode 100644 index 000000000..850dffccd --- /dev/null +++ b/src/Service/Sso/Makefile @@ -0,0 +1,12 @@ +.EXPORT_ALL_VARIABLES: + +initialize: start-docker +start-docker: + echo "Noop" + +test: initialize + ./vendor/bin/simple-phpunit + +clean: stop-docker +stop-docker: + echo "Noop" diff --git a/src/Service/Sso/README.md b/src/Service/Sso/README.md new file mode 100644 index 000000000..dc66bdc3b --- /dev/null +++ b/src/Service/Sso/README.md @@ -0,0 +1,20 @@ +# AsyncAws Sso Client + +![](https://github.com/async-aws/sso/workflows/Tests/badge.svg?branch=master) +![](https://github.com/async-aws/sso/workflows/BC%20Check/badge.svg?branch=master) + +An API client for Sso. + +## Install + +```cli +composer require async-aws/sso +``` + +## Documentation + +See https://async-aws.com/clients/sso.html for documentation. + +## Contribute + +Contributions are welcome and appreciated. Please read https://async-aws.com/contribute/ diff --git a/src/Service/Sso/composer.json b/src/Service/Sso/composer.json new file mode 100644 index 000000000..33eb30a1b --- /dev/null +++ b/src/Service/Sso/composer.json @@ -0,0 +1,32 @@ +{ + "name": "async-aws/sso", + "description": "Sso client, part of the AWS SDK provided by AsyncAws.", + "license": "MIT", + "type": "library", + "keywords": [ + "aws", + "amazon", + "sdk", + "async-aws", + "sso" + ], + "require": { + "php": "^7.2.5 || ^8.0", + "async-aws/core": "^1.9" + }, + "autoload": { + "psr-4": { + "AsyncAws\\Sso\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "AsyncAws\\Sso\\Tests\\": "tests/" + } + }, + "extra": { + "branch-alias": { + "dev-master": "0.1-dev" + } + } +} diff --git a/src/Service/Sso/phpunit.xml.dist b/src/Service/Sso/phpunit.xml.dist new file mode 100644 index 000000000..9894ce353 --- /dev/null +++ b/src/Service/Sso/phpunit.xml.dist @@ -0,0 +1,23 @@ + + + + + ./src + + + + + + + + ./tests/ + + + diff --git a/src/Service/Sso/src/Exception/InvalidRequestException.php b/src/Service/Sso/src/Exception/InvalidRequestException.php new file mode 100644 index 000000000..93f9b4694 --- /dev/null +++ b/src/Service/Sso/src/Exception/InvalidRequestException.php @@ -0,0 +1,13 @@ +roleName = $input['roleName'] ?? null; + $this->accountId = $input['accountId'] ?? null; + $this->accessToken = $input['accessToken'] ?? null; + parent::__construct($input); + } + + /** + * @param array{ + * roleName?: string, + * accountId?: string, + * accessToken?: string, + * '@region'?: string|null, + * }|GetRoleCredentialsRequest $input + */ + public static function create($input): self + { + return $input instanceof self ? $input : new self($input); + } + + public function getAccessToken(): ?string + { + return $this->accessToken; + } + + public function getAccountId(): ?string + { + return $this->accountId; + } + + public function getRoleName(): ?string + { + return $this->roleName; + } + + /** + * @internal + */ + public function request(): Request + { + // Prepare headers + $headers = ['content-type' => 'application/json']; + if (null === $v = $this->accessToken) { + throw new InvalidArgument(sprintf('Missing parameter "accessToken" for "%s". The value cannot be null.', __CLASS__)); + } + $headers['x-amz-sso_bearer_token'] = $v; + + // Prepare query + $query = []; + if (null === $v = $this->roleName) { + throw new InvalidArgument(sprintf('Missing parameter "roleName" for "%s". The value cannot be null.', __CLASS__)); + } + $query['role_name'] = $v; + if (null === $v = $this->accountId) { + throw new InvalidArgument(sprintf('Missing parameter "accountId" for "%s". The value cannot be null.', __CLASS__)); + } + $query['account_id'] = $v; + + // Prepare URI + $uriString = '/federation/credentials'; + + // Prepare Body + $body = ''; + + // Return the Request + return new Request('GET', $uriString, $query, $headers, StreamFactory::create($body)); + } + + public function setAccessToken(?string $value): self + { + $this->accessToken = $value; + + return $this; + } + + public function setAccountId(?string $value): self + { + $this->accountId = $value; + + return $this; + } + + public function setRoleName(?string $value): self + { + $this->roleName = $value; + + return $this; + } +} diff --git a/src/Service/Sso/src/Result/GetRoleCredentialsResponse.php b/src/Service/Sso/src/Result/GetRoleCredentialsResponse.php new file mode 100644 index 000000000..d43082e80 --- /dev/null +++ b/src/Service/Sso/src/Result/GetRoleCredentialsResponse.php @@ -0,0 +1,41 @@ +initialize(); + + return $this->roleCredentials; + } + + protected function populateResult(Response $response): void + { + $data = $response->toArray(); + + $this->roleCredentials = empty($data['roleCredentials']) ? null : $this->populateResultRoleCredentials($data['roleCredentials']); + } + + private function populateResultRoleCredentials(array $json): RoleCredentials + { + return new RoleCredentials([ + 'accessKeyId' => isset($json['accessKeyId']) ? (string) $json['accessKeyId'] : null, + 'secretAccessKey' => isset($json['secretAccessKey']) ? (string) $json['secretAccessKey'] : null, + 'sessionToken' => isset($json['sessionToken']) ? (string) $json['sessionToken'] : null, + 'expiration' => isset($json['expiration']) ? (int) $json['expiration'] : null, + ]); + } +} diff --git a/src/Service/Sso/src/SsoClient.php b/src/Service/Sso/src/SsoClient.php new file mode 100644 index 000000000..28a0cc0ca --- /dev/null +++ b/src/Service/Sso/src/SsoClient.php @@ -0,0 +1,97 @@ +getResponse($input->request(), new RequestContext(['operation' => 'GetRoleCredentials', 'region' => $input->getRegion(), 'exceptionMapping' => [ + 'InvalidRequestException' => InvalidRequestException::class, + 'UnauthorizedException' => UnauthorizedException::class, + 'TooManyRequestsException' => TooManyRequestsException::class, + 'ResourceNotFoundException' => ResourceNotFoundException::class, + ]])); + + return new GetRoleCredentialsResponse($response); + } + + protected function getAwsErrorFactory(): AwsErrorFactoryInterface + { + return new JsonRestAwsErrorFactory(); + } + + protected function getEndpointMetadata(?string $region): array + { + if (null === $region) { + $region = Configuration::DEFAULT_REGION; + } + + switch ($region) { + case 'af-south-1': + case 'ap-east-1': + case 'ap-northeast-1': + case 'ap-northeast-2': + case 'ap-northeast-3': + case 'ap-south-1': + case 'ap-southeast-1': + case 'ap-southeast-2': + case 'ap-southeast-3': + case 'ca-central-1': + case 'eu-central-1': + case 'eu-north-1': + case 'eu-south-1': + case 'eu-west-1': + case 'eu-west-2': + case 'eu-west-3': + case 'me-south-1': + case 'sa-east-1': + case 'us-east-1': + case 'us-east-2': + case 'us-gov-east-1': + case 'us-gov-west-1': + case 'us-west-1': + case 'us-west-2': + return [ + 'endpoint' => "https://portal.sso.$region.amazonaws.com", + 'signRegion' => $region, + 'signService' => 'awsssoportal', + 'signVersions' => ['v4'], + ]; + } + + throw new UnsupportedRegion(sprintf('The region "%s" is not supported by "Sso".', $region)); + } +} diff --git a/src/Service/Sso/src/ValueObject/RoleCredentials.php b/src/Service/Sso/src/ValueObject/RoleCredentials.php new file mode 100644 index 000000000..ffb781c9c --- /dev/null +++ b/src/Service/Sso/src/ValueObject/RoleCredentials.php @@ -0,0 +1,95 @@ +accessKeyId = $input['accessKeyId'] ?? null; + $this->secretAccessKey = $input['secretAccessKey'] ?? null; + $this->sessionToken = $input['sessionToken'] ?? null; + $this->expiration = $input['expiration'] ?? null; + } + + /** + * @param array{ + * accessKeyId?: null|string, + * secretAccessKey?: null|string, + * sessionToken?: null|string, + * expiration?: null|int, + * }|RoleCredentials $input + */ + public static function create($input): self + { + return $input instanceof self ? $input : new self($input); + } + + public function getAccessKeyId(): ?string + { + return $this->accessKeyId; + } + + public function getExpiration(): ?int + { + return $this->expiration; + } + + public function getSecretAccessKey(): ?string + { + return $this->secretAccessKey; + } + + public function getSessionToken(): ?string + { + return $this->sessionToken; + } +} diff --git a/src/Service/Sso/tests/.gitignore b/src/Service/Sso/tests/.gitignore new file mode 100644 index 000000000..e69de29bb diff --git a/src/Service/Sso/tests/Integration/SsoClientTest.php b/src/Service/Sso/tests/Integration/SsoClientTest.php new file mode 100644 index 000000000..ba042add1 --- /dev/null +++ b/src/Service/Sso/tests/Integration/SsoClientTest.php @@ -0,0 +1,36 @@ +getClient(); + + $input = new GetRoleCredentialsRequest([ + 'roleName' => 'change me', + 'accountId' => 'change me', + 'accessToken' => 'change me', + ]); + $result = $client->getRoleCredentials($input); + + $result->resolve(); + + // self::assertTODO(expected, $result->getRoleCredentials()); + } + + private function getClient(): SsoClient + { + self::fail('Not implemented'); + + return new SsoClient([ + 'endpoint' => 'http://localhost', + ], new NullProvider()); + } +} diff --git a/src/Service/Sso/tests/Unit/Input/GetRoleCredentialsRequestTest.php b/src/Service/Sso/tests/Unit/Input/GetRoleCredentialsRequestTest.php new file mode 100644 index 000000000..b9c7614e9 --- /dev/null +++ b/src/Service/Sso/tests/Unit/Input/GetRoleCredentialsRequestTest.php @@ -0,0 +1,32 @@ + 'change me', + 'accountId' => 'change me', + 'accessToken' => 'change me', + ]); + + // see https://docs.aws.amazon.com/singlesignon/latest/PortalAPIReference/API_GetRoleCredentials.html + $expected = ' + GET / HTTP/1.0 + Content-Type: application/json + + { + "change": "it" + } + '; + + self::assertRequestEqualsHttpRequest($expected, $input->request()); + } +} diff --git a/src/Service/Sso/tests/Unit/Result/GetRoleCredentialsResponseTest.php b/src/Service/Sso/tests/Unit/Result/GetRoleCredentialsResponseTest.php new file mode 100644 index 000000000..5fb65a824 --- /dev/null +++ b/src/Service/Sso/tests/Unit/Result/GetRoleCredentialsResponseTest.php @@ -0,0 +1,28 @@ +request('POST', 'http://localhost'), $client, new NullLogger())); + + // self::assertTODO(expected, $result->getRoleCredentials()); + } +} diff --git a/src/Service/Sso/tests/Unit/SsoClientTest.php b/src/Service/Sso/tests/Unit/SsoClientTest.php new file mode 100644 index 000000000..2d20996d2 --- /dev/null +++ b/src/Service/Sso/tests/Unit/SsoClientTest.php @@ -0,0 +1,28 @@ + 'change me', + 'accountId' => 'change me', + 'accessToken' => 'change me', + ]); + $result = $client->getRoleCredentials($input); + + self::assertInstanceOf(GetRoleCredentialsResponse::class, $result); + self::assertFalse($result->info()['resolved']); + } +}