Skip to content

Commit e3532ba

Browse files
authored
Add support for SSO authentication (#1831)
1 parent 892a843 commit e3532ba

File tree

5 files changed

+241
-6
lines changed

5 files changed

+241
-6
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Added
66

77
- Support for SsoOidc
8+
- Support for SSO authentication
89

910
### Changed
1011

src/Credentials/IniFileLoader.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ final class IniFileLoader
2323
public const KEY_ROLE_SESSION_NAME = 'role_session_name';
2424
public const KEY_SOURCE_PROFILE = 'source_profile';
2525
public const KEY_WEB_IDENTITY_TOKEN_FILE = 'web_identity_token_file';
26+
public const KEY_SSO_SESSION = 'sso_session';
2627
public const KEY_SSO_START_URL = 'sso_start_url';
2728
public const KEY_SSO_REGION = 'sso_region';
2829
public const KEY_SSO_ACCOUNT_ID = 'sso_account_id';

src/Credentials/IniFileProvider.php

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,24 @@ private function getCredentialsFromProfile(array $profilesData, string $profile,
9393
return $this->getCredentialsFromRole($profilesData, $profileData, $profile, $circularCollector);
9494
}
9595

96+
if (isset($profileData[IniFileLoader::KEY_SSO_SESSION])) {
97+
if (!class_exists(SsoClient::class)) {
98+
$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]);
99+
100+
return null;
101+
}
102+
103+
return $this->getCredentialsFromSsoSession($profilesData, $profileData, $profile);
104+
}
105+
96106
if (isset($profileData[IniFileLoader::KEY_SSO_START_URL])) {
97107
if (class_exists(SsoClient::class)) {
98-
return $this->getCredentialsFromLegacySso($profileData, $profile);
99-
}
108+
$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]);
100109

101-
$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]);
110+
return null;
111+
}
102112

103-
return null;
113+
return $this->getCredentialsFromLegacySso($profileData, $profile);
104114
}
105115

106116
$this->logger->info('No credentials found for profile "{profile}".', ['profile' => $profile]);
@@ -158,6 +168,44 @@ private function getCredentialsFromRole(array $profilesData, array $profileData,
158168
);
159169
}
160170

171+
/**
172+
* @param array<string, array<string, string>> $profilesData
173+
* @param array<string, string> $profileData
174+
*/
175+
private function getCredentialsFromSsoSession(array $profilesData, array $profileData, string $profile): ?Credentials
176+
{
177+
if (!isset($profileData[IniFileLoader::KEY_SSO_SESSION])) {
178+
$this->logger->warning('Profile "{profile}" does not contains required SSO session config.', ['profile' => $profile]);
179+
180+
return null;
181+
}
182+
183+
$sessionName = $profileData[IniFileLoader::KEY_SSO_SESSION];
184+
if (!isset($profilesData['sso-session ' . $sessionName])) {
185+
$this->logger->warning('Profile "{profile}" refers to a the "{session}" sso-session that is not present in the configuration file.', ['profile' => $profile, 'session' => $sessionName]);
186+
187+
return null;
188+
}
189+
190+
$sessionData = $profilesData['sso-session ' . $sessionName];
191+
if (!isset(
192+
$sessionData[IniFileLoader::KEY_SSO_START_URL],
193+
$sessionData[IniFileLoader::KEY_SSO_REGION]
194+
)) {
195+
$this->logger->warning('SSO Session "{session}" does not contains required SSO config.', ['session' => $sessionName]);
196+
197+
return null;
198+
}
199+
200+
$ssoTokenProvider = new SsoTokenProvider($this->httpClient, $this->logger);
201+
$token = $ssoTokenProvider->getToken($sessionName, $sessionData);
202+
if (null === $token) {
203+
return null;
204+
}
205+
206+
return $this->getCredentialsFromSsoToken($profileData, $sessionData[IniFileLoader::KEY_SSO_REGION], $profile, $token);
207+
}
208+
161209
/**
162210
* @param array<string, string> $profileData
163211
*/
@@ -181,13 +229,18 @@ private function getCredentialsFromLegacySso(array $profileData, string $profile
181229
return null;
182230
}
183231

232+
return $this->getCredentialsFromSsoToken($profileData, $profileData[IniFileLoader::KEY_SSO_REGION], $profile, $tokenData[SsoCacheFileLoader::KEY_ACCESS_TOKEN]);
233+
}
234+
235+
private function getCredentialsFromSsoToken(array $profileData, string $ssoRegion, string $profile, string $accessToken): ?Credentials
236+
{
184237
$ssoClient = new SsoClient(
185-
['region' => $profileData[IniFileLoader::KEY_SSO_REGION]],
238+
['region' => $ssoRegion],
186239
new NullProvider(), // no credentials required as we provide an access token via the role credentials request
187240
$this->httpClient
188241
);
189242
$result = $ssoClient->getRoleCredentials([
190-
'accessToken' => $tokenData[SsoCacheFileLoader::KEY_ACCESS_TOKEN],
243+
'accessToken' => $accessToken,
191244
'accountId' => $profileData[IniFileLoader::KEY_SSO_ACCOUNT_ID],
192245
'roleName' => $profileData[IniFileLoader::KEY_SSO_ROLE_NAME],
193246
]);

src/Credentials/SsoCacheFileLoader.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
/**
1212
* Load and parse AWS SSO cache file.
13+
*
14+
* @internal
1315
*/
1416
final class SsoCacheFileLoader
1517
{

src/Credentials/SsoTokenProvider.php

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AsyncAws\Core\Credentials;
6+
7+
use AsyncAws\Core\EnvVar;
8+
use AsyncAws\SsoOidc\SsoOidcClient;
9+
use Psr\Log\LoggerInterface;
10+
use Psr\Log\NullLogger;
11+
use Symfony\Contracts\HttpClient\HttpClientInterface;
12+
13+
/**
14+
* Load and refresh AWS SSO tokens.
15+
*
16+
* @internal
17+
*/
18+
final class SsoTokenProvider
19+
{
20+
public const KEY_CLIENT_ID = 'clientId';
21+
public const KEY_CLIENT_SECRET = 'clientSecret';
22+
public const KEY_REFRESH_TOKEN = 'refreshToken';
23+
public const KEY_ACCESS_TOKEN = 'accessToken';
24+
public const KEY_EXPIRES_AT = 'expiresAt';
25+
26+
private const REFRESH_WINDOW = 300;
27+
28+
/**
29+
* @var LoggerInterface
30+
*/
31+
private $logger;
32+
33+
/**
34+
* @var ?HttpClientInterface
35+
*/
36+
private $httpClient;
37+
38+
public function __construct(?HttpClientInterface $httpClient = null, ?LoggerInterface $logger = null)
39+
{
40+
$this->httpClient = $httpClient;
41+
$this->logger = $logger ?? new NullLogger();
42+
}
43+
44+
/**
45+
* @param array<string, string> $sessionData
46+
*/
47+
public function getToken(string $sessionName, array $sessionData): ?string
48+
{
49+
$tokenData = $this->loadSsoToken($sessionName);
50+
if (null === $tokenData) {
51+
return null;
52+
}
53+
54+
$tokenData = $this->refreshTokenIfNeeded($sessionName, $sessionData, $tokenData);
55+
if (!isset($tokenData[self::KEY_ACCESS_TOKEN])) {
56+
$this->logger->warning('The token for SSO session "{session}" does not contains accessToken.', ['session' => $sessionName]);
57+
58+
return null;
59+
}
60+
61+
return $tokenData[self::KEY_ACCESS_TOKEN];
62+
}
63+
64+
/**
65+
* @param array<string, string> $sessionData
66+
*/
67+
private function refreshTokenIfNeeded(string $sessionName, array $sessionData, array $tokenData): array
68+
{
69+
if (!isset($tokenData[self::KEY_EXPIRES_AT])) {
70+
$this->logger->warning('The token for SSO session "{session}" does not contains expiration date.', ['session' => $sessionName]);
71+
72+
return $tokenData;
73+
}
74+
75+
$tokenExpiresAt = new \DateTimeImmutable($tokenData[self::KEY_EXPIRES_AT]);
76+
$tokenRefreshAt = $tokenExpiresAt->sub(new \DateInterval(\sprintf('PT%dS', self::REFRESH_WINDOW)));
77+
78+
// If token expiration is in the 5 minutes window
79+
if ($tokenRefreshAt > new \DateTimeImmutable()) {
80+
return $tokenData;
81+
}
82+
83+
if (!isset(
84+
$tokenData[self::KEY_CLIENT_ID],
85+
$tokenData[self::KEY_CLIENT_SECRET],
86+
$tokenData[self::KEY_REFRESH_TOKEN]
87+
)) {
88+
$this->logger->warning('The token for SSO session "{session}" does not contains required properties and cannot be refreshed.', ['session' => $sessionName]);
89+
90+
return $tokenData;
91+
}
92+
93+
$ssoOidcClient = new SsoOidcClient(
94+
['region' => $sessionData[IniFileLoader::KEY_SSO_REGION]],
95+
new NullProvider(),
96+
// no credentials required as we provide an access token via the role credentials request
97+
$this->httpClient
98+
);
99+
100+
$result = $ssoOidcClient->createToken([
101+
'clientId' => $tokenData[self::KEY_CLIENT_ID],
102+
'clientSecret' => $tokenData[self::KEY_CLIENT_SECRET],
103+
'grantType' => 'refresh_token', // REQUIRED
104+
'refreshToken' => $tokenData[self::KEY_REFRESH_TOKEN],
105+
]);
106+
107+
$tokenData = [
108+
self::KEY_ACCESS_TOKEN => $result->getAccessToken(),
109+
self::KEY_REFRESH_TOKEN => $result->getRefreshToken(),
110+
] + $tokenData;
111+
112+
if (null === $expiresIn = $result->getExpiresIn()) {
113+
$this->logger->warning('The token for SSO session "{session}" does not contains expiration time.', ['session' => $sessionName]);
114+
} else {
115+
$tokenData[self::KEY_EXPIRES_AT] = (new \DateTimeImmutable())->add(new \DateInterval(\sprintf('PT%dS', $expiresIn)))->format(\DateTime::ATOM);
116+
}
117+
118+
$this->dumpSsoToken($sessionName, $tokenData);
119+
120+
return $tokenData;
121+
}
122+
123+
private function dumpSsoToken(string $sessionName, array $tokenData): void
124+
{
125+
$filepath = \sprintf('%s/.aws/sso/cache/%s.json', $this->getHomeDir(), sha1($sessionName));
126+
127+
file_put_contents($filepath, json_encode(array_filter($tokenData)));
128+
}
129+
130+
/**
131+
* @return array<string, string>|null
132+
*/
133+
private function loadSsoToken(string $sessionName): ?array
134+
{
135+
$filepath = \sprintf('%s/.aws/sso/cache/%s.json', $this->getHomeDir(), sha1($sessionName));
136+
if (!is_readable($filepath)) {
137+
$this->logger->warning('The sso cache file {path} is not readable.', ['path' => $filepath]);
138+
139+
return null;
140+
}
141+
142+
if (false === ($content = @file_get_contents($filepath))) {
143+
$this->logger->warning('The sso cache file {path} is not readable.', ['path' => $filepath]);
144+
145+
return null;
146+
}
147+
148+
try {
149+
return json_decode(
150+
$content,
151+
true,
152+
512,
153+
\JSON_BIGINT_AS_STRING | (\PHP_VERSION_ID >= 70300 ? \JSON_THROW_ON_ERROR : 0)
154+
);
155+
} catch (\JsonException $e) {
156+
$this->logger->warning(
157+
'The sso cache file {path} contains invalide JSON.',
158+
['path' => $filepath, 'ecxeption' => $e]
159+
);
160+
161+
return null;
162+
}
163+
}
164+
165+
private function getHomeDir(): string
166+
{
167+
// On Linux/Unix-like systems, use the HOME environment variable
168+
if (null !== $homeDir = EnvVar::get('HOME')) {
169+
return $homeDir;
170+
}
171+
172+
// Get the HOMEDRIVE and HOMEPATH values for Windows hosts
173+
$homeDrive = EnvVar::get('HOMEDRIVE');
174+
$homePath = EnvVar::get('HOMEPATH');
175+
176+
return ($homeDrive && $homePath) ? $homeDrive . $homePath : '/';
177+
}
178+
}

0 commit comments

Comments
 (0)