Authenticating phone numbers with Firebase on iOS

I wanted to have a way for my users to associate a phone number with their user profiles in my Firebase-based iOS app. Here's what I came up with. Bear in mind that this is something that worked, but isn't engineered beautifully; at some point I'll give presentLogin a better name and make it static.

The basic approach was to use FirebaseUI to do all the authentication. I needed to keep the user logged in to the current app, though, so I instantiated an app with a different "name" using the same settings.

I had a hiccup when I used a FUIAuthDelegate that wasn't also a UIViewController: authUI.delegate is a weak reference, so when my AuthGetPhoneNumber object went out of scope, it got garbage collected and my callbacks never got called. The "avoidGarbageCollection" family of functions at the bottom of the class is how I avoided that; I wonder whether Swift has a better built-in way of handling this.

I'm mildly proud of how I record the phone number as belonging to a particular user in the database. I need both the holder of a phone number P and a user U to agree that P and U are the same person. I also want P to be associated with zero or one users. So I have a spot in Firestore with the following rules.

    match /authVerifiedPhoneNumbers/{phoneNumber} {
      // The owner of the user ID and the owner of the phone number can both delete and read this.
      allow read, delete: if request.auth.uid == resource.data.userId || request.auth.token.phone_number == phoneNumber;
      // The owner of the phone number can set the userId, as long as the approval status is set to false.
      allow write: if request.auth.token.phone_number == phoneNumber &&
                      request.resource.data.size() == 2 &&
                      request.resource.data.userId is string &&
                      request.resource.data.approved == false;
      // The owner of the user ID can change the "approved" status.
      allow update: if request.auth.uid == resource.data.userId &&
                       request.resource.data.size() == 2 &&
                       request.resource.data.userId == resource.data.userId &&
                       request.resource.data.approved is bool;

    }

So first the owner of the phone number writes to /authVerifiedPhoneNumbers/{phoneNumber} that U is the owner, but can only write that "approved" is false. Then U comes, and can only change approved to true (or leave it at false). Both the holder of a phone number and a user can see, and delete, what's in the database.

Here's the client-side code, using Firebase 4.8.2 and FirebaseUI 4.5.1:

import Firebase
import UIKit
import FirebasePhoneAuthUI

class AuthGetPhoneNumber: NSObject, FUIAuthDelegate {
  fileprivate var firebaseApp: FirebaseApp {
    let appInstanceName = "FIREBASE_APP_FOR_PHONE_AUTH"
    if let app = FirebaseApp.app(name: appInstanceName) {
      return app
    }
    let options = FirebaseOptions.defaultOptions()!
    FirebaseApp.configure(name: appInstanceName, options: options)
    return FirebaseApp.app(name: appInstanceName)!
  }
  
  fileprivate var completion: ((String?, Error?) -> Void)?

  public func presentLogin(withPresenting viewController: UIViewController, completion: @escaping ((String?, Error?) -> Void)) {
    self.completion = completion
    let auth = Auth.auth(app: self.firebaseApp)
    try? auth.signOut()
    let authUI = FUIAuth(uiWith: auth)!
    authUI.delegate = self  // This is a weak reference, so we need to avoid garbage collection.
    avoidGarbageCollection()
    let phoneAuth = FUIPhoneAuth(authUI: authUI)
    authUI.providers = [phoneAuth]
    phoneAuth.signIn(withPresenting: viewController, phoneNumber: nil)
  }

  public func authUI(_ authUI: FUIAuth, didSignInWith firUser: User?, error: Error?) {
    stopAvoidingGarbageCollection()
    if let error = error {
      self.completion?(nil, error)
    } else {
      associatePhoneNumberWithUser()
    }
  }

  fileprivate func associatePhoneNumberWithUser() {
    let app = self.firebaseApp
    let emailUserId = Auth.auth().currentUser!.uid
    let phoneNumber = Auth.auth(app: app).currentUser!.phoneNumber!
    let path = "authVerifiedPhoneNumbers/\(phoneNumber)"
    var claim: [String: Any] = ["userId": emailUserId, "approved": false]
    Firestore.firestore(app: app).document(path).setData(claim) { error in
      if let error = error {
        self.completion?(nil, error)
        return
      }
      claim["approved"] = true
      Firestore.firestore().document(path).setData(claim) { error in
        if let error = error {
          self.completion?(nil, error)
          return
        } else {
          self.completion?(phoneNumber, nil)
          return
        }
      }
    }
  }

  fileprivate var strongSelfForAvoidingGarbageCollection: AuthGetPhoneNumber?
  fileprivate func avoidGarbageCollection() {
    strongSelfForAvoidingGarbageCollection = self
  }
  fileprivate func stopAvoidingGarbageCollection() {
    strongSelfForAvoidingGarbageCollection = nil
  }

}

// Invoke as follows:

    let gpn = AuthGetPhoneNumber()
    gpn.presentLogin(withPresenting: self) { phoneNumber, error in
    }

Comments

Popular posts from this blog

No, programming competitions don't produce bad engineers

Authenticating with Cloud Functions for Firebase from your iOS Swift app