Class: Spaceship::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/spaceship/ui.rb,
lib/spaceship/two_step_client.rb,
lib/spaceship/portal/ui/select_team.rb,
lib/spaceship/client.rb

Direct Known Subclasses

DUClient, PortalClient, TunesClient

Defined Under Namespace

Classes: AppleTimeoutError, BasicPreferredInfoError, InvalidUserCredentialsError, NoUserCredentialsError, UnauthorizedAccessError, UnexpectedResponse, UserInterface

Constant Summary collapse

PROTOCOL_VERSION =
"QH65B2"
USER_AGENT =
"Spaceship #{Spaceship::VERSION}"

Helpers collapse

Instance Attribute Summary collapse

Automatic Paging collapse

Login and Team Selection collapse

Helpers collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeClient

Returns a new instance of Client.


104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# 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

Instance Attribute Details

#clientObject (readonly)

Returns the value of attribute client


25
26
27
# File 'lib/spaceship/client.rb', line 25

def client
  @client
end

#csrf_tokensObject

memorize the last csrf tokens from responses


384
385
386
# File 'lib/spaceship/client.rb', line 384

def csrf_tokens
  @csrf_tokens
end

#loggerObject

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


32
33
34
# File 'lib/spaceship/client.rb', line 32

def logger
  @logger
end

#userObject

The user that is currently logged in


28
29
30
# File 'lib/spaceship/client.rb', line 28

def user
  @user
end

Class Method Details

.hostnameObject


100
101
102
# File 'lib/spaceship/client.rb', line 100

def self.hostname
  raise "You must implemented self.hostname"
end

.login(user = nil, password = nil) ⇒ Spaceship::Client

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)

Parameters:

  • user (String) (defaults to: nil)

    (optional): The username (usually the email address)

  • password (String) (defaults to: nil)

    (optional): The password

Returns:

Raises:

  • InvalidUserCredentialsError: raised if authentication failed


91
92
93
94
95
96
97
98
# File 'lib/spaceship/client.rb', line 91

def self.(user = nil, password = nil)
  instance = self.new
  if instance.(user, password)
    instance
  else
    raise InvalidUserCredentialsError.new, "Invalid User Credentials"
  end
end

Instance Method Details

Return the session cookie.

Returns:


155
156
157
# File 'lib/spaceship/client.rb', line 155

def cookie
  @cookie.map(&:to_s).join(';')
end

#handle_two_factor(response) ⇒ Object


45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# 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) ⇒ Object


3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# 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_keyObject


331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# 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_envObject


89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# 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_fileObject

Only needed for 2 step


80
81
82
83
84
85
86
87
# 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) ⇒ Spaceship::Client

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)

Parameters:

  • user (String) (defaults to: nil)

    (optional): The username (usually the email address)

  • password (String) (defaults to: nil)

    (optional): The password

Returns:

Raises:

  • InvalidUserCredentialsError: raised if authentication failed


230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
# File 'lib/spaceship/client.rb', line 230

def (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
    (user, password)
  rescue InvalidUserCredentialsError => ex
    raise ex unless keychain_entry

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

#page_sizeObject

The page size we want to request, defaults to 500


192
193
194
# File 'lib/spaceship/client.rb', line 192

def page_size
  @page_size ||= 500
end

#pagingObject

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


198
199
200
201
202
203
204
205
206
207
208
209
210
211
# 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) ⇒ Object


408
409
410
411
412
413
414
415
416
417
418
419
420
421
# 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

Returns preferred path for storing cookie for two step verification.


171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/spaceship/client.rb', line 171

def persistent_cookie_path
  if ENV["SPACESHIP_COOKIE_PATH"]
    path = File.expand_path(File.join(ENV["SPACESHIP_COOKIE_PATH"], "spaceship", self.user, "cookie"))
  else
    ["~/.spaceship", "/var/tmp/spaceship", "#{Dir.tmpdir}/spaceship"].each do |dir|
      dir_parts = File.split(dir)
      if directory_accessible?(dir_parts.first)
        path = File.expand_path(File.join(dir, self.user, "cookie"))
        break
      end
    end
  end

  return path
end

#request(method, url_or_path = nil, params = nil, headers = {}, &block) ⇒ Object


388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
# 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) ⇒ Object


109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# 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) ⇒ Object

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


260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
# File 'lib/spaceship/client.rb', line 260

def (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

159
160
161
162
163
164
165
166
167
# File 'lib/spaceship/client.rb', line 159

def store_cookie(path: nil)
  path ||= persistent_cookie_path
  FileUtils.mkdir_p(File.expand_path("..", path))

  # really important to specify the session to true
  # otherwise myacinfo and more won't be stored
  @cookie.save(path, :yaml, session: true)
  return File.read(path)
end

#store_sessionObject


170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
# 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

#UIObject

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


11
12
13
# File 'lib/spaceship/ui.rb', line 11

def UI
  UserInterface.new(self)
end

#with_retry(tries = 5, &_block) ⇒ Object


362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
# 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
    (self.user, @password)
    sleep 3 unless defined? SpecHelper
    retry
  end
  raise ex # re-raise the exception
end