| 1 |
efrain |
1 |
<?php
|
|
|
2 |
namespace Aws\Token;
|
|
|
3 |
|
|
|
4 |
use Aws\Exception\TokenException;
|
| 1441 |
ariadna |
5 |
use Aws\SSOOIDC\SSOOIDCClient;
|
| 1 |
efrain |
6 |
use GuzzleHttp\Promise;
|
|
|
7 |
|
|
|
8 |
/**
|
|
|
9 |
* Token that comes from the SSO provider
|
|
|
10 |
*/
|
|
|
11 |
class SsoTokenProvider implements RefreshableTokenProviderInterface
|
|
|
12 |
{
|
|
|
13 |
use ParsesIniTrait;
|
|
|
14 |
|
|
|
15 |
const ENV_PROFILE = 'AWS_PROFILE';
|
| 1441 |
ariadna |
16 |
const REFRESH_WINDOW_IN_SECS = 300;
|
|
|
17 |
const REFRESH_ATTEMPT_WINDOW_IN_SECS = 30;
|
| 1 |
efrain |
18 |
|
| 1441 |
ariadna |
19 |
/** @var string $profileName */
|
|
|
20 |
private $profileName;
|
|
|
21 |
|
|
|
22 |
/** @var string $configFilePath */
|
|
|
23 |
private $configFilePath;
|
|
|
24 |
|
|
|
25 |
/** @var SSOOIDCClient $ssoOidcClient */
|
| 1 |
efrain |
26 |
private $ssoOidcClient;
|
|
|
27 |
|
| 1441 |
ariadna |
28 |
/** @var string $ssoSessionName */
|
|
|
29 |
private $ssoSessionName;
|
|
|
30 |
|
| 1 |
efrain |
31 |
/**
|
|
|
32 |
* Constructs a new SsoTokenProvider object, which will fetch a token from an authenticated SSO profile
|
| 1441 |
ariadna |
33 |
* @param string $profileName The name of the profile that contains the sso_session key
|
|
|
34 |
* @param string|null $configFilePath Name of the config file to sso profile from
|
|
|
35 |
* @param SSOOIDCClient|null $ssoOidcClient The sso client for generating a new token
|
| 1 |
efrain |
36 |
*/
|
| 1441 |
ariadna |
37 |
public function __construct(
|
|
|
38 |
$profileName,
|
|
|
39 |
$configFilePath = null,
|
|
|
40 |
?SSOOIDCClient $ssoOidcClient = null
|
|
|
41 |
) {
|
|
|
42 |
$this->profileName = $this->resolveProfileName($profileName);
|
|
|
43 |
$this->configFilePath = $this->resolveConfigFile($configFilePath);
|
| 1 |
efrain |
44 |
$this->ssoOidcClient = $ssoOidcClient;
|
|
|
45 |
}
|
|
|
46 |
|
| 1441 |
ariadna |
47 |
/**
|
|
|
48 |
* This method resolves the profile name to be used. The
|
|
|
49 |
* profile provided as instantiation argument takes precedence,
|
|
|
50 |
* followed by AWS_PROFILE env variable, otherwise `default` is
|
|
|
51 |
* used.
|
| 1 |
efrain |
52 |
*
|
| 1441 |
ariadna |
53 |
* @param string|null $argProfileName The profile provided as argument.
|
|
|
54 |
*
|
|
|
55 |
* @return string
|
| 1 |
efrain |
56 |
*/
|
| 1441 |
ariadna |
57 |
private function resolveProfileName($argProfileName): string
|
|
|
58 |
{
|
|
|
59 |
if (empty($argProfileName)) {
|
|
|
60 |
return getenv(self::ENV_PROFILE) ?: 'default';
|
|
|
61 |
} else {
|
|
|
62 |
return $argProfileName;
|
|
|
63 |
}
|
|
|
64 |
}
|
|
|
65 |
|
|
|
66 |
/**
|
|
|
67 |
* This method resolves the config file from where the profiles
|
|
|
68 |
* are going to be loaded from. If $argFileName is not empty then,
|
|
|
69 |
* it takes precedence over the default config file location.
|
|
|
70 |
*
|
|
|
71 |
* @param string|null $argConfigFilePath The config path provided as argument.
|
|
|
72 |
*
|
|
|
73 |
* @return string
|
|
|
74 |
*/
|
|
|
75 |
private function resolveConfigFile($argConfigFilePath): string
|
|
|
76 |
{
|
|
|
77 |
if (empty($argConfigFilePath)) {
|
|
|
78 |
return self::getHomeDir() . '/.aws/config';
|
|
|
79 |
} else{
|
|
|
80 |
return $argConfigFilePath;
|
|
|
81 |
}
|
|
|
82 |
}
|
|
|
83 |
|
|
|
84 |
/**
|
|
|
85 |
* Loads cached sso credentials.
|
|
|
86 |
*
|
|
|
87 |
* @return Promise\PromiseInterface
|
|
|
88 |
*/
|
| 1 |
efrain |
89 |
public function __invoke()
|
|
|
90 |
{
|
|
|
91 |
return Promise\Coroutine::of(function () {
|
| 1441 |
ariadna |
92 |
if (empty($this->configFilePath) || !is_readable($this->configFilePath)) {
|
|
|
93 |
throw new TokenException("Cannot read profiles from {$this->configFilePath}");
|
| 1 |
efrain |
94 |
}
|
| 1441 |
ariadna |
95 |
|
|
|
96 |
$profiles = self::loadProfiles($this->configFilePath);
|
|
|
97 |
if (!isset($profiles[$this->profileName])) {
|
|
|
98 |
throw new TokenException("Profile `{$this->profileName}` does not exist in {$this->configFilePath}.");
|
| 1 |
efrain |
99 |
}
|
| 1441 |
ariadna |
100 |
|
|
|
101 |
$profile = $profiles[$this->profileName];
|
|
|
102 |
if (empty($profile['sso_session'])) {
|
| 1 |
efrain |
103 |
throw new TokenException(
|
| 1441 |
ariadna |
104 |
"Profile `{$this->profileName}` in {$this->configFilePath} must contain an sso_session."
|
| 1 |
efrain |
105 |
);
|
|
|
106 |
}
|
|
|
107 |
|
| 1441 |
ariadna |
108 |
$ssoSessionName = $profile['sso_session'];
|
|
|
109 |
$this->ssoSessionName = $ssoSessionName;
|
|
|
110 |
$profileSsoSession = 'sso-session ' . $ssoSessionName;
|
|
|
111 |
if (empty($profiles[$profileSsoSession])) {
|
| 1 |
efrain |
112 |
throw new TokenException(
|
| 1441 |
ariadna |
113 |
"Sso session `{$ssoSessionName}` does not exist in {$this->configFilePath}"
|
| 1 |
efrain |
114 |
);
|
|
|
115 |
}
|
|
|
116 |
|
| 1441 |
ariadna |
117 |
$sessionProfileData = $profiles[$profileSsoSession];
|
|
|
118 |
foreach (['sso_start_url', 'sso_region'] as $requiredProp) {
|
|
|
119 |
if (empty($sessionProfileData[$requiredProp])) {
|
|
|
120 |
throw new TokenException(
|
|
|
121 |
"Sso session `{$ssoSessionName}` in {$this->configFilePath} is missing the required property `{$requiredProp}`"
|
|
|
122 |
);
|
|
|
123 |
}
|
| 1 |
efrain |
124 |
}
|
|
|
125 |
|
| 1441 |
ariadna |
126 |
$tokenData = $this->refresh();
|
|
|
127 |
$tokenLocation = self::getTokenLocation($ssoSessionName);
|
|
|
128 |
$this->validateTokenData($tokenLocation, $tokenData);
|
|
|
129 |
$ssoToken = SsoToken::fromTokenData($tokenData);
|
|
|
130 |
// To make sure the token is not expired
|
|
|
131 |
if ($ssoToken->isExpired()) {
|
|
|
132 |
throw new TokenException("Cached SSO token returned an expired token.");
|
| 1 |
efrain |
133 |
}
|
| 1441 |
ariadna |
134 |
|
|
|
135 |
yield $ssoToken;
|
| 1 |
efrain |
136 |
});
|
|
|
137 |
}
|
|
|
138 |
|
|
|
139 |
/**
|
| 1441 |
ariadna |
140 |
* This method attempt to refresh when possible.
|
|
|
141 |
* If a refresh is not possible then it just returns
|
|
|
142 |
* the current token data as it is.
|
|
|
143 |
*
|
|
|
144 |
* @return array
|
|
|
145 |
* @throws TokenException
|
| 1 |
efrain |
146 |
*/
|
| 1441 |
ariadna |
147 |
public function refresh(): array
|
|
|
148 |
{
|
|
|
149 |
$tokenLocation = self::getTokenLocation($this->ssoSessionName);
|
|
|
150 |
$tokenData = $this->getTokenData($tokenLocation);
|
|
|
151 |
if (!$this->shouldAttemptRefresh()) {
|
|
|
152 |
return $tokenData;
|
|
|
153 |
}
|
|
|
154 |
|
|
|
155 |
if (null === $this->ssoOidcClient) {
|
|
|
156 |
throw new TokenException(
|
|
|
157 |
"Cannot refresh this token without an 'ssooidcClient' "
|
|
|
158 |
);
|
|
|
159 |
}
|
|
|
160 |
|
|
|
161 |
foreach (['clientId', 'clientSecret', 'refreshToken'] as $requiredProp) {
|
|
|
162 |
if (empty($tokenData[$requiredProp])) {
|
| 1 |
efrain |
163 |
throw new TokenException(
|
| 1441 |
ariadna |
164 |
"Cannot refresh this token without `{$requiredProp}` being set"
|
| 1 |
efrain |
165 |
);
|
|
|
166 |
}
|
| 1441 |
ariadna |
167 |
}
|
| 1 |
efrain |
168 |
|
| 1441 |
ariadna |
169 |
$response = $this->ssoOidcClient->createToken([
|
|
|
170 |
'clientId' => $tokenData['clientId'],
|
|
|
171 |
'clientSecret' => $tokenData['clientSecret'],
|
|
|
172 |
'grantType' => 'refresh_token', // REQUIRED
|
|
|
173 |
'refreshToken' => $tokenData['refreshToken'],
|
|
|
174 |
]);
|
|
|
175 |
if ($response['@metadata']['statusCode'] !== 200) {
|
|
|
176 |
throw new TokenException('Unable to create a new sso token');
|
|
|
177 |
}
|
| 1 |
efrain |
178 |
|
| 1441 |
ariadna |
179 |
$tokenData['accessToken'] = $response['accessToken'];
|
|
|
180 |
$tokenData['expiresAt'] = time () + $response['expiresIn'];
|
|
|
181 |
$tokenData['refreshToken'] = $response['refreshToken'];
|
|
|
182 |
|
|
|
183 |
return $this->writeNewTokenDataToDisk($tokenData, $tokenLocation);
|
| 1 |
efrain |
184 |
}
|
|
|
185 |
|
| 1441 |
ariadna |
186 |
/**
|
|
|
187 |
* This method checks for whether a token refresh should happen.
|
|
|
188 |
* It will return true just if more than 30 seconds has happened
|
|
|
189 |
* since last refresh, and if the expiration is within a 5-minutes
|
|
|
190 |
* window from the current time.
|
|
|
191 |
*
|
|
|
192 |
* @return bool
|
|
|
193 |
*/
|
|
|
194 |
public function shouldAttemptRefresh(): bool
|
| 1 |
efrain |
195 |
{
|
| 1441 |
ariadna |
196 |
$tokenLocation = self::getTokenLocation($this->ssoSessionName);
|
| 1 |
efrain |
197 |
$tokenData = $this->getTokenData($tokenLocation);
|
| 1441 |
ariadna |
198 |
if (empty($tokenData['expiresAt'])) {
|
|
|
199 |
throw new TokenException(
|
|
|
200 |
"Token file at $tokenLocation must contain an expiration date"
|
|
|
201 |
);
|
|
|
202 |
}
|
|
|
203 |
|
|
|
204 |
$tokenExpiresAt = strtotime($tokenData['expiresAt']);
|
|
|
205 |
$lastRefreshAt = filemtime($tokenLocation);
|
|
|
206 |
$now = \time();
|
|
|
207 |
|
|
|
208 |
// If last refresh happened after 30 seconds
|
|
|
209 |
// and if the token expiration is in the 5 minutes window
|
|
|
210 |
return ($now - $lastRefreshAt) > self::REFRESH_ATTEMPT_WINDOW_IN_SECS
|
|
|
211 |
&& ($tokenExpiresAt - $now) < self::REFRESH_WINDOW_IN_SECS;
|
| 1 |
efrain |
212 |
}
|
|
|
213 |
|
|
|
214 |
/**
|
|
|
215 |
* @param $sso_session
|
|
|
216 |
* @return string
|
|
|
217 |
*/
|
| 1441 |
ariadna |
218 |
public static function getTokenLocation($sso_session): string
|
| 1 |
efrain |
219 |
{
|
|
|
220 |
return self::getHomeDir()
|
|
|
221 |
. '/.aws/sso/cache/'
|
|
|
222 |
. mb_convert_encoding(sha1($sso_session), "UTF-8")
|
|
|
223 |
. ".json";
|
|
|
224 |
}
|
|
|
225 |
|
|
|
226 |
/**
|
|
|
227 |
* @param $tokenLocation
|
|
|
228 |
* @return array
|
|
|
229 |
*/
|
| 1441 |
ariadna |
230 |
function getTokenData($tokenLocation): array
|
| 1 |
efrain |
231 |
{
|
| 1441 |
ariadna |
232 |
if (empty($tokenLocation) || !is_readable($tokenLocation)) {
|
|
|
233 |
throw new TokenException("Unable to read token file at {$tokenLocation}");
|
|
|
234 |
}
|
|
|
235 |
|
| 1 |
efrain |
236 |
return json_decode(file_get_contents($tokenLocation), true);
|
|
|
237 |
}
|
|
|
238 |
|
|
|
239 |
/**
|
|
|
240 |
* @param $tokenData
|
|
|
241 |
* @param $tokenLocation
|
|
|
242 |
* @return mixed
|
|
|
243 |
*/
|
|
|
244 |
private function validateTokenData($tokenLocation, $tokenData)
|
|
|
245 |
{
|
| 1441 |
ariadna |
246 |
foreach (['accessToken', 'expiresAt'] as $requiredProp) {
|
|
|
247 |
if (empty($tokenData[$requiredProp])) {
|
|
|
248 |
throw new TokenException(
|
|
|
249 |
"Token file at {$tokenLocation} must contain the required property `{$requiredProp}`"
|
|
|
250 |
);
|
|
|
251 |
}
|
| 1 |
efrain |
252 |
}
|
|
|
253 |
|
|
|
254 |
$expiration = strtotime($tokenData['expiresAt']);
|
|
|
255 |
if ($expiration === false) {
|
|
|
256 |
throw new TokenException("Cached SSO token returned an invalid expiration");
|
|
|
257 |
} elseif ($expiration < time()) {
|
|
|
258 |
throw new TokenException("Cached SSO token returned an expired token");
|
|
|
259 |
}
|
| 1441 |
ariadna |
260 |
|
| 1 |
efrain |
261 |
return $tokenData;
|
|
|
262 |
}
|
|
|
263 |
|
|
|
264 |
/**
|
|
|
265 |
* @param array $tokenData
|
|
|
266 |
* @param string $tokenLocation
|
| 1441 |
ariadna |
267 |
*
|
|
|
268 |
* @return array
|
| 1 |
efrain |
269 |
*/
|
| 1441 |
ariadna |
270 |
private function writeNewTokenDataToDisk(array $tokenData, $tokenLocation): array
|
| 1 |
efrain |
271 |
{
|
|
|
272 |
$tokenData['expiresAt'] = gmdate(
|
|
|
273 |
'Y-m-d\TH:i:s\Z',
|
|
|
274 |
$tokenData['expiresAt']
|
|
|
275 |
);
|
|
|
276 |
file_put_contents($tokenLocation, json_encode(array_filter($tokenData)));
|
| 1441 |
ariadna |
277 |
|
|
|
278 |
return $tokenData;
|
| 1 |
efrain |
279 |
}
|
|
|
280 |
}
|