diff --git a/src/Core/CHANGELOG.md b/src/Core/CHANGELOG.md index 86302fae4..21b231304 100644 --- a/src/Core/CHANGELOG.md +++ b/src/Core/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Support for SsoOidc +- Support for SSO authentication ### Changed diff --git a/src/Core/src/Credentials/IniFileLoader.php b/src/Core/src/Credentials/IniFileLoader.php index df45ed73b..7be76bb01 100644 --- a/src/Core/src/Credentials/IniFileLoader.php +++ b/src/Core/src/Credentials/IniFileLoader.php @@ -23,6 +23,7 @@ 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_SESSION = 'sso_session'; 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'; diff --git a/src/Core/src/Credentials/IniFileProvider.php b/src/Core/src/Credentials/IniFileProvider.php index c8476c49d..2caab8ee3 100644 --- a/src/Core/src/Credentials/IniFileProvider.php +++ b/src/Core/src/Credentials/IniFileProvider.php @@ -93,14 +93,24 @@ private function getCredentialsFromProfile(array $profilesData, string $profile, return $this->getCredentialsFromRole($profilesData, $profileData, $profile, $circularCollector); } + if (isset($profileData[IniFileLoader::KEY_SSO_SESSION])) { + if (!class_exists(SsoClient::class)) { + $this->logger->warning('The profile "{profile}" contains SSO session config but the "async-aws/sso" package is not installed. Try running "composer require async-aws/sso".', ['profile' => $profile]); + + return null; + } + + return $this->getCredentialsFromSsoSession($profilesData, $profileData, $profile); + } + if (isset($profileData[IniFileLoader::KEY_SSO_START_URL])) { if (class_exists(SsoClient::class)) { - return $this->getCredentialsFromLegacySso($profileData, $profile); - } + $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]); - $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]); + return null; + } - return null; + return $this->getCredentialsFromLegacySso($profileData, $profile); } $this->logger->info('No credentials found for profile "{profile}".', ['profile' => $profile]); @@ -158,6 +168,44 @@ private function getCredentialsFromRole(array $profilesData, array $profileData, ); } + /** + * @param array> $profilesData + * @param array $profileData + */ + private function getCredentialsFromSsoSession(array $profilesData, array $profileData, string $profile): ?Credentials + { + if (!isset($profileData[IniFileLoader::KEY_SSO_SESSION])) { + $this->logger->warning('Profile "{profile}" does not contains required SSO session config.', ['profile' => $profile]); + + return null; + } + + $sessionName = $profileData[IniFileLoader::KEY_SSO_SESSION]; + if (!isset($profilesData['sso-session ' . $sessionName])) { + $this->logger->warning('Profile "{profile}" refers to a the "{session}" sso-session that is not present in the configuration file.', ['profile' => $profile, 'session' => $sessionName]); + + return null; + } + + $sessionData = $profilesData['sso-session ' . $sessionName]; + if (!isset( + $sessionData[IniFileLoader::KEY_SSO_START_URL], + $sessionData[IniFileLoader::KEY_SSO_REGION] + )) { + $this->logger->warning('SSO Session "{session}" does not contains required SSO config.', ['session' => $sessionName]); + + return null; + } + + $ssoTokenProvider = new SsoTokenProvider($this->httpClient, $this->logger); + $token = $ssoTokenProvider->getToken($sessionName, $sessionData); + if (null === $token) { + return null; + } + + return $this->getCredentialsFromSsoToken($profileData, $sessionData[IniFileLoader::KEY_SSO_REGION], $profile, $token); + } + /** * @param array $profileData */ @@ -181,13 +229,18 @@ private function getCredentialsFromLegacySso(array $profileData, string $profile return null; } + return $this->getCredentialsFromSsoToken($profileData, $profileData[IniFileLoader::KEY_SSO_REGION], $profile, $tokenData[SsoCacheFileLoader::KEY_ACCESS_TOKEN]); + } + + private function getCredentialsFromSsoToken(array $profileData, string $ssoRegion, string $profile, string $accessToken): ?Credentials + { $ssoClient = new SsoClient( - ['region' => $profileData[IniFileLoader::KEY_SSO_REGION]], + ['region' => $ssoRegion], new NullProvider(), // no credentials required as we provide an access token via the role credentials request $this->httpClient ); $result = $ssoClient->getRoleCredentials([ - 'accessToken' => $tokenData[SsoCacheFileLoader::KEY_ACCESS_TOKEN], + 'accessToken' => $accessToken, 'accountId' => $profileData[IniFileLoader::KEY_SSO_ACCOUNT_ID], 'roleName' => $profileData[IniFileLoader::KEY_SSO_ROLE_NAME], ]); diff --git a/src/Core/src/Credentials/SsoCacheFileLoader.php b/src/Core/src/Credentials/SsoCacheFileLoader.php index fa6f3bc2c..5a3e70b2a 100644 --- a/src/Core/src/Credentials/SsoCacheFileLoader.php +++ b/src/Core/src/Credentials/SsoCacheFileLoader.php @@ -10,6 +10,8 @@ /** * Load and parse AWS SSO cache file. + * + * @internal */ final class SsoCacheFileLoader { diff --git a/src/Core/src/Credentials/SsoTokenProvider.php b/src/Core/src/Credentials/SsoTokenProvider.php new file mode 100644 index 000000000..b0d179bae --- /dev/null +++ b/src/Core/src/Credentials/SsoTokenProvider.php @@ -0,0 +1,178 @@ +httpClient = $httpClient; + $this->logger = $logger ?? new NullLogger(); + } + + /** + * @param array $sessionData + */ + public function getToken(string $sessionName, array $sessionData): ?string + { + $tokenData = $this->loadSsoToken($sessionName); + if (null === $tokenData) { + return null; + } + + $tokenData = $this->refreshTokenIfNeeded($sessionName, $sessionData, $tokenData); + if (!isset($tokenData[self::KEY_ACCESS_TOKEN])) { + $this->logger->warning('The token for SSO session "{session}" does not contains accessToken.', ['session' => $sessionName]); + + return null; + } + + return $tokenData[self::KEY_ACCESS_TOKEN]; + } + + /** + * @param array $sessionData + */ + private function refreshTokenIfNeeded(string $sessionName, array $sessionData, array $tokenData): array + { + if (!isset($tokenData[self::KEY_EXPIRES_AT])) { + $this->logger->warning('The token for SSO session "{session}" does not contains expiration date.', ['session' => $sessionName]); + + return $tokenData; + } + + $tokenExpiresAt = new \DateTimeImmutable($tokenData[self::KEY_EXPIRES_AT]); + $tokenRefreshAt = $tokenExpiresAt->sub(new \DateInterval(\sprintf('PT%dS', self::REFRESH_WINDOW))); + + // If token expiration is in the 5 minutes window + if ($tokenRefreshAt > new \DateTimeImmutable()) { + return $tokenData; + } + + if (!isset( + $tokenData[self::KEY_CLIENT_ID], + $tokenData[self::KEY_CLIENT_SECRET], + $tokenData[self::KEY_REFRESH_TOKEN] + )) { + $this->logger->warning('The token for SSO session "{session}" does not contains required properties and cannot be refreshed.', ['session' => $sessionName]); + + return $tokenData; + } + + $ssoOidcClient = new SsoOidcClient( + ['region' => $sessionData[IniFileLoader::KEY_SSO_REGION]], + new NullProvider(), + // no credentials required as we provide an access token via the role credentials request + $this->httpClient + ); + + $result = $ssoOidcClient->createToken([ + 'clientId' => $tokenData[self::KEY_CLIENT_ID], + 'clientSecret' => $tokenData[self::KEY_CLIENT_SECRET], + 'grantType' => 'refresh_token', // REQUIRED + 'refreshToken' => $tokenData[self::KEY_REFRESH_TOKEN], + ]); + + $tokenData = [ + self::KEY_ACCESS_TOKEN => $result->getAccessToken(), + self::KEY_REFRESH_TOKEN => $result->getRefreshToken(), + ] + $tokenData; + + if (null === $expiresIn = $result->getExpiresIn()) { + $this->logger->warning('The token for SSO session "{session}" does not contains expiration time.', ['session' => $sessionName]); + } else { + $tokenData[self::KEY_EXPIRES_AT] = (new \DateTimeImmutable())->add(new \DateInterval(\sprintf('PT%dS', $expiresIn)))->format(\DateTime::ATOM); + } + + $this->dumpSsoToken($sessionName, $tokenData); + + return $tokenData; + } + + private function dumpSsoToken(string $sessionName, array $tokenData): void + { + $filepath = \sprintf('%s/.aws/sso/cache/%s.json', $this->getHomeDir(), sha1($sessionName)); + + file_put_contents($filepath, json_encode(array_filter($tokenData))); + } + + /** + * @return array|null + */ + private function loadSsoToken(string $sessionName): ?array + { + $filepath = \sprintf('%s/.aws/sso/cache/%s.json', $this->getHomeDir(), sha1($sessionName)); + if (!is_readable($filepath)) { + $this->logger->warning('The sso cache file {path} is not readable.', ['path' => $filepath]); + + return null; + } + + if (false === ($content = @file_get_contents($filepath))) { + $this->logger->warning('The sso cache file {path} is not readable.', ['path' => $filepath]); + + return null; + } + + try { + return json_decode( + $content, + true, + 512, + \JSON_BIGINT_AS_STRING | (\PHP_VERSION_ID >= 70300 ? \JSON_THROW_ON_ERROR : 0) + ); + } catch (\JsonException $e) { + $this->logger->warning( + 'The sso cache file {path} contains invalide JSON.', + ['path' => $filepath, 'ecxeption' => $e] + ); + + return null; + } + } + + 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 : '/'; + } +}