Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Added

- Support for SsoOidc
- Support for SSO authentication

### Changed

Expand Down
1 change: 1 addition & 0 deletions src/Core/src/Credentials/IniFileLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
65 changes: 59 additions & 6 deletions src/Core/src/Credentials/IniFileProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this check for SsoOidcClient instead ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

already addressed by #1833

$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)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this change the branches without inverting the condition

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]);
Expand Down Expand Up @@ -158,6 +168,44 @@ private function getCredentialsFromRole(array $profilesData, array $profileData,
);
}

/**
* @param array<string, array<string, string>> $profilesData
* @param array<string, string> $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<string, string> $profileData
*/
Expand All @@ -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],
]);
Expand Down
2 changes: 2 additions & 0 deletions src/Core/src/Credentials/SsoCacheFileLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

/**
* Load and parse AWS SSO cache file.
*
* @internal
*/
final class SsoCacheFileLoader
{
Expand Down
178 changes: 178 additions & 0 deletions src/Core/src/Credentials/SsoTokenProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
<?php

declare(strict_types=1);

namespace AsyncAws\Core\Credentials;

use AsyncAws\Core\EnvVar;
use AsyncAws\SsoOidc\SsoOidcClient;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
* Load and refresh AWS SSO tokens.
*
* @internal
*/
final class SsoTokenProvider
{
public const KEY_CLIENT_ID = 'clientId';
public const KEY_CLIENT_SECRET = 'clientSecret';
public const KEY_REFRESH_TOKEN = 'refreshToken';
public const KEY_ACCESS_TOKEN = 'accessToken';
public const KEY_EXPIRES_AT = 'expiresAt';

private const REFRESH_WINDOW = 300;

/**
* @var LoggerInterface
*/
private $logger;

/**
* @var ?HttpClientInterface
*/
private $httpClient;

public function __construct(?HttpClientInterface $httpClient = null, ?LoggerInterface $logger = null)
{
$this->httpClient = $httpClient;
$this->logger = $logger ?? new NullLogger();
}

/**
* @param array<string, string> $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<string, string> $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<string, string>|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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you also need the error handling for PHP 7.2 as it won't throw an error there.

$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 : '/';
}
}
Loading