| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280 | <?phpnamespace Aws\Token;use Aws\Exception\TokenException;use Aws\SSOOIDC\SSOOIDCClient;use GuzzleHttp\Promise;/** * Token that comes from the SSO provider */class SsoTokenProvider implements RefreshableTokenProviderInterface{    use ParsesIniTrait;    const ENV_PROFILE = 'AWS_PROFILE';    const REFRESH_WINDOW_IN_SECS = 300;    const REFRESH_ATTEMPT_WINDOW_IN_SECS = 30;    /** @var string $profileName */    private $profileName;    /** @var string $configFilePath */    private $configFilePath;    /** @var SSOOIDCClient $ssoOidcClient */    private $ssoOidcClient;    /** @var string $ssoSessionName */    private $ssoSessionName;    /**     * Constructs a new SsoTokenProvider object, which will fetch a token from an authenticated SSO profile     * @param string $profileName The name of the profile that contains the sso_session key     * @param string|null $configFilePath Name of the config file to sso profile from     * @param SSOOIDCClient|null $ssoOidcClient The sso client for generating a new token     */    public function __construct(        $profileName,        $configFilePath = null,        SSOOIDCClient $ssoOidcClient = null    ) {        $this->profileName = $this->resolveProfileName($profileName);        $this->configFilePath =  $this->resolveConfigFile($configFilePath);        $this->ssoOidcClient = $ssoOidcClient;    }    /**     * This method resolves the profile name to be used. The     * profile provided as instantiation argument takes precedence,     * followed by AWS_PROFILE env variable, otherwise `default` is     * used.     *     * @param string|null $argProfileName The profile provided as argument.     *     * @return string     */    private function resolveProfileName($argProfileName): string    {        if (empty($argProfileName)) {            return getenv(self::ENV_PROFILE) ?: 'default';        } else {            return $argProfileName;        }    }    /**     * This method resolves the config file from where the profiles     * are going to be loaded from. If $argFileName is not empty then,     * it takes precedence over the default config file location.     *     * @param string|null $argConfigFilePath The config path provided as argument.     *     * @return string     */    private function resolveConfigFile($argConfigFilePath): string    {        if (empty($argConfigFilePath)) {            return self::getHomeDir() . '/.aws/config';        } else{            return $argConfigFilePath;        }    }    /**     *  Loads cached sso credentials.     *     * @return Promise\PromiseInterface     */    public function __invoke()    {        return Promise\Coroutine::of(function () {            if (empty($this->configFilePath) || !is_readable($this->configFilePath)) {                throw new TokenException("Cannot read profiles from {$this->configFilePath}");            }            $profiles = self::loadProfiles($this->configFilePath);            if (!isset($profiles[$this->profileName])) {                throw new TokenException("Profile `{$this->profileName}` does not exist in {$this->configFilePath}.");            }            $profile = $profiles[$this->profileName];            if (empty($profile['sso_session'])) {                throw new TokenException(                    "Profile `{$this->profileName}` in {$this->configFilePath} must contain an sso_session."                );            }            $ssoSessionName = $profile['sso_session'];            $this->ssoSessionName = $ssoSessionName;            $profileSsoSession = 'sso-session ' . $ssoSessionName;            if (empty($profiles[$profileSsoSession])) {                throw new TokenException(                    "Sso session `{$ssoSessionName}` does not exist in {$this->configFilePath}"                );            }            $sessionProfileData = $profiles[$profileSsoSession];            foreach (['sso_start_url', 'sso_region'] as $requiredProp) {                if (empty($sessionProfileData[$requiredProp])) {                    throw new TokenException(                        "Sso session `{$ssoSessionName}` in {$this->configFilePath} is missing the required property `{$requiredProp}`"                    );                }            }            $tokenData = $this->refresh();            $tokenLocation = self::getTokenLocation($ssoSessionName);            $this->validateTokenData($tokenLocation, $tokenData);            $ssoToken = SsoToken::fromTokenData($tokenData);            // To make sure the token is not expired            if ($ssoToken->isExpired()) {                throw new TokenException("Cached SSO token returned an expired token.");            }            yield $ssoToken;        });    }    /**     * This method attempt to refresh when possible.     * If a refresh is not possible then it just returns     * the current token data as it is.     *     * @return array     * @throws TokenException     */    public function refresh(): array    {        $tokenLocation = self::getTokenLocation($this->ssoSessionName);        $tokenData = $this->getTokenData($tokenLocation);        if (!$this->shouldAttemptRefresh()) {            return $tokenData;        }        if (null === $this->ssoOidcClient) {            throw new TokenException(                "Cannot refresh this token without an 'ssooidcClient' "            );        }        foreach (['clientId', 'clientSecret', 'refreshToken'] as $requiredProp) {            if (empty($tokenData[$requiredProp])) {                throw new TokenException(                    "Cannot refresh this token without `{$requiredProp}` being set"                );            }        }        $response = $this->ssoOidcClient->createToken([            'clientId' => $tokenData['clientId'],            'clientSecret' => $tokenData['clientSecret'],            'grantType' => 'refresh_token', // REQUIRED            'refreshToken' => $tokenData['refreshToken'],        ]);        if ($response['@metadata']['statusCode'] !== 200) {            throw new TokenException('Unable to create a new sso token');        }        $tokenData['accessToken'] = $response['accessToken'];        $tokenData['expiresAt'] = time () + $response['expiresIn'];        $tokenData['refreshToken'] = $response['refreshToken'];        return $this->writeNewTokenDataToDisk($tokenData, $tokenLocation);    }    /**     * This method checks for whether a token refresh should happen.     * It will return true just if more than 30 seconds has happened     * since last refresh, and if the expiration is within a 5-minutes     * window from the current time.     *     * @return bool     */    public function shouldAttemptRefresh(): bool    {        $tokenLocation = self::getTokenLocation($this->ssoSessionName);        $tokenData = $this->getTokenData($tokenLocation);        if (empty($tokenData['expiresAt'])) {            throw new TokenException(                "Token file at $tokenLocation must contain an expiration date"            );        }        $tokenExpiresAt = strtotime($tokenData['expiresAt']);        $lastRefreshAt = filemtime($tokenLocation);        $now = \time();        // If last refresh happened after 30 seconds        // and if the token expiration is in the 5 minutes window        return ($now - $lastRefreshAt) > self::REFRESH_ATTEMPT_WINDOW_IN_SECS            && ($tokenExpiresAt - $now) < self::REFRESH_WINDOW_IN_SECS;    }    /**     * @param $sso_session     * @return string     */    public static function getTokenLocation($sso_session): string    {        return self::getHomeDir()            . '/.aws/sso/cache/'            . mb_convert_encoding(sha1($sso_session), "UTF-8")            . ".json";    }    /**     * @param $tokenLocation     * @return array     */    function getTokenData($tokenLocation): array    {        if (empty($tokenLocation) || !is_readable($tokenLocation)) {            throw new TokenException("Unable to read token file at {$tokenLocation}");        }        return json_decode(file_get_contents($tokenLocation), true);    }    /**     * @param $tokenData     * @param $tokenLocation     * @return mixed     */    private function validateTokenData($tokenLocation, $tokenData)    {        foreach (['accessToken', 'expiresAt'] as $requiredProp) {            if (empty($tokenData[$requiredProp])) {                throw new TokenException(                    "Token file at {$tokenLocation} must contain the required property `{$requiredProp}`"                );            }        }        $expiration = strtotime($tokenData['expiresAt']);        if ($expiration === false) {            throw new TokenException("Cached SSO token returned an invalid expiration");        } elseif ($expiration < time()) {            throw new TokenException("Cached SSO token returned an expired token");        }        return $tokenData;    }    /**     * @param array $tokenData     * @param string $tokenLocation     *     * @return array     */    private function writeNewTokenDataToDisk(array $tokenData, $tokenLocation): array    {        $tokenData['expiresAt'] = gmdate(            'Y-m-d\TH:i:s\Z',            $tokenData['expiresAt']        );        file_put_contents($tokenLocation, json_encode(array_filter($tokenData)));        return $tokenData;    }}
 |