Dealing with Apple 2FA for iOS automated app delivery

Starting in February 2021, Apple added an additional layer of security by making it mandatory to use the two-factor authentication or two-step authentication system when signing users into the App Store Connect.

Although the two-factor authentication is a great asset for account security, the fact that it was made mandatory has caused some trouble on our CI/CD pipeline. In this article we’ll see how we can overcome those issues.

Using the new API

At WWDC18, Apple announced an API for App Store Connect automation. It provides a familiar REST API designed to facilitate the automation of many tasks that we would normally do manually on the website or using fastlane.

Historically, fastlane has been using Apple ID with username and password to log into the Apple Developer Portal and App Store Connect so it can update the certificates, mobile provisioning profiles, upload app binaries, etc. This method worked quite well throughout the years. But since fastlane shipped a new version that leverages the new Apple API, it is recommended to use the latter when we are able to.

Fastlane will continue to use these two ways of authentication since there are benefits and but still some downsides when using the new API.

Pros:

Cons:

If you are not affected by these downsides, migrating to App Store Connect API is a way to solve the 2FA problem. To do this, the Apple account holder needs to generate the key from appstore connect and add it the CI as an environnement variable, for exemple: APP_STORE_CONNECT_API_KEY_CONTENT.

app_store_connect_api_key(
    key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
    issuer_id: ENV["APP_STORE_CONNECT_API_ISSUER_ID"],
    key_content: "#{ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]}".gsub('\n', '\\n'),
    in_house: false,
)

The app_store_connect_api_key action sets the API key value into the global lane context dictionary and actions like pilot will automatically load the API Key from the lane_context dictionary.

Using Apple ID:

For Enterprise accounts, using Apple ID with fastlane is the only way to go.

Fastlane has recently added an option to opt-in to skipping 2FA upgrade with the SPACESHIP_SKIP_2FA_UPGRADE=1 environnement variable. This feature is available on version 2.173.0 and works only for accounts that still has not enabled the 2FA. It’s a short term solution before 2FA is fully forced by Apple, and can be used to buy time while implementing a more sustainable solution.

At Fabernovel, we have a main Apple account that manages certificates on multiple teams. It is used by our CI/CD system and project managers / developers to access the developer Portal and App Store connect.

With 2FA enabled, we have to ensure that this account is accessible by both parties without much trouble logging-in.

The idea is to use fastlane’s spaceauth command line to generate a FASTLANE_SESSION (cookie) and add it as an environment variable of our CI system. This session will be reused instead of triggering a new login each time fastlane communicates with Apple’s APIs.

Under the hood, spaceauth is really just making HTTP requests to App Store connect with the provided username and password. It needs the 2FA authentication code to be able to log in and retrieve the cookie.

We needed a way to automate the retrieval of the 2FA code and post it to our Slack so that people can use it whenever they try to log-in. And also when using spaceauth to generate the fastlane session each month (or so we thought). So we tried different leads.

2FA code retrieval with Twilio:

Using SMS:

Our first plan was to use a Twilio number as trusted phone on our Apple account, then retrieve the SMS containing the 2FA code using a webhook, and send it to slack. Sounds easy right ?

Unfortunately, we were never able to receive any 2FA code using a Twilio number. After a little digging it turns out the problem was that Apple uses “short code numbers” to send 2FA codes, and receiving SMS from these kind of numbers is not a feature that is enabled by default on Twilio.

After getting in touch with the support to enable communication with short numbers, we found out that this feature is not fully supported and has multiple restrictions. Short Numbers can only be used on to receive SMS from the same country, and even then, Twilio does not guarantee that all SMSs will be received. Using their numbers for 2FA codes is not something that they recommend :

These codes are supposed to be sent to mobile devices. Apple may intentionally not route them to Twilio, or it may suddenly stop working one day, so it is not recommended to use Twilio numbers for this use.

Using a phone call

Another quick attempt that we tried was to use a phone call instead of SMS as we noticed that Apple does not use short numbers in this case. We used the “record and transcribe” feature provided by Twilio but with no luck. Apple hangs out as soon as we begin to record the call.

By then, it was clear that using Twilio wasn’t really convenient for our use case.

Using our own CI machine.

Besides using a trusted phone number to receive the 2FA code, we can also use a trusted macOS device that displays the 2FA code when we try to log in.

2FA window
macOS 2FA code window

We had this crazy idea of retrieving the code using the automation scripting language Applescript that worked quite well surprisingly.

#! /usr/bin/env osascript
set repeat_delay to 60
repeat
  tell application "System Events"
    if name of every process contains "FollowUpUI" then
      tell window 1 of process "FollowUpUI"
        set allow_button to a reference to (first button whose name is "Allow")
        if allow_button exists then
          log ("Allow 2FA request")
          click allow_button
          delay 2
        else
          log ("Skipping 'Authorize' step, button cannot be found.")
        end if
        set code_static_text to a reference to (static text 1 of group 1)
        if code_static_text exists then
          set code to value of code_static_text
          log ("2FA Code : " & code)
          # curl to slack channel with code
          log ("Send code to Slack channel...")
          set slack_webhook to "<slack_channel_url>"
          set post_data to "{\"text\":\"2FA: " & code & "\"}"
          set curl_command to "curl -X POST -H 'Content-Type: application/json' --data '" & post_data & "' " & slack_webhook
          do shell script curl_command
          log ("Done ! Close window")
          click button "Done"
        end if
      end tell
    else
      log ("Couldn't find 2FA window")
    end if
  end tell
  log ("Relaunch script in " & repeat_delay & " seconds")
  delay repeat_delay
end repeat

This script runs on one of our CI machines every minute to check if there is any 2FA UI open, then retrieve the text code and send it to slack directly so that anyone trying to connect may receive the code on a dedicated channel.

We later realized, after some tests, that the FASTLANE_SESSION provided by spaceauth only lasts for eight hours. So manually interacting with spaceauth and updating the environment variable on our CI wasn’t an option anymore, we needed a way to automate this process.

Spaceauth can only be used with interactive-mode. Therefore, we created a ruby script that is scheduled multiple times a day to interacts with it using the ruby_expect library so we can get and update the session.

require "ruby_expect"

class FastlaneSessionExtractor
  def self.execute_process(path)
    r, w = IO.pipe
    Process.spawn(path, :out => w, :err => [:child, :out])
    pid, status = Process.wait2
    w.close
    output = r.read
    r.close
    output
  end

  def self.get_fastlane_session(user, password)
    session = 'none'
    exp = RubyExpect::Expect.spawn("FASTLANE_USER=\"#{user}\" FASTLANE_PASSWORD=\"#{password}\" bundle exec fastlane spaceauth", { debug: true })
    exp.timeout = 10
    # exp.interact # useful for debugging
    exp.procedure do
      received_input_index = 0
      while !received_input_index.nil? && received_input_index != 3
        STDERR.puts "Handle new expectation from spaceauth"
        received_input_index = any do
          expect(/(.*The login credentials for .* seem to be wrong.*|.*Could not find a matching phone number.*)/) do
            STDERR.puts "Error while logging in:"
            STDERR.puts last_match
            exit 1
          end
          expect(/.*Please enter the 6 digit code.*/) do
            STDERR.puts "Obtain 2FA code from UI"
            output = FastlaneSessionExtractor.execute_process("bash -c '#{__dir__}/apple_2fa_interceptor.applescript'")
            code = output.gsub(/\s+/, "")
            STDERR.puts code
            send code
          end
          expect(/.*Should fastlane copy the cookie into your clipboard, so you can easily paste it.*/) do
            STDERR.puts "Copy session"
            send 'y'
          end
          expect(/.*Successfully copied text into your clipboard.*/) do
            STDERR.puts "Spaceauth finished obtaining session !"
            session = FastlaneSessionExtractor.execute_process("pbpaste")
            FastlaneSessionExtractor.execute_process("bash -c 'echo -n \"\" | pbcopy'")
          end
        end
      end
    end
    session
  end
end

After the session retrieval we just had to update the new value as an organization secret using the Github API and voila.

# This assumes valid credentials are filled on a config file.
session = FastlaneSessionExtractor.get_fastlane_session(Config::APPLE_IDENTIFIERS[:email], Config::APPLE_IDENTIFIERS[:password])
github_api_token = GithubActionsAPI.generate_github_api_token(
    Config::GITHUB[:identifiers][:private_key],
    Config::GITHUB[:identifiers][:app_id],
    Config::GITHUB[:identifiers][:installation_id]
)
public_key_data = GithubActionsAPI.get_secrets_public_key_data(github_api_token, Config::GITHUB[:scope])
encrypted_data = GithubActionsAPI.encrypt_value(public_key_data['key'], session)
update_response = GithubActionsAPI.update_secret(github_api_token, Config::GITHUB[:scope], "FASTLANE_SESSION", encrypted_data, public_key_data['key_id'])

With this setup, we were able to connect manually to our main Apple account using the first script that checks periodically for the pop-up and sends the 2FA code to slack. And we had our FASTLANE_SESSION updated many times a day so that our CI uses a valid session token.

Conclusion

Implementing a solution to properly retrieve and update our CI with a fastlane session environment variable wasn’t really a trivial task as it seemed at first. We ended up using custom scripts on one of our CI machines that is trusted by our main Apple account to retireve the code to be sent to slack or used in spaceauth. This is very specific to our organization, you may have other constraints, but the same ideas can be reused.