SsoTokenProvider.php 9.3 KB

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