24/7 twenty-four seven

iOS/OS X application programing topics.

Automate solving two-factor authentication for Apple ID on CI systems

Apple enforcing 2FA for all accounts.

Starting February 2021, additional authentication will be required for all users to sign in to App Store Connect. This extra layer of security for your Apple ID helps ensure that you’re the only person who can access your account. You can enable two-step verification or two-factor authentication now in the Security section of your Apple ID account or in the Apple ID section of Settings on your device.

It prevents some Fastlane automated tasks such as binary uploads without 2FA.

The best solution is to use the App Store Connect API key instead of the username and password authentication.

lane :release do
  api_key = app_store_connect_api_key(
    key_id: "D383SF739",
    issuer_id: "6053b7fe-68a8-4acb-89be-165aa6465141",
    key_filepath: "./AuthKey_D383SF739.p8",
    duration: 1200, # optional
    in_house: false, # optional but may be required if using match/sigh
  )

  pilot(api_key: api_key)
end

See also:

docs.fastlane.tools

However, if you want to automate an operation that is not supported by the App Store Connect API, such as downloading dSYM, or if you are unable to obtain an App Store Connect API key for some reason, I will share a way to automate to prompt the two-factor authentication code.

Set up a 3rd party web service to receive SMS

Vonage SMS API and their Australian phone number is able to receive SMS from Apple. Not many services seem to be able to receive Apple's 2FA SMS. I have tried Twilio and Clickatell, but they cannot receive Apple's SMS.

Sign up for Vonage and go to "Buy Numbers" to get an Australian phone number.

f:id:KishikawaKatsumi:20210126163252p:plain:w600

Add the phone number as a trusted phone number for Apple ID two-factor authentication. You can set multiple phone numbers.

f:id:KishikawaKatsumi:20210126165346p:plain:w600

Update Fastlane session cookies periodically on CI

When using Fastlane with an Apple ID that has two-factor authentication enabled, you will be asked to enter the two-factor authentication number when App Store Connect requires login.

In an interactive environment, you can enter the code, but on a CI systems, you cannot enter it and you will be stuck there. To solve this problem, you can generate a session cookie for successful login in advance and reuse it.

You can generate a session cookie with the fastlane spaceauth command and save the session variable or session cookie files to the CI cache for reuse.

See also:

docs.fastlane.tools

The following code automates 2FA authentication and updating Fastlane login sessions. You can run this code periodically on CI.

The generated session cookies are saved in the ~/.fastlane directory, so the ~/.fastlane directory is saved in the cache of CI and restored in each job of CI.

require "net/http"
require "uri"
require "pty"
require "expect"
require "fastlane"
require "spaceship"
require_relative "./setup_credentials"

def fastlane_spaceauth(user, password, default_phone_number)
  ENV["FASTLANE_USER"] = user
  ENV["FASTLANE_PASSWORD"] = password

  # SMS will be sent to the number specified
  # by `SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER` environment variable.
  ENV["SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER"] = default_phone_number

  $expect_verbose = true

  # Run the `fastlane spaceauth` command using PTY to respond to 2FA input
  cmd = "fastlane spaceauth"
  PTY.spawn(cmd) do |i, o|
    o.sync = true

    # If there is a valid session cookie, do nothing
    i.expect(/Pass the following via the FASTLANE_SESSION environment variable:/, 10) do |match|
      if match
        o.puts "y"
        return
      end
    end

    # If the session is invalid and need to enter the 2FA code
    i.expect(/Please enter the 6 digit code you received at .+:/, 60) do |match|
      raise "UnknownError" unless match
      sleep 10

      now = Time.now.utc - 120
      date_start = now.strftime("%Y-%m-%dT%H:%M:%SZ")
      date_end = (now + 120 + 120).strftime("%Y-%m-%dT%H:%M:%SZ")

      api_key = ENV["VONAGE_API_KEY"]
      api_secret = ENV["VONAGE_API_SECRET"]

      # Retrieve SMS containing 2FA code from the API
      uri = URI.parse("https://api.nexmo.com/v2/reports/records?account_id=#{api_key}&product=SMS&direction=inbound&include_message=true&date_start=#{date_start}&date_end=#{date_end}")
      request = Net::HTTP::Get.new(uri)
      request.basic_auth(api_key, api_secret)

      options = {
        use_ssl: true,
      }

      response = Net::HTTP.start(uri.hostname, uri.port, options) do |http|
        http.request(request)
      end

      records = JSON.parse(response.body)["records"]
      if records.nil? || records.empty?
        raise "NotFoundError"
      end
      message_body = records[0]["message_body"]

      # Parse a 2FA code from the SMS body
      code = message_body[/\d{6}/]
      if code.nil? || code.empty?
        raise "NotFoundError"
      end

      # Enter the code
      o.puts code
    end

    i.expect(/Pass the following via the FASTLANE_SESSION environment variable:/, 10) do |match|
      raise "UnknownError" unless match
      o.puts "y"
    end

    begin
      while (i.eof? == false)
        puts i.gets
      end
    rescue Errno::EIO
    end
  end
end

Another solution: Forwarding SMS using an Android device

On Android, you can get SMS from the program. So it may be possible to achieve this more cheaply by Android device with SIM and setting up a program to forward SMS to Gmail, etc.

See the following example:

github.com