Firebase Authentication in Jetpack Compose

Deleting User Account & Revoke Access Token

Learn how to delete Firebase user, and revoke Google access tokens, and check for revoked tokens.

Marwa Diab
9 min readApr 4, 2024

--

Photo by Rohit Tandon on Unsplash

In the previous parts of this series, we implemented

Account Deletion Requirement

Google has added a new requirement in the Account Deletion Requirement section that requires you to offer an account deletion flow in your app if your app allows users to create accounts.

Google

If your app allows users to create an account from within your app, our User data policy requires that it must also allow users to request for their account to be deleted.

When you delete an app account based on a user’s request, you must also delete the user data associated with that app account.

Google Play Services

Applications are required to provide users that are signed in with Google the ability to disconnect their Google account from the app. If the user deletes their account, you must delete the information that your app obtained from the Google APIs. ~ GoogleSignInClient

Prepare Authorization Request

In HomeScreen add the following code:

  1. Before the Scaffold, create a ManagedActivityResultLauncher to register a request, and the launch(signInResult:) function.
  2. After the Scaffold add the OneTapSignIn composable.
// 1. 
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
try {
val credentials = authViewModel.oneTapClient.getSignInCredentialFromIntent(result.data)
// TODO: Delete account
}
catch (e: ApiException) {
Log.e("HomeScreen:Launcher","Re-auth error: $e")
}
}
}

fun launch(signInResult: BeginSignInResult) {
val intent = IntentSenderRequest.Builder(signInResult.pendingIntent.intentSender).build()
launcher.launch(intent)
}

/// Scaffold content...

// 2.
OneTapSignIn (
launch = {
launch(it)
}
)

This code snippet was previously explained in Sign in with Google part of this tutorial.

Re-authentication requirement

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 FirebaseAuthRecentLoginRequiredException 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.

Delete User Account

Create new file named Date-isWithinPast in the Model folder and add the following function to Date extension:

fun Date.isWithinPast(minutes: Int): Boolean {
val now = Date()
val timeAgo = Date(now.time - (60 * minutes * 1000))
val range = timeAgo..now
return range.contains(this)
}

Next, In AuthRepositoryImpl add the following code:
(Remember to add the functions declaration in AuthRepository interface)

// 1.
override fun checkNeedsReAuth(): Boolean {
auth.currentUser?.metadata?.lastSignInTimestamp?.let { lastSignInDate ->
return !Date(lastSignInDate).isWithinPast(5)
}
return false
}

override suspend fun deleteUserAccount(googleIdToken: String?): DeleteAccountResponse {
return try {
auth.currentUser?.let { user ->
// 2.
if (user.providerData.map { it.providerId }.contains("google.com")) {
// 3.
if (checkNeedsReAuth() && googleIdToken != null) {
// TODO: Re-authenticate
}
// TODO: Revoke access
}
// 4.
auth.currentUser?.delete()?.await()
Response.Success(true)
}
Response.Success(false)
}
catch (e: Exception) {
Response.Failure(e)
}
}

Here is what happens in this code snippet:

  1. Check if the user has signed-in in the past five minutes.
  2. Check if providerData contains Google providerId, then re-authenticate if required and revoke access token before deleting the user’s account.
  3. In case re-authentication is required, and googleIdToken is available, run re-authentication flow, and then revoke the access token.
  4. Delete Firebase Auth user account.

silentSignIn() 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 his/her Google account — i.e. revoked access token — , it’s still possible to sign-in silently from the locally cached user, after one hour when silentSignIn() requests new token, it will throw an exception, and so initiating the sign-in flow is needed for re-authenticating the user.

Re-authentication Flow

Add the following authorizeGoogleSignIn() function in AuthRepositoryImpl, that will be used later in AuthViewModel, to determine whether to show the user the One Tap UI for re-authentication or not.

override suspend fun authorizeGoogleSignIn(): String? {
auth.currentUser?.let { user ->
if (user.providerData.map { it.providerId }.contains("google.com")) {
try {
// 1.
val account = googleSignInClient.silentSignIn().await()
// 2.
return account.idToken
} catch (e: ApiException) {
Log.e(TAG, "Error: ${e.message}")
}
}
}
return null
}

Here is what happens in this code snippet:

  1. Check the Google credential by calling silentSignIn() on GoogleSignInClient instance that returns the GoogleSignInAccount for the user who is signed in to the app.
  2. Return idToken from retrieved account.
  3. Return null otherwise.

(At this point googleSignInClient is not defined yet, Unresolved reference: googleSignInClient, it will be fixed later)

Next, add the following reauthenticate(credential:) function to re-authenticate the user with Google:

  1. Using the given googleIdToken to create a fresh auth credential.
  2. Then call user.reauthenticate(AuthCredential).
private suspend fun reauthenticate(googleIdToken: String) {
// 1.
val googleCredential = GoogleAuthProvider
.getCredential(googleIdToken, null)
// 2.
auth.currentUser?.reauthenticate(googleCredential)?.await()
}

Then replace // TODO: Re-authenticate in deleteUserAccount(googleIdToken:) with the following call:

reauthenticate(googleIdToken)

Next, in AuthViewModel add the following function:

fun checkNeedsReAuth() = CoroutineScope(Dispatchers.IO).launch {
// 1.
if (repository.checkNeedsReAuth()) {
// 2.
val idToken = repository.authorizeGoogleSignIn()
if (idToken != null) {
deleteAccount(idToken)
}
else {
// 3.
oneTapSignIn()
// 4.
Log.i("AuthViewModel:deleteAccount","OneTapSignIn")
}
} else {
// 1.
deleteAccount(null)
}
}

Here is what happens in this code snippet:

  1. Check if user needs re-authentication, if that’s not the case, then call deleteAccount(googleIdToken:) without passing value for credential.
  2. Call authorizeGoogleSignIn() that was created earlier to fetch idToken, if available, call deleteAccount(googleIdToken:) and pass idToken.
  3. If failed, initiate oneTapSignIn flow to create new authorization request.
  4. deleteAccount(googleIdToken:) will be called from OneTap result callback.

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.

When user signs in with Google, your app will receive an ID token with information about the user. Your app then will be listed in the third party apps & services dashboard in user’s Google Account, in order for the app to be removed from this list, you will have to revoke any refresh tokens that are associated with it.

Implement access token revocation

In AppModule add the following:

  1. provide a GoogleSignInOptions instance that will be used to get GoogleSignInClient.
  2. provide a GoogleSignInClient instance that will be used to sign in silently and to revoke access token.
@Provides
fun provideGoogleSignInOptions(
app: Application,
) = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestIdToken(app.getString(R.string.web_client_id))
.requestEmail()
.build()

@Provides
fun provideGoogleSignInClient(
app: Application,
options: GoogleSignInOptions
) = GoogleSignIn.getClient(app, options)

In AuthRepositoryImpl inject the following GoogleSignInClient parameter in constructor:

private var googleSignInClient: GoogleSignInClient

Then replace // TODO: Revoke in deleteUserAccount(googleIdToken:) with the following:

googleSignInClient.revokeAccess().await()
oneTapClient.signOut().await()

Check Revoked Tokens

Since the user can delete the access token from outside the app, from the third party apps & services in the user’s Google account, so checking for revoked token is an important step upon app launch.

Add the following function in AuthRepositoryImpl:

override suspend fun verifyGoogleSignIn(): Boolean {
auth.currentUser?.let { user ->
val providersList = user.providerData.map { it.providerId }
if (providersList.contains("google.com")) {
return try {
// 1.
googleSignInClient.silentSignIn().await()
true
} catch (e: Exception) {
// 2.
signOut()
false
}
}
}
return false
}

Here is what happens in this code snippet:

  1. Check the Google credential by calling silentSignIn() on GoogleSignInClient instance that returns the information for the user who is signed in to the app.
  2. The attempt to sign in silently will fail if the user revoked authorization for the app, or the user’s credential is not found, so call signOut() function and return false.

Next, In AuthViewModel, add the following in init after getAuthState():

CoroutineScope(Dispatchers.IO).launch {
repository.verifyGoogleSignIn()
}

Then add the following functions:

// 1.
fun checkNeedsReAuth() = CoroutineScope(Dispatchers.IO).launch {
if (repository.checkNeedsReAuth()) {
oneTapSignIn()
} else {
deleteAccount(null)
}
}

// 2.
fun deleteAccount(googleIdToken: String?) = CoroutineScope(Dispatchers.IO).launch {
DataProvider.deleteAccountResponse = Response.Loading
DataProvider.deleteAccountResponse = repository.deleteUserAccount(googleIdToken)
}

Here is what happens in this code snippet:

  1. Check if user needs re-authentication, then run oneTapSignIn flow to create new authorization request, if that’s not the case, then call deleteAccount(googleIdToken:) without passing value for credential.
  2. Using given credential, start deleting account process by calling deleteUserAccount(googleIdToken)on repository instance.

Delete Account Button

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

Inside HomeScreen, add the following property to show an AlertDialog to the user before deleting the account:

val openDeleteAccountAlertDialog = remember { mutableStateOf(false) }

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

Row() {
/// Sign-out button content...

Button(
onClick = {
openDeleteAccountAlertDialog.value = true
},
modifier = Modifier
.size(width = 200.dp, height = 50.dp)
.padding(horizontal = 16.dp),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.tertiary,
)
) {
Text(
text = "Delete Account",
modifier = Modifier.padding(6.dp),
color = Color.Red
)
}
}

Next, add the following code for the AlertDialog, inside onClick block of confirmationButton, call authViewModel.checkNeedsReAuth() to start deleting user account process.

AnimatedVisibility(visible = openDeleteAccountAlertDialog.value) {
AlertDialog(
onDismissRequest = {
openDeleteAccountAlertDialog.value = false
},
title = { Text("Delete Account") },
text = {
Text("Deleting account is permanent. Are you sure you want to delete your account?")
},
confirmButton = {
TextButton(
onClick = {
authViewModel.checkNeedsReAuth()
openDeleteAccountAlertDialog.value = false
}
) {
Text("Yes, Delete", color = Color.Red)
}
},
dismissButton = {
TextButton(
onClick = {
openDeleteAccountAlertDialog.value = false
}
) {
Text("Dismiss")
}
}
)
}

Go back to launcher, and replace // TODO: Delete account with the following:

authViewModel.deleteAccount(credential.googleIdToken)

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 from two devices, and then deletes the account from one device, both the silentSignIn() and the AuthStateListener will retrieve the locally cached user (GoogleSignInAccount and FirebaseUser), 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 getIdToken(forceRefresh:) on currentUser instance to force a token refresh from the server, this function will throw an exception if the token is invalid and so the user will be signed out from the app.

Add the following verifyAuthTokenResult() function in AuthRepositoryImpl:

private suspend fun verifyAuthTokenResult(): Boolean {
return try {
auth.currentUser?.getIdToken(true)?.await()
true
} catch (e: Exception) {
Log.i(TAG, "Error retrieving id token result. $e")
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 AuthStateListener callback.

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

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

val authStateListener = AuthStateListener { auth ->
auth.currentUser?.let { user ->
try {
userRepository.getUserDocument(user).await()
}
catch (e: FirestoreException) {
if (e.message == "DocumentDoesNotExist") {
Log.i(TAG, "User Document Does Not Exist!")
GlobalScope.launch(Dispatchers.IO) {
verifyAuthTokenResult()
}
}
}
}
/// ...
}
/// ...

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

To learn how to use Delete User Data extension when deleting user’s account, check the following Firebase documentation:

Or follow the steps in the “Deleting the user’s data section in the video tutorial by Peter Friese on Firebase YouTube channel.

Take it for a spin

Run the app on your phone or on the Emulator. 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.”

--

--