class Spaceship::Client
rubocop:disable Metrics/ClassLength
Constants
- AUTH_TYPES
- AccessForbiddenError
- AppleTimeoutError
- BadGatewayError
- BasicPreferredInfoError
legacy support
- GatewayTimeoutError
- InsufficientPermissions
- InternalServerError
- InvalidUserCredentialsError
- NoUserCredentialsError
- PROTOCOL_VERSION
- ProgramLicenseAgreementUpdated
- TooManyRequestsError
- USER_AGENT
- UnexpectedResponse
Attributes
The logger in which all requests are logged /tmp/spaceship[time]_[pid].log by default
The user that is currently logged in
The email of the user that is currently logged in
Public Class Methods
# File spaceship/lib/spaceship/client.rb, line 63 def self.hostname raise "You must implement self.hostname" end
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 spaceship/lib/spaceship/client.rb, line 364 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
# File spaceship/lib/spaceship/client.rb, line 214 def initialize(cookie: nil, current_team_id: nil, csrf_tokens: nil, timeout: nil) options = { request: { timeout: (ENV["SPACESHIP_TIMEOUT"] || timeout || 300).to_i, open_timeout: (ENV["SPACESHIP_TIMEOUT"] || timeout || 300).to_i } } @current_team_id = current_team_id @csrf_tokens = csrf_tokens @cookie = cookie || HTTP::CookieJar.new @client = Faraday.new(self.class.hostname, options) do |c| c.response(:json, content_type: /\bjson$/) c.response(:plist, content_type: /\bplist$/) c.use(:cookie_jar, jar: @cookie) c.use(FaradayMiddleware::RelsMiddleware) c.use(Spaceship::StatsMiddleware) 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" c.ssl[:verify_mode] = OpenSSL::SSL::VERIFY_NONE elsif ENV["SPACESHIP_PROXY"] c.proxy = ENV["SPACESHIP_PROXY"] c.ssl[:verify_mode] = OpenSSL::SSL::VERIFY_NONE if ENV["SPACESHIP_PROXY_SSL_VERIFY_NONE"] end if ENV["DEBUG"] puts("To run spaceship through a local proxy, use SPACESHIP_DEBUG") end end end
Fetch the session cookie from the environment (if exists)
# File spaceship/lib/spaceship/client.rb, line 632 def self.spaceship_session_env ENV["FASTLANE_SESSION"] || ENV["SPACESHIP_SESSION"] end
Public Instance Methods
Public getter for all UI
related code rubocop:disable Naming/MethodName
# File spaceship/lib/spaceship/ui.rb, line 22 def UI UserInterface.new(self) end
extracted into its own method for testing
# File spaceship/lib/spaceship/two_step_or_factor_client.rb, line 227 def ask_for_2fa_code(text) ask(text) end
extracted into its own method for testing
# File spaceship/lib/spaceship/two_step_or_factor_client.rb, line 232 def choose_phone_number(opts) choose(*opts) end
# File spaceship/lib/spaceship/client.rb, line 783 def detect_most_common_errors_and_raise_exceptions(body) # Check if the failure is due to missing permissions (App Store Connect) if body["messages"] && body["messages"]["error"].include?("Forbidden") raise_insufficient_permission_error! elsif body["messages"] && body["messages"]["error"].include?("insufficient privileges") # Passing a specific `caller_location` here to make sure we return the correct method # With the default location the error would say that `parse_response` is the caller raise_insufficient_permission_error!(caller_location: 3) elsif body.to_s.include?("Internal Server Error - Read") raise InternalServerError, "Received an internal server error from App Store Connect / Developer Portal, please try again later" elsif body.to_s.include?("Gateway Timeout - In read") raise GatewayTimeoutError, "Received a gateway timeout error from App Store Connect / Developer Portal, please try again later" elsif (body["userString"] || "").include?("Program License Agreement") raise ProgramLicenseAgreementUpdated, "#{body['userString']} Please manually log into your Apple Developer account to review and accept the updated agreement." end end
This is a duplicate method of fastlane_core/fastlane_core.rb#fastlane_user_dir
# File spaceship/lib/spaceship/client.rb, line 297 def fastlane_user_dir path = File.expand_path(File.join(Dir.home, ".fastlane")) FileUtils.mkdir_p(path) unless File.directory?(path) return path end
Get the `itctx` from the new (22nd May 2017) API endpoint “olympus” Update (29th March 2019) olympus migrates to new appstoreconnect API
# File spaceship/lib/spaceship/client.rb, line 549 def fetch_olympus_session response = request(:get, "https://appstoreconnect.apple.com/olympus/v1/session") body = response.body if body body = JSON.parse(body) if body.kind_of?(String) user_map = body["user"] if user_map self.user_email = user_map["emailAddress"] end provider = body["provider"] if provider self.provider = Spaceship::Provider.new(provider_hash: provider) return true end end return false end
Get contract messages from App
Store Connect's “olympus” endpoint
# File spaceship/lib/spaceship/client.rb, line 637 def fetch_program_license_agreement_messages all_messages = [] messages_request = request(:get, "https://appstoreconnect.apple.com/olympus/v1/contractMessages") body = messages_request.body if body body = JSON.parse(body) if body.kind_of?(String) body.map do |messages| all_messages.push(messages["message"]) end end return all_messages end
# File spaceship/lib/spaceship/two_step_or_factor_client.rb, line 104 def handle_two_factor(response, depth = 0) if depth == 0 puts("Two-factor Authentication (6 digits code) is enabled for account '#{self.user}'") puts("More information about Two-factor Authentication: https://support.apple.com/en-us/HT204915") puts("") two_factor_url = "https://github.com/fastlane/fastlane/tree/master/spaceship#2-step-verification" puts("If you're running this in a non-interactive session (e.g. server or CI)") puts("check out #{two_factor_url}") end # "verification code" has already be pushed to devices security_code = response.body["securityCode"] # "securityCode": { # "length": 6, # "tooManyCodesSent": false, # "tooManyCodesValidated": false, # "securityCodeLocked": false # }, code_length = security_code["length"] puts("") env_2fa_sms_default_phone_number = ENV["SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER"] if env_2fa_sms_default_phone_number raise Tunes::Error.new, "Environment variable SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER is set, but empty." if env_2fa_sms_default_phone_number.empty? puts("Environment variable `SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER` is set, automatically requesting 2FA token via SMS to that number") puts("SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER = #{env_2fa_sms_default_phone_number}") puts("") phone_number = env_2fa_sms_default_phone_number phone_id = phone_id_from_number(response.body["trustedPhoneNumbers"], phone_number) push_mode = push_mode_from_number(response.body["trustedPhoneNumbers"], phone_number) # don't request sms if no trusted devices and env default is the only trusted number, # code was automatically sent should_request_code = !sms_automatically_sent(response) code_type = 'phone' body = request_two_factor_code_from_phone(phone_id, phone_number, code_length, push_mode, should_request_code) elsif sms_automatically_sent(response) # sms fallback, code was automatically sent fallback_number = response.body["trustedPhoneNumbers"].first phone_number = fallback_number["numberWithDialCode"] phone_id = fallback_number["id"] push_mode = fallback_number['pushMode'] code_type = 'phone' body = request_two_factor_code_from_phone(phone_id, phone_number, code_length, push_mode, false) elsif sms_fallback(response) # sms fallback but code wasn't sent bec > 1 phone number code_type = 'phone' body = request_two_factor_code_from_phone_choose(response.body["trustedPhoneNumbers"], code_length) else puts("(Input `sms` to escape this prompt and select a trusted phone number to send the code as a text message)") puts("") puts("(You can also set the environment variable `SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER` to automate this)") puts("(Read more at: https://github.com/fastlane/fastlane/blob/master/spaceship/docs/Authentication.md#auto-select-sms-via-spaceship_2fa_sms_default_phone_number)") puts("") code = ask_for_2fa_code("Please enter the #{code_length} digit code:") code_type = 'trusteddevice' body = { "securityCode" => { "code" => code.to_s } }.to_json # User exited by entering `sms` and wants to choose phone number for SMS if code == 'sms' code_type = 'phone' body = request_two_factor_code_from_phone_choose(response.body["trustedPhoneNumbers"], code_length) end end puts("Requesting session...") # Send "verification code" back to server to get a valid session r = request(:post) do |req| req.url("https://idmsa.apple.com/appleauth/auth/verify/#{code_type}/securitycode") req.headers['Content-Type'] = 'application/json' req.body = body update_request_headers(req) end begin # we use `Spaceship::TunesClient.new.handle_itc_response` # since this might be from the Dev Portal, but for 2 factor Spaceship::TunesClient.new.handle_itc_response(r.body) # this will fail if the code is invalid rescue => ex # If the code was entered wrong # { # "service_errors": [{ # "code": "-21669", # "title": "Incorrect Verification Code", # "message": "Incorrect verification code." # }], # "hasError": true # } if ex.to_s.include?("verification code") # to have a nicer output puts("Error: Incorrect verification code") depth += 1 return handle_two_factor(response, depth) end raise ex end store_session return true end
# File spaceship/lib/spaceship/two_step_or_factor_client.rb, line 27 def handle_two_step(response) if response.body.fetch("securityCode", {})["tooManyCodesLock"].to_s.length > 0 raise Tunes::Error.new, "Too many verification codes have been sent. Enter the last code you received, use one of your devices, or try again later." end puts("Two-step Verification (4 digits code) is enabled for account '#{self.user}'") puts("More information about Two-step Verification: https://support.apple.com/en-us/HT204152") puts("") puts("Please select a trusted device to verify your identity") available = response.body["trustedDevices"].collect do |current| "#{current['name']}\t#{current['modelName'] || 'SMS'}\t(#{current['id']})" end result = choose(*available) device_id = result.match(/.*\t.*\t\((.*)\)/)[1] handle_two_step_for_device(device_id) end
this is extracted into its own method so it can be called multiple times (see end)
# File spaceship/lib/spaceship/two_step_or_factor_client.rb, line 47 def handle_two_step_for_device(device_id) # Request token to device r = request(:put) do |req| req.url("https://idmsa.apple.com/appleauth/auth/verify/device/#{device_id}/securitycode") update_request_headers(req) 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 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['Content-Type'] = 'application/json' req.body = { "code" => code.to_s }.to_json update_request_headers(req) 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 handle_two_step_for_device(device_id) end raise ex end store_session return true end
# File spaceship/lib/spaceship/two_step_or_factor_client.rb, line 6 def handle_two_step_or_factor(response) raise "2FA can only be performed in interactive mode" if ENV["SPACESHIP_ONLY_ALLOW_INTERACTIVE_2FA"] == "true" && ENV["FASTLANE_IS_INTERACTIVE"] == "false" # extract `x-apple-id-session-id` and `scnt` from response, to be used by `update_request_headers` @x_apple_id_session_id = response["x-apple-id-session-id"] @scnt = response["scnt"] # get authentication options r = request(:get) do |req| req.url("https://idmsa.apple.com/appleauth/auth") update_request_headers(req) end if r.body.kind_of?(Hash) && r.body["trustedDevices"].kind_of?(Array) handle_two_step(r) elsif r.body.kind_of?(Hash) && r.body["trustedPhoneNumbers"].kind_of?(Array) && r.body["trustedPhoneNumbers"].first.kind_of?(Hash) handle_two_factor(r) else raise "Although response from Apple indicated activated Two-step Verification or Two-factor Authentication, spaceship didn't know how to handle this response: #{r.body}" end end
# File spaceship/lib/spaceship/client.rb, line 569 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) # Fixes issue https://github.com/fastlane/fastlane/issues/13281 # Even though we are using https://appstoreconnect.apple.com, the service key needs to still use a # hostname through itunesconnect.apple.com response = request(:get, "https://appstoreconnect.apple.com/olympus/v1/app/config?hostname=itunesconnect.apple.com") @service_key = response.body["authServiceKey"].to_s raise "Service key is empty" if @service_key.length == 0 # 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 App Store Connect, this might be a server issue." end
# File spaceship/lib/spaceship/client.rb, line 611 def load_session_from_env return if self.class.spaceship_session_env.to_s.length == 0 puts("Loading session from environment variable") if Spaceship::Globals.verbose? file = Tempfile.new('cookie.yml') file.write(self.class.spaceship_session_env.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
@!group Session
# File spaceship/lib/spaceship/client.rb, line 597 def load_session_from_file begin if File.exist?(persistent_cookie_path) puts("Loading session from '#{persistent_cookie_path}'") if Spaceship::Globals.verbose? @cookie.load(persistent_cookie_path) return true end rescue => ex puts(ex.to_s) puts("Continuing with normal login.") end return false end
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 spaceship/lib/spaceship/client.rb, line 386 def login(user = nil, password = nil) if user.to_s.empty? || password.to_s.empty? require 'credentials_manager/account_manager' puts("Reading keychain entry, because either user or password were empty") if Spaceship::Globals.verbose? keychain_entry = CredentialsManager::AccountManager.new(user: user, password: password) user ||= keychain_entry.user password = keychain_entry.password end if user.to_s.strip.empty? || password.to_s.strip.empty? raise NoUserCredentialsError.new, "No login data provided" end self.user = user @password = password begin do_login(user, password) # calls `send_login_request` in sub class (which then will redirect back here to `send_shared_login_request`, below) rescue InvalidUserCredentialsError => ex raise ex unless keychain_entry if keychain_entry.invalid_credentials login(user) else raise ex end end end
# File spaceship/lib/spaceship/two_step_or_factor_client.rb, line 258 def match_phone_to_masked_phone(phone_number, masked_number) characters_to_remove_from_phone_numbers = ' \-()"' # start with e.g. +49 162 1234585 or +1-123-456-7866 phone_number = phone_number.tr(characters_to_remove_from_phone_numbers, '') # cleaned: +491621234585 or +11234567866 # rubocop:disable Style/AsciiComments # start with: +49 •••• •••••85 or +1 (•••) •••-••66 number_with_dialcode_masked = masked_number.tr(characters_to_remove_from_phone_numbers, '') # cleaned: +49•••••••••85 or +1••••••••66 # rubocop:enable Style/AsciiComments maskings_count = number_with_dialcode_masked.count('•') # => 9 or 8 pattern = /^([0-9+]{2,4})([•]{#{maskings_count}})([0-9]{2})$/ # following regex: range from maskings_count-2 because sometimes the masked number has 1 or 2 dots more than the actual number # e.g. https://github.com/fastlane/fastlane/issues/14969 replacement = "\\1([0-9]{#{maskings_count - 2},#{maskings_count}})\\3" number_with_dialcode_regex_part = number_with_dialcode_masked.gsub(pattern, replacement) # => +49([0-9]{8,9})85 or +1([0-9]{7,8})66 backslash = '\\' number_with_dialcode_regex_part = backslash + number_with_dialcode_regex_part number_with_dialcode_regex = /^#{number_with_dialcode_regex_part}$/ # => /^\+49([0-9]{8})85$/ or /^\+1([0-9]{7,8})66$/ return phone_number =~ number_with_dialcode_regex # +491621234585 matches /^\+49([0-9]{8})85$/ end
The page size we want to request, defaults to 500
# File spaceship/lib/spaceship/client.rb, line 326 def page_size @page_size ||= 500 end
Handles the paging for you… for free Just pass a block and use the parameter as page number
# File spaceship/lib/spaceship/client.rb, line 332 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
# File spaceship/lib/spaceship/client.rb, line 748 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. # the returned error message and info, is html encoded -> "issued" -> make this readable -> "issued" response.body["userString"] = CGI.unescapeHTML(response.body["userString"]) if response.body["userString"] response.body["resultString"] = CGI.unescapeHTML(response.body["resultString"]) if response.body["resultString"] content = expected_key ? response.body[expected_key] : response.body end # if content (filled with whole body or just expected_key) is missing if content.nil? detect_most_common_errors_and_raise_exceptions(response.body) if response.body raise UnexpectedResponse, response.body # else if it is a hash and `resultString` includes `NotAllowed` elsif content.kind_of?(Hash) && (content["resultString"] || "").include?("NotAllowed") # example content when doing a Developer Portal action with not enough permission # => {"responseId"=>"e5013d83-c5cb-4ba0-bb62-734a8d56007f", # "resultCode"=>1200, # "resultString"=>"webservice.certificate.downloadNotAllowed", # "userString"=>"You are not permitted to download this certificate.", # "creationTimestamp"=>"2017-01-26T22:44:13Z", # "protocolVersion"=>"QH65B2", # "userLocale"=>"en_US", # "requestUrl"=>"https://developer.apple.com/services-account/QH65B2/account/ios/certificate/downloadCertificateContent.action", # "httpCode"=>200} raise_insufficient_permission_error!(additional_error_string: content["userString"]) else store_csrf_tokens(response) content end end
# File spaceship/lib/spaceship/two_step_or_factor_client.rb, line 288 def phone_id_from_masked_number(phone_numbers, masked_number) phone_numbers.each do |phone| return phone['id'] if phone['numberWithDialCode'] == masked_number end end
# File spaceship/lib/spaceship/two_step_or_factor_client.rb, line 236 def phone_id_from_number(phone_numbers, phone_number) phone_numbers.each do |phone| return phone['id'] if match_phone_to_masked_phone(phone_number, phone['numberWithDialCode']) end # Handle case of phone_number not existing in phone_numbers because ENV var is wrong or matcher is broken raise Tunes::Error.new, %( Could not find a matching phone number to #{phone_number} in #{phone_numbers}. Make sure your environment variable is set to the correct phone number. If it is, please open an issue at https://github.com/fastlane/fastlane/issues/new and include this output so we can fix our matcher. Thanks. ) end
# File spaceship/lib/spaceship/two_step_or_factor_client.rb, line 294 def push_mode_from_masked_number(phone_numbers, masked_number) phone_numbers.each do |phone| return phone['pushMode'] if phone['numberWithDialCode'] == masked_number end # If no pushMode was supplied, assume sms return "sms" end
# File spaceship/lib/spaceship/two_step_or_factor_client.rb, line 249 def push_mode_from_number(phone_numbers, phone_number) phone_numbers.each do |phone| return phone['pushMode'] if match_phone_to_masked_phone(phone_number, phone['numberWithDialCode']) end # If no pushMode was supplied, assume sms return "sms" end
This also gets called from subclasses
# File spaceship/lib/spaceship/client.rb, line 801 def raise_insufficient_permission_error!(additional_error_string: nil, caller_location: 2) # get the method name of the request that failed # `block in` is used very often for requests when surrounded for paging or retrying blocks # The ! is part of some methods when they modify or delete a resource, so we don't want to show it # Using `sub` instead of `delete` as we don't want to allow multiple matches calling_method_name = caller_locations(caller_location, 2).first.label.sub("block in", "").delete("!").strip # calling the computed property self.team_id can get us into an exception handling loop team_id = @current_team_id ? "(Team ID #{@current_team_id}) " : "" error_message = "User #{self.user} #{team_id}doesn't have enough permission for the following action: #{calling_method_name}" error_message += " (#{additional_error_string})" if additional_error_string.to_s.length > 0 raise InsufficientPermissions, error_message end
# File spaceship/lib/spaceship/client.rb, line 725 def request(method, url_or_path = nil, params = nil, headers = {}, auto_paginate = false, &block) headers.merge!(csrf_tokens) headers.merge!(additional_headers) headers['User-Agent'] = USER_AGENT # Before encoding the parameters, log them log_request(method, url_or_path, params, headers, &block) # 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 = if auto_paginate send_request_auto_paginate(method, url_or_path, params, headers, &block) else send_request(method, url_or_path, params, headers, &block) end return response end
this is used in two places: after choosing a phone number and when a phone number is set via ENV var
# File spaceship/lib/spaceship/two_step_or_factor_client.rb, line 317 def request_two_factor_code_from_phone(phone_id, phone_number, code_length, push_mode = "sms", should_request_code = true) if should_request_code # Request code r = request(:put) do |req| req.url("https://idmsa.apple.com/appleauth/auth/verify/phone") req.headers['Content-Type'] = 'application/json' req.body = { "phoneNumber" => { "id" => phone_id }, "mode" => push_mode }.to_json update_request_headers(req) 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 text message to #{phone_number}") end code = ask_for_2fa_code("Please enter the #{code_length} digit code you received at #{phone_number}:") return { "securityCode" => { "code" => code.to_s }, "phoneNumber" => { "id" => phone_id }, "mode" => push_mode }.to_json end
# File spaceship/lib/spaceship/two_step_or_factor_client.rb, line 303 def request_two_factor_code_from_phone_choose(phone_numbers, code_length) puts("Please select a trusted phone number to send code to:") available = phone_numbers.collect do |current| current['numberWithDialCode'] end chosen = choose_phone_number(available) phone_id = phone_id_from_masked_number(phone_numbers, chosen) push_mode = push_mode_from_masked_number(phone_numbers, chosen) request_two_factor_code_from_phone(phone_id, chosen, code_length, push_mode) end
see `sms_fallback` + account has only one trusted number for receiving an sms
# File spaceship/lib/spaceship/two_step_or_factor_client.rb, line 222 def sms_automatically_sent(response) (response.body["trustedPhoneNumbers"] || []).count == 1 && sms_fallback(response) end
Account is not signed into any devices that can display a verification code
# File spaceship/lib/spaceship/two_step_or_factor_client.rb, line 217 def sms_fallback(response) response.body["noTrustedDevices"] end
# File spaceship/lib/spaceship/two_step_or_factor_client.rb, line 339 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 App Store 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") update_request_headers(req) 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
@return (String
) The currently selected Team ID
# File spaceship/lib/spaceship/client.rb, line 132 def team_id return @current_team_id if @current_team_id if teams.count > 1 puts("The current user is in #{teams.count} teams. Pass a team ID or call `select_team` to choose a team. Using the first one for now.") end @current_team_id ||= user_details_data['sessionToken']['contentProviderId'] end
Set a new team ID which will be used from now on
# File spaceship/lib/spaceship/client.rb, line 142 def team_id=(team_id) # First, we verify the team actually exists, because otherwise iTC would return the # following confusing error message # # invalid content provider id # available_teams = teams.collect do |team| { team_id: (team["contentProvider"] || {})["contentProviderId"], public_team_id: (team["contentProvider"] || {})["contentProviderPublicId"], team_name: (team["contentProvider"] || {})["name"] } end result = available_teams.find do |available_team| team_id.to_s == available_team[:team_id].to_s end unless result error_string = "Could not set team ID to '#{team_id}', only found the following available teams:\n\n#{available_teams.map { |team| "- #{team[:team_id]} (#{team[:team_name]})" }.join("\n")}\n" raise Tunes::Error.new, error_string end response = request(:post) do |req| req.url("https://appstoreconnect.apple.com/olympus/v1/providerSwitchRequests") req.body = { "data": { "type": "providerSwitchRequests", "relationships": { "provider": { "data": { "type": "providers", "id": result[:public_team_id] } } } } }.to_json req.headers['Content-Type'] = 'application/json' end handle_itc_response(response.body) # clear user_details_data cache, as session switch will have changed sessionToken attribute @_cached_user_details = nil @current_team_id = team_id end
@return (Hash
) Fetches all information of the currently used team
# File spaceship/lib/spaceship/client.rb, line 192 def team_information teams.find do |t| t['teamId'] == team_id end end
@return (String
) Fetches name from currently used team
# File spaceship/lib/spaceship/client.rb, line 199 def team_name (team_information || {})['name'] end
@return (Array
) A list of all available teams
# File spaceship/lib/spaceship/client.rb, line 72 def teams user_details_data['associatedAccounts'].sort_by do |team| [ team['contentProvider']['name'], team['contentProvider']['contentProviderId'] ] end end
# File spaceship/lib/spaceship/upgrade_2fa_later_client.rb, line 6 def try_upgrade_2fa_later(response) if ENV['SPACESHIP_SKIP_2FA_UPGRADE'].nil? return false end puts("This account is being prompted to upgrade to 2FA") puts("Attempting to automatically bypass the upgrade until a later date") puts("To disable this, remove SPACESHIP_SKIP_2FA_UPGRADE=1 environment variable") # Get URL that requests a repair and gets the widget key widget_key_location = response.headers['location'] uri = URI.parse(widget_key_location) params = CGI.parse(uri.query) widget_key = params.dig('widgetKey', 0) if widget_key.nil? STDERR.puts("Couldn't find widgetKey to continue with requests") return false end # Step 1 - Request repair response_repair = request(:get) do |req| req.url(widget_key_location) end # Step 2 - Request repair options response_repair_options = request(:get) do |req| req.url("https://appleid.apple.com/account/manage/repair/options") req.headers['scnt'] = response_repair.headers['scnt'] req.headers['X-Apple-Id-Session-Id'] = response.headers['X-Apple-Id-Session-Id'] req.headers['X-Apple-Session-Token'] = response.headers['X-Apple-Repair-Session-Token'] req.headers['X-Apple-Skip-Repair-Attributes'] = '[]' req.headers['X-Apple-Widget-Key'] = widget_key req.headers['Content-Type'] = 'application/json' req.headers['X-Requested-With'] = 'XMLHttpRequest' req.headers['Accept'] = 'application/json, text/javascript' end # Step 3 - Request setup later request(:get) do |req| req.url("https://appleid.apple.com/account/security/upgrade/setuplater") req.headers['scnt'] = response_repair_options.headers['scnt'] req.headers['X-Apple-Id-Session-Id'] = response.headers['X-Apple-Id-Session-Id'] req.headers['X-Apple-Session-Token'] = response_repair_options.headers['x-apple-session-token'] req.headers['X-Apple-Skip-Repair-Attributes'] = '[]' req.headers['X-Apple-Widget-Key'] = widget_key req.headers['Content-Type'] = 'application/json' req.headers['X-Requested-With'] = 'XMLHttpRequest' req.headers['Accept'] = 'application/json, text/javascript' end # Step 4 - Post complete response_repair_complete = request(:post) do |req| req.url("https://idmsa.apple.com/appleauth/auth/repair/complete") req.body = '' req.headers['scnt'] = response.headers['scnt'] req.headers['X-Apple-Id-Session-Id'] = response.headers['X-Apple-Id-Session-Id'] req.headers['X-Apple-Repair-Session-Token'] = response_repair_options.headers['X-Apple-Session-Token'] req.headers['X-Apple-Widget-Key'] = widget_key req.headers['Content-Type'] = 'application/json' req.headers['X-Requested-With'] = 'XMLHttpRequest' req.headers['Accept'] = 'application/json;charset=utf-8' end if response_repair_complete.status == 204 return true else STDERR.puts("Failed with status code of #{response_repair_complete.status}") return false end rescue => error STDERR.puts(error.backtrace) STDERR.puts("Failed to bypass 2FA upgrade") STDERR.puts("To disable this from trying again, set SPACESHIP_SKIP_UPGRADE_2FA_LATER=1") return false end
Responsible for setting all required header attributes for the requests to succeed
# File spaceship/lib/spaceship/two_step_or_factor_client.rb, line 365 def update_request_headers(req) req.headers["X-Apple-Id-Session-Id"] = @x_apple_id_session_id req.headers["X-Apple-Widget-Key"] = self.itc_service_key req.headers["Accept"] = "application/json" req.headers["scnt"] = @scnt end
Fetch the general information of the user, is used by various methods across spaceship Sample return value
> {“associatedAccounts”=>¶ ↑
[{"contentProvider"=>{"contentProviderId"=>11142800, "name"=>"Felix Krause", "contentProviderTypes"=>["Purple Software"]}, "roles"=>["Developer"], "lastLogin"=>1468784113000}], "sessionToken"=>{"dsId"=>"8501011116", "contentProviderId"=>18111111, "expirationDate"=>nil, "ipAddress"=>nil}, "permittedActivities"=> {"EDIT"=> ["UserManagementSelf", "GameCenterTestData", "AppAddonCreation"], "REPORT"=> ["UserManagementSelf", "AppAddonCreation"], "VIEW"=> ["TestFlightAppExternalTesterManagement", ... "HelpGeneral", "HelpApplicationLoader"]}, "preferredCurrencyCode"=>"EUR", "preferredCountryCode"=>nil, "countryOfOrigin"=>"AT", "isLocaleNameReversed"=>false, "feldsparToken"=>nil, "feldsparChannelName"=>nil, "hasPendingFeldsparBindingRequest"=>false, "isLegalUser"=>false, "userId"=>"1771111155", "firstname"=>"Detlef", "lastname"=>"Mueller", "isEmailInvalid"=>false, "hasContractInfo"=>false, "canEditITCUsersAndRoles"=>false, "canViewITCUsersAndRoles"=>true, "canEditIAPUsersAndRoles"=>false, "transporterEnabled"=>false, "contentProviderFeatures"=>["APP_SILOING", "PROMO_CODE_REDESIGN", ...], "contentProviderType"=>"Purple Software", "displayName"=>"Detlef", "contentProviderId"=>"18742800", "userFeatures"=>[], "visibility"=>true, "DYCVisibility"=>false, "contentProvider"=>"Felix Krause", "userName"=>"detlef@krausefx.com"}
# File spaceship/lib/spaceship/client.rb, line 125 def user_details_data return @_cached_user_details if @_cached_user_details r = request(:get, '/WebObjects/iTunesConnect.woa/ra/user/detail') @_cached_user_details = parse_response(r, 'data') end
@!group Helpers
# File spaceship/lib/spaceship/client.rb, line 656 def with_retry(tries = 5, &_block) return yield rescue \ Faraday::ConnectionFailed, Faraday::TimeoutError, BadGatewayError, AppleTimeoutError, GatewayTimeoutError, AccessForbiddenError => ex tries -= 1 unless tries.zero? msg = "Timeout received: '#{ex.class}', '#{ex.message}'. Retrying after 3 seconds (remaining: #{tries})..." puts(msg) if Spaceship::Globals.verbose? logger.warn(msg) sleep(3) unless Object.const_defined?("SpecHelper") retry end raise ex # re-raise the exception rescue TooManyRequestsError => ex tries -= 1 unless tries.zero? msg = "Timeout received: '#{ex.class}', '#{ex.message}'. Retrying after #{ex.retry_after} seconds (remaining: #{tries})..." puts(msg) if Spaceship::Globals.verbose? logger.warn(msg) sleep(ex.retry_after) unless Object.const_defined?("SpecHelper") retry end raise ex # re-raise the exception rescue \ Faraday::ParsingError, # <h2>Internal Server Error</h2> with content type json InternalServerError => ex tries -= 1 unless tries.zero? msg = "Internal Server Error received: '#{ex.class}', '#{ex.message}'. Retrying after 3 seconds (remaining: #{tries})..." puts(msg) if Spaceship::Globals.verbose? logger.warn(msg) sleep(3) unless Object.const_defined?("SpecHelper") retry end raise ex # re-raise the exception rescue UnauthorizedAccessError => ex if @loggedin && !(tries -= 1).zero? msg = "Auth error received: '#{ex.class}', '#{ex.message}'. Login in again then retrying after 3 seconds (remaining: #{tries})..." puts(msg) if Spaceship::Globals.verbose? logger.warn(msg) if self.class.spaceship_session_env.to_s.length > 0 raise UnauthorizedAccessError.new, "Authentication error, you passed an invalid session using the environment variable FASTLANE_SESSION or SPACESHIP_SESSION" end do_login(self.user, @password) sleep(3) unless Object.const_defined?("SpecHelper") retry end raise ex # re-raise the exception end
Private Instance Methods
# File spaceship/lib/spaceship/client.rb, line 818 def directory_accessible?(path) Dir.exist?(File.expand_path(path)) end
# File spaceship/lib/spaceship/client.rb, line 822 def do_login(user, password) @loggedin = false ret = send_login_request(user, password) # different in subclasses @loggedin = true ret end
# File spaceship/lib/spaceship/client.rb, line 947 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
# File spaceship/lib/spaceship/client.rb, line 869 def extract_key_from_block(key, &block) if block_given? obj = Object.new class << obj attr_accessor :body, :headers, :params, :url, :options # rubocop: disable Style/TrivialAccessors # the block calls `url` (not `url=`) so need to define `url` method def url(url) @url = url end def options options_obj = Object.new class << options_obj attr_accessor :params_encoder end options_obj end # rubocop: enable Style/TrivialAccessors end obj.headers = {} yield(obj) obj.instance_variable_get("@#{key}") end end
# File spaceship/lib/spaceship/client.rb, line 916 def handle_error(response) case response.status when 401 msg = "Auth lost" logger.warn(msg) raise UnauthorizedAccessError.new, "Unauthorized Access" when 403 msg = "Access forbidden" logger.warn(msg) raise AccessForbiddenError.new, msg when 429 raise TooManyRequestsError, response.to_hash end end
# File spaceship/lib/spaceship/client.rb, line 839 def log_request(method, url, params, headers = nil, &block) url ||= extract_key_from_block('url', &block) body = extract_key_from_block('body', &block) body_to_log = '[undefined body]' if body begin body = JSON.parse(body) # replace password in body if present body['password'] = '***' if body.kind_of?(Hash) && body.key?("password") body_to_log = body.to_json rescue JSON::ParserError # no json, no password to replace body_to_log = "[non JSON body]" end end 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}: #{body_to_log} #{params_to_log.join(', ')}") end
# File spaceship/lib/spaceship/client.rb, line 863 def log_response(method, url, response, headers = nil, &block) url ||= extract_key_from_block('url', &block) body = response.body.kind_of?(String) ? response.body.force_encoding(Encoding::UTF_8) : response.body logger.debug("<< #{method.upcase} #{url}: #{response.status} #{body}") end
Actually sends the request to the remote server Automatically retries the request up to 3 times if something goes wrong
# File spaceship/lib/spaceship/client.rb, line 897 def send_request(method, url_or_path, params, headers, &block) with_retry do response = @client.send(method, url_or_path, params, headers, &block) log_response(method, url_or_path, response, headers, &block) handle_error(response) if response.body.to_s.include?("<title>302 Found</title>") raise AppleTimeoutError.new, "Apple 302 detected - this might be temporary server error, check https://developer.apple.com/system-status/ to see if there is a known downtime" end if response.body.to_s.include?("<h3>Bad Gateway</h3>") raise BadGatewayError.new, "Apple 502 detected - this might be temporary server error, try again later" end return response end end
# File spaceship/lib/spaceship/client.rb, line 931 def send_request_auto_paginate(method, url_or_path, params, headers, &block) response = send_request(method, url_or_path, params, headers, &block) return response unless should_process_next_rel?(response) last_response = response while last_response.env.rels[:next] last_response = send_request(method, last_response.env.rels[:next], params, headers, &block) break unless should_process_next_rel?(last_response) response.body['data'].concat(last_response.body['data']) end response end
# File spaceship/lib/spaceship/client.rb, line 943 def should_process_next_rel?(response) response.body.kind_of?(Hash) && response.body['data'].kind_of?(Array) end
Is called from `parse_response` to store the latest csrf_token (if available)
# File spaceship/lib/spaceship/client.rb, line 830 def store_csrf_tokens(response) if response && response.headers tokens = response.headers.select { |k, v| %w(csrf csrf_ts).include?(k) } if tokens && !tokens.empty? @csrf_tokens = tokens end end end