Native Apple OAuth with Hotwire Native
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.
- Call the Devise Ominauth callback endpoint with the
authorization
data - 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.