Firebase Authentication in SwiftUI
Deleting User Account & Revoke Access Token
Learn how to delete Firebase users, and revoke Apple & Google access tokens
In previous parts of this series, we implemented
- Firebase Anonymous Authentication
- Google Sign-In Authentication
- Apple Authentication
- Handle Firebase Authentication Errors
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).
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:
- Understand why it is required to re-authenticate before deleting the Firebase User account.
- Setup Apple service and key identifiers for the token revocation.
- 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:
- After restoring the user’s sign-in, get a refreshed token by calling
refreshTokensIfNeeded()
in case the access token has expired. - 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:
- Check if the user has signed-in in the past five minutes.
- 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.
- In case the providers contain
apple.com
, request the AppleID credential by calling the previously createdrequestAppleAuthorization()
, the returnedappleIDCredential
will be used to re-authenticate if required, and to revoke the AppleID token. - In case re-authentication is required, run the re-authentication flow for AppleID/Google, and then revoke the access token.
- Delete the Firebase Auth user account.
- 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:
- Get the
identityToken
from the givenappleIDCredential
, and thenonce
used in the authorization flow. - Using the
identityToken
and thenonce
from the provider to create a fresh credential and then calluser.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:
- Get the
googleUser
by calling thesignInWithGoogle()
, and theidToken
. - Using the
idToken
and theaccessToken
strings form the provider to create a fresh credential and then calluser.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:
- Check the AppleID credentials using the
credentialState(forUserID:)
function that retrieves the state of the user identifier saved in provider data, theverifySignInWithAppleID()
function will returnfalse
if the user revoked authorization for the app, or the user’s credential state is not found. - Check the Google credential by calling
restorePreviousSignIn()
onGIDSignIn
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 returnfalse
. - Create
isAppleCredentialRevoked
andisGoogleCredentialRevoked
to check if the AppleID/Google tokens are revoked or not found. - If both tokens are not available, then sign out if the user is not signed out, or signed in anonymously.
- Replace
verifySignInWithAppleID()
withverifySignInProvider()
ininit
right afterconfigureAuthStateChanges()
. Make sure to put it insideTask { 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.
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.”
Resources
“ Everyone has something to learn. Everyone has something to teach.” ~ Paul Hudson