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.
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.
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:
- Before the
Scaffold
, create aManagedActivityResultLauncher
to register a request, and thelaunch(signInResult:)
function. - After the
Scaffold
add theOneTapSignIn
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:
- Check if the user has signed-in in the past five minutes.
- Check if
providerData
contains GoogleproviderId
, then re-authenticate if required and revoke access token before deleting the user’s account. - In case re-authentication is required, and
googleIdToken
is available, run re-authentication flow, and then revoke the access token. - 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:
- Check the Google credential by calling
silentSignIn()
onGoogleSignInClient
instance that returns theGoogleSignInAccount
for the user who is signed in to the app. - Return
idToken
from retrieved account. - 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:
- Using the given
googleIdToken
to create a fresh auth credential. - 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:
- Check if user needs re-authentication, if that’s not the case, then call
deleteAccount(googleIdToken:)
without passing value for credential. - Call
authorizeGoogleSignIn()
that was created earlier to fetchidToken
, if available, calldeleteAccount(googleIdToken:)
and passidToken
. - If failed, initiate
oneTapSignIn
flow to create new authorization request. 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:
- provide a
GoogleSignInOptions
instance that will be used to getGoogleSignInClient
. - 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:
- Check the Google credential by calling
silentSignIn()
onGoogleSignInClient
instance that returns the information for the user who is signed in to the app. - 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 returnfalse
.
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:
- Check if user needs re-authentication, then run
oneTapSignIn
flow to create new authorization request, if that’s not the case, then calldeleteAccount(googleIdToken:)
without passing value for credential. - Using given credential, start deleting account process by calling
deleteUserAccount(googleIdToken)
onrepository
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.”
Resources
“ Everyone has something to learn. Everyone has something to teach.” ~ Paul Hudson