Firebase Authentication in SwiftUI

Deleting User Account & Revoke Access Token

Learn how to delete Firebase users, and revoke Apple & Google access tokens

Marwa Diab
Firebase Developers

--

Photo by Paul Hanaoka on Unsplash

In previous parts of this series, we implemented

Account Deletion Requirement

Apple has added a paragraph to the App Review Guidelines that requires you to offer an account deletion flow in your app if your app allows users to create accounts.

Apple

Apps that support account creation must let users initiate deletion of their account within the app starting June 30, 2022, as described in App Store Review Guideline 5.1.1(v).

Google

It is highly recommended that you provide users that signed in with Google the ability to disconnect their Google account from your app. If the user deletes their account, you must delete the information that your app obtained from the Google APIs.

Feel free to check out the following video tutorial by Peter Friese on the Firebase YouTube channel, I highly recommend watching it to:

  1. Understand why it is required to re-authenticate before deleting the Firebase User account.
  2. Setup Apple service and key identifiers for the token revocation.
  3. How to install and use the Delete User Data Firebase extension. (This part is not covered in this tutorial)

As explained in the video

Since deleting a user account is a security-sensitive operation, Firebase requires that the user has signed in recently. If you try to perform a security-sensitive operation and the user hasn’t signed in recently, Firebase will throw a FIRAuthErrorCodeCredentialTooOld exception.

To prevent this, we’ll check if the user has signed-in in the past five minutes, if that’s not the case we will run a re-authentication flow.

Prepare Authorization Request

Inside AppleSignInManager, add the following code to create a new AppleID authorization request, and start the specified authorization flows.

class AppleSignInManager: NSObject {
// class properties...

private var continuation : CheckedContinuation<ASAuthorizationAppleIDCredential, Error>?

func requestAppleAuthorization() async throws -> ASAuthorizationAppleIDCredential {
return try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation

let appleIdProvider = ASAuthorizationAppleIDProvider()
let request = appleIdProvider.createRequest()
requestAppleAuthorization(request)

let authorizationController = ASAuthorizationController(authorizationRequests: [request])
authorizationController.delegate = self
authorizationController.performRequests()
}
}
// class content...
}

Then, to handle the AppleID authorization response, add the following extension to the AppleSignInManager class that conforms to the ASAuthorizationControllerDelegate.

extension AppleSignInManager: ASAuthorizationControllerDelegate {
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
if case let appleIDCredential as ASAuthorizationAppleIDCredential = authorization.credential {
continuation?.resume(returning: appleIDCredential)
}
}

func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
continuation?.resume(throwing: error)
}
}

Next, inside GoogleSignInManager, replace the signInWithGoogle() method that was previously implemented in Sign in with Google with the following code:

@MainActor 
func signInWithGoogle() async throws -> GIDGoogleUser? {
if GIDSignIn.sharedInstance.hasPreviousSignIn() {
do {
try await GIDSignIn.sharedInstance.restorePreviousSignIn()
// 1.
return try await GIDSignIn.sharedInstance.currentUser?.refreshTokensIfNeeded()
}
catch {
// 2.
return try await googleSignInFlow()
}
} else {
return try await googleSignInFlow()
}
}

@MainActor
private func googleSignInFlow() async throws -> GIDGoogleUser? {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return nil }
guard let rootViewController = windowScene.windows.first?.rootViewController else { return nil }

let result = try await GIDSignIn.sharedInstance.signIn(withPresenting: rootViewController)
return result.user
}

Here is what changed in this code snippet:

  1. After restoring the user’s sign-in, get a refreshed token by calling refreshTokensIfNeeded() in case the access token has expired.
  2. If restorePreviousSignIn() failed, possibly because of a previous revocation of the access token, then initiate the sign-in flow.

restorePreviousSignIn() restores a locally cached user so it does not send any server requests if the tokens haven't expired (less than an hour old).
When you force a sign out from Google Accounts you are invalidating the token on the server but the client doesn’t know until it requests new tokens.

If the user deleted the connection between the app and their Google account — i.e. revoked access token — , it’s still possible to retrieve the previous sign-in from the locally cached user. After one hour when restorePreviousSignIn() requests new token, it will throw "invalid_grant": "Token has been expired or revoked" exception, and so initiating the sign-in flow is needed for re-authenticating the user.

Delete User Account

Create a new Swift file named AuthManager-DeleteAccount.swift in the Model folder, and add the following code:

extension AuthManager {
func deleteUserAccount() async throws {
guard let user = Auth.auth().currentUser,
let lastSignInDate = user.metadata.lastSignInDate else { return }
// 1.
let needsReAuth = !lastSignInDate.isWithinPast(minutes: 5)
// 2.
let providers = user.providerData.map { $0.providerID }

do {
if providers.contains("apple.com") {
// 3.
let appleIDCredential = try await AppleSignInManager.shared.requestAppleAuthorization()
// 4.
if needsReAuth {
// TODO: Re-authenticate AppleID
}
// TODO: Revoke AppleID
}
if providers.contains("google.com") {
// 4.
if needsReAuth {
// TODO: Re-authenticate Google Account
}
// TODO: Revoke Google Account
}
// 5.
try await user.delete()
// 6.
updateState(user: user)
}
catch {
print("FirebaseAuthError: Failed to delete auth user. \(error)")
throw error
}
}
}

extension Date {
func isWithinPast(minutes: Int) -> Bool {
let now = Date.now
let timeAgo = Date.now.addingTimeInterval(-1 * TimeInterval(60 * minutes))
let range = timeAgo...now
return range.contains(self)
}
}

Here is what happens in this code snippet:

  1. Check if the user has signed-in in the past five minutes.
  2. Fetch the list of providers the user used to sign in to the app. This list will be used to check which provider needs to be re-authenticated and revoked before deleting the user’s account.
  3. In case the providers contain apple.com, request the AppleID credential by calling the previously created requestAppleAuthorization(), the returned appleIDCredential will be used to re-authenticate if required, and to revoke the AppleID token.
  4. In case re-authentication is required, run the re-authentication flow for AppleID/Google, and then revoke the access token.
  5. Delete the Firebase Auth user account.
  6. Update authState to refresh the UI.

Add the following AuthErrors enum in the same file, these error will be used later to display different error messages to the user.

enum AuthErrors: Error {
case ReauthenticateApple
case ReauthenticateGoogle
case RevokeAppleID
case RevokeGoogle
}

Re-authentication Flow

Add the following reauthenticateAppleID(_:for:) function to re-authenticate the user with AppleID:

  1. Get the identityToken from the given appleIDCredential, and the nonce used in the authorization flow.
  2. Using the identityToken and the nonce from the provider to create a fresh credential and then call user.reauthenticate(with:).
private func reauthenticateAppleID(
_ appleIDCredential: ASAuthorizationAppleIDCredential,
for user: User
) async throws {
do {
// 1.
guard let appleIDToken = appleIDCredential.identityToken else { return }
guard let idTokenString = String(data: appleIDToken, encoding: .utf8) else { return }
let nonce = AppleSignInManager.nonce
// 2.
let credential = OAuthProvider.credential(
withProviderID: "apple.com",
idToken: idTokenString,
rawNonce: nonce
)
try await user.reauthenticate(with: credential)
}
catch {
throw AuthErrors.ReauthenticateApple
}
}

Add the following reauthenticateGoogleAccount(for:) function to re-authenticate the user with Google:

  1. Get the googleUser by calling the signInWithGoogle(), and the idToken.
  2. Using the idToken and the accessToken strings form the provider to create a fresh credential and then call user.reauthenticate(with:).
private func reauthenticateGoogleAccount(for user: User) async throws {
do {
// 1.
guard let googleUser = try await GoogleSignInManager.shared.signInWithGoogle() else {
return
}
guard let idToken = googleUser.idToken?.tokenString else { return }
// 2.
let credential = GoogleAuthProvider.credential(
withIDToken: idToken,
accessToken: googleUser.accessToken.tokenString
)
try await user.reauthenticate(with: credential)
}
catch {
throw AuthErrors.ReauthenticateGoogle
}
}

Revoke Access Token

Deleting the user’s access and refresh tokens is a very important step in the process of deleting the user from the app.

As Peter explained in the previously mentioned video

When user signs in with Apple, your app will receive an ID token with information about the user, along with an authorization code. Your app then will be listed in the user’s Apple ID dashboard, in order for the app to be removed from this list, you will have to revoke any refresh tokens that are associated with it.

Same thing goes for Google Sign in, your app will be listed in the user’s third party apps & services dashboard.

(Note: Make sure to follow the steps in the “Setting up Sign in with Apple for Token Revocation part of the previously mentioned video.)

Implement access token revocation

Add the following revokeAppleIDToken(for:) function to revoke the user’s AppleID access token:

Using the authenticationCode from the given appleIDCredential to call revokeToken(withAuthorizationCode:) on auth object to revoke the AppleID tokens.

Firebase will retrieve the access token associated with this user and then call Apple’s token revocation endpoint to revoke the token.

private func revokeAppleIDToken(_ appleIDCredential: ASAuthorizationAppleIDCredential) async throws {
guard let authorizationCode = appleIDCredential.authorizationCode else { return }
guard let authCodeString = String(data: authorizationCode, encoding: .utf8) else { return }

do {
try await Auth.auth().revokeToken(withAuthorizationCode: authCodeString)
}
catch {
throw AuthErrors.RevokeAppleID
}
}

Add the following revokeGoogleAccount() function to revoke the user’s Google access token:

Call disconnect() on GIDSignIn instance to sign the user out and disconnect their account and revoke tokens.

private func revokeGoogleAccount() async throws {
do {
try await GIDSignIn.sharedInstance.disconnect()
}
catch {
throw AuthErrors.RevokeGoogle
}
}

Finally, replace each // TODO in the deleteUserAccount() function with the corresponding function call, code should look like the following:

if providers.contains("apple.com")  {
let appleIDCredential = try await AppleSignInManager.shared.requestAppleAuthorization()

if needsReAuth {
try await reauthenticateAppleID(appleIDCredential, for: user)
}
try await revokeAppleIDToken(appleIDCredential)
}
if providers.contains("google.com") {
if needsReAuth {
try await reauthenticateGoogleAccount(for: user)
}
try await revokeGoogleAccount()
}

Check Revoked Tokens

Since the user can delete the access token from outside the app (e.g., from the AppleID dashboard in the user’s AppleID account, or from the third party apps & services in the user’s Google account), checking for revoked token is an important step upon app launch.

Checking AppleID Credential was already implemented in Sign in with Apple part of this tutorial series, but checking for the AppleID credential alone might lead to an unexpected results, for example if the user is signed in with Google only, the app will always sign out because the AppleID credential was not found!

The following code includes checking for both the AppleID and the Google credentials, and signs the user out only if both are not available or have been revoked.

class AuthManager: ObservableObject {
// ...

private func verifySignInWithAppleID() async -> Bool {
let appleIDProvider = ASAuthorizationAppleIDProvider()

guard let providerData = Auth.auth().currentUser?.providerData,
let appleProviderData = providerData.first(where: { $0.providerID == "apple.com" }) else {
return false
}

do {
// 1.
let credentialState = try await appleIDProvider.credentialState(forUserID: appleProviderData.uid)
return credentialState != .revoked && credentialState != .notFound
}
catch {
return false
}
}

private func verifyGoogleSignIn() async -> Bool {
guard let providerData = Auth.auth().currentUser?.providerData,
providerData.contains(where: { $0.providerID == "google.com" }) else { return false }

do {
// 2.
try await GIDSignIn.sharedInstance.restorePreviousSignIn()
return true
}
catch {
return false
}
}

private func verifySignInProvider() async {
guard let providerData = Auth.auth().currentUser?.providerData else { return }
// 3.
var isAppleCredentialRevoked = false
var isGoogleCredentialRevoked = false

if providerData.contains(where: { $0.providerID == "apple.com" }) {
isAppleCredentialRevoked = await !verifySignInWithAppleID()
}

if providerData.contains(where: { $0.providerID == "google.com" }) {
isGoogleCredentialRevoked = await !verifyGoogleSignIn()
}

// 4.
if isAppleCredentialRevoked && isGoogleCredentialRevoked {
if authState != .signedIn {
do {
try await self.signOut()
}
catch {
print("FirebaseAuthError: verifySignInProvider() failed. \(error)")
}
}
}
}
// ...
}

Here is what happens in this code snippet:

  1. Check the AppleID credentials using the credentialState(forUserID:) function that retrieves the state of the user identifier saved in provider data, the verifySignInWithAppleID() function will return false if the user revoked authorization for the app, or the user’s credential state is not found.
  2. Check the Google credential by calling restorePreviousSignIn() on GIDSignIn instance to try to restore a previous user sign-in, the attempt to restore the previous user sign-in will fail if the user revoked authorization for the app, or the user’s credential is not found, so function will return false.
  3. Create isAppleCredentialRevoked and isGoogleCredentialRevoked to check if the AppleID/Google tokens are revoked or not found.
  4. If both tokens are not available, then sign out if the user is not signed out, or signed in anonymously.
  5. Replace verifySignInWithAppleID() with verifySignInProvider() in init right after configureAuthStateChanges(). Make sure to put it inside Task { await }.

Delete Account Button

Now that the delete user account code is ready, let’s implement the delete button in the HomeView.

Inside HomeView, add the following @State property to show a confirmationDialog to the user before deleting the account:

@State private var showDeleteAccountAlert = false

Next, move the Sign Out button into a HStack, and add the following Delete Account button after the Sign Out button, inside the HStack.

HStack {
// Sign out button...

Button {
showDeleteAccountAlert = true
} label: {
Text("Delete Account")
.font(.body.bold())
.frame(width: 150, height: 45, alignment: .center)
.foregroundStyle(.red)
.background(Color(.loginBlue))
.cornerRadius(10)
}
}

Then, add the following code for the confirmationDialog, catch the AuthErrors thrown from the re-authenticating and the revoking, and present a descriptive message to the user explaining the issue and how to solve it.

NavigationStack {
// VStack content...
.confirmationDialog("Delete Account", isPresented: $showDeleteAccountAlert) {
Button("Yes, Delete", role: .destructive) {
Task {
do {
try await authManager.deleteUserAccount()
}
catch AuthErrors.ReauthenticateApple {
// AppleID re-authentication failed
}
catch AuthErrors.RevokeAppleID {
// AppleID token revocation failed
}
catch AuthErrors.ReauthenticateGoogle {
// Google re-authentication failed
}
catch AuthErrors.RevokeGoogle {
// Google token revocation failed
}
catch {
// Show generic error message
}
}
}
} message: {
Text("Deleting account is permanent. Are you sure you want to delete your account?")
}
}

Force Refresh Auth Token

As mentioned earlier

When you force a sign out from Google Accounts you are invalidating the token on the server but the client doesn’t know until it requests new tokens.

Edge case

If the user is signed in using Google provider from two devices, and then deletes the account from one device, both the restorePreviousSignIn() and the auth().addStateDidChangeListener will retrieve the locally cached user (GoogleUser and AuthUser), and so the auth status will not be reflected on the second device (for an hour), in that case it’s better to force refresh the token to get the updated results.

Solution

To fix this call getIDTokenResult(forcingRefresh:) on currentUser instance to force a token refresh from the server, this function will throw the following exception if the token is invalid and so the user will be signed out from the app.

ERROR_USER_NOT_FOUND

Add the following verifyAuthTokenResult() function in AuthManager:

private func verifyAuthTokenResult() async -> Bool {
do {
try await Auth.auth().currentUser?.getIDTokenResult(forcingRefresh: true)
return true
}
catch {
print("Error retrieving id token result. \(error)")
return false
}
}

Calling this function upon app launch loses the benefit of retrieving locally cached Auth user that Firebase uses so it doesn’t send any server requests if the tokens haven’t expired yet, so it’s not a good practice to call it when the app starts.

Instead, one of the good practices is to save the user’s profile data in a document in the users collection in Firestore, including uid, email, name, creation date, last update date, (gender, age, birthdate — if required in the app).. etc, and retrieve the user’s document when auth state changes, inside the addStateDidChangeListener(_:) callback.

When getting the user’s document in Firestore, check if the document exists, if the document does not exist, throw a DocumentDoesNotExist exception.

func getUserDocument(_ user: User) async throws -> Bool {
let userDocumentReference = db.collection(Collection.Users).document(user.uid)
let document = try await userDocumentReference.getDocument()
guard document.exists else {
throw FirestoreErrors.DocumentDoesNotExist
}
// function content...
}

Then in the catch block of getUserDocument(user) call, check for DocumentDoesNotExist error, and call verifyAuthTokenResult() to force refresh auth token.

addStateDidChangeListener { auth, user in
// ...
if let user {
do {
try await firestore.getUserDocument(user)
}
catch FirestoreErrors.DocumentDoesNotExist {
print("User Document Does Not Exist!")
await verifyAuthTokenResult()
return
}
catch {
// Other errors
}
}
}

That way the call to verifyAuthTokenResult() will only be initiated if you try to get the user’s document after it got deleted.

(Make sure to follow the steps in the “Deleting the user’s data part of the previously mentioned video.)

Take it for a spin

Run the app on your phone or on the iOS Simulator. You should now be able to test all the different scenarios since most cases are handled, even the edge cases that might rarely happen — but it still could happen.

As Doug Linder once said: “a good programmer is the kind of person who looks both ways before crossing a one-way street.”

--

--