Skip to content

Commit

Permalink
Add support for SSO authentication (#1831)
Browse files Browse the repository at this point in the history
  • Loading branch information
jderusse authored Jan 15, 2025
1 parent 6bcd36c commit 45227e0
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 6 deletions.
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)) {
$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]);
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) {
$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 : '/';
}
}

0 comments on commit 45227e0

Please sign in to comment.