lib/aws-sdk-core/sso_credentials.rb



# frozen_string_literal: true

module Aws
  # An auto-refreshing credential provider that assumes a role via
  # {Aws::SSO::Client#get_role_credentials} using a cached access
  # token. When `sso_session` is specified, token refresh logic from
  # {Aws::SSOTokenProvider} will be used to refresh the token if possible.
  # This class does NOT implement the SSO login token flow - tokens
  # must generated separately by running `aws login` from the
  # AWS CLI with the correct profile. The `SSOCredentials` will
  # auto-refresh the AWS credentials from SSO.
  #
  #     # You must first run aws sso login --profile your-sso-profile
  #     sso_credentials = Aws::SSOCredentials.new(
  #       sso_account_id: '123456789',
  #       sso_role_name: "role_name",
  #       sso_region: "us-east-1",
  #       sso_session: 'my_sso_session'
  #     )
  #     ec2 = Aws::EC2::Client.new(credentials: sso_credentials)
  #
  # If you omit `:client` option, a new {Aws::SSO::Client} object will be
  # constructed with additional options that were provided.
  #
  # @see Aws::SSO::Client#get_role_credentials
  # @see https://docs.aws.amazon.com/singlesignon/latest/userguide/what-is.html
  class SSOCredentials

    include CredentialProvider
    include RefreshingCredentials

    # @api private
    LEGACY_REQUIRED_OPTS =         [:sso_start_url, :sso_account_id, :sso_region, :sso_role_name].freeze
    TOKEN_PROVIDER_REQUIRED_OPTS = [:sso_session, :sso_account_id, :sso_region, :sso_role_name].freeze

    # @api private
    SSO_LOGIN_GUIDANCE = 'The SSO session associated with this profile has '\
    'expired or is otherwise invalid. To refresh this SSO session run '\
    'aws sso login with the corresponding profile.'.freeze

    # @option options [required, String] :sso_account_id The AWS account ID
    #   that temporary AWS credentials will be resolved for
    #
    # @option options [required, String] :sso_role_name The corresponding
    #   IAM role in the AWS account that temporary AWS credentials
    #   will be resolved for.
    #
    # @option options [required, String] :sso_region The AWS region where the
    #   SSO directory for the given sso_start_url is hosted.
    #
    # @option options [String] :sso_session The SSO Token used for fetching
    #   the token. If provided, refresh logic from the {Aws::SSOTokenProvider}
    #   will be used.
    #
    # @option options [String] :sso_start_url (legacy profiles) If provided,
    #   legacy token fetch behavior will be used, which does not support
    #   token refreshing.  The start URL is provided by the SSO
    #   service via the console and is the URL used to
    #   login to the SSO directory. This is also sometimes referred to as
    #   the "User Portal URL".
    #
    # @option options [SSO::Client] :client Optional `SSO::Client`.  If not
    #   provided, a client will be constructed.
    #
    # @option options [Callable] before_refresh Proc called before
    #   credentials are refreshed. `before_refresh` is called
    #   with an instance of this object when
    #   AWS credentials are required and need to be refreshed.
    def initialize(options = {})
      options = options.select {|k, v| !v.nil? }
      if (options[:sso_session])
        missing_keys = TOKEN_PROVIDER_REQUIRED_OPTS.select { |k| options[k].nil? }
        unless missing_keys.empty?
          raise ArgumentError, "Missing required keys: #{missing_keys}"
        end
        @legacy = false
        @sso_role_name = options.delete(:sso_role_name)
        @sso_account_id = options.delete(:sso_account_id)

        # if client has been passed, don't pass through to SSOTokenProvider
        @client = options.delete(:client)
        options.delete(:sso_start_url)
        @token_provider = Aws::SSOTokenProvider.new(options.dup)
        @sso_session = options.delete(:sso_session)
        @sso_region = options.delete(:sso_region)

        unless @client
          client_opts = {}
          options.each_pair { |k,v| client_opts[k] = v unless CLIENT_EXCLUDE_OPTIONS.include?(k) }
          client_opts[:region] = @sso_region
          client_opts[:credentials] = nil
          @client = Aws::SSO::Client.new(client_opts)
        end
      else # legacy behavior
        missing_keys = LEGACY_REQUIRED_OPTS.select { |k| options[k].nil? }
        unless missing_keys.empty?
          raise ArgumentError, "Missing required keys: #{missing_keys}"
        end
        @legacy = true
        @sso_start_url = options.delete(:sso_start_url)
        @sso_region = options.delete(:sso_region)
        @sso_role_name = options.delete(:sso_role_name)
        @sso_account_id = options.delete(:sso_account_id)

        # validate we can read the token file
        read_cached_token

        client_opts = {}
        options.each_pair { |k,v| client_opts[k] = v unless CLIENT_EXCLUDE_OPTIONS.include?(k) }
        client_opts[:region] = @sso_region
        client_opts[:credentials] = nil

        @client = options[:client] || Aws::SSO::Client.new(client_opts)
      end

      @async_refresh = true
      super
    end

    # @return [SSO::Client]
    attr_reader :client

    private

    def read_cached_token
      cached_token = Json.load(File.read(sso_cache_file))
      # validation
      unless cached_token['accessToken'] && cached_token['expiresAt']
        raise ArgumentError, 'Missing required field(s)'
      end
      expires_at = DateTime.parse(cached_token['expiresAt'])
      if expires_at < DateTime.now
        raise ArgumentError, 'Cached SSO Token is expired.'
      end
      cached_token
    rescue Errno::ENOENT, Aws::Json::ParseError, ArgumentError
      raise Errors::InvalidSSOCredentials, SSO_LOGIN_GUIDANCE
    end

    def refresh
      c = if @legacy
            cached_token = read_cached_token
            @client.get_role_credentials(
              account_id: @sso_account_id,
              role_name: @sso_role_name,
              access_token: cached_token['accessToken']
            ).role_credentials
          else
            @client.get_role_credentials(
              account_id: @sso_account_id,
              role_name: @sso_role_name,
              access_token: @token_provider.token.token
            ).role_credentials
          end

      @credentials = Credentials.new(
        c.access_key_id,
        c.secret_access_key,
        c.session_token
      )
      @expiration = Time.at(c.expiration / 1000.0)
    end

    def sso_cache_file
      start_url_sha1 = OpenSSL::Digest::SHA1.hexdigest(@sso_start_url.encode('utf-8'))
      File.join(Dir.home, '.aws', 'sso', 'cache', "#{start_url_sha1}.json")
    rescue ArgumentError
      # Dir.home raises ArgumentError when ENV['home'] is not set
      raise ArgumentError, "Unable to load sso_cache_file: ENV['HOME'] is not set."
    end
  end
end