class Spaceship::Client

Constants

PROTOCOL_VERSION
USER_AGENT

Attributes

client[R]
csrf_tokens[RW]
logger[RW]

The logger in which all requests are logged /tmp/spaceship[time]_[pid].log by default

user[RW]

The user that is currently logged in

Public Class Methods

hostname() click to toggle source
# File lib/spaceship/client.rb, line 100
def self.hostname
  raise "You must implemented self.hostname"
end
login(user = nil, password = nil) click to toggle source

Authenticates with Apple's web services. This method has to be called once to generate a valid session. The session will automatically be used from then on.

This method will automatically use the username from the Appfile (if available) and fetch the password from the Keychain (if available)

@param user (String) (optional): The username (usually the email address) @param password (String) (optional): The password

@raise InvalidUserCredentialsError: raised if authentication failed

@return (Spaceship::Client) The client the login method was called for

# File lib/spaceship/client.rb, line 91
def self.login(user = nil, password = nil)
  instance = self.new
  if instance.login(user, password)
    instance
  else
    raise InvalidUserCredentialsError.new, "Invalid User Credentials"
  end
end
new() click to toggle source
# File lib/spaceship/client.rb, line 104
def initialize
  options = {
   request: {
      timeout:       (ENV["SPACESHIP_TIMEOUT"] || 300).to_i,
      open_timeout:  (ENV["SPACESHIP_TIMEOUT"] || 300).to_i
    }
  }
  @cookie = HTTP::CookieJar.new
  @client = Faraday.new(self.class.hostname, options) do |c|
    c.response :json, content_type: /\bjson$/
    c.response :xml, content_type: /\bxml$/
    c.response :plist, content_type: /\bplist$/
    c.use :cookie_jar, jar: @cookie
    c.adapter Faraday.default_adapter

    if ENV['SPACESHIP_DEBUG']
      # for debugging only
      # This enables tracking of networking requests using Charles Web Proxy
      c.proxy "https://127.0.0.1:8888"
    end

    if ENV["DEBUG"]
      puts "To run _spaceship_ through a local proxy, use SPACESHIP_DEBUG"
    end
  end
end

Public Instance Methods

UI() click to toggle source

Public getter for all UI related code rubocop:disable Style/MethodName

# File lib/spaceship/ui.rb, line 11
def UI
  UserInterface.new(self)
end
handle_two_factor(response) click to toggle source
# File lib/spaceship/two_step_client.rb, line 45
def handle_two_factor(response)
  two_factor_url = "https://github.com/fastlane/fastlane/tree/master/spaceship#2-step-verification"
  puts "Two Factor Authentication for account '#{self.user}' is enabled"
  puts "If you're running this in a non-interactive session (e.g. server or CI)"
  puts "check out #{two_factor_url}"

  security_code = response.body["phoneNumberVerification"]["securityCode"]
  # {"length"=>6,
  #  "tooManyCodesSent"=>false,
  #  "tooManyCodesValidated"=>false,
  #  "securityCodeLocked"=>false}
  code_length = security_code["length"]
  code = ask("Please enter the #{code_length} digit code: ")
  puts "Requesting session..."

  # Send securityCode back to server to get a valid session
  r = request(:post) do |req|
    req.url "https://idmsa.apple.com/appleauth/auth/verify/trusteddevice/securitycode"
    req.headers["Accept"] = "application/json"
    req.headers['Content-Type'] = 'application/json'
    req.headers["scnt"] = @scnt
    req.headers["X-Apple-Id-Session-Id"] = @x_apple_id_session_id
    req.body = { "securityCode" => { "code" => code.to_s } }.to_json
  end

  # we use `Spaceship::TunesClient.new.handle_itc_response`
  # since this might be from the Dev Portal, but for 2 step
  Spaceship::TunesClient.new.handle_itc_response(r.body)

  store_session

  return true
end
handle_two_step(response) click to toggle source
# File lib/spaceship/two_step_client.rb, line 3
def handle_two_step(response)
  @x_apple_id_session_id = response["x-apple-id-session-id"]
  @scnt = response["scnt"]

  r = request(:get) do |req|
    req.url "https://idmsa.apple.com/appleauth/auth"
    req.headers["scnt"] = @scnt
    req.headers["X-Apple-Id-Session-Id"] = @x_apple_id_session_id
    req.headers["Accept"] = "application/json"
  end

  if r.body.kind_of?(Hash) && r.body["trustedDevices"].kind_of?(Array)
    if r.body.fetch("securityCode", {})["tooManyCodesLock"].to_s.length > 0
      raise ITunesConnectError.new, "Too many verification codes have been sent. Enter the last code you received, use one of your devices, or try again later."
    end

    old_client = (begin
                    Tunes::RecoveryDevice.client
                  rescue
                    nil # since client might be nil, which raises an exception
                  end)
    Tunes::RecoveryDevice.client = self # temporary set it as it's required by the factory method
    devices = r.body["trustedDevices"].collect do |current|
      Tunes::RecoveryDevice.factory(current)
    end
    Tunes::RecoveryDevice.client = old_client

    puts "Two Step Verification for account '#{self.user}' is enabled"
    puts "Please select a device to verify your identity"
    available = devices.collect do |c|
      "#{c.name}\t#{c.model_name || 'SMS'}\t(#{c.device_id})"
    end
    result = choose(*available)
    device_id = result.match(/.*\t.*\t\((.*)\)/)[1]
    select_device(r, device_id)
  elsif r.body.kind_of?(Hash) && r.body["phoneNumberVerification"].kind_of?(Hash)
    handle_two_factor(r)
  else
    raise "Invalid 2 step response #{r.body}"
  end
end
itc_service_key() click to toggle source
# File lib/spaceship/client.rb, line 331
def itc_service_key
  return @service_key if @service_key

  # Check if we have a local cache of the key
  itc_service_key_path = "/tmp/spaceship_itc_service_key.txt"
  return File.read(itc_service_key_path) if File.exist?(itc_service_key_path)

  # Some customers in Asia have had trouble with the CDNs there that cache and serve this content, leading
  # to "buffer error (Zlib::BufError)" from deep in the Ruby HTTP stack. Setting this header requests that
  # the content be served only as plain-text, which seems to work around their problem, while not affecting
  # other clients.
  #
  # https://github.com/fastlane/fastlane/issues/4610
  headers = { 'Accept-Encoding' => 'identity' }
  # We need a service key from a JS file to properly auth
  js = request(:get, "https://itunesconnect.apple.com/itc/static-resources/controllers/login_cntrl.js", nil, headers)
  @service_key = js.body.match(/itcServiceKey = '(.*)'/)[1]

  # Cache the key locally
  File.write(itc_service_key_path, @service_key)

  return @service_key
rescue => ex
  puts ex.to_s
  raise AppleTimeoutError.new, "Could not receive latest API key from iTunes Connect, this might be a server issue."
end
load_session_from_env() click to toggle source
# File lib/spaceship/two_step_client.rb, line 89
def load_session_from_env
  yaml_text = ENV["FASTLANE_SESSION"] || ENV["SPACESHIP_SESSION"]
  return if yaml_text.to_s.length == 0
  puts "Loading session from environment variable" if $verbose

  file = Tempfile.new('cookie.yml')
  file.write(yaml_text.gsub("\\n", "\n"))
  file.close

  begin
    @cookie.load(file.path)
  rescue => ex
    puts "Error loading session from environment"
    puts "Make sure to pass the session in a valid format"
    raise ex
  ensure
    file.unlink
  end
end
load_session_from_file() click to toggle source

Only needed for 2 step

# File lib/spaceship/two_step_client.rb, line 80
def load_session_from_file
  if File.exist?(persistent_cookie_path)
    puts "Loading session from '#{persistent_cookie_path}'" if $verbose
    @cookie.load(persistent_cookie_path)
    return true
  end
  return false
end
login(user = nil, password = nil) click to toggle source

Authenticates with Apple's web services. This method has to be called once to generate a valid session. The session will automatically be used from then on.

This method will automatically use the username from the Appfile (if available) and fetch the password from the Keychain (if available)

@param user (String) (optional): The username (usually the email address) @param password (String) (optional): The password

@raise InvalidUserCredentialsError: raised if authentication failed

@return (Spaceship::Client) The client the login method was called for

# File lib/spaceship/client.rb, line 230
def login(user = nil, password = nil)
  if user.to_s.empty? or password.to_s.empty?
    require 'credentials_manager'

    keychain_entry = CredentialsManager::AccountManager.new(user: user, password: password)
    user ||= keychain_entry.user
    password = keychain_entry.password
  end

  if user.to_s.strip.empty? or password.to_s.strip.empty?
    raise NoUserCredentialsError.new, "No login data provided"
  end

  self.user = user
  @password = password
  begin
    do_login(user, password)
  rescue InvalidUserCredentialsError => ex
    raise ex unless keychain_entry

    if keychain_entry.invalid_credentials
      login(user)
    else
      puts "Please run this tool again to apply the new password"
    end
  end
end
page_size() click to toggle source

The page size we want to request, defaults to 500

# File lib/spaceship/client.rb, line 192
def page_size
  @page_size ||= 500
end
paging() { |page| ... } click to toggle source

Handles the paging for you… for free Just pass a block and use the parameter as page number

# File lib/spaceship/client.rb, line 198
def paging
  page = 0
  results = []
  loop do
    page += 1
    current = yield(page)

    results += current

    break if (current || []).count < page_size # no more results
  end

  return results
end
parse_response(response, expected_key = nil) click to toggle source
# File lib/spaceship/client.rb, line 408
def parse_response(response, expected_key = nil)
  if response.body
    # If we have an `expected_key`, select that from response.body Hash
    # Else, don't.
    content = expected_key ? response.body[expected_key] : response.body
  end

  if content.nil?
    raise UnexpectedResponse, response.body
  else
    store_csrf_tokens(response)
    content
  end
end
request(method, url_or_path = nil, params = nil, headers = {}, &block) click to toggle source
# File lib/spaceship/client.rb, line 388
def request(method, url_or_path = nil, params = nil, headers = {}, &block)
  headers.merge!(csrf_tokens)
  headers['User-Agent'] = USER_AGENT

  # Before encoding the parameters, log them
  log_request(method, url_or_path, params)

  # form-encode the params only if there are params, and the block is not supplied.
  # this is so that certain requests can be made using the block for more control
  if method == :post && params && !block_given?
    params, headers = encode_params(params, headers)
  end

  response = send_request(method, url_or_path, params, headers, &block)

  log_response(method, url_or_path, response)

  return response
end
select_device(r, device_id) click to toggle source
# File lib/spaceship/two_step_client.rb, line 109
def select_device(r, device_id)
  # Request Token
  r = request(:put) do |req|
    req.url "https://idmsa.apple.com/appleauth/auth/verify/device/#{device_id}/securitycode"
    req.headers["Accept"] = "application/json"
    req.headers["scnt"] = @scnt
    req.headers["X-Apple-Id-Session-Id"] = @x_apple_id_session_id
  end

  # we use `Spaceship::TunesClient.new.handle_itc_response`
  # since this might be from the Dev Portal, but for 2 step
  Spaceship::TunesClient.new.handle_itc_response(r.body)

  puts "Successfully requested notification"
  code = ask("Please enter the 4 digit code: ")
  puts "Requesting session..."

  # Send token back to server to get a valid session
  r = request(:post) do |req|
    req.url "https://idmsa.apple.com/appleauth/auth/verify/device/#{device_id}/securitycode"
    req.headers["Accept"] = "application/json"
    req.headers["scnt"] = @scnt
    req.headers["X-Apple-Id-Session-Id"] = @x_apple_id_session_id
    req.body = { "code" => code.to_s }.to_json
    req.headers['Content-Type'] = 'application/json'
  end

  begin
    Spaceship::TunesClient.new.handle_itc_response(r.body) # this will fail if the code is invalid
  rescue => ex
    # If the code was entered wrong
    # {
    #   "securityCode": {
    #     "code": "1234"
    #   },
    #   "securityCodeLocked": false,
    #   "recoveryKeyLocked": false,
    #   "recoveryKeySupported": true,
    #   "manageTrustedDevicesLinkName": "appleid.apple.com",
    #   "suppressResend": false,
    #   "authType": "hsa",
    #   "accountLocked": false,
    #   "validationErrors": [{
    #     "code": "-21669",
    #     "title": "Incorrect Verification Code",
    #     "message": "Incorrect verification code."
    #   }]
    # }
    if ex.to_s.include?("verification code") # to have a nicer output
      puts "Error: Incorrect verification code"
      return select_device(r, device_id)
    end

    raise ex
  end

  store_session

  return true
end
send_shared_login_request(user, password) click to toggle source

This method is used for both the Apple Dev Portal and iTunes Connect This will also handle 2 step verification

# File lib/spaceship/client.rb, line 260
def send_shared_login_request(user, password)
  # First we see if we have a stored cookie for 2 step enabled accounts
  # this is needed as it stores the information on if this computer is a
  # trusted one. In general I think spaceship clients should be trusted
  load_session_from_file
  # If this is a CI, the user can pass the session via environment variable
  load_session_from_env

  data = {
    accountName: user,
    password: password,
    rememberMe: true
  }

  begin
    # The below workaround is only needed for 2 step verified machines
    # Due to escaping of cookie values we have a little workaround here
    # By default the cookie jar would generate the following header
    #   DES5c148...=HSARM.......xaA/O69Ws/CHfQ==SRVT
    # However we need the following
    #   DES5c148...="HSARM.......xaA/O69Ws/CHfQ==SRVT"
    # There is no way to get the cookie jar value with " around the value
    # so we manually modify the cookie (only this one) to be properly escaped
    # Afterwards we pass this value manually as a header
    # It's not enough to just modify @cookie, it needs to be done after self.cookie
    # as a string operation
    important_cookie = @cookie.store.entries.find { |a| a.name.include?("DES") }
    if important_cookie
      modified_cookie = self.cookie # returns a string of all cookies
      unescaped_important_cookie = "#{important_cookie.name}=#{important_cookie.value}"
      escaped_important_cookie = "#{important_cookie.name}=\"#{important_cookie.value}\""
      modified_cookie.gsub!(unescaped_important_cookie, escaped_important_cookie)
    end

    response = request(:post) do |req|
      req.url "https://idmsa.apple.com/appleauth/auth/signin?widgetKey=#{itc_service_key}"
      req.body = data.to_json
      req.headers['Content-Type'] = 'application/json'
      req.headers['X-Requested-With'] = 'XMLHttpRequest'
      req.headers['Accept'] = 'application/json, text/javascript'
      req.headers["Cookie"] = modified_cookie if modified_cookie
    end
  rescue UnauthorizedAccessError
    raise InvalidUserCredentialsError.new, "Invalid username and password combination. Used '#{user}' as the username."
  end

  # get woinst, wois, and itctx cookie values
  request(:get, "https://itunesconnect.apple.com/WebObjects/iTunesConnect.woa/wa")

  case response.status
  when 403
    raise InvalidUserCredentialsError.new, "Invalid username and password combination. Used '#{user}' as the username."
  when 200
    return response
  else
    location = response["Location"]
    if location && URI.parse(location).path == "/auth" # redirect to 2 step auth page
      handle_two_step(response)
      return true
    elsif (response.body || "").include?('invalid="true"')
      # User Credentials are wrong
      raise InvalidUserCredentialsError.new, "Invalid username and password combination. Used '#{user}' as the username."
    elsif (response['Set-Cookie'] || "").include?("itctx")
      raise "Looks like your Apple ID is not enabled for iTunes Connect, make sure to be able to login online"
    else
      info = [response.body, response['Set-Cookie']]
      raise TunesClient::ITunesConnectError.new, info.join("\n")
    end
  end
end
store_session() click to toggle source
# File lib/spaceship/two_step_client.rb, line 170
def store_session
  # If the request was successful, r.body is actually nil
  # The previous request will fail if the user isn't on a team
  # on iTunes Connect, but it still works, so we're good

  # Tell iTC that we are trustworthy (obviously)
  # This will update our local cookies to something new
  # They probably have a longer time to live than the other poor cookies
  # Changed Keys
  # - myacinfo
  # - DES5c148586dfd451e55afb0175f62418f91
  # We actually only care about the DES value

  request(:get) do |req|
    req.url "https://idmsa.apple.com/appleauth/auth/2sv/trust"
    req.headers["scnt"] = @scnt
    req.headers["X-Apple-Id-Session-Id"] = @x_apple_id_session_id
  end
  # This request will fail if the user isn't added to a team on iTC
  # However we don't really care, this request will still return the
  # correct DES... cookie

  self.store_cookie
end
with_retry(tries = 5) { || ... } click to toggle source

@!group Helpers

# File lib/spaceship/client.rb, line 362
def with_retry(tries = 5, &_block)
  return yield
rescue Faraday::Error::ConnectionFailed, Faraday::Error::TimeoutError, AppleTimeoutError, Errno::EPIPE => ex # New Faraday version: Faraday::TimeoutError => ex
  unless (tries -= 1).zero?
    logger.warn("Timeout received: '#{ex.message}'.  Retrying after 3 seconds (remaining: #{tries})...")
    sleep 3 unless defined? SpecHelper
    retry
  end
  raise ex # re-raise the exception
rescue UnauthorizedAccessError => ex
  if @loggedin && !(tries -= 1).zero?
    msg = "Auth error received: '#{ex.message}'. Login in again then retrying after 3 seconds (remaining: #{tries})..."
    puts msg if $verbose
    logger.warn msg
    do_login(self.user, @password)
    sleep 3 unless defined? SpecHelper
    retry
  end
  raise ex # re-raise the exception
end

Private Instance Methods

directory_accessible?(path) click to toggle source
# File lib/spaceship/client.rb, line 425
def directory_accessible?(path)
  Dir.exist?(File.expand_path(path))
end
do_login(user, password) click to toggle source
# File lib/spaceship/client.rb, line 429
def do_login(user, password)
  @loggedin = false
  ret = send_login_request(user, password) # different in subclasses
  @loggedin = true
  ret
end
encode_params(params, headers) click to toggle source
# File lib/spaceship/client.rb, line 480
def encode_params(params, headers)
  params = Faraday::Utils::ParamsHash[params].to_query
  headers = { 'Content-Type' => 'application/x-www-form-urlencoded' }.merge(headers)
  return params, headers
end
log_request(method, url, params) click to toggle source
# File lib/spaceship/client.rb, line 446
def log_request(method, url, params)
  params_to_log = Hash(params).dup # to also work with nil
  params_to_log.delete(:accountPassword) # Dev Portal
  params_to_log.delete(:theAccountPW) # iTC
  params_to_log = params_to_log.collect do |key, value|
    "{#{key}: #{value}}"
  end
  logger.info(">> #{method.upcase}: #{url} #{params_to_log.join(', ')}")
end
log_response(method, url, response) click to toggle source
# File lib/spaceship/client.rb, line 456
def log_response(method, url, response)
  body = response.body.kind_of?(String) ? response.body.force_encoding(Encoding::UTF_8) : response.body
  logger.debug("<< #{method.upcase}: #{url}: #{body}")
end
send_request(method, url_or_path, params, headers, &block) click to toggle source

Actually sends the request to the remote server Automatically retries the request up to 3 times if something goes wrong

# File lib/spaceship/client.rb, line 463
def send_request(method, url_or_path, params, headers, &block)
  with_retry do
    response = @client.send(method, url_or_path, params, headers, &block)
    resp_hash = response.to_hash
    if resp_hash[:status] == 401
      msg = "Auth lost"
      logger.warn msg
      raise UnauthorizedAccessError.new, "Unauthorized Access"
    end

    if response.body.to_s.include?("<title>302 Found</title>")
      raise AppleTimeoutError.new, "Apple 302 detected"
    end
    return response
  end
end
store_csrf_tokens(response) click to toggle source

Is called from `parse_response` to store the latest csrf_token (if available)

# File lib/spaceship/client.rb, line 437
def store_csrf_tokens(response)
  if response and response.headers
    tokens = response.headers.select { |k, v| %w(csrf csrf_ts).include?(k) }
    if tokens and !tokens.empty?
      @csrf_tokens = tokens
    end
  end
end