I recently struggled a lot with implementing Apple OAuth for my Hotwire Native app Calendar Vision so I figured I would share out what I did. I would love to hear if there are better ways to do some of this.

The first screen the user sees when opening the iOS app is a native screen that allows the user to sign in with Apple. The callback that Apple redirects to after authentication is the same Devise Ominauth endpoint that the web app uses. This allowed me to use the same cookie-based authentication regardless of log in method.

iOS Code

The native screen is implemented with a custom view controller. This view controller is visited by configuring the getting started URL to point at a view controller in the path configuration.

{
  patterns: [
    hotwire_native_get_started_path
  ],
  properties: {
    uri: "hotwire://fragment/welcome",
    title: "Welcome"
  }
}

The SceneController handles this path with the WelcomeController

extension SceneController: NavigatorDelegate {
    func handle(proposal: VisitProposal, from navigator: Navigator) -> ProposalResult {
        switch proposal.viewController {
        case WelcomeController.pathConfigurationIdentifier:
            window?.rootViewController = navigator.rootViewController
            return .acceptCustom(welcomeController!)
        default:
            return .accept
        }
    }
}

WelcomeController is defined like this.

extension SceneController: UIWindowSceneDelegate {
    func scene(
        _ scene: UIScene,
        willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        guard let windowScene = scene as? UIWindowScene else { return }
        
        UNUserNotificationCenter.current().delegate = notificationRouter
        
        welcomeController = WelcomeController(onSignInWithAppleSuccess:{ [self] in
            window?.rootViewController = tabBarController
            tabBarController.load(HotwireTab.all)
        }, onError: { [self] message in
            showErrorAlert(title: "Unable to Authenticate", message: message)
        })
        
        Task { await checkAuth(windowScene: windowScene) }
    }
}

The WelcomeController is given an onSignInWithAppleSuccess function that will load the tabBarController when the user successfully signs in with Apple.

The WelcomeController delegates to the WelcomeView

class WelcomeController: UIHostingController<WelcomeView>, PathConfigurationIdentifiable {
    static var pathConfigurationIdentifier: String { "welcome" }
    
    convenience init(onSignInWithAppleSuccess: @escaping () -> Void, onPasswordSignUpClicked: @escaping () -> Void, onContinueWithoutSignUpClicked: @escaping () -> Void, onError: @escaping (String) -> Void) {
        let view = WelcomeView(onSignInWithAppleSuccess: onSignInWithAppleSuccess, onPasswordSignUpClicked: onPasswordSignUpClicked, onContinueWithoutSignUpClicked: onContinueWithoutSignUpClicked, onError: onError)
        self.init(rootView: view)
    }
}

The WelcomeView uses Swift UI and the built in SignInWithAppleButton component.

struct WelcomeView: View {
    var onSignInWithAppleSuccess: () -> Void
    var onError: (String) -> Void
    
    var body: some View {
        SignInWithAppleButton(.signUp) { request in
            request.requestedScopes = [.fullName, .email]
        } onCompletion: { result in
            switch result {
            case .success(let authorization):
                handleSuccessfulLogin(with: authorization)
            case .failure(let error):
                handleLoginError(with: error)
            }
        }
        .frame(height: 50)
        .cornerRadius(8)
    }
}

When authentication with Apple is successful, we’re giving authorization data and we need to call the Rails app to log in.

private func handleSuccessfulLogin(with authorization: ASAuthorization) {
    if let userCredential = authorization.credential as? ASAuthorizationAppleIDCredential {
        guard let identityToken = userCredential.identityToken,
              let identityTokenString = String(data: identityToken, encoding: .utf8),
              let authorizationCode = userCredential.authorizationCode,
              let authorizationCodeString = String(data: authorizationCode, encoding: .utf8) else {
            print("Failed to get identity token or authorization code")
            return
        }
        
        Task {
            await sendToOmniauthCallback(
                identityToken: identityTokenString,
                authorizationCode: authorizationCodeString,
                userIdentifier: userCredential.user,
                email: userCredential.email,
                fullName: userCredential.fullName
            )
        }
    }
}

private func sendToOmniauthCallback(
    identityToken: String,
    authorizationCode: String,
    userIdentifier: String,
    email: String?,
    fullName: PersonNameComponents?
) async {
    var components = URLComponents()
    components.scheme = baseUrl.scheme
    components.host = baseUrl.host
    components.port = baseUrl.port
    components.path = "/users/auth/apple/callback"
    
    let queryItems = [
        URLQueryItem(name: "id_token", value: identityToken),
        URLQueryItem(name: "code", value: authorizationCode),
    ]
    
    components.queryItems = queryItems
    
    guard let callbackURL = components.url else {
        print("Failed to build callback URL")
        return
    }
    
    print("Sending callback to: \(callbackURL)")
    
    var request = URLRequest(url: callbackURL)
    request.httpMethod = "POST"
    
    do {
        let (_, response) = try await URLSession.shared.data(for: request)
        
        if let httpResponse = response as? HTTPURLResponse {
            print("Rails callback response status: \(httpResponse.statusCode)")
            
            if httpResponse.statusCode == 200 || httpResponse.statusCode == 302 {
                // Cookies from the callback must be copied to the cookie store so the rest of the app can be authenticated
                await copyCookiesToCookieStore(from: httpResponse)
                
                // Refresh tabs to reflect authenticated state
                await MainActor.run {
                    onSignInWithAppleSuccess()
                }
            } else {
                print("Rails callback failed with status: \(httpResponse.statusCode)")
            }
        }
    } catch {
        print("Failed to send callback to Rails: \(error)")
    }
}

// I'd like to refactor this to a utility class but I got a EXC_BREAKPOINT when I tried that
private func copyCookiesToCookieStore(from response: HTTPURLResponse) async {
    guard let url = response.url,
          let headerFields = response.allHeaderFields as? [String: String] else {
        return
    }
    
    let cookies = HTTPCookie.cookies(withResponseHeaderFields: headerFields, for: url)
    let cookieStore = WKWebsiteDataStore.default().httpCookieStore
    
    print("Copying \(cookies.count) cookies to web view store")
    
    for cookie in cookies {
        await cookieStore.setCookie(cookie)
    }
}

There’s a lot of code here but it’s really just two things happening.

  1. Call the Devise Ominauth callback endpoint with the authorization data
  2. Copy the cookies from that request callback request into the cookie store so the rest of the Hotwire Native app can make use of the authenticated session.

Rails Code

The Rails code for this is pretty similar to the code needed to get Sign in with Apple working on the web. I’m using Devise, Omniauth and the Omniauth Apple gems.

These gems need to be configured with all the different Apple identifiers. This was probably the trickiest part of the Rails code.

  config.omniauth(
    :apple,
    Rails.application.credentials.dig(:ios, :service_identifier),
    "",
    {
      scope: "email name",
      team_id: Rails.application.credentials.dig(:ios, :team_id),
      key_id: Rails.application.credentials.dig(:ios, :key_id),
      pem: Rails.application.credentials.dig(:ios, :apns_key),
      provider_ignores_state: true,
      authorized_client_ids: [ Rails.application.credentials.dig(:ios, :service_identifier), Rails.application.credentials.dig(:ios, :bundle_identifier) ],
      nonce: :local
    },
  )

The Omniauth callback looks like most Omniauth callback endpoints

class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  skip_before_action :verify_authenticity_token

  def apple
    user = User.from_omniauth(auth)
    if user.present?
      flash[:notice] = t "devise.omniauth_callbacks.success", kind: "Apple"
      sign_in_and_redirect user, event: :authentication
    else
      flash[:alert] =
        t "devise.omniauth_callbacks.failure", kind: "Apple", reason: "#{auth.info.email} is not authorized."
      redirect_to root_path
    end
  end
end

I did need to monkey patch the Omniauth Apple gem. This issue discussion details why this is necessary and was really useful for understanding what needed to be done.

module OmniAuth
  module Strategies
    class Apple
      private def verify_nonce!(id_token)
        invalid_claim! :nonce unless id_token[:nonce] == stored_nonce
      end
    end
  end
end

What’s Next

  • I’d love to hear about a better approach to this so feel free to @ me or message me on my socials.
  • I implemented Native Google OAuth on the Android app and I’ll be following up with a post about that.
  • I recently released Calendar Vision so check that out if you’ve ever struggled with adding events to your calendar.