Skip to content

Commit 41fc3de

Browse files
committed
Add support for SSO authentication
1 parent 6bcd36c commit 41fc3de

File tree

5 files changed

+234
-6
lines changed

5 files changed

+234
-6
lines changed

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

0 commit comments

Comments
 (0)