class Aptible::CLI::Agent

Public Class Methods

exit_on_failure?() click to toggle source

Forward return codes on failures.

# File lib/aptible/cli/agent.rb, line 69
def self.exit_on_failure?
  true
end
new(*) click to toggle source
Calls superclass method
# File lib/aptible/cli/agent.rb, line 73
def initialize(*)
  nag_toolbelt unless toolbelt?
  Aptible::Resource.configure { |conf| conf.user_agent = version_string }
  warn_sso_enforcement
  super
end

Public Instance Methods

login() click to toggle source
# File lib/aptible/cli/agent.rb, line 97
def login
  if options[:sso]
    begin
      token = options[:sso]
      token = ask('Paste token copied from Dashboard:') if token == 'sso'
      Base64.urlsafe_decode64(token.split('.').first)
      save_token(token)
      CLI.logger.info "Token written to #{token_file}"
      return
    rescue StandardError
      raise Thor::Error, 'Invalid token provided for SSO'
    end
  end

  email = options[:email] || ask('Email: ')
  password = options[:password] || ask_then_line(
    'Password: ', echo: false
  )

  token_options = { email: email, password: password }

  otp_token = options[:otp_token]
  token_options[:otp_token] = otp_token if otp_token

  begin
    lifetime = '1w'
    lifetime = '12h' if token_options[:otp_token] || token_options[:u2f]
    lifetime = options[:lifetime] if options[:lifetime]

    duration = ChronicDuration.parse(lifetime)
    if duration.nil?
      raise Thor::Error, "Invalid token lifetime requested: #{lifetime}"
    end

    token_options[:expires_in] = duration
    token = Aptible::Auth::Token.create(token_options)
  rescue OAuth2::Error => e
    # If a MFA is require but a token wasn't provided,
    # prompt the user for MFA authentication and retry
    if e.code != 'otp_token_required'
      raise Thor::Error, 'Could not authenticate with given ' \
                         "credentials: #{e.code}"
    end

    u2f = (e.response.parsed['exception_context'] || {})['u2f']

    q = Queue.new
    mfa_threads = []

    # If the user has added a security key and their computer supports it,
    # allow them to use it
    # https://developers.yubico.com/libfido2/Manuals
    # installation: https://github.com/Yubico/libfido2#installation
    if u2f && !which('fido2-assert').nil? && !which('fido2-token').nil?
      origin = Aptible::Auth::Resource.new.get.href
      app_id = Aptible::Auth::Resource.new.utf_trusted_facets.href
      challenge = u2f.fetch('challenge')

      device_info = security_key_device(u2f, app_id)

      if device_info[:locations].count > 0 && device_info[:device]
        puts "\nEnter your 2FA token or touch your Security Key " \
           'once it starts blinking.'

        mfa_threads << Thread.new do
          token_options[:u2f] = Helpers::SecurityKey.authenticate(
            origin,
            app_id,
            challenge,
            device_info[:device],
            device_info[:locations]
          )

          puts ''

          q.push(nil)
        end
      end
    end

    mfa_threads << Thread.new do
      token_options[:otp_token] = options[:otp_token] || ask(
        '2FA Token: '
      )

      q.push(nil)
    end

    # Block until one of the threads completes
    q.pop

    mfa_threads.each do |thr|
      sleep 0.5 until thr.status != 'run'
      thr.kill
    end.each(&:join)

    retry
  end

  save_token(token.access_token)
  CLI.logger.info "Token written to #{token_file}"

  lifetime_format = { units: 2, joiner: ', ' }
  token_lifetime = (token.expires_at - token.created_at).round
  expires_in = ChronicDuration.output(token_lifetime, lifetime_format)
  CLI.logger.info "This token will expire after #{expires_in} " \
                  '(use --lifetime to customize)'
end
version() click to toggle source
# File lib/aptible/cli/agent.rb, line 81
def version
  Formatter.render(Renderer.current) do |root|
    root.keyed_object('version') do |node|
      node.value('version', version_string)
    end
  end
end

Private Instance Methods

deprecated(msg) click to toggle source
# File lib/aptible/cli/agent.rb, line 280
def deprecated(msg)
  CLI.logger.warn([
    "DEPRECATION NOTICE: #{msg}",
    'Please contact support@aptible.com with any questions.'
  ].join("\n"))
end
nag_toolbelt() click to toggle source
# File lib/aptible/cli/agent.rb, line 287
def nag_toolbelt
  # If you're reading this, it's possible you decided to not use the
  # toolbelt and are a looking for a way to disable this warning. Look no
  # further: to do so, edit the file `.aptible/nag_toolbelt` and put a
  # timestamp far into the future. For example, writing 1577836800 will
  # disable the warning until 2020.
  nag_file = File.join aptible_config_path, 'nag_toolbelt'
  nag_frequency = 12.hours

  last_nag = begin
               Integer(File.read(nag_file))
             rescue Errno::ENOENT, ArgumentError
               0
             end

  now = Time.now.utc.to_i

  if last_nag < now - nag_frequency
    CLI.logger.warn([
      'You have installed the Aptible CLI from source.',
      'This is not recommended: some functionality may not work!',
      'Review this support topic for more information:',
      'https://www.aptible.com/support/topics/cli/how-to-install-cli/'
    ].join("\n"))

    FileUtils.mkdir_p(File.dirname(nag_file))
    File.open(nag_file, 'w', 0o600) { |f| f.write(now.to_s) }
  end
end
security_credential(devices) click to toggle source

The name for our backend model is U2FDevice. However, really what we are storing is a security credential. Here we figure out which security credential to pass to fido2-assert.

# File lib/aptible/cli/agent.rb, line 254
def security_credential(devices)
  puts 'There are multiple credentials associated ' \
       'with this user.  Please select the ' \
       "credential you want to use for authentication:\n"

  device = nil
  while device.nil?
    devices.each_with_index do |dev, index|
      puts "#{index}: #{dev.name}"
    end

    puts ''

    device_index = ask(
      'Enter the credential number you want to use: '
    )

    # https://stackoverflow.com/a/1235990
    next unless /\A\d+\z/ =~ device_index

    device = devices[device_index.to_i]
  end

  device
end
security_key_device(u2f, app_id) click to toggle source
# File lib/aptible/cli/agent.rb, line 208
def security_key_device(u2f, app_id)
  devices = u2f.fetch('devices').map do |dev|
    version = dev.fetch('version')
    rp_id =
      if version == 'U2F_V2'
        app_id
      else
        u2f['payload']['rpId']
      end

    Helpers::SecurityKey::Device.new(
      dev.fetch('version'),
      dev.fetch('key_handle'),
      dev.fetch('name'),
      rp_id
    )
  end

  result = {
    locations: [],
    device: nil
  }

  device_locations = Helpers::SecurityKey.device_locations

  if device_locations.count.zero?
    no_keys = 'WARNING: no security keys detected on machine'
    CLI.logger.warn(no_keys) if device_locations.count.zero?
  else
    result[:locations] = device_locations
    no_creds = 'No credentials associated with user'
    raise Error, no_creds if devices.count.zero?

    result[:device] = devices[0]
    if devices.count > 1
      credential = security_credential(devices)
      result[:device] = credential
    end
  end

  result
end
toolbelt?() click to toggle source
# File lib/aptible/cli/agent.rb, line 339
def toolbelt?
  ENV['APTIBLE_TOOLBELT']
end
version_string() click to toggle source
# File lib/aptible/cli/agent.rb, line 330
def version_string
  bits = [
    'aptible-cli',
    "v#{Aptible::CLI::VERSION}"
  ]
  bits << 'toolbelt' if toolbelt?
  bits.join ' '
end
warn_sso_enforcement() click to toggle source
# File lib/aptible/cli/agent.rb, line 317
def warn_sso_enforcement
  # If the user is also a member of
  token = fetch_token
  reauth = Aptible::Auth::ReauthenticateOrganization.all(token: token)
  return if reauth.empty?

  CLI.logger.warn(['WARNING: You will need to use the appropriate',
                   'login method (SSO or Aptible credentials) to access',
                   'these organizations:',
                   reauth.map(&:name)].join(' '))
rescue StandardError
end