diff --git a/AGENTS.md b/AGENTS.md index b2a83c8..5918c1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,11 +111,12 @@ npm ci && node --check index.js && npm test ``` `npm run test:rules` and `npm run test:emulator` run against the Firestore and Storage -emulators, through the root `firebase.json`. They need a Java runtime and the Firebase CLI, -and the emulator ports free (8080 and 9199): a run refuses to start otherwise, naming the -port, and one run cannot share another's emulators, so run them one after the other. The -functions' suites deliver signed events to the webhook over HTTP and point -`STRIPE_API_URL` at a stand-in for Stripe's API, so nothing reaches Stripe. +emulators, through the root `firebase.json`; the functions' suites start the Auth emulator +too. They need a Java runtime and the Firebase CLI, and the emulator ports free (8080 and +9199, and 9099 for Auth): a run refuses to start otherwise, naming the port, and one run +cannot share another's emulators, so run them one after the other. The functions' suites +deliver signed events to the webhook over HTTP and point `STRIPE_API_URL` at a stand-in for +Stripe's API, so nothing reaches Stripe. CI (`.github/workflows/ci.yml`) runs the functions' unit tests, the web app's lint, key vectors and builds, the Android unit tests and the encryption reference's tests on every diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/AccountDeletion.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/AccountDeletion.kt new file mode 100644 index 0000000..3e02aaa --- /dev/null +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/AccountDeletion.kt @@ -0,0 +1,235 @@ +package com.makerocketgonow.pushthat + +import android.content.Context +import android.util.Log +import com.google.firebase.Firebase +import com.google.firebase.FirebaseException +import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException +import com.google.firebase.auth.FirebaseAuthInvalidUserException +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.auth +import com.google.firebase.messaging.FirebaseMessaging +import com.makerocketgonow.pushthat.data.AppDatabase +import com.makerocketgonow.pushthat.e2ee.AccountKeys +import com.makerocketgonow.pushthat.e2ee.signOut +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * "Delete account" from the list's menu (docs/account-deletion.md at the repository root): + * the password is proved with a fresh sign-in, the `deleteAccount` function deletes the + * account and everything stored under it, and then this install forgets what it held. + * + * The function is called over plain HTTPS as a Firebase callable, as `Plan` calls + * `getPlan`, so no further SDK is needed. + */ +object AccountDeletion { + private const val DELETE_ACCOUNT_URL = "https://us-central1-push-that.cloudfunctions.net/deleteAccount" + + /** + * The function said no before deleting anything, in [message]: a sentence it wrote for + * the person (a subscription that could not be cancelled, say). + */ + class RefusedException(message: String) : Exception(message) + + /** + * The call ended with no answer from the function (a timeout, a dropped connection, a + * crash on the server) and the account still exists. Nothing can be said about how far + * the deletion got: once the function is past Stripe it has left a marker, and the + * server finishes what it started within about two hours whether or not anyone asks + * again. So the message for this never says that nothing was deleted. + */ + class OutcomeUnknownException(cause: Exception) : Exception(cause) + + // A little more than the five minutes the function allows itself (`timeoutSeconds` in + // functions/src/account-deletion.ts), so the function always answers first. It deletes + // an account's pushes five hundred at a time before it answers and sends nothing + // meanwhile; at two minutes this gave up on a large account that the server then went + // on to delete, and told its owner nothing had been removed. + private val http = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(310, TimeUnit.SECONDS) + .build() + + /** The statuses the function refuses with before it has deleted anything. */ + private val REFUSALS = setOf("FAILED_PRECONDITION", "UNAVAILABLE", "UNAUTHENTICATED") + + /** + * Deletes the signed-in account. Throws what the sign-in throws for a wrong password + * (`FirebaseAuthInvalidCredentialsException`) or no network, [RefusedException] when + * the function refuses, and [OutcomeUnknownException] when the call ended with no + * answer; in each case nothing on this install has changed and the session is as it + * was. + * + * An account found to be gone already counts as deleted, whichever step finds it: an + * earlier try whose answer was lost has finished on the server, and what is left to do + * is here. + */ + suspend fun delete(context: Context, password: String) { + val app = context.applicationContext + val user = Firebase.auth.currentUser ?: throw IllegalStateException("not signed in") + val uid = user.uid + // The function refuses a sign-in older than five minutes, so the token is one made + // by this proof of the password, not the one the session has been using. + val token = try { + AccountKeys(app).freshTokenFor(password) + } catch (e: FirebaseAuthInvalidUserException) { + // Only "no such user" is an account already deleted. The same exception is + // thrown for a session revoked by a password change and for a disabled + // account, and neither of those has deleted anything. + if (e.errorCode != USER_NOT_FOUND) throw e + forget(app, uid) + return + } catch (e: FirebaseAuthInvalidCredentialsException) { + // With email enumeration protection on, which this project's sign-in assumes + // (AccountKeys), Firebase says "wrong password" for an account that no longer + // exists, never "no such user". So a refused password is checked against the + // account itself, which `reload` answers truthfully whatever that setting is. + if (!isGone(user, revokedMeansGone = false)) throw e + forget(app, uid) + return + } + // From here on, not cancelled with the screen. The call can take five minutes, and + // leaving the screen meanwhile (back, or the activity finishing) clears the view + // model's scope: the request still finished on its thread and the server still + // deleted the account, but the cancellation was rethrown on the way back and + // `forget` never ran, leaving an install signed in to nothing with the deleted + // account's queue and token. + // + // Swiping the app out of Recents is not that: it kills the process, which nothing + // here survives. The session then ends on its own once the revoked token is + // refused, and `forgetSession` drops the key, but the queued pushes and the + // notification token stay until this install is next signed out. + withContext(NonCancellable) { deleteAndForget(app, user, token) } + } + + private suspend fun deleteAndForget(app: Context, user: FirebaseUser, token: String) { + val uid = user.uid + try { + callDeleteAccount(token) + } catch (e: IOException) { + // The Auth user is the last thing the function deletes, so gone means done. The + // session was re-authenticated a moment ago, so only the function can have + // revoked it since, and it does that after leaving the marker that gets the + // deletion finished. + if (!isGone(user, revokedMeansGone = true)) throw OutcomeUnknownException(e) + } + forget(app, uid) + } + + /** + * Whether the account no longer exists, asked of Firebase. [revokedMeansGone] says + * whether a revoked session counts: it does straight after this deletion's own sign-in, + * and it must not for a session of unknown age, which a password change or reset on + * another device also revokes. Read as "gone" there, a mistyped password signed the + * app out and said the account was deleted when the server had not been asked. + */ + private suspend fun isGone(user: FirebaseUser, revokedMeansGone: Boolean): Boolean = try { + user.reload().await() + false + } catch (e: FirebaseAuthInvalidUserException) { + e.errorCode == USER_NOT_FOUND || (revokedMeansGone && e.errorCode == USER_TOKEN_EXPIRED) + } catch (e: FirebaseException) { + // Could not ask (no network): not known to be gone. + false + } + + // Firebase Auth's own codes for `FirebaseAuthInvalidUserException`. + private const val USER_NOT_FOUND = "ERROR_USER_NOT_FOUND" + private const val USER_TOKEN_EXPIRED = "ERROR_USER_TOKEN_EXPIRED" + + private suspend fun callDeleteAccount(token: String) = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url(DELETE_ACCOUNT_URL) + .header("Authorization", "Bearer $token") + .post("{\"data\":{}}".toRequestBody("application/json".toMediaType())) + .build() + http.newCall(request).execute().use { response -> + when (val answer = answerOf(response.code, response.body.string())) { + Answer.Deleted -> Unit + is Answer.Refused -> throw RefusedException(answer.message) + Answer.Unknown -> throw IOException("deleteAccount answered ${response.code}") + } + } + } + + /** What the function's response says happened. */ + internal sealed interface Answer { + data object Deleted : Answer + + /** Refused before anything was deleted, with the function's sentence for the person. */ + class Refused(val message: String) : Answer + + /** Nothing can be said about how far the deletion got. */ + data object Unknown : Answer + } + + /** + * Reads a response's status and body. Apart from the request so that it can be tested: + * this is where "nothing was deleted" and "it may have been" are told apart, and a + * mistake either way tells someone the wrong thing about their account. + * + * Success is a 200 with `{"result": {"deleted": true}}`. A callable's refusal is + * `{"error": {"status", "message"}}`, and the function makes its own before it deletes + * anything, each with a sentence. Any other failure (INTERNAL, a gateway's error page, + * a body that is not JSON) is treated like an answer that never came. + */ + internal fun answerOf(code: Int, body: String?): Answer { + val json = body?.let { runCatching { JSONObject(it) }.getOrNull() } + if (code in 200..299 && json?.optJSONObject("result")?.optBoolean("deleted") == true) return Answer.Deleted + val error = json?.optJSONObject("error") ?: return Answer.Unknown + val message = error.optString("message").takeIf { it.isNotBlank() } ?: return Answer.Unknown + return if (error.optString("status") in REFUSALS) Answer.Refused(message) else Answer.Unknown + } + + /** + * What this install held for the account, beyond what any sign-out drops. Each step is + * tried on its own: the account is already gone, so one that fails must not keep the + * rest, or the sign-out, from happening. + * + * - The pushes queued for a retry. Sign-out keeps them, for an account that will sign + * in again; this one never will. + * - The notification token, so Firebase stops addressing this install as that account's + * device. The copy in the user document went with the document. + * - The Analytics data and the installation's Analytics id, so what this install + * reports from here on is not joined to what it reported for the deleted account. + * + * Then the sign-out every button uses, which drops the account key, the last decrypted + * file and the session. The server has deleted the user, so there is nothing to ask of + * Firebase but to forget it here. + */ + private suspend fun forget(app: Context, uid: String) { + withContext(Dispatchers.IO) { + attempt("queued pushes") { AppDatabase.getDatabase(app).pendingPushDao().deleteAllFor(uid) } + // Bounded: this asks Firebase over the network, behind a dialog that cannot be + // dismissed, for an account that is already gone. The token is a courtesy to + // clear; the copy that addressed this device went with the user document. + attempt("notification token") { + withTimeoutOrNull(5_000) { FirebaseMessaging.getInstance().deleteToken().await() } + } + } + attempt("analytics") { FirebaseAnalytics.getInstance(app).resetAnalyticsData() } + signOut(app) + } + + private inline fun attempt(what: String, step: () -> Unit) { + try { + step() + } catch (e: Exception) { + Log.w(TAG, "Could not clear the $what after deleting the account", e) + } + } + + private const val TAG = "AccountDeletion" +} diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/KeySettingsViewModel.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/KeySettingsViewModel.kt index 93cbbf1..8c7cf69 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/KeySettingsViewModel.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/KeySettingsViewModel.kt @@ -6,6 +6,8 @@ import androidx.annotation.StringRes import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.google.firebase.FirebaseException +import com.google.firebase.FirebaseNetworkException +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException import com.makerocketgonow.pushthat.e2ee.AccountKeyStore import com.makerocketgonow.pushthat.e2ee.AccountKeys import com.makerocketgonow.pushthat.e2ee.KeysChangedException @@ -16,10 +18,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch /** - * "Change password" and "Show a new recovery key" from the list's menu. Outside the - * activity for the same reason as `LoginViewModel`: a new recovery key replaces the old - * one's wrap before it is shown, so a rotation that cancelled the step in between would - * leave a key that works and that nobody saw. + * "Change password", "Show a new recovery key" and "Delete account" from the list's menu. + * Outside the activity for the same reason as `LoginViewModel`: a new recovery key replaces + * the old one's wrap before it is shown, so a rotation that cancelled the step in between + * would leave a key that works and that nobody saw. A deletion cancelled between the + * server's answer and the sign-out would leave an install signed in to no account. */ class KeySettingsViewModel(application: Application) : AndroidViewModel(application) { private val keys = AccountKeys(application) @@ -29,6 +32,8 @@ class KeySettingsViewModel(application: Application) : AndroidViewModel(applicat class Working(@param:StringRes val message: Int) : State class RecoveryKey(val recoveryKey: String) : State class Done(@param:StringRes val message: Int) : State + /** The account is gone and this install is signed out: only the sign-in screen is left. */ + data object AccountDeleted : State class Failed(val message: String) : State } @@ -44,6 +49,31 @@ class KeySettingsViewModel(application: Application) : AndroidViewModel(applicat State.RecoveryKey(keys.replaceRecoveryKey()) } + /** + * Its failures are worded here and not in [messageFor], which all three settings share: + * what is true of a deletion that failed ("nothing was deleted") is not true of a + * password change, and was being said to someone who had only mistyped their current + * password there. + */ + fun deleteAccount(password: String) = run(R.string.deleting_account) { + val app = getApplication() + try { + AccountDeletion.delete(app, password) + State.AccountDeleted + } catch (e: FirebaseAuthInvalidCredentialsException) { + State.Failed(app.getString(R.string.wrong_password)) + } catch (e: AccountDeletion.RefusedException) { + // The function's own sentence, written for the person. + State.Failed(e.message ?: app.getString(R.string.delete_account_unconfirmed)) + } catch (e: AccountDeletion.OutcomeUnknownException) { + Log.w(TAG, "The deletion ended without an answer", e) + State.Failed(app.getString(R.string.delete_account_unconfirmed)) + } catch (e: FirebaseNetworkException) { + // The sign-in that proves the password never reached Firebase: nothing was asked. + State.Failed(app.getString(R.string.delete_account_offline)) + } + } + /** The result has been seen. */ fun acknowledge() { if (_state.value !is State.Working) _state.value = State.Idle @@ -74,6 +104,8 @@ class KeySettingsViewModel(application: Application) : AndroidViewModel(applicat private fun messageFor(e: Exception): String { val app = getApplication() return when (e) { + // Before FirebaseException, whose own message for this is about a "credential". + is FirebaseAuthInvalidCredentialsException -> app.getString(R.string.wrong_password) // Retrying cannot help: the key this install holds is not the account's. is KeysChangedException, is AccountKeys.NoAccountKeyException -> app.getString(R.string.keys_changed_here) // Firebase's own message for its errors: a wrong current password, no network. diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/MainActivity.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/MainActivity.kt index d24ee80..e4d030f 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/MainActivity.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/MainActivity.kt @@ -535,6 +535,10 @@ class MainActivity : AppCompatActivity() { .show() true } + R.id.nav_delete_account -> { + showDeleteAccount() + true + } R.id.action_delete -> { showDeleteConfirmation() true @@ -630,6 +634,36 @@ class MainActivity : AppCompatActivity() { } } + /** + * What deleting does, said before anything is asked, and the password that confirms it. + * One dialog rather than a warning and then a prompt: the password is the confirmation, + * and the function refuses without a sign-in made in the last five minutes. As with the + * password change, the button is wired after `show()` so an empty field keeps it open. + */ + private fun showDeleteAccount() { + val view = layoutInflater.inflate(R.layout.dialog_delete_account, null) + val password = view.findViewById(R.id.deleteAccountPasswordInput) + val dialog = AlertDialog.Builder(this, R.style.DeleteDialogStyle) + .setTitle(R.string.delete_account_title) + .setView(view) + .setPositiveButton(R.string.delete_account, null) + .setNegativeButton(R.string.cancel, null) + .show() + dialog.getButton(AlertDialog.BUTTON_POSITIVE).apply { + setTextColor(ContextCompat.getColor(this@MainActivity, R.color.color_error)) + setOnClickListener { + // Not trimmed: the keys are derived from the password exactly as typed. + val typed = password.text.toString() + if (typed.isEmpty()) { + password.error = getString(R.string.password_required) + } else { + dialog.dismiss() + keySettings.deleteAccount(typed) + } + } + } + } + /** * The settings' progress, result and recovery key, one dialog at a time, shown again * from the view model's state after a rotation. The recovery key cannot be dismissed @@ -640,6 +674,13 @@ class MainActivity : AppCompatActivity() { val builder = AlertDialog.Builder(this, R.style.DeleteDialogStyle) keySettingsDialog = when (state) { KeySettingsViewModel.State.Idle -> null + KeySettingsViewModel.State.AccountDeleted -> { + // The view model has signed this install out; there is no list to go back to. + Toast.makeText(this, R.string.account_deleted, Toast.LENGTH_LONG).show() + startActivity(Intent(this, LoginActivity::class.java)) + finish() + null + } is KeySettingsViewModel.State.Working -> builder .setMessage(state.message) .setCancelable(false) diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/data/PendingPushDao.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/data/PendingPushDao.kt index 81f27ab..9abda26 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/data/PendingPushDao.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/data/PendingPushDao.kt @@ -26,4 +26,10 @@ interface PendingPushDao { @Delete fun delete(pendingPush: PendingPush): Int + + // For an account that has been deleted (AccountDeletion): its queued pushes have no + // account left to be written to. A row from before rows named their account has no + // user_id, and stays for whoever signs in next, as it always has. + @Query("DELETE FROM pending_pushes WHERE user_id = :userId") + fun deleteAllFor(userId: String): Int } \ No newline at end of file diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/AccountKeys.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/AccountKeys.kt index 7a9996c..364ee77 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/AccountKeys.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/AccountKeys.kt @@ -115,8 +115,7 @@ class AccountKeys( auth.signInWithEmailAndPassword(email, firebasePassword).await() return false } catch (first: FirebaseAuthInvalidCredentialsException) { - for (legacyPassword in listOf(password, password.trim()).distinct()) { - if (legacyPassword.isEmpty()) continue + for (legacyPassword in legacyPasswords(password)) { try { auth.signInWithEmailAndPassword(email, legacyPassword).await() return true @@ -243,6 +242,43 @@ class AccountKeys( ?: throw if (AccountKeyStore.hasKey(appContext, uid)) KeyUnavailableException() else NoAccountKeyException() } + /** + * What Firebase may hold for an account that has not moved to `authKey`, in the order + * to try them (the reasons are on [signInToFirebase]). One list, so that the sign-in and + * the re-authentication before a deletion cannot come to disagree about it. + */ + private fun legacyPasswords(password: String): List = + listOf(password, password.trim()).distinct().filter { it.isNotEmpty() } + + /** + * Proves [password] with a fresh sign-in and returns the ID token it gives, for the one + * call that needs a recent sign-in: deleting the account (`AccountDeletion`), which the + * function refuses from a session older than five minutes. Reads and writes no keys. + * + * `authKey` and then [legacyPasswords], as [signInToFirebase] tries them, so an account + * that still has its raw password in Firebase (made before encryption, or straight after a reset) + * can be deleted without first being moved to `authKey`. Throws + * [FirebaseAuthInvalidCredentialsException] when none of them is the password. + */ + suspend fun freshTokenFor(password: String): String { + val user = auth.currentUser ?: throw IllegalStateException("not signed in") + val email = user.email ?: throw IllegalStateException("the account has no email") + val keys = derive(email, password) + var refusal: FirebaseAuthInvalidCredentialsException? = null + for (candidate in (listOf(keys.firebasePassword) + legacyPasswords(password)).distinct()) { + try { + user.reauthenticate(EmailAuthProvider.getCredential(email, candidate)).await() + refusal = null + break + } catch (e: FirebaseAuthInvalidCredentialsException) { + refusal = refusal ?: e + } + } + refusal?.let { throw it } + // Forced: the token in hand carries the old sign-in's time, not this one's. + return user.getIdToken(true).await().token ?: throw IllegalStateException("no ID token after signing in") + } + /** * The spec's order: a new password wrap first, then the Firebase password, then the old * wrap goes. A crash between any two leaves an account that opens with either password, diff --git a/apps/android/app/src/main/res/drawable/ic_delete_account.xml b/apps/android/app/src/main/res/drawable/ic_delete_account.xml new file mode 100644 index 0000000..01799ae --- /dev/null +++ b/apps/android/app/src/main/res/drawable/ic_delete_account.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/apps/android/app/src/main/res/layout/dialog_delete_account.xml b/apps/android/app/src/main/res/layout/dialog_delete_account.xml new file mode 100644 index 0000000..13151f9 --- /dev/null +++ b/apps/android/app/src/main/res/layout/dialog_delete_account.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + diff --git a/apps/android/app/src/main/res/menu/overflow_menu.xml b/apps/android/app/src/main/res/menu/overflow_menu.xml index 774a5c2..d818863 100644 --- a/apps/android/app/src/main/res/menu/overflow_menu.xml +++ b/apps/android/app/src/main/res/menu/overflow_menu.xml @@ -22,6 +22,11 @@ android:icon="@drawable/ic_key" android:title="@string/new_recovery_key" android:theme="@style/PopupMenuTextStyle" /> + Making a new recovery key… That did not work. Please try again. Your encryption keys were changed on another device. Sign out and sign in again to use them here. + + Delete account + Delete your account? + This is permanent. Your account, your email address, every link and file you have pushed, and your encryption keys and recovery key are deleted, and every device is signed out.\n\nYour pushes are encrypted with those keys, so once they are gone nobody can recover them, including us. A Pro subscription is cancelled.\n\nEnter your password to delete your account. + Deleting your account… + Your account is deleted. + + We could not confirm that your account was deleted. It may still be finishing. Try again in a few minutes. If signing in with the right password no longer works, the account is gone. + Your account was not deleted: PushThat could not reach the server. Check your connection and try again. + That is not your password. Enter your password again to finish. diff --git a/apps/android/app/src/test/java/com/makerocketgonow/pushthat/AccountDeletionTest.kt b/apps/android/app/src/test/java/com/makerocketgonow/pushthat/AccountDeletionTest.kt new file mode 100644 index 0000000..eef6e52 --- /dev/null +++ b/apps/android/app/src/test/java/com/makerocketgonow/pushthat/AccountDeletionTest.kt @@ -0,0 +1,70 @@ +package com.makerocketgonow.pushthat + +import com.makerocketgonow.pushthat.AccountDeletion.Answer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * How the app reads the `deleteAccount` function's response. The function's own suite + * (functions/test/emulator) covers what it sends; this is the other side of that contract, + * where a refusal ("nothing was deleted") is told from no answer ("it may have been"). + */ +class AccountDeletionTest { + private fun refusal(status: String, message: String) = + """{"error":{"status":"$status","message":"$message"}}""" + + @Test + fun `a 200 that says deleted is a deletion`() { + assertSame(Answer.Deleted, AccountDeletion.answerOf(200, """{"result":{"deleted":true}}""")) + } + + @Test + fun `a 200 that does not say deleted is not taken for one`() { + assertSame(Answer.Unknown, AccountDeletion.answerOf(200, """{"result":{}}""")) + assertSame(Answer.Unknown, AccountDeletion.answerOf(200, """{"result":{"deleted":false}}""")) + assertSame(Answer.Unknown, AccountDeletion.answerOf(200, "")) + } + + @Test + fun `each refusal the function makes before deleting is shown in its own words`() { + val refusals = mapOf( + "FAILED_PRECONDITION" to 400, + "UNAVAILABLE" to 503, + "UNAUTHENTICATED" to 401, + ) + for ((status, code) in refusals) { + val answer = AccountDeletion.answerOf(code, refusal(status, "A sentence for the person.")) + assertTrue("$status should be a refusal", answer is Answer.Refused) + assertEquals("A sentence for the person.", (answer as Answer.Refused).message) + } + } + + @Test + fun `an internal error says nothing about how far the deletion got`() { + assertSame(Answer.Unknown, AccountDeletion.answerOf(500, refusal("INTERNAL", "INTERNAL"))) + } + + @Test + fun `a status the app does not know is not shown as a refusal`() { + assertSame(Answer.Unknown, AccountDeletion.answerOf(429, refusal("RESOURCE_EXHAUSTED", "Slow down."))) + } + + @Test + fun `a refusal with no sentence is not shown as one`() { + assertSame(Answer.Unknown, AccountDeletion.answerOf(503, """{"error":{"status":"UNAVAILABLE","message":" "}}""")) + assertSame(Answer.Unknown, AccountDeletion.answerOf(503, """{"error":{"status":"UNAVAILABLE"}}""")) + } + + @Test + fun `a gateway's error page is no answer`() { + assertSame(Answer.Unknown, AccountDeletion.answerOf(504, "504 Gateway Timeout")) + assertSame(Answer.Unknown, AccountDeletion.answerOf(502, null)) + } + + @Test + fun `an error body on a 200 is not a deletion`() { + assertTrue(AccountDeletion.answerOf(200, refusal("UNAVAILABLE", "Try again shortly.")) is Answer.Refused) + } +} diff --git a/apps/android/store/whatsnew/whatsnew-en-US b/apps/android/store/whatsnew/whatsnew-en-US index 7d8d1ed..dae247a 100644 --- a/apps/android/store/whatsnew/whatsnew-en-US +++ b/apps/android/store/whatsnew/whatsnew-en-US @@ -1,3 +1,5 @@ -Your pushes are now end-to-end encrypted: only devices you sign in on can read them. Signing in shows a recovery key. Save it: it is the only way back to your pushes if you forget your password. Change password and Show a new recovery key are in the menu. +Your pushes are now end-to-end encrypted: only devices you sign in on can read them. Signing in shows a recovery key. Save it: it is the only way back to your pushes if you forget your password. -A link's icon now comes from its page and travels with the push, so your list never asks Google. Older links show a plain icon. Notifications and search work on encrypted pushes, and an unconfirmed share is retried, not dropped. +New in the menu: Delete account, beside Change password and Show a new recovery key. + +A link's icon now comes from its page, so your list never asks Google. Older links show a plain icon. Notifications and search work on encrypted pushes, and an unconfirmed share is retried, not dropped. diff --git a/apps/ios/PushThat/Models/PendingPush.swift b/apps/ios/PushThat/Models/PendingPush.swift index d9d6df4..080e793 100644 --- a/apps/ios/PushThat/Models/PendingPush.swift +++ b/apps/ios/PushThat/Models/PendingPush.swift @@ -59,6 +59,24 @@ final class PendingPush { prepared.flatMap { try? JSONDecoder().decode(PreparedPush.self, from: $0) } } + /// On account deletion: the account's queued pushes, which would otherwise wait for an + /// account that no longer exists. Opened the way `RetryManager` opens the store (the + /// default store `PushThatApp` also uses), and filtered here rather than in a + /// `#Predicate`, since `userId` is optional and the queue is a handful of rows at most. + /// A failure is logged and not thrown: the account is gone by the time this runs, and + /// the rows cannot be written anywhere without it. + static func deleteAll(for uid: String) { + do { + let context = ModelContext(try ModelContainer(for: PendingPush.self)) + for pending in try context.fetch(FetchDescriptor()) where pending.userId == uid { + context.delete(pending) + } + try context.save() + } catch { + print("Queued pushes were not removed after the account was deleted: \(Swift.type(of: error))") + } + } + /// A push queued before the encryption update, sealed under a new id with `keys`. /// /// A file push of that time names its plaintext upload by its title diff --git a/apps/ios/PushThat/ViewModels/AuthViewModel.swift b/apps/ios/PushThat/ViewModels/AuthViewModel.swift index 50b7d80..1aabc9a 100644 --- a/apps/ios/PushThat/ViewModels/AuthViewModel.swift +++ b/apps/ios/PushThat/ViewModels/AuthViewModel.swift @@ -1,5 +1,6 @@ import Foundation import FirebaseAuth +import FirebaseAnalytics import FirebaseFirestore import FirebaseMessaging @@ -464,6 +465,173 @@ class AuthViewModel: ObservableObject { } } + // MARK: - Delete account + + /// The `deleteAccount` callable (functions/), called over plain HTTPS the way `getPlan` + /// is (`PlanService`), so no FirebaseFunctions SDK is needed. + private static let deleteAccountURL = URL(string: "https://us-central1-push-that.cloudfunctions.net/deleteAccount")! + + /// For a call that ended with no answer. The same words as the web page and the Android + /// app, and never "nothing was deleted": by then something may have been. + static let deleteAccountUnconfirmed = "We could not confirm that your account was deleted. It may still be finishing. Try again in a few minutes. If signing in with the right password no longer works, the account is gone." + + /// The call ended with no answer from the function: a timeout, a dropped connection, or + /// a failure that is not one of the function's own refusals. Nothing can be said about + /// how far the deletion got, so the account itself is asked (`isGone`). + private struct OutcomeUnknown: Error {} + + /// Deletes the account on the server, then everything this device keeps for it, and + /// signs out. Throws with a sentence to show, and leaves the session as it was, when the + /// password is wrong, the server refuses, or the outcome cannot be confirmed. + /// + /// The server deletes the Firebase user itself, so `user.delete()` is never called: by + /// the time the callable answers there is no user left to delete. + /// + /// An account found to be gone already counts as deleted, whichever step finds it: an + /// earlier try whose answer was lost has finished on the server, and what is left to do + /// is on this device. + func deleteAccount(password: String) async throws { + guard let user = auth.currentUser, let email = user.email else { throw FileUploadError.notAuthenticated } + guard !password.isEmpty else { throw Problem("Enter your password.") } + let uid = user.uid + + do { + try await reauthenticate(user: user, email: email, password: password) + } catch { + // With email enumeration protection on, which `isWrongPassword` assumes, + // Firebase says "wrong password" for an account that no longer exists, never + // "no such user". So any refusal is checked against the account itself, which + // `reload` answers truthfully whatever that setting is. + // Only "no such user" counts here, not a revoked session: see `isUserGone`. + guard await Self.isGone(user, revokedMeansGone: false) else { throw error } + forgetDeletedAccount(uid: uid) + return + } + // The server refuses a sign-in older than five minutes. Re-authenticating does not + // by itself replace the ID token the SDK holds, so one is minted now that carries + // this sign-in's time. + let token = try await user.getIDTokenResult(forcingRefresh: true).token + do { + try await Self.callDeleteAccount(idToken: token) + } catch is OutcomeUnknown { + // The Auth user is the last thing the function deletes, so gone means done. The + // session was re-authenticated a moment ago, so only the function can have + // revoked it since, after leaving the marker that gets the deletion finished. + guard await Self.isGone(user, revokedMeansGone: true) else { throw Problem(Self.deleteAccountUnconfirmed) } + } + forgetDeletedAccount(uid: uid) + } + + /// What this device held for an account the server has deleted, and then the sign-out. + private func forgetDeletedAccount(uid: String) { + // Sign-out does not empty the retry queue, since a queued push waits for its own + // account to sign in again. This account will not, and its rows would wait forever. + PendingPush.deleteAll(for: uid) + // A fresh analytics id for this install, so nothing collected from here on joins + // what was collected while the deleted account was signed in. + Analytics.resetAnalyticsData() + // The notification token too, so Firebase stops addressing this install as the + // deleted account's device. Not waited for: it asks the network, the account is + // already gone, and the copy the server used went with the user document. + Messaging.messaging().deleteToken { _ in } + signOut() + } + + /// Firebase saying the signed-in account no longer exists. `revokedMeansGone` says + /// whether a revoked session counts: it does straight after this deletion's own + /// sign-in, and it must not for a session of unknown age, which a password change or + /// reset on another device also revokes. Read as "gone" there, a mistyped password + /// signed the app out and said the account was deleted when the server had not been asked. + nonisolated static func isUserGone(_ error: Error, revokedMeansGone: Bool) -> Bool { + let error = error as NSError + guard error.domain == AuthErrorDomain else { return false } + return error.code == AuthErrorCode.userNotFound.rawValue + || (revokedMeansGone && error.code == AuthErrorCode.userTokenExpired.rawValue) + } + + /// Whether the account no longer exists, asked of Firebase. A failure to ask (no + /// network) is not an answer, and reads as "still there". + private static func isGone(_ user: User, revokedMeansGone: Bool) async -> Bool { + do { + try await user.reload() + return false + } catch { + return isUserGone(error, revokedMeansGone: revokedMeansGone) + } + } + + /// Re-authenticates as "Change password" does, with the Firebase password derived from + /// the password (the same `derive` every sign-in uses), and then, only on a wrong + /// password, with the raw password for an account that has not moved yet, as + /// `signInWithKeys` does. Change password never meets such an account (it needs the + /// key, which the move writes first), but deleting one should not be refused. + private func reauthenticate(user: User, email: String, password: String) async throws { + let keys = try await Self.derive(email: email, password: password) + do { + _ = try await user.reauthenticate(with: EmailAuthProvider.credential(withEmail: email, password: keys.firebasePassword)) + } catch let first where Self.isWrongPassword(first) { + do { + _ = try await user.reauthenticate(with: EmailAuthProvider.credential(withEmail: email, password: password)) + } catch let second where Self.isWrongPassword(second) { + throw Problem("That is not your password.") + } catch { + // Any other failure says nothing about the raw password: the first is the + // one that describes what went wrong. + throw first + } + } + } + + /// The statuses the function refuses with before it has deleted anything, each with a + /// sentence it wrote for the person. + private static let refusals: Set = ["FAILED_PRECONDITION", "UNAVAILABLE", "UNAUTHENTICATED"] + + /// POSTs the callable's empty request. Success is a 200 with `{"result":{"deleted":true}}`. + /// A refusal carries `{"error":{"status","message"}}` and is thrown as its message; + /// anything else, a lost connection included, is `OutcomeUnknown`. + private static func callDeleteAccount(idToken: String) async throws { + var request = URLRequest(url: deleteAccountURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(idToken)", forHTTPHeaderField: "Authorization") + request.httpBody = Data("{\"data\":{}}".utf8) + // A little more than the five minutes the function allows itself (`timeoutSeconds` + // in functions/src/account-deletion.ts), so the function always answers first. It + // deletes every push and file before it answers and sends nothing meanwhile; at two + // minutes this gave up on a large account that the server then went on to delete. + request.timeoutInterval = 310 + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw OutcomeUnknown() + } + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + + struct Success: Decodable { + struct Result: Decodable { let deleted: Bool } + let result: Result + } + struct Failure: Decodable { + struct Body: Decodable { + let status: String? + let message: String? + } + let error: Body + } + if status == 200, (try? JSONDecoder().decode(Success.self, from: data))?.result.deleted == true { + return + } + if let failure = (try? JSONDecoder().decode(Failure.self, from: data))?.error, + let message = failure.message, !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let refusal = failure.status, refusals.contains(refusal) { + throw Problem(message) + } + throw OutcomeUnknown() + } + // MARK: - Sign out /// Clears the account key and the last decrypted file first, so that a sign-out that diff --git a/apps/ios/PushThat/Views/AccountSettings.swift b/apps/ios/PushThat/Views/AccountSettings.swift index 2844ffa..f4458cf 100644 --- a/apps/ios/PushThat/Views/AccountSettings.swift +++ b/apps/ios/PushThat/Views/AccountSettings.swift @@ -1,14 +1,15 @@ import SwiftUI -/// "Change password" and "Show a new recovery key", from the main screen's menu beside -/// Plan (docs/e2ee.md, "The recovery key"; the web app has no settings page, so these are -/// on the apps only). This file is the same in `apps/ios` and `apps/macos`. +/// "Change password", "Show a new recovery key" and "Delete account", from the main +/// screen's menu beside Plan (docs/e2ee.md, "The recovery key"). This file is the same in +/// `apps/ios` and `apps/macos`. extension View { - func accountSettings(authViewModel: AuthViewModel, showChangePassword: Binding, confirmNewRecoveryKey: Binding) -> some View { + func accountSettings(authViewModel: AuthViewModel, showChangePassword: Binding, confirmNewRecoveryKey: Binding, showDeleteAccount: Binding) -> some View { modifier(AccountSettingsModifier( authViewModel: authViewModel, showChangePassword: showChangePassword, - confirmNewRecoveryKey: confirmNewRecoveryKey + confirmNewRecoveryKey: confirmNewRecoveryKey, + showDeleteAccount: showDeleteAccount )) } } @@ -23,6 +24,7 @@ private struct AccountSettingsModifier: ViewModifier { @ObservedObject var authViewModel: AuthViewModel @Binding var showChangePassword: Bool @Binding var confirmNewRecoveryKey: Bool + @Binding var showDeleteAccount: Bool @State private var shownRecoveryKey: ShownRecoveryKey? @State private var problem: String? /// While a new recovery key is being made. Each run replaces every recovery wrap, so a @@ -48,6 +50,13 @@ private struct AccountSettingsModifier: ViewModifier { .frame(minWidth: 380, minHeight: 380) #endif } + .sheet(isPresented: $showDeleteAccount) { + DeleteAccountView() + .environmentObject(authViewModel) + #if os(macOS) + .frame(minWidth: 420, minHeight: 460) + #endif + } .sheet(item: $shownRecoveryKey) { shown in RecoveryKeyView(recoveryKey: shown.key) { authViewModel.recoveryKeySaved() @@ -168,6 +177,94 @@ struct ChangePasswordView: View { } } +/// What deleting the account takes with it, then the password. A sheet rather than an +/// alert: the consequences need more than an alert's line, the password needs a field, and +/// a wrong password or a refusal from the server has to be shown where it can be retried. +/// +/// On success the session ends (`AuthViewModel.deleteAccount`), which replaces the main +/// screen, and this sheet with it, by the sign-in screen. +struct DeleteAccountView: View { + @EnvironmentObject var authViewModel: AuthViewModel + @Environment(\.dismiss) private var dismiss + @State private var password = "" + @State private var isWorking = false + @State private var problem: String? + + var body: some View { + NavigationStack { + Form { + Section { + Text("This permanently deletes your PushThat account and its email address, every push and file, and your encryption keys and recovery key. Every device is signed out.") + Text("Your pushes are encrypted, so once they are deleted nobody can recover them, including us.") + Text("A Pro subscription is cancelled.") + } + Section { + SecureField("Password", text: $password) + .textContentType(.password) + .disabled(isWorking) + } footer: { + Text("Enter your password to confirm.") + } + if let problem { + Section { + Text(problem) + .foregroundColor(.red) + } + } + Section { + Button(role: .destructive) { delete() } label: { + HStack { + Text(isWorking ? "Deleting account…" : "Delete account") + if isWorking { + Spacer() + ProgressView() + } + } + } + .disabled(isWorking || password.isEmpty) + } footer: { + // The call stays open until the server has deleted everything, which + // for a large account is minutes. iOS suspends an app soon after it is + // put away, and on a Mac quitting the app or the Mac sleeping does the + // same: the call is lost, the account is still deleted, and this sheet + // can then only say it could not confirm that. + if isWorking { + Text("Keep PushThat open until this finishes. It can take a few minutes.") + } + } + } + .navigationTitle("Delete account") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .disabled(isWorking) + } + } + // Not while it runs: the deletion carries on on the server either way, and a + // closed sheet would hide whether it finished. + .interactiveDismissDisabled(isWorking) + } + } + + private func delete() { + // A second tap while the first runs would re-authenticate and call the server again. + guard !isWorking else { return } + isWorking = true + problem = nil + Task { + do { + try await authViewModel.deleteAccount(password: password) + } catch { + problem = AuthViewModel.message(for: error) + } + isWorking = false + } + } +} + private extension View { /// Lets iOS offer a strong password and save it. macOS 13 has no such content type. @ViewBuilder diff --git a/apps/ios/PushThat/Views/MainView.swift b/apps/ios/PushThat/Views/MainView.swift index 17ba2ca..7ca6182 100644 --- a/apps/ios/PushThat/Views/MainView.swift +++ b/apps/ios/PushThat/Views/MainView.swift @@ -10,6 +10,7 @@ struct MainView: View { @State private var showPlan = false @State private var showChangePassword = false @State private var confirmNewRecoveryKey = false + @State private var showDeleteAccount = false var body: some View { NavigationStack { @@ -48,7 +49,8 @@ struct MainView: View { .accountSettings( authViewModel: authViewModel, showChangePassword: $showChangePassword, - confirmNewRecoveryKey: $confirmNewRecoveryKey + confirmNewRecoveryKey: $confirmNewRecoveryKey, + showDeleteAccount: $showDeleteAccount ) .alert("Error", isPresented: $viewModel.showError) { Button("OK") {} @@ -171,6 +173,10 @@ struct MainView: View { Label("Show a new recovery key", systemImage: "lock.rotation") } + Button(role: .destructive, action: { showDeleteAccount = true }) { + Label("Delete account", systemImage: "person.crop.circle.badge.xmark") + } + Button(action: { authViewModel.signOut() }) { Label("Logout", systemImage: "rectangle.portrait.and.arrow.right") } diff --git a/apps/macos/PushThat/Models/PendingPush.swift b/apps/macos/PushThat/Models/PendingPush.swift index d9d6df4..080e793 100644 --- a/apps/macos/PushThat/Models/PendingPush.swift +++ b/apps/macos/PushThat/Models/PendingPush.swift @@ -59,6 +59,24 @@ final class PendingPush { prepared.flatMap { try? JSONDecoder().decode(PreparedPush.self, from: $0) } } + /// On account deletion: the account's queued pushes, which would otherwise wait for an + /// account that no longer exists. Opened the way `RetryManager` opens the store (the + /// default store `PushThatApp` also uses), and filtered here rather than in a + /// `#Predicate`, since `userId` is optional and the queue is a handful of rows at most. + /// A failure is logged and not thrown: the account is gone by the time this runs, and + /// the rows cannot be written anywhere without it. + static func deleteAll(for uid: String) { + do { + let context = ModelContext(try ModelContainer(for: PendingPush.self)) + for pending in try context.fetch(FetchDescriptor()) where pending.userId == uid { + context.delete(pending) + } + try context.save() + } catch { + print("Queued pushes were not removed after the account was deleted: \(Swift.type(of: error))") + } + } + /// A push queued before the encryption update, sealed under a new id with `keys`. /// /// A file push of that time names its plaintext upload by its title diff --git a/apps/macos/PushThat/ViewModels/AuthViewModel.swift b/apps/macos/PushThat/ViewModels/AuthViewModel.swift index 9ecf3cc..7c78023 100644 --- a/apps/macos/PushThat/ViewModels/AuthViewModel.swift +++ b/apps/macos/PushThat/ViewModels/AuthViewModel.swift @@ -463,6 +463,169 @@ class AuthViewModel: ObservableObject { } } + // MARK: - Delete account + + /// The `deleteAccount` callable (functions/), called over plain HTTPS the way `getPlan` + /// is (`PlanService`), so no FirebaseFunctions SDK is needed. + private static let deleteAccountURL = URL(string: "https://us-central1-push-that.cloudfunctions.net/deleteAccount")! + + /// For a call that ended with no answer. The same words as the web page and the Android + /// app, and never "nothing was deleted": by then something may have been. + static let deleteAccountUnconfirmed = "We could not confirm that your account was deleted. It may still be finishing. Try again in a few minutes. If signing in with the right password no longer works, the account is gone." + + /// The call ended with no answer from the function: a timeout, a dropped connection, or + /// a failure that is not one of the function's own refusals. Nothing can be said about + /// how far the deletion got, so the account itself is asked (`isGone`). + private struct OutcomeUnknown: Error {} + + /// Deletes the account on the server, then everything this device keeps for it, and + /// signs out. Throws with a sentence to show, and leaves the session as it was, when the + /// password is wrong, the server refuses, or the outcome cannot be confirmed. + /// + /// The server deletes the Firebase user itself, so `user.delete()` is never called: by + /// the time the callable answers there is no user left to delete. + /// + /// An account found to be gone already counts as deleted, whichever step finds it: an + /// earlier try whose answer was lost has finished on the server, and what is left to do + /// is on this device. + func deleteAccount(password: String) async throws { + guard let user = auth.currentUser, let email = user.email else { throw FileUploadError.notAuthenticated } + guard !password.isEmpty else { throw Problem("Enter your password.") } + let uid = user.uid + + do { + try await reauthenticate(user: user, email: email, password: password) + } catch { + // With email enumeration protection on, which `isWrongPassword` assumes, + // Firebase says "wrong password" for an account that no longer exists, never + // "no such user". So any refusal is checked against the account itself, which + // `reload` answers truthfully whatever that setting is. + // Only "no such user" counts here, not a revoked session: see `isUserGone`. + guard await Self.isGone(user, revokedMeansGone: false) else { throw error } + forgetDeletedAccount(uid: uid) + return + } + // The server refuses a sign-in older than five minutes. Re-authenticating does not + // by itself replace the ID token the SDK holds, so one is minted now that carries + // this sign-in's time. + let token = try await user.getIDTokenResult(forcingRefresh: true).token + do { + try await Self.callDeleteAccount(idToken: token) + } catch is OutcomeUnknown { + // The Auth user is the last thing the function deletes, so gone means done. The + // session was re-authenticated a moment ago, so only the function can have + // revoked it since, after leaving the marker that gets the deletion finished. + guard await Self.isGone(user, revokedMeansGone: true) else { throw Problem(Self.deleteAccountUnconfirmed) } + } + forgetDeletedAccount(uid: uid) + } + + /// What this device held for an account the server has deleted, and then the sign-out. + private func forgetDeletedAccount(uid: String) { + // Sign-out does not empty the retry queue, since a queued push waits for its own + // account to sign in again. This account will not, and its rows would wait forever. + PendingPush.deleteAll(for: uid) + // No `Analytics.resetAnalyticsData()` and no notification token to delete here, + // unlike iOS: neither FirebaseAnalytics nor FirebaseMessaging is linked into the + // macOS app. + signOut() + } + + /// Firebase saying the signed-in account no longer exists. `revokedMeansGone` says + /// whether a revoked session counts: it does straight after this deletion's own + /// sign-in, and it must not for a session of unknown age, which a password change or + /// reset on another device also revokes. Read as "gone" there, a mistyped password + /// signed the app out and said the account was deleted when the server had not been asked. + nonisolated static func isUserGone(_ error: Error, revokedMeansGone: Bool) -> Bool { + let error = error as NSError + guard error.domain == AuthErrorDomain else { return false } + return error.code == AuthErrorCode.userNotFound.rawValue + || (revokedMeansGone && error.code == AuthErrorCode.userTokenExpired.rawValue) + } + + /// Whether the account no longer exists, asked of Firebase. A failure to ask (no + /// network) is not an answer, and reads as "still there". + private static func isGone(_ user: User, revokedMeansGone: Bool) async -> Bool { + do { + try await user.reload() + return false + } catch { + return isUserGone(error, revokedMeansGone: revokedMeansGone) + } + } + + /// Re-authenticates as "Change password" does, with the Firebase password derived from + /// the password (the same `derive` every sign-in uses), and then, only on a wrong + /// password, with the raw password for an account that has not moved yet, as + /// `signInWithKeys` does. Change password never meets such an account (it needs the + /// key, which the move writes first), but deleting one should not be refused. + private func reauthenticate(user: User, email: String, password: String) async throws { + let keys = try await Self.derive(email: email, password: password) + do { + _ = try await user.reauthenticate(with: EmailAuthProvider.credential(withEmail: email, password: keys.firebasePassword)) + } catch let first where Self.isWrongPassword(first) { + do { + _ = try await user.reauthenticate(with: EmailAuthProvider.credential(withEmail: email, password: password)) + } catch let second where Self.isWrongPassword(second) { + throw Problem("That is not your password.") + } catch { + // Any other failure says nothing about the raw password: the first is the + // one that describes what went wrong. + throw first + } + } + } + + /// The statuses the function refuses with before it has deleted anything, each with a + /// sentence it wrote for the person. + private static let refusals: Set = ["FAILED_PRECONDITION", "UNAVAILABLE", "UNAUTHENTICATED"] + + /// POSTs the callable's empty request. Success is a 200 with `{"result":{"deleted":true}}`. + /// A refusal carries `{"error":{"status","message"}}` and is thrown as its message; + /// anything else, a lost connection included, is `OutcomeUnknown`. + private static func callDeleteAccount(idToken: String) async throws { + var request = URLRequest(url: deleteAccountURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(idToken)", forHTTPHeaderField: "Authorization") + request.httpBody = Data("{\"data\":{}}".utf8) + // A little more than the five minutes the function allows itself (`timeoutSeconds` + // in functions/src/account-deletion.ts), so the function always answers first. It + // deletes every push and file before it answers and sends nothing meanwhile; at two + // minutes this gave up on a large account that the server then went on to delete. + request.timeoutInterval = 310 + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw OutcomeUnknown() + } + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + + struct Success: Decodable { + struct Result: Decodable { let deleted: Bool } + let result: Result + } + struct Failure: Decodable { + struct Body: Decodable { + let status: String? + let message: String? + } + let error: Body + } + if status == 200, (try? JSONDecoder().decode(Success.self, from: data))?.result.deleted == true { + return + } + if let failure = (try? JSONDecoder().decode(Failure.self, from: data))?.error, + let message = failure.message, !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let refusal = failure.status, refusals.contains(refusal) { + throw Problem(message) + } + throw OutcomeUnknown() + } + // MARK: - Sign out /// Clears the account key and the last decrypted file first, so that a sign-out that diff --git a/apps/macos/PushThat/Views/AccountSettings.swift b/apps/macos/PushThat/Views/AccountSettings.swift index 2844ffa..f4458cf 100644 --- a/apps/macos/PushThat/Views/AccountSettings.swift +++ b/apps/macos/PushThat/Views/AccountSettings.swift @@ -1,14 +1,15 @@ import SwiftUI -/// "Change password" and "Show a new recovery key", from the main screen's menu beside -/// Plan (docs/e2ee.md, "The recovery key"; the web app has no settings page, so these are -/// on the apps only). This file is the same in `apps/ios` and `apps/macos`. +/// "Change password", "Show a new recovery key" and "Delete account", from the main +/// screen's menu beside Plan (docs/e2ee.md, "The recovery key"). This file is the same in +/// `apps/ios` and `apps/macos`. extension View { - func accountSettings(authViewModel: AuthViewModel, showChangePassword: Binding, confirmNewRecoveryKey: Binding) -> some View { + func accountSettings(authViewModel: AuthViewModel, showChangePassword: Binding, confirmNewRecoveryKey: Binding, showDeleteAccount: Binding) -> some View { modifier(AccountSettingsModifier( authViewModel: authViewModel, showChangePassword: showChangePassword, - confirmNewRecoveryKey: confirmNewRecoveryKey + confirmNewRecoveryKey: confirmNewRecoveryKey, + showDeleteAccount: showDeleteAccount )) } } @@ -23,6 +24,7 @@ private struct AccountSettingsModifier: ViewModifier { @ObservedObject var authViewModel: AuthViewModel @Binding var showChangePassword: Bool @Binding var confirmNewRecoveryKey: Bool + @Binding var showDeleteAccount: Bool @State private var shownRecoveryKey: ShownRecoveryKey? @State private var problem: String? /// While a new recovery key is being made. Each run replaces every recovery wrap, so a @@ -48,6 +50,13 @@ private struct AccountSettingsModifier: ViewModifier { .frame(minWidth: 380, minHeight: 380) #endif } + .sheet(isPresented: $showDeleteAccount) { + DeleteAccountView() + .environmentObject(authViewModel) + #if os(macOS) + .frame(minWidth: 420, minHeight: 460) + #endif + } .sheet(item: $shownRecoveryKey) { shown in RecoveryKeyView(recoveryKey: shown.key) { authViewModel.recoveryKeySaved() @@ -168,6 +177,94 @@ struct ChangePasswordView: View { } } +/// What deleting the account takes with it, then the password. A sheet rather than an +/// alert: the consequences need more than an alert's line, the password needs a field, and +/// a wrong password or a refusal from the server has to be shown where it can be retried. +/// +/// On success the session ends (`AuthViewModel.deleteAccount`), which replaces the main +/// screen, and this sheet with it, by the sign-in screen. +struct DeleteAccountView: View { + @EnvironmentObject var authViewModel: AuthViewModel + @Environment(\.dismiss) private var dismiss + @State private var password = "" + @State private var isWorking = false + @State private var problem: String? + + var body: some View { + NavigationStack { + Form { + Section { + Text("This permanently deletes your PushThat account and its email address, every push and file, and your encryption keys and recovery key. Every device is signed out.") + Text("Your pushes are encrypted, so once they are deleted nobody can recover them, including us.") + Text("A Pro subscription is cancelled.") + } + Section { + SecureField("Password", text: $password) + .textContentType(.password) + .disabled(isWorking) + } footer: { + Text("Enter your password to confirm.") + } + if let problem { + Section { + Text(problem) + .foregroundColor(.red) + } + } + Section { + Button(role: .destructive) { delete() } label: { + HStack { + Text(isWorking ? "Deleting account…" : "Delete account") + if isWorking { + Spacer() + ProgressView() + } + } + } + .disabled(isWorking || password.isEmpty) + } footer: { + // The call stays open until the server has deleted everything, which + // for a large account is minutes. iOS suspends an app soon after it is + // put away, and on a Mac quitting the app or the Mac sleeping does the + // same: the call is lost, the account is still deleted, and this sheet + // can then only say it could not confirm that. + if isWorking { + Text("Keep PushThat open until this finishes. It can take a few minutes.") + } + } + } + .navigationTitle("Delete account") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .disabled(isWorking) + } + } + // Not while it runs: the deletion carries on on the server either way, and a + // closed sheet would hide whether it finished. + .interactiveDismissDisabled(isWorking) + } + } + + private func delete() { + // A second tap while the first runs would re-authenticate and call the server again. + guard !isWorking else { return } + isWorking = true + problem = nil + Task { + do { + try await authViewModel.deleteAccount(password: password) + } catch { + problem = AuthViewModel.message(for: error) + } + isWorking = false + } + } +} + private extension View { /// Lets iOS offer a strong password and save it. macOS 13 has no such content type. @ViewBuilder diff --git a/apps/macos/PushThat/Views/MainView.swift b/apps/macos/PushThat/Views/MainView.swift index 94ca0c8..a4c82e3 100644 --- a/apps/macos/PushThat/Views/MainView.swift +++ b/apps/macos/PushThat/Views/MainView.swift @@ -10,6 +10,7 @@ struct MainView: View { @State private var showPlan = false @State private var showChangePassword = false @State private var confirmNewRecoveryKey = false + @State private var showDeleteAccount = false var body: some View { NavigationStack { @@ -44,7 +45,8 @@ struct MainView: View { .accountSettings( authViewModel: authViewModel, showChangePassword: $showChangePassword, - confirmNewRecoveryKey: $confirmNewRecoveryKey + confirmNewRecoveryKey: $confirmNewRecoveryKey, + showDeleteAccount: $showDeleteAccount ) .alert("Error", isPresented: $viewModel.showError) { Button("OK") {} @@ -209,6 +211,10 @@ struct MainView: View { Label("Show a new recovery key", systemImage: "lock.rotation") } + Button(role: .destructive, action: { showDeleteAccount = true }) { + Label("Delete account", systemImage: "person.crop.circle.badge.xmark") + } + Button(action: { authViewModel.signOut() }) { Label("Logout", systemImage: "rectangle.portrait.and.arrow.right") } diff --git a/apps/web/src/Layout.tsx b/apps/web/src/Layout.tsx index b980ca1..2e88360 100644 --- a/apps/web/src/Layout.tsx +++ b/apps/web/src/Layout.tsx @@ -56,6 +56,9 @@ export default function Layout({ children }: LayoutProps) { {isMenuOpen && (
+ {/* Above Logout, where the Android, iOS and macOS menus have it. It leads to the + public page, which asks for the password before anything goes. */} +
)} diff --git a/apps/web/src/components/DeleteAccountForm.module.css b/apps/web/src/components/DeleteAccountForm.module.css new file mode 100644 index 0000000..64de980 --- /dev/null +++ b/apps/web/src/components/DeleteAccountForm.module.css @@ -0,0 +1,68 @@ +.form { + max-width: 400px; + margin: 1.5em 0 2em; + padding: 16px; + border: 1px solid var(--color-error); + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.875rem; +} + +.input { + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: 4px; + font-size: 0.875rem; + background-color: var(--color-surface); + color: var(--color-text); +} + +.input:focus { + outline: none; + border-color: var(--color-primary); +} + +.confirm { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 0.875rem; +} + +.confirm input { + margin-top: 4px; +} + +.error { + color: var(--color-error); +} + +.deleteButton { + padding: 10px 16px; + border: 1px solid var(--color-error); + border-radius: 4px; + background: var(--color-error); + color: var(--color-background); + font-weight: 700; + cursor: pointer; +} + +.deleteButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.done { + margin: 1.5em 0 2em; + padding: 16px; + border: 1px solid var(--color-border); + border-radius: 8px; +} diff --git a/apps/web/src/components/DeleteAccountForm.tsx b/apps/web/src/components/DeleteAccountForm.tsx new file mode 100644 index 0000000..ef61ebb --- /dev/null +++ b/apps/web/src/components/DeleteAccountForm.tsx @@ -0,0 +1,159 @@ +import { FirebaseError } from 'firebase/app'; +import { useEffect, useRef, useState } from 'react'; + +import { useAuth } from '@/contexts/AuthContext'; +import { OutcomeUnknownError, deleteAccount } from '@/services/account'; +import { errorText } from '@/services/callable'; +import { WrongPasswordError } from '@/services/signIn'; + +import styles from './DeleteAccountForm.module.css'; + +/** + * The form on the delete account page (pages/DeleteAccount.tsx), and what it says once the + * account is gone. Apart from the page because it imports Firebase and holds state, and + * the page is rendered to static HTML by the build, which can have neither. + * + * It asks for the password even from a signed-in browser. The function refuses a sign-in + * older than five minutes, so that an open session on a shared computer cannot delete the + * account, and the password is the proof. + */ + +/** + * Whether an account was deleted from this form since the page loaded. Outside the component + * because deleting from a signed-in browser ends its session, `Layout` then swaps its + * signed-in frame for the signed-out one, and the page is mounted anew: state held in the + * component was lost at exactly the moment it had something to say, and the person was + * shown the empty form again with no word that anything had happened. + * + * It is for that one remount and no longer: the page that mounts next reads it and puts + * it back, so a later visit in the same tab (the next person at a shared computer, or the + * same one signed in to another account) gets the form and not someone else's result. + */ +let deletedHere = false; + +/** Firebase has throttled sign-ins for this account: not a connection problem, and said as what it is. */ +const TOO_MANY_TRIES = 'Too many tries. Nothing was deleted. Wait a few minutes and try again.'; + +/** For a failure before the function was called. The same words as the Android app. */ +const NOT_ASKED = + 'Your account was not deleted: PushThat could not reach the server. Check your connection and try again.'; + +/** For a call that ended with no answer (`OutcomeUnknownError`). The same words on every client. */ +const UNCONFIRMED = + 'We could not confirm that your account was deleted. It may still be finishing. Try again in a few minutes. If signing in with the right password no longer works, the account is gone.'; + +export function DeleteAccountForm() { + const { user } = useAuth(); + const sessionEmail = user?.email ?? ''; + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [understood, setUnderstood] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [done, setDone] = useState(deletedHere); + // On mount only, so the page that started the deletion does not clear it before the one + // mounted in its place has read it. + const doneRef = useRef(null); + useEffect(() => { + // The page that was mounted anew starts at its top, and this is at its foot: without + // this the person who had just pressed the button saw the page's first paragraph. + if (deletedHere) doneRef.current?.scrollIntoView({ block: 'center' }); + deletedHere = false; + }, []); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (busy || !understood) return; + setBusy(true); + setError(''); + try { + await deleteAccount(sessionEmail || email.trim(), password, () => { + // Only a signed-in browser is remounted; signed out, this page stays and shows + // the result itself, and a flag left set would greet the next visit with it. + deletedHere = !!sessionEmail; + }); + setPassword(''); + setDone(true); + } catch (err) { + setError( + // "Could not confirm" is for a call that was made and not answered. A failure + // before that (no network for the sign-in, too many attempts) asked nothing of + // the server, and saying the account "may still be finishing" would be untrue the + // other way. The function's own refusals come with their own sentence. A wrong + // password does not say "nothing was deleted": on a second try it may be the + // first try having worked. + err instanceof WrongPasswordError + ? // Signed in there is no email field, so only the password can be what was wrong. + sessionEmail + ? 'That is not your password.' + : 'The email or password is not right.' + : err instanceof OutcomeUnknownError + ? UNCONFIRMED + : err instanceof FirebaseError && err.code === 'auth/too-many-requests' + ? TOO_MANY_TRIES + : errorText(err, NOT_ASKED) + ); + } finally { + setBusy(false); + } + }; + + return ( + <> + {done ? ( +
+

+ Your account is deleted. Everything listed above under “What is deleted” is gone, and this + browser is signed out. Your other devices sign out within the hour. You can uninstall the app. +

+
+ ) : ( +
+ {sessionEmail ? ( +

+ Signed in as {sessionEmail}. This deletes that account. +

+ ) : ( + + )} + + + {error && ( +

+ {error} +

+ )} + +
+ )} + + ); +} diff --git a/apps/web/src/hooks/usePlan.ts b/apps/web/src/hooks/usePlan.ts index 4768032..6c1e494 100644 --- a/apps/web/src/hooks/usePlan.ts +++ b/apps/web/src/hooks/usePlan.ts @@ -1,7 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useAuth } from '@/contexts/AuthContext'; -import { PlanReport, errorText, planApi } from '@/services/plan'; +import { errorText } from '@/services/callable'; +import { PlanReport, planApi } from '@/services/plan'; /** * The signed-in account's plan, read once per mount and again on demand. `plan` is diff --git a/apps/web/src/pages/DeleteAccount.module.css b/apps/web/src/pages/DeleteAccount.module.css new file mode 100644 index 0000000..654131d --- /dev/null +++ b/apps/web/src/pages/DeleteAccount.module.css @@ -0,0 +1,10 @@ +.root { + ol { + margin: 0 0 1em; + padding-left: 1em; + } +} + +.noscript { + color: var(--color-error); +} diff --git a/apps/web/src/pages/DeleteAccount.tsx b/apps/web/src/pages/DeleteAccount.tsx new file mode 100644 index 0000000..6e1ce98 --- /dev/null +++ b/apps/web/src/pages/DeleteAccount.tsx @@ -0,0 +1,189 @@ +import { ComponentType, useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router'; + +import LegalContainer from '@/components/LegalContainer'; +import { LIMITS, REFUND_DAYS, SUPPORT_EMAIL } from '@/config/plan'; + +import styles from './DeleteAccount.module.css'; + +/** + * The form, fetched once the page is on screen. It signs in and calls the function, so it + * imports Firebase, and a static page may not (src/staticPages.tsx): the build renders this + * page in Node, where Firebase Auth keeps the process from exiting. + * + * Fetched in an effect, and not with `lazy`: rendering a `lazy` component starts its import + * wherever the render happens, the build included, which loaded Firebase there and hung it. + * An effect never runs in the build's render, so the static copy holds the line below and + * the form arrives in the browser. That makes this the one piece of state on a static page; + * its first render is still the same for everyone, which is what the rule is for. + */ +function FormWhenLoaded() { + const [Form, setForm] = useState(null); + useEffect(() => { + let wanted = true; + import('@/components/DeleteAccountForm').then( + (module) => wanted && setForm(() => module.DeleteAccountForm), + // The chunk did not load (offline since the page did, or a deploy replaced it): + // the line below stays, and a reload fetches the page and the form afresh. + (error) => console.error('The delete account form did not load:', error) + ); + return () => { + wanted = false; + }; + }, []); + // Read with JavaScript off too, where it is all there will ever be under the notice + // above it: so it must not only say to reload, which cannot help there. + return Form ? ( +
+ ) : ( +

+ Loading the form… If it does not appear, reload the page, or write to {SUPPORT_EMAIL} from the email address on + the account. +

+ ); +} + +/** + * Delete account, `/delete-account`: what deleting removes, what is kept and for how long, + * and the form that does it. This is the address given to Google Play as the app's account + * deletion page, so it is public and it names the app and the developer: the person + * reading it may have uninstalled the app, and Play's reviewer has no account at all. + * + * It is one of the static pages (src/staticPages.tsx), so everything except the form reads + * without JavaScript. Keep the facts in the text itself for that reason, not behind state + * or an effect. What is kept and for how long is recorded, with the reasons, in + * docs/account-deletion.md at the repository root; the two change together. + */ +export function DeleteAccount() { + return ( + + + Delete your PushThat account + + +

Delete your PushThat account

+ +

+ PushThat is an app by MakeRocketGoNow LLC for sending links and files between your phone and your other devices. + This page explains how to delete your PushThat account and the data stored with it, and lets you do it right + here. Deleting is permanent and takes effect at once: the account is removed, not switched off, and there is no + waiting period and no way to restore it. +

+ +
HOW TO DELETE YOUR ACCOUNT
+ +

In the app:

+
    +
  1. + Open PushThat and open its menu: the three dots at the top of the list on Android, the circled three dots at + the top of the list on iPhone and Mac, and the menu at the top right on the website and in the Chrome + extension. +
  2. +
  3. + Choose Delete account. On the website and in the extension this opens this page: use the{' '} + form below. +
  4. +
  5. + In the Android, iPhone and Mac apps, enter your password to confirm, and press Delete account + . +
  6. +
+ +

Without the app, on this page:

+
    +
  1. + Enter the email address and password of the account in the form below. +
  2. +
  3. Tick the box to confirm that you understand it is permanent.
  4. +
  5. + Press Delete my account. It is deleted while you wait, and the page says so when it is done. +
  6. +
+ +

+ If you have forgotten your password, reset it first from the sign-in page (Forgot your + password?), then come back and use the new one. You do not need your recovery key to delete the account. If you + cannot sign in at all, or this page does not work for you, write to{' '} + {SUPPORT_EMAIL} from the email address on the account and we will delete + it for you. +

+ +
WHAT IS DELETED
+ +

All of this is deleted when you press the button:

+
    +
  • Your account: your email address, your sign-in and your account ID.
  • +
  • + Every push: the links, the titles and icons of their pages, and the file records, which are stored encrypted, + and the search tokens made from them. +
  • +
  • Every file you pushed that is still stored, in its encrypted form.
  • +
  • + Your encryption keys as we hold them: the copy locked by your password and the copy locked by your recovery + key. Without them your encrypted pushes cannot be read by anyone, so nothing can be recovered afterwards, by + you or by us. +
  • +
  • Your device records: the notification tokens and device IDs of the devices you signed in on.
  • +
  • Your monthly usage counts and the record of your plan.
  • +
  • + Your Pro subscription, if you have one: it is cancelled at once and you are not charged again, and your + customer record at Stripe, our payment provider, is deleted. Deleting does not refund a payment by itself; a + charge made in the last {REFUND_DAYS} days is refunded if you ask (see the{' '} + refund policy). +
  • +
  • + Every session: you are signed out on this device at once, and on every other device within an hour, as each + one next checks in. +
  • +
+ +
WHAT IS KEPT, AND FOR HOW LONG
+ +
    +
  • + About two hours: a marker holding only the deleted account’s ID and the time. A device that + is still signed in can go on sending for up to an hour; the marker is how anything it sends in that time is + found and deleted too. Then the marker is deleted. +
  • +
  • + 30 days: our server logs, which record the account ID and the storage paths of deleted files + against the actions our servers took. They never hold your email address or the content of a push. A file + pushed before end-to-end encryption was stored under its own name, so its name can appear in a path. +
  • +
  • + Up to 180 days: backup copies that Google, which runs our servers, keeps of its own systems. + We cannot read an account out of them, and they expire on Google’s schedule. +
  • +
  • + As long as the law requires: if you ever paid for Pro, Stripe keeps its records of those + payments (receipts and invoices, with the email address they were sent to) for tax and accounting. Stripe was + the seller of those payments and holds these records itself. +
  • +
  • + Up to 14 months: usage statistics from the Android and iPhone apps (for example that the app + was opened, and on what kind of device), collected by Google Analytics for Firebase. They are tied to an + identifier for an installation of the app, not to your account or your email address, and the app resets that + identifier when you delete your account from it. +
  • +
  • Email you have sent us stays in our mailbox until you ask us to delete it too.
  • +
+ +

+ Apart from deleting your account, a pushed file is always deleted on its own {LIMITS.free.keepDays} days after + it was pushed on the free plan and {LIMITS.pro.keepDays} days on Pro, and you can delete any push from the list + at any time. The privacy policy describes everything PushThat stores. +

+ +
DELETE YOUR ACCOUNT NOW
+ + + +
+ ); +} diff --git a/apps/web/src/pages/Plan.tsx b/apps/web/src/pages/Plan.tsx index 3b03527..4a818d4 100644 --- a/apps/web/src/pages/Plan.tsx +++ b/apps/web/src/pages/Plan.tsx @@ -5,8 +5,9 @@ import { Link, useNavigate, useSearchParams } from 'react-router'; import Button from '@/components/Button'; import { LIMITS, formatMb } from '@/config/plan'; import { usePlan } from '@/hooks/usePlan'; +import { errorText } from '@/services/callable'; import { deviceId } from '@/services/keyStore'; -import { PlanReport, errorText, planApi } from '@/services/plan'; +import { PlanReport, planApi } from '@/services/plan'; import styles from './Plan.module.css'; diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index bccea27..d9430f6 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -169,12 +169,19 @@ export default function Privacy() {

When you create a subscription through the App, we will maintain your Subscription Information for our records - unless and until you ask us to delete this information. + unless and until you delete your account or ask us to delete this information. Stripe keeps its own records of + your payments for as long as tax and accounting law requires.

- A link push is kept until you delete it, or until you ask us to delete your account. A file push, with its - record, is deleted when the retention period of the plan your account was on when you pushed it ends ( + You can delete your account, with everything stored under it, at any time: from the App’s menu, or at{' '} + {'https://pushthat.link/delete-account'}, which also lists exactly what is + deleted, the little that is kept afterwards (server logs, backups and payment records), and for how long. +

+ +

+ A link push is kept until you delete it, or until you delete your account. A file push, with its record, is + deleted when the retention period of the plan your account was on when you pushed it ends ( {LIMITS.free.keepDays} days on the free plan, {LIMITS.pro.keepDays} days on Pro).

diff --git a/apps/web/src/pages/Support.tsx b/apps/web/src/pages/Support.tsx index 08775b4..42d291e 100644 --- a/apps/web/src/pages/Support.tsx +++ b/apps/web/src/pages/Support.tsx @@ -69,7 +69,11 @@ export default function Support() { largest file is {formatMb(LIMITS.free.fileBytes)} on the free plan and {formatMb(LIMITS.pro.fileBytes)} on Pro.

-

To close your account, write to us from its address and we will delete it, with everything pushed from it.

+

+ To close your account, use Delete account, here or in the app’s menu: it + deletes the account at once, with everything pushed from it. If you cannot sign in, write to us from the + account’s address and we will delete it for you. +

BUGS AND IDEAS
diff --git a/apps/web/src/pages/Terms.tsx b/apps/web/src/pages/Terms.tsx index 679349f..8ab6ab7 100644 --- a/apps/web/src/pages/Terms.tsx +++ b/apps/web/src/pages/Terms.tsx @@ -20,7 +20,7 @@ export default function Terms() { SERVICES, YOU HEREBY AGREE TO BE BOUND BY THE TERMS AND ALL TERMS INCORPORATED HEREIN BY REFERENCE. IT IS THE RESPONSIBILITY OF YOU, THE USER, CUSTOMER, OR PROSPECTIVE CUSTOMER TO READ THE TERMS AND CONDITIONS BEFORE PROCEEDING TO USE THIS SITE. IF YOU DO NOT EXPRESSLY AGREE TO ALL OF THE TERMS AND CONDITIONS, THEN PLEASE DO - NOT ACCESS OR USE OUR SITES OR OUR SERVICES. THIS TERMS OF SERVICE AGREEMENT IS EFFECTIVE AS OF 10/27/2026 + NOT ACCESS OR USE OUR SITES OR OUR SERVICES. THIS TERMS OF SERVICE AGREEMENT IS EFFECTIVE AS OF 11/12/2026

ACCEPTANCE OF TERMS

@@ -358,7 +358,9 @@ export default function Terms() {

As a member of {'https://pushthat.link'}, you may cancel or terminate your account, associated email address - and/or access to our Services by submitting a cancellation or termination request to terminate@pushthat.link. + and/or access to our Services by submitting a cancellation or termination request to terminate@pushthat.link, or + by deleting your account yourself at {'https://pushthat.link/delete-account'} or from the menu of our + applications.

diff --git a/apps/web/src/services/account.ts b/apps/web/src/services/account.ts new file mode 100644 index 0000000..1a06c40 --- /dev/null +++ b/apps/web/src/services/account.ts @@ -0,0 +1,113 @@ +import { FirebaseError, deleteApp, initializeApp } from 'firebase/app'; +import { type User, inMemoryPersistence, initializeAuth, signOut } from 'firebase/auth'; +import { getFunctions, httpsCallable } from 'firebase/functions'; + +import { app, auth } from '@/config/firebase'; + +import { errorText } from './callable'; +import { clearAccountKey } from './keyStore'; +import { proveByPassword } from './signIn'; + +/** + * How long the call may take: a little more than the function's own five minutes + * (`timeoutSeconds` in functions/src/account-deletion.ts), so the function always answers + * first. The SDK's default is 70 seconds, and an account with tens of thousands of pushes + * takes longer than that to delete: the page gave up, said nothing had been removed, and + * the server went on to delete everything. + */ +const CALL_TIMEOUT_MS = 310_000; + +/** + * The call ended with no answer from the function (a timeout, a dropped connection, a + * crash on the server) and the account still exists. Nothing can be said about how far + * the deletion got: once the function is past Stripe it has left a marker, and the sweep + * finishes what it started within about two hours whether or not anyone asks again. + */ +export class OutcomeUnknownError extends Error {} + +/** + * Whether the account `user` signed in to no longer exists, asked of Firebase. The Auth + * user is the last thing the function deletes, so gone means the deletion finished. + * + * Only for the user this deletion signed in moments ago. "Token expired" counts as gone + * here because nothing but the function can have revoked a session that new, and the + * function revokes after it has left the marker that gets the deletion finished. Asked of + * an older session it would also be true after a password change, which deletes nothing. + */ +async function isGone(user: User): Promise { + try { + await user.reload(); + return false; + } catch (error) { + return ( + error instanceof FirebaseError && + (error.code === 'auth/user-not-found' || error.code === 'auth/user-token-expired') + ); + } +} + +/** + * Ends the site's own session for an account the server has deleted. The key goes first, + * as at any sign-out (KeysContext clears it too, once the session ends; doing it here + * does not wait on that). + */ +async function endSession(): Promise { + await clearAccountKey().catch((error) => console.error('Could not clear the account key:', error)); + // Caught, like the line above: the account is gone by now, and a failure thrown from + // here reached the page as "your account was not deleted". Firebase ends a session + // whose account is gone by itself, at the next token refresh. + await signOut(auth).catch((error) => console.error("Could not end the deleted account's session:", error)); +} + +/** + * Deletes the account that `email` and `password` sign in to, for good (docs/account-deletion.md + * at the repository root): the `deleteAccount` function removes the account and everything + * stored under it, and then this browser forgets its key and its session. + * + * The function needs a sign-in made in the last five minutes, so the password is always + * asked for, whether or not the browser is signed in. That sign-in is made on a second + * Firebase app that lives only for this call, with its session in memory, and not on the + * site's own: + * + * - The page is public, and the person deleting may not be signed in here, or may have + * uninstalled every app. Signing them in on the site's own `auth` would flip the site + * into its signed-in frame partway through (`Layout`), remount the page that asked, and + * tell the extension's worker that an account had arrived, all for a session that is + * gone a second later. + * - If the function refuses (a subscription that will not cancel), nobody has been signed + * in to this browser as a side effect. + * + * `onDeleted` is called once the function has answered, before this browser's own session + * is ended: ending it remounts the page that asked (see above), so a caller that waited + * for this to resolve would be setting state on a component that is already gone. + * + * Throws `WrongPasswordError` for a wrong email or password, whatever the function + * refused with in its own words (`errorText` in callable.ts reads it), and + * `OutcomeUnknownError` when the call ended without an answer and the account is still + * there. + */ +export async function deleteAccount(email: string, password: string, onDeleted: () => void): Promise { + const scratch = initializeApp(app.options, `delete-account-${Date.now()}`); + try { + // A refused password is only ever reported as that. This once asked the browser's own + // session whether its account was gone, to catch a retry after a lost answer. But + // Firebase gives the web one answer, "token expired", both for an account that was + // deleted and for a session revoked by a password change or reset, and the page tells + // people who forgot their password to reset it first: someone who then mistyped was + // told "Your account is deleted" when nothing had been asked of the server. + const user = await proveByPassword(initializeAuth(scratch, { persistence: inMemoryPersistence }), email, password); + try { + await httpsCallable(getFunctions(scratch), 'deleteAccount', { timeout: CALL_TIMEOUT_MS })(); + } catch (error) { + // A sentence from the function is a refusal made before anything was deleted (an old + // sign-in, a subscription that would not cancel). Anything else is no answer at + // all, and the answer is then read off the account itself. + if (errorText(error, '')) throw error; + if (!(await isGone(user))) throw new OutcomeUnknownError(); + } + onDeleted(); + if (auth.currentUser?.uid === user.uid) await endSession(); + } finally { + await deleteApp(scratch).catch(() => undefined); + } +} diff --git a/apps/web/src/services/callable.ts b/apps/web/src/services/callable.ts new file mode 100644 index 0000000..d86f3aa --- /dev/null +++ b/apps/web/src/services/callable.ts @@ -0,0 +1,15 @@ +import { FunctionsError } from 'firebase/functions'; + +/** + * The message a callable's refusal carries, or `fallback` when the SDK has only the + * bare code: a network failure, a timeout or an uncaught server error leaves + * `message` as "internal" or "deadline-exceeded", which is nothing to show anyone. + * A refusal the function threw itself (`HttpsError` with a sentence) comes through + * whole, colons and all. + */ +export function errorText(err: unknown, fallback: string): string { + if (!(err instanceof FunctionsError) || !err.message) return fallback; + const code = err.code.replace(/^functions\//, ''); + const bareCode = err.message.toLowerCase().replace(/_/g, '-') === code; + return code === 'internal' || bareCode ? fallback : err.message; +} diff --git a/apps/web/src/services/plan.ts b/apps/web/src/services/plan.ts index 1cde9ea..61b6a17 100644 --- a/apps/web/src/services/plan.ts +++ b/apps/web/src/services/plan.ts @@ -1,4 +1,4 @@ -import { FunctionsError, getFunctions, httpsCallable } from 'firebase/functions'; +import { getFunctions, httpsCallable } from 'firebase/functions'; import type { TierLimits } from '@/config/plan'; @@ -29,17 +29,3 @@ export const planApi = { createPortalSession: (flow?: 'payment') => call<{ flow?: 'payment' }, { url: string }>('createPortalSession')({ flow }).then((r) => r.data), }; - -/** - * The message a callable's refusal carries, or `fallback` when the SDK has only the - * bare code: a network failure, a timeout or an uncaught server error leaves - * `message` as "internal" or "deadline-exceeded", which is nothing to show anyone. - * A refusal the function threw itself (`HttpsError` with a sentence) comes through - * whole, colons and all. - */ -export function errorText(err: unknown, fallback: string): string { - if (!(err instanceof FunctionsError) || !err.message) return fallback; - const code = err.code.replace(/^functions\//, ''); - const bareCode = err.message.toLowerCase().replace(/_/g, '-') === code; - return code === 'internal' || bareCode ? fallback : err.message; -} diff --git a/apps/web/src/services/signIn.ts b/apps/web/src/services/signIn.ts index e2c0611..57d60c9 100644 --- a/apps/web/src/services/signIn.ts +++ b/apps/web/src/services/signIn.ts @@ -1,5 +1,11 @@ import { FirebaseError } from 'firebase/app'; -import { type User, createUserWithEmailAndPassword, signInWithEmailAndPassword, updatePassword } from 'firebase/auth'; +import { + type Auth, + type User, + createUserWithEmailAndPassword, + signInWithEmailAndPassword, + updatePassword, +} from 'firebase/auth'; import { getFirestore } from 'firebase/firestore'; import { app, auth } from '@/config/firebase'; @@ -67,21 +73,33 @@ const isWrongPassword = (error: unknown) => error instanceof FirebaseError && WR * in this way and then moves the account back to `authKey`. It can go only once a reset * sets `authKey` itself, which needs the reset email's action URL unlocked. */ -async function signInEitherWay(email: string, password: string, keys: DerivedKeys) { +async function signInEitherWay(email: string, password: string, keys: DerivedKeys, on: Auth = auth) { try { - const { user } = await signInWithEmailAndPassword(auth, email, keys.firebasePassword); + const { user } = await signInWithEmailAndPassword(on, email, keys.firebasePassword); return { user, legacy: false }; } catch (error) { if (!isWrongPassword(error)) throw error; } try { - const { user } = await signInWithEmailAndPassword(auth, email, password); + const { user } = await signInWithEmailAndPassword(on, email, password); return { user, legacy: true }; } catch (error) { throw isWrongPassword(error) ? new WrongPasswordError('wrong email or password') : error; } } +/** + * Proves the password and nothing more: a sign-in on `on` that reads and writes no keys. + * For deleting the account (account.ts), which needs a fresh sign-in and must not make + * keys or a recovery key for an account that is about to go. The legacy fallback applies + * here as at any sign-in, so an account whose password was just reset can still be + * deleted. Throws `WrongPasswordError` for a wrong email or password. + */ +export async function proveByPassword(on: Auth, email: string, password: string): Promise { + const keys = await deriveKeys(email, password); + return (await signInEitherWay(email, password, keys, on)).user; +} + /** * Signs in (or, for a session that has no key on this device, signs in again) and opens * the account key. About a second of PBKDF2 first. diff --git a/apps/web/src/staticPages.tsx b/apps/web/src/staticPages.tsx index e33713e..37809b8 100644 --- a/apps/web/src/staticPages.tsx +++ b/apps/web/src/staticPages.tsx @@ -1,5 +1,6 @@ import { ComponentType } from 'react'; +import { DeleteAccount } from './pages/DeleteAccount'; import Privacy from './pages/Privacy'; import { Security } from './pages/Security'; import Support from './pages/Support'; @@ -26,4 +27,7 @@ export const STATIC_PAGES: { path: string; Page: ComponentType }[] = [ { path: '/terms', Page: Terms }, { path: '/support', Page: Support }, { path: '/security', Page: Security }, + // The account deletion page Google Play is given (docs/account-deletion.md). Its text is + // the same for everyone; its form is loaded apart (pages/DeleteAccount.tsx). + { path: '/delete-account', Page: DeleteAccount }, ]; diff --git a/docs/README.md b/docs/README.md index b40f080..12c7698 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,5 +13,7 @@ house style ([AGENTS.md](../AGENTS.md)). and what stays manual. - [Billing](billing.md): the plan, the Stripe accounts, and how the functions and the rules enforce it. +- [Account deletion](account-deletion.md): what deleting an account removes, the callable and + its second purge, and what is kept for how long. - [End-to-end encryption](e2ee.md): the specification for encrypting pushes, which every client implements and the rules require. diff --git a/docs/account-deletion.md b/docs/account-deletion.md new file mode 100644 index 0000000..a0f7f4c --- /dev/null +++ b/docs/account-deletion.md @@ -0,0 +1,177 @@ +# Account deletion + +Anyone with an account can delete it, with everything stored under it, from inside each +app and from a public page, [pushthat.link/delete-account](https://pushthat.link/delete-account). +Google Play requires both of any app that lets people make an account, and requires that +deleting the account deletes its data: an account that is only switched off does not count. + +## What an account is, and where it lives + +Everything the project holds about an account is keyed by its Firebase uid: + +| Where | What | +| ---------------------------------------- | ----------------------------------------------------------------------------------------------- | +| Firebase Authentication | The email address, the password hash, the sessions | +| `users/{uid}` | Email, device notification tokens, the wrapped account key and recovery wrap, usage, plan, Stripe ids | +| `pushes` where `userId` is the uid | The encrypted links and file records, with their search tokens and device ids | +| Storage, `uploads/{uid}/` | The encrypted files | +| `stripeCustomers`, `billingEvents` | Which Stripe customer is the account's, and the webhook events applied to it | +| Stripe | The customer (email) and its subscription | + +`functions/src/account-deletion.ts` empties all of them. Its emulator suite +(`functions/test/emulator/account-deletion.test.ts`) does not list those places: it seeds +an account, deletes it, and then walks the whole database, the whole bucket and the Auth +emulator for the uid. A collection added later that is keyed by the account and missed by +the purge fails that suite without anyone remembering to add it. + +## The `deleteAccount` callable + +A Firebase callable, `https://us-central1-push-that.cloudfunctions.net/deleteAccount`, +called with the account's ID token and an empty payload. It answers `{ deleted: true }`. + +**It needs a sign-in no older than five minutes** (`FRESH_SIGN_IN_S`, read from the token's +`auth_time`). Every client asks for the password and signs in again right before the call. +The check is in the function because one made only in a client is one a modified client +skips. The function is not given the password itself: Firebase's password for an account +is a key derived from it (see [end-to-end encryption](e2ee.md)), and the sign-in that +proves it is the one every client already has. + +In order: + +1. **Stripe.** Open checkouts are ended, any subscription still running is cancelled, and + the customer is deleted. This goes first because it is the one step that can refuse, + and a deleted account that is still charged at its renewal has no page left to cancel + from: if the subscription cannot be cancelled, nothing has been deleted yet and the + person is told to try again. A customer that will not delete does not stop the rest; + it is logged to be removed by hand. No refund is made here: the refund policy is a + separate request (the terms, Refunds). +2. **A marker**, `accountDeletions/{uid}`, holding two times and nothing else (see below). +3. **The refresh tokens are revoked**, so no device can renew its session. +4. **The data**: the pushes, everything under `uploads/{uid}/`, the billing rows, and + `users/{uid}` with anything under it. +5. **The Auth user**, last. It is what lets the person ask again, so if a step before it + fails they can still sign in, and the next call picks up where this one stopped. + +Deleting the pushes starts `onPushRecordDeleted` once per push, link pushes included: for +those it logs a line and returns. An account with tens of thousands of pushes therefore +costs that many short invocations and log lines when it is deleted. That is the trigger as +it has always been, and account deletion is the first thing to delete in bulk; it is noted +here so the burst in the logs after a large deletion is recognised for what it is. + +Every step is content to find its work already done, so the call is idempotent: a second +call, or one for an account already deleted (its last ID token can still reach the function +for an hour), succeeds and changes nothing. + +## Signing out the other devices, and the second purge + +Deleting the Auth user signs a device out when its ID token next refreshes, which is up to +an hour away. Until then that token still passes the Firestore and Storage rules, so a +phone left signed in could write its notification token back to `users/{uid}`, and a push +made in the same second as the deletion can still be counted by `onNewPushCreated` +afterwards, which makes the user document it does not find. + +Refusing those writes in the rules would mean reading a second document on every push for +the life of the project. Instead `finishAccountDeletions` runs every hour and, for each +marker older than 70 minutes (an ID token's hour, and ten minutes for a slow clock or a +late trigger), empties the same places again and removes the marker. So the first purge is +immediate, and the second is what makes "nothing is left" true without depending on what +the clients do. The marker is the only thing kept about a deleted account in our own +database, for at most about two hours. + +The same hour is why `createCheckoutSession` refuses an account that has a marker, and why +the sweep asks Stripe again before it purges: a subscription bought with the deleted +account's last token would otherwise renew with nothing left that points at it. Outside +the emulator the function also refuses, before deleting anything, when the account has a +Stripe customer and billing is not configured, for the same reason. + +## What cannot be deleted at once + +This is the list the public page and the Play Data safety form are written from. If one of +these changes (a backup turned on, a log retention raised), change the page with it. + +| What | Kept for | Why | +| ------------------------------------------------- | ------------------------------- | -------------------------------------------------------------------- | +| The marker, `accountDeletions/{uid}` | About two hours | The second purge, above | +| Server logs (Cloud Logging) | 30 days, Cloud Logging's default | The functions log the uid and stored file paths, never an email or push content | +| Google's backups of Authentication, Firestore and Storage | Up to 180 days | Google's own disaster-recovery copies, which expire on its schedule and cannot be read out per account | +| Payment records at Stripe | As long as tax law requires | Stripe is the seller of record and keeps its own books | +| Google Analytics for Firebase events (Android, iOS) | Up to 14 months | Not keyed to the account or email, only to an installation id, which the app resets on deletion | + +Three of these depend on settings outside the repository, which nothing here can check: + +- **Firestore backups and point-in-time recovery are off**, and must stay off or be added + to the table: with either on, a deleted account's documents would be restorable for the + backup's retention. What a restore could bring back is ciphertext whose key is gone, + apart from the email and the device tokens, which are plaintext. +- **The Storage bucket's soft delete.** A bucket made since 2024 keeps deleted objects for + seven days by default. Turn it off for `push-that.firebasestorage.app` + (`gcloud storage buckets update gs://push-that.firebasestorage.app --clear-soft-delete`), + or add seven days to the table and the page. The objects are ciphertext either way. +- **Analytics retention** is set in the Analytics property (Admin, Data retention): two + months or fourteen. The page says "up to 14 months", which holds for both. + +## Local data on each device + +After the function answers, the client that asked clears what it holds and signs out. What +each client holds differs, because each keeps different things: + +| | Web and extension | Android | iOS | macOS | +| --- | --- | --- | --- | --- | +| The account key and any unconfirmed recovery key | yes | yes | yes | yes | +| Decrypted files in the cache | none kept | yes | yes | yes | +| Pushes queued for a retry | none kept | yes | yes | yes | +| Icons decoded from the list's pushes, in memory | until the tab closes | until the app closes | until the app closes | until the app closes | +| The notification (FCM) token | not held by the page | yes | yes | no FCM | +| The Analytics installation id | no Analytics | yes | yes | no Analytics | + +On Android, a process killed during the call (the app swiped out of Recents) never gets to +this cleanup: the session ends on its own once its revoked token is refused and the key +goes with it, but the queued pushes and the notification token stay until that install is +next signed out. + +The decoded icons are the one thing here that outlives the sign-out: each app keeps a +bounded cache of the icons it has drawn, in memory only, which neither sign-out nor deletion +empties. They go with the process. + +The device id stays everywhere, as it does at sign-out: it belongs to the install, not the +account. The extension's worker keeps its own FCM token in `chrome.storage`; it stops being +addressed when the user document goes, and is replaced at the next sign-in. + +## When the client hears nothing back + +The function may run for five minutes on a large account, so every client waits a little +longer than that (310 seconds). If the call still ends with no answer (a dropped connection, +a crash on the server), the client does not know how far the deletion got, and must not say +"nothing was deleted": past Stripe the marker is written and the sweep will finish the job +regardless. It asks Firebase whether the account still exists. The Auth user is the last +thing deleted, so gone means done, and the client clears its local data and signs out; +otherwise it says the outcome could not be confirmed, and that the account is gone once +signing in with the right password no longer works. A retry that finds the account already +gone is treated as the deletion having succeeded, on the apps. With email enumeration +protection on, Firebase refuses a deleted account's password exactly as it would a wrong +one, so the apps ask about the session they already hold (`reload`) and count only "no such +user". A revoked session does not count there: a password change or reset on another device +revokes every session too, and reading that as "deleted" told someone who had mistyped that +their account was gone when the server had not been asked. It counts only straight after the +deletion's own sign-in, when nothing but the function can have revoked it. The web cannot +tell the two apart (Firebase answers both with "token expired" there), so a retry on the web +reports a refused password ("That is not your password." when the browser is signed in, "The +email or password is not right." when it is not), and the unconfirmed message says what that +means. Only the function's own refusals (an old sign-in, a subscription that would not +cancel), which are made before anything is deleted, are shown as refusals. + +## The public page + +`/delete-account` (`apps/web/src/pages/DeleteAccount.tsx`) names the app and the developer, +gives the steps, lists what is deleted and what is kept, and has the form. It is one of the +static pages ([the web app](web.md#pages-that-read-without-javascript)), so all of it but +the form reads without JavaScript, and it is reachable signed out: the person may have +uninstalled every app. A static page may not import Firebase or hold state, so the form is +a component of its own (`components/DeleteAccountForm.tsx`) that the page fetches once it +is on screen. The form signs in on a second Firebase app that lives only for +the call (`services/account.ts` says why), so it works the same signed in or out. Someone +who has forgotten their password resets it first; someone who cannot sign in at all writes +to support. + +The website's and the extension's menu item leads to this page. The Android, iOS and macOS +apps ask for the password in a dialog of their own and call the function directly. diff --git a/docs/billing.md b/docs/billing.md index 1f4118b..312d4e0 100644 --- a/docs/billing.md +++ b/docs/billing.md @@ -84,4 +84,4 @@ A file is uploaded before its push is made, so an upload whose push never comes Locally, the three secrets go in `functions/.secret.local` (ignored by git) as `NAME=value` lines, and `stripe listen` forwards the sandbox's events to the emulator as above. `APP_URL` may be set in the emulator's environment to send the checkout back to a local web app. -The rules are tested on their own with `npm test` (`plan.test.ts`, `stripe.test.ts`, `stripe-events.test.ts`). The glue around them, the webhook's refusals and its event record, the stamp's guard in `applyPatch`, the order deliveries land in, the refresh, the checkout, the count a push adds and the limit applied again at it, and the expiry sweep and backfill, is tested against the Firestore emulator with `npm run test:emulator` (`functions/test/emulator`, a Java runtime and the Firebase CLI needed), and so is the orphan sweep's listing, paging, cap and deletes, against the Storage emulator beside it. A run needs the emulator ports free (8080 and 9199) and refuses to start otherwise, naming the port: one run cannot share another's emulators. The emulators load the rules the root `firebase.json` names, but the suites drive the functions through the Admin SDK, which rules never apply to, so they say nothing about what the clients may do: the rules live in `apps/web` and are tested there with `npm run test:rules`. Those suites deliver signed events to the webhook over HTTP and point `STRIPE_API_URL` at a stand-in for Stripe's API, so nothing reaches Stripe. +The rules are tested on their own with `npm test` (`plan.test.ts`, `stripe.test.ts`, `stripe-events.test.ts`). The glue around them, the webhook's refusals and its event record, the stamp's guard in `applyPatch`, the order deliveries land in, the refresh, the checkout, the count a push adds and the limit applied again at it, and the expiry sweep and backfill, is tested against the Firestore emulator with `npm run test:emulator` (`functions/test/emulator`, a Java runtime and the Firebase CLI needed), and so is the orphan sweep's listing, paging, cap and deletes, against the Storage emulator beside it. A run needs the emulator ports free (8080 and 9199, and 9099 for the Auth emulator that account deletion's suite uses) and refuses to start otherwise, naming the port: one run cannot share another's emulators. The emulators load the rules the root `firebase.json` names, but the suites drive the functions through the Admin SDK, which rules never apply to, so they say nothing about what the clients may do: the rules live in `apps/web` and are tested there with `npm run test:rules`. Those suites deliver signed events to the webhook over HTTP and point `STRIPE_API_URL` at a stand-in for Stripe's API, so nothing reaches Stripe. diff --git a/docs/web.md b/docs/web.md index 6b8309c..1d505bb 100644 --- a/docs/web.md +++ b/docs/web.md @@ -54,14 +54,14 @@ A push to `main` runs this deploy from CI when it changes `apps/web/`; see ## Pages that read without JavaScript -Privacy, Terms, Support and Security are prerendered. `npm run build` ends with -`npm run prerender`, which builds `src/prerender.tsx` for Node (`vite build --ssr`, into -`node_modules/.prerender`) and runs `scripts/prerender.mjs`. That writes `dist/.html` -for each entry in `src/staticPages.tsx`: the built `index.html` with the page rendered into -`#root`, the page's own title (as `` and `og:title`) and its own `og:url`. So the -pages read with JavaScript off, and crawlers and link previews see each page's title rather -than the front page's. The descriptions stay the product's, since they say what the page -belongs to. +Privacy, Terms, Support, Security and the account deletion page are prerendered. `npm run +build` ends with `npm run prerender`, which builds `src/prerender.tsx` for Node (`vite build +--ssr`, into `node_modules/.prerender`) and runs `scripts/prerender.mjs`. That writes +`dist/<page>.html` for each entry in `src/staticPages.tsx`: the built `index.html` with the +page rendered into `#root`, the page's own title (as `<title>` and `og:title`) and its own +`og:url`. So the pages read with JavaScript off, and crawlers and link previews see each +page's title rather than the front page's. The descriptions stay the product's, since they +say what the page belongs to. Hosting reaches each file through a rewrite of its own in the root `firebase.json`, ahead of the `**` catch-all, and the script fails the build when one is missing. `cleanUrls` would do @@ -71,10 +71,14 @@ page is `/index.html?view=…`. A page belongs on the list only if it renders the same signed in or out, holds no state and imports nothing from Firebase. Firebase Auth started in Node keeps the script from exiting, so the script refuses a bundle that imports Firebase rather than hang the build. That is why -`useAuth` lives apart from its provider (`src/contexts/AuthContext.tsx`, -`AuthProvider.tsx`) and the password rule is in `src/config/password.ts`. The front page is -not on the list: every unknown path is answered with `index.html`, so filling that file -would show the front page on `/links` and the rest until the bundle loaded. +`useAuth` lives apart from its provider (`src/contexts/AuthContext.tsx`, `AuthProvider.tsx`) +and the password rule is in `src/config/password.ts`. A page that needs either for part of +itself keeps that part in a component it imports from an effect, as the deletion page does +its form (`src/components/DeleteAccountForm.tsx`): effects do not run in the build's render, +so the chunk is never loaded there. Not `lazy`, which starts its import wherever it is +rendered, the build included. The front page is not on the list: every unknown path is +answered with `index.html`, so filling that file would show the front page on `/links` and +the rest until the bundle loaded. With JavaScript on, `src/main.tsx` waits for Firebase to say who is signed in, then mounts with `createRoot`, which replaces the prerendered markup with the same page (and the nav, diff --git a/firebase.json b/firebase.json index 92fab1d..6990141 100644 --- a/firebase.json +++ b/firebase.json @@ -7,6 +7,10 @@ "**/node_modules/**" ], "rewrites": [ + { + "source": "/delete-account", + "destination": "/delete-account.html" + }, { "source": "/privacy", "destination": "/privacy.html" @@ -53,6 +57,9 @@ } ], "emulators": { + "auth": { + "port": 9099 + }, "functions": { "port": 5001 }, diff --git a/functions/jest.emulator.config.js b/functions/jest.emulator.config.js index fa89241..e0b9fc4 100644 --- a/functions/jest.emulator.config.js +++ b/functions/jest.emulator.config.js @@ -1,4 +1,4 @@ -// The suites that run against the Firestore and Storage emulators (test/emulator): `npm run test:emulator`. +// The suites that run against the Auth, Firestore and Storage emulators (test/emulator): `npm run test:emulator`. module.exports = { preset: 'ts-jest', testEnvironment: 'node', diff --git a/functions/package.json b/functions/package.json index 0d85bbf..180e26f 100644 --- a/functions/package.json +++ b/functions/package.json @@ -11,7 +11,7 @@ "logs": "firebase functions:log", "test": "jest", "pretest:emulator": "node test/emulator/check-ports.mjs", - "test:emulator": "firebase emulators:exec --only firestore,storage --project demo-pushthat 'jest --config jest.emulator.config.js --silent'", + "test:emulator": "firebase emulators:exec --only auth,firestore,storage --project demo-pushthat 'jest --config jest.emulator.config.js --silent'", "test:watch": "jest --watch" }, "engines": { diff --git a/functions/src/account-deletion.test.ts b/functions/src/account-deletion.test.ts new file mode 100644 index 0000000..173dea6 --- /dev/null +++ b/functions/src/account-deletion.test.ts @@ -0,0 +1,35 @@ +import { FRESH_SIGN_IN_S, signedInRecently } from './account-deletion'; + +/** + * The one rule in account deletion that is arithmetic: how old a sign-in may be. What a + * deletion removes is tested against the emulators (test/emulator/account-deletion.test.ts). + */ + +const NOW = new Date('2026-11-12T12:00:00Z'); +const seconds = (date: Date) => Math.floor(date.getTime() / 1000); + +describe('signedInRecently', () => { + test('a sign-in a moment ago passes', () => { + expect(signedInRecently(seconds(NOW) - 5, NOW)).toBe(true); + }); + + test('a sign-in at the edge passes, and one past it does not', () => { + expect(signedInRecently(seconds(NOW) - FRESH_SIGN_IN_S, NOW)).toBe(true); + expect(signedInRecently(seconds(NOW) - FRESH_SIGN_IN_S - 1, NOW)).toBe(false); + }); + + test('a session from yesterday does not pass', () => { + expect(signedInRecently(seconds(NOW) - 24 * 60 * 60, NOW)).toBe(false); + }); + + test('a clock a few seconds ahead passes, and a sign-in far in the future does not', () => { + expect(signedInRecently(seconds(NOW) + 10, NOW)).toBe(true); + expect(signedInRecently(seconds(NOW) + 3600, NOW)).toBe(false); + }); + + test('a token with no sign-in time does not pass', () => { + expect(signedInRecently(undefined, NOW)).toBe(false); + expect(signedInRecently('1700000000', NOW)).toBe(false); + expect(signedInRecently(NaN, NOW)).toBe(false); + }); +}); diff --git a/functions/src/account-deletion.ts b/functions/src/account-deletion.ts new file mode 100644 index 0000000..dd87ea5 --- /dev/null +++ b/functions/src/account-deletion.ts @@ -0,0 +1,368 @@ +import { getAuth } from 'firebase-admin/auth'; +import { Firestore, Timestamp, getFirestore } from 'firebase-admin/firestore'; +import { getStorage } from 'firebase-admin/storage'; +import * as logger from 'firebase-functions/logger'; +import { HttpsError, onCall } from 'firebase-functions/v2/https'; +import { onSchedule } from 'firebase-functions/v2/scheduler'; + +import { deletionMarkers, deletionRef } from './deletion-marker'; +import { + ENDED_SUBSCRIPTION, + StripeConfig, + StripeError, + deleteCustomer, + ensureSubscriptionCanceled, + expireOpenCheckouts, + isMissingAtStripe, + subscriptionsOf, +} from './stripe'; +import { secrets, stripeConfig, uidOf } from './stripe-functions'; + +/** + * Deleting an account (docs/account-deletion.md): the `deleteAccount` callable every + * client and the public page at /delete-account call, and the sweep that finishes the + * job an hour later. + * + * Everything the project holds about an account is keyed by its uid, in these places, + * and all of them are emptied: + * + * - `users/{uid}` and anything under it: the email, the device tokens, the wrapped + * account key and recovery wrap (`e2ee`), the month's usage, the plan and Stripe ids. + * - `pushes` where `userId` is the uid: the encrypted links and file records. + * - Storage under `uploads/{uid}/`: the encrypted files, whether or not a push names them. + * - `stripeCustomers` and `billingEvents` rows that name the uid. + * - The Firebase Authentication user: the email, the password hash and every session. + * - At Stripe, the customer, after its subscription is ended, so a deleted account is + * never charged again. + * + * It is a delete, not a flag: nothing is kept to restore from, and the account key going + * is what makes the pushes unrecoverable even from a backup of their ciphertext. + * + * Deleting the Auth user signs a device out only when its ID token next refreshes, which + * is up to an hour away, and until then that token still passes the rules: a phone left + * signed in could write its notification token back to `users/{uid}`, or a push made in + * the same second could be counted by `onNewPushCreated` after the purge. Refusing those + * in the rules would cost every push a second document read for the life of the project. + * Instead a marker, `accountDeletions/{uid}`, is left holding only two times, and + * `finishAccountDeletions` purges the same places again once every token the account + * ever had has expired, then removes the marker. So the first purge is immediate and the + * second is what makes "nothing is left" true without depending on the clients. + */ + +/** + * How recent the sign-in must be, in seconds. The clients ask for the password and sign + * in again right before the call, so five minutes is time for the round trip and no + * more: a session left open on a shared computer cannot delete the account, which is + * what asking for the password is for. Checked here, from the token's `auth_time`, + * because a check made only in a client is one a modified client skips. + */ +export const FRESH_SIGN_IN_S = 5 * 60; + +/** + * How long after a deletion the second purge waits: an ID token lives an hour, and ten + * minutes on top covers a clock that is off and a trigger that started late. + */ +export const DELETION_SETTLE_MS = 70 * 60 * 1000; + +/** How many documents one batch deletes: Firestore's limit on a commit. */ +const DELETE_BATCH = 500; + +/** The two calls made to Firebase Authentication, so the suites can watch them. */ +export interface AuthAdmin { + revokeRefreshTokens(uid: string): Promise<void>; + deleteUser(uid: string): Promise<void>; +} + +export interface DeletionDeps { + db?: Firestore; + auth?: AuthAdmin; + /** Billing, when it is on. */ + stripe?: StripeConfig; + /** Refuse, rather than skip Stripe, when billing is off and the account has a customer. */ + billingRequired?: boolean; + now?: Date; +} + +/** Whether the token's sign-in (`auth_time`, Unix seconds) is recent enough at `now`. */ +export function signedInRecently(authTime: unknown, now: Date, maxAgeS = FRESH_SIGN_IN_S): boolean { + if (typeof authTime !== 'number' || !Number.isFinite(authTime)) return false; + const age = now.getTime() / 1000 - authTime; + // A minute of clock skew the other way is let through; a sign-in from the future is not. + return age <= maxAgeS && age >= -60; +} + +/** Deletes every document `query` matches, a commit's worth at a time; returns how many. */ +async function deleteMatching(db: Firestore, query: FirebaseFirestore.Query): Promise<number> { + let deleted = 0; + for (;;) { + const page = await query.limit(DELETE_BATCH).get(); + if (page.empty) return deleted; + const batch = db.batch(); + for (const doc of page.docs) batch.delete(doc.ref); + await batch.commit(); + deleted += page.size; + } +} + +/** How many stored objects are listed, and how many deleted at once, as the orphan sweep does. */ +const UPLOAD_PAGE = 1000; +const UPLOAD_DELETE_CHUNK = 50; + +/** + * Deletes every object under `uploads/{uid}/`. Listed and deleted here rather than with + * the SDK's `deleteFiles`, which stops at the first object that is already gone. + */ +async function deleteUploads(uid: string): Promise<void> { + const bucket = getStorage().bucket(); + let pageToken: string | undefined; + do { + const [files, next] = await bucket.getFiles({ + prefix: `uploads/${uid}/`, + autoPaginate: false, + maxResults: UPLOAD_PAGE, + pageToken, + }); + pageToken = (next as { pageToken?: string } | null)?.pageToken; + for (let i = 0; i < files.length; i += UPLOAD_DELETE_CHUNK) { + await Promise.all(files.slice(i, i + UPLOAD_DELETE_CHUNK).map((file) => file.delete({ ignoreNotFound: true }))); + } + } while (pageToken); +} + +export interface PurgeReport { + pushes: number; + billingRows: number; +} + +/** + * Empties every place the project keeps something under `uid`, except the Auth user and + * Stripe. Safe to run again: each step deletes what is there and is content with nothing. + * + * The stored files are deleted by prefix rather than left to `onPushRecordDeleted`, which + * removes a file push's object as its document goes: that trigger only sees an object a + * push names, and an upload whose push never came would wait for the orphan sweep's + * seven days. + * + * And they go first, before the push documents. Each document deleted starts that + * trigger, which looks for its object and deletes it. With the documents first, the + * trigger and this were deleting the same objects at the same moment, and whichever lost + * got "no such object": here that failed the whole deletion before the Auth user went, + * and the more files an account had the likelier it was. With the files first, every + * trigger finds its object already gone and stops. An object that vanishes anyway (the + * expiry sweep, a push deleted on another device) is what this wants, not an error. + * + * A push made in the moment before this ran may still be on its way through + * `onNewPushCreated`, whose count makes the user document it does not find. No order of + * the steps here closes that, which is one of the things the second purge is for. + */ +export async function purgeAccountData(db: Firestore, uid: string): Promise<PurgeReport> { + await deleteUploads(uid); + const pushes = await deleteMatching(db, db.collection('pushes').where('userId', '==', uid)); + const billingRows = + (await deleteMatching(db, db.collection('billingEvents').where('userId', '==', uid))) + + (await deleteMatching(db, db.collection('stripeCustomers').where('userId', '==', uid))); + // Recursive, for whatever is under the document: `usage` was a subcollection once. + await db.recursiveDelete(db.collection('users').doc(uid)); + return { pushes, billingRows }; +} + +/** The Stripe customers the account has: the one its document names, and any row that names the account. */ +async function customersOf(db: Firestore, uid: string): Promise<string[]> { + const [user, rows] = await Promise.all([ + db.collection('users').doc(uid).get(), + db.collection('stripeCustomers').where('userId', '==', uid).get(), + ]); + const fromDoc = (user.data() as { billing?: { customerId?: unknown } } | undefined)?.billing?.customerId; + return [...new Set([...(typeof fromDoc === 'string' && fromDoc ? [fromDoc] : []), ...rows.docs.map((d) => d.id)])]; +} + +/** + * Ends what the account has at Stripe. The subscription is what must not survive: an + * account that no longer exists would be charged again at its renewal, with no page left + * to cancel from. So a subscription Stripe will not cancel stops the deletion before + * anything has been deleted, and the person can try again. Open checkouts are ended + * first, so one left in another tab cannot be paid afterwards. + * + * Deleting the customer itself is not allowed to stop it. By then nothing can charge the + * account, and refusing to delete someone's data because a record at the payment + * processor would not go is the worse failure. It is logged with the customer's id, to + * be removed by hand in the dashboard. + * + * A customer Stripe no longer has is one an earlier call already deleted, before it + * failed at a later step: the rows that name it are only removed by the purge, so a + * retry meets it again. Stripe may answer a list filtered by a deleted customer with + * "no such customer" rather than an empty list, and that must read as nothing left to + * end, not as a subscription that could not be cancelled. + */ +async function endBilling(stripe: StripeConfig, customerIds: string[], uid: string): Promise<void> { + for (const customerId of customerIds) { + try { + await expireOpenCheckouts(stripe, customerId); + for (const sub of await subscriptionsOf(stripe, customerId)) { + if (!ENDED_SUBSCRIPTION.has(sub.status)) await ensureSubscriptionCanceled(stripe, sub.id); + } + } catch (err) { + if (!isMissingAtStripe(err)) throw err; + } + try { + await deleteCustomer(stripe, customerId); + } catch (err) { + logger.error( + `[account] user=${uid}: Stripe would not delete customer ${customerId}; delete it by hand: ${(err as Error).message}` + ); + } + } +} + +/** + * The account has a Stripe customer and there is no Stripe configuration to end its + * subscription with. Thrown before anything is deleted. + */ +export class BillingOffError extends Error { + constructor() { + super('billing is off, and the account has a Stripe customer'); + this.name = 'BillingOffError'; + } +} + +const isUserNotFound = (err: unknown) => (err as { code?: string } | null)?.code === 'auth/user-not-found'; + +/** Runs `step`, taking "there is no such user" as done: a second call finds the user already gone. */ +async function unlessGone(step: () => Promise<void>): Promise<void> { + try { + await step(); + } catch (err) { + if (!isUserNotFound(err)) throw err; + } +} + +/** + * Deletes the account `uid`, for good. In order: Stripe (the one step that can refuse, + * so it goes while nothing is deleted yet), the marker for the second purge, the refresh + * tokens (so no device can renew its session from here on), the data, and last the Auth + * user. The user goes last because it is what lets the person ask again: if a step in + * between fails, they can still sign in and the next call picks up where this one + * stopped. Every step is content to find its work already done, so a call for an account + * already deleted succeeds and changes nothing. + */ +export async function deleteAccountOf( + uid: string, + { db = getFirestore(), auth = getAuth(), stripe, billingRequired = false, now = new Date() }: DeletionDeps = {} +): Promise<void> { + const customerIds = await customersOf(db, uid); + if (customerIds.length > 0) { + if (stripe) { + await endBilling(stripe, customerIds, uid); + } else if (billingRequired) { + // The purge deletes `billing.customerId` and the `stripeCustomers` row, the only + // things that say which Stripe customer was this account's. Going on without + // Stripe would leave a subscription renewing with nothing left that points at it. + throw new BillingOffError(); + } else { + // The emulator's case (stripe-functions.ts), where no customer is a real one. + logger.warn(`[account] user=${uid}: billing is off, so nothing was asked of Stripe`); + } + } + + await deletionRef(db, uid).set({ + deletedAt: Timestamp.fromDate(now), + settleAt: Timestamp.fromMillis(now.getTime() + DELETION_SETTLE_MS), + }); + await unlessGone(() => auth.revokeRefreshTokens(uid)); + const report = await purgeAccountData(db, uid); + await unlessGone(() => auth.deleteUser(uid)); + logger.info( + `[account] deleted user=${uid}: ${report.pushes} push${report.pushes === 1 ? '' : 'es'}, ${report.billingRows} billing row${report.billingRows === 1 ? '' : 's'}` + ); +} + +/** + * The second purge (see the top of this file): for every deletion whose tokens have all + * expired by `now`, empties the same places again and removes the marker. Returns the + * accounts finished. One that fails keeps its marker and is tried again the next hour. + * + * Stripe is asked again too, before the purge removes the rows that say which customer + * to ask about. `createCheckoutSession` refuses an account with a marker, but a checkout + * opened in the second before the marker was written could still have been paid since, + * and that subscription would renew for an account that does not exist. + */ +export async function finishDeletions( + now: Date, + { db = getFirestore(), auth = getAuth(), stripe }: Pick<DeletionDeps, 'db' | 'auth' | 'stripe'> = {} +): Promise<string[]> { + const due = await deletionMarkers(db).where('settleAt', '<=', Timestamp.fromDate(now)).get(); + const finished: string[] = []; + for (const marker of due.docs) { + const uid = marker.id; + try { + const customerIds = await customersOf(db, uid); + if (stripe && customerIds.length > 0) await endBilling(stripe, customerIds, uid); + const report = await purgeAccountData(db, uid); + // Already gone unless the first call stopped before it got there. + await unlessGone(() => auth.deleteUser(uid)); + await marker.ref.delete(); + finished.push(uid); + if (report.pushes > 0) { + logger.info( + `[account] user=${uid}: ${report.pushes} push${report.pushes === 1 ? '' : 'es'} written after the deletion, removed` + ); + } + } catch (err) { + logger.error(`[account] user=${uid}: the second purge failed and will be tried again`, err); + } + } + return finished; +} + +/** + * Deletes the caller's account. The caller must have signed in within the last five + * minutes (`FRESH_SIGN_IN_S`): every client asks for the password and signs in again + * before calling, and this is where that is enforced. Answers `{ deleted: true }`, also + * for an account already deleted, whose last ID token can still reach here for an hour. + * + * Five minutes of timeout rather than the default one: an account with thousands of + * pushes is deleted five hundred at a time, and a deletion cut off halfway is finished by + * the next call or by the sweep, but the person would have seen an error. + */ +export const deleteAccount = onCall({ cors: true, secrets, timeoutSeconds: 300 }, async (request) => { + const uid = uidOf(request); + const now = new Date(); + if (!signedInRecently(request.auth?.token.auth_time, now)) { + throw new HttpsError('failed-precondition', 'Enter your password again to delete the account.'); + } + try { + // A deployed instance always has billing on (a deploy refuses a function whose + // secret does not exist), but a secret set to an empty value reads as off. Only the + // emulator may skip Stripe. + await deleteAccountOf(uid, { + stripe: stripeConfig(), + billingRequired: process.env.FUNCTIONS_EMULATOR !== 'true', + now, + }); + } catch (err) { + if (!(err instanceof StripeError) && !(err instanceof BillingOffError)) throw err; + logger.error(`[account] user=${uid}: ending the subscription was refused, nothing deleted: ${err.message}`); + throw new HttpsError( + 'unavailable', + 'Your subscription could not be cancelled right now, so nothing was deleted. Try again shortly.' + ); + } + return { deleted: true }; +}); + +/** + * Nine minutes, the most a scheduled function may have, not the default one: this is + * also what finishes a deletion the callable was cut off in the middle of, which is by + * definition an account too large for five minutes. On the default, each hourly run + * would spend its minute on that one marker and be killed, and every deletion queued + * behind it would wait an hour per run, past the two hours the public page promises. + */ +export const finishAccountDeletions = onSchedule( + { schedule: 'every 60 minutes', timeZone: 'Etc/UTC', timeoutSeconds: 540, secrets }, + async () => { + const finished = await finishDeletions(new Date(), { stripe: stripeConfig() }); + if (finished.length > 0) { + logger.info(`finishAccountDeletions: finished ${finished.length} deletion${finished.length === 1 ? '' : 's'}`); + } + } +); diff --git a/functions/src/deletion-marker.ts b/functions/src/deletion-marker.ts new file mode 100644 index 0000000..e9b6745 --- /dev/null +++ b/functions/src/deletion-marker.ts @@ -0,0 +1,15 @@ +import { CollectionReference, DocumentReference, Firestore } from 'firebase-admin/firestore'; + +/** + * The marker a deleted account leaves until its second purge (account-deletion.ts): two + * times and nothing else, at `accountDeletions/{uid}`. + * + * In a file of its own because two files need it and one of them imports the other: + * account-deletion.ts writes and sweeps the markers, and `createCheckoutSession` in + * stripe-functions.ts refuses an account that has one. With the collection's name written + * out in both, renaming it in one would have quietly let a deleted account's last token + * open a checkout again. + */ +export const deletionMarkers = (db: Firestore): CollectionReference => db.collection('accountDeletions'); + +export const deletionRef = (db: Firestore, uid: string): DocumentReference => deletionMarkers(db).doc(uid); diff --git a/functions/src/index.ts b/functions/src/index.ts index 2df8b49..5c8b2f4 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -31,6 +31,7 @@ export { refreshSubscription, stripeWebhook, } from './stripe-functions'; +export { deleteAccount, finishAccountDeletions } from './account-deletion'; export { expireFilePushes } from './expiry'; export { deleteOrphanedUploads } from './orphans'; @@ -64,21 +65,12 @@ export const onPushRecordDeleted = onDocumentDeleted('pushes/{pushId}', async (e } storagePath = path; - // Get storage reference - const storage = getStorage(); - const bucket = storage.bucket(); - const file = bucket.file(storagePath); - - // Check if file exists before attempting to delete - const [exists] = await file.exists(); - if (!exists) { - logger.info(`File ${storagePath} does not exist in storage`); - return; - } - - // Delete the file - await file.delete(); - logger.info(`Successfully deleted file: ${storagePath}`); + // `ignoreNotFound`, and no look first to see whether it is there: the account purge + // (account-deletion.ts) and the orphan sweep delete these objects too, and one that + // went between a look and the delete made this throw and log an error for a file that + // was exactly where it should be, gone. + await getStorage().bucket().file(storagePath).delete({ ignoreNotFound: true }); + logger.info(`Deleted stored file, or found it already gone: ${storagePath}`); } catch (error) { logger.error(`Error deleting ${storagePath} from storage:`, error); throw error; diff --git a/functions/src/stripe-functions.ts b/functions/src/stripe-functions.ts index a0bdbff..e87bb2d 100644 --- a/functions/src/stripe-functions.ts +++ b/functions/src/stripe-functions.ts @@ -4,6 +4,7 @@ import * as logger from 'firebase-functions/logger'; import { defineSecret } from 'firebase-functions/params'; import { CallableRequest, HttpsError, onCall, onRequest } from 'firebase-functions/v2/https'; +import { deletionRef } from './deletion-marker'; import { PlanUser, canSubscribe, compRunning, planReportOf } from './plan'; import { ENDED_SUBSCRIPTION, @@ -63,7 +64,8 @@ import { const STRIPE_SECRET_KEY = defineSecret('STRIPE_SECRET_KEY'); const STRIPE_WEBHOOK_SECRET = defineSecret('STRIPE_WEBHOOK_SECRET'); const STRIPE_PRO_PRICE_ID = defineSecret('STRIPE_PRO_PRICE_ID'); -const secrets = [STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRO_PRICE_ID]; +/** Exported for `deleteAccount` (account-deletion.ts), which ends the account's subscription. */ +export const secrets = [STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRO_PRICE_ID]; /** Where the checkout and the portal come back to. The emulator may point it elsewhere. */ const APP_URL = (process.env.APP_URL || 'https://pushthat.link').replace(/\/$/, ''); @@ -72,7 +74,7 @@ function planPageUrl(query?: string): string { return `${APP_URL}/plan${query ? `?${query}` : ''}`; } -function stripeConfig(): StripeConfig | undefined { +export function stripeConfig(): StripeConfig | undefined { return stripeOf({ STRIPE_SECRET_KEY: STRIPE_SECRET_KEY.value(), STRIPE_WEBHOOK_SECRET: STRIPE_WEBHOOK_SECRET.value(), @@ -82,7 +84,7 @@ function stripeConfig(): StripeConfig | undefined { } /** The signed-in account, or the refusal a callable gives without one. */ -function uidOf(request: CallableRequest): string { +export function uidOf(request: CallableRequest): string { if (!request.auth) throw new HttpsError('unauthenticated', 'Sign in first.'); return request.auth.uid; } @@ -222,6 +224,12 @@ export const createCheckoutSession = onCall({ cors: true, secrets }, async (requ const uid = uidOf(request); const db = getFirestore(); const now = new Date(); + // A deleted account's last ID token still gets here for up to an hour (docs/account-deletion.md), + // and a checkout made with it would be a customer and a subscription for an account + // that is gone. + if ((await deletionRef(db, uid).get()).exists) { + throw new HttpsError('failed-precondition', 'This account has been deleted.'); + } const user = await readUser(db, uid); const billing = user?.billing; // The comp first, so it gets its own sentence; then the one test the report's diff --git a/functions/src/stripe.ts b/functions/src/stripe.ts index a7457ca..e9a040a 100644 --- a/functions/src/stripe.ts +++ b/functions/src/stripe.ts @@ -181,6 +181,26 @@ export async function createCustomer( const CUSTOMER_KEY_WINDOW_MS = 10 * 60 * 1000; +/** Stripe saying the thing asked about does not exist (any more). */ +export const isMissingAtStripe = (err: unknown): boolean => + err instanceof StripeError && (err.status === 404 || err.code === 'resource_missing'); + +/** + * Deletes the customer, for an account being deleted (account-deletion.ts): Stripe drops + * the customer and the email it was made with from the account's customer list, and ends + * any subscription still running. The payments already made stay with Stripe, which is + * the seller of record and keeps them for its own books. A customer already gone is what + * this wants, so a second call for the same account is not a refusal. + */ +export async function deleteCustomer(stripe: StripeConfig, customerId: string): Promise<void> { + try { + await stripeCall(stripe, 'DELETE', `/v1/customers/${encodeURIComponent(customerId)}`); + } catch (err) { + if (isMissingAtStripe(err)) return; + throw err; + } +} + export interface StripeCheckoutSession { id: string; /** Where to send the person: Stripe's hosted page. Null once the session is complete or expired. */ diff --git a/functions/test/emulator/account-deletion.test.ts b/functions/test/emulator/account-deletion.test.ts new file mode 100644 index 0000000..9fd1219 --- /dev/null +++ b/functions/test/emulator/account-deletion.test.ts @@ -0,0 +1,507 @@ +import { getAuth } from 'firebase-admin/auth'; +import { Timestamp } from 'firebase-admin/firestore'; +import { getStorage } from 'firebase-admin/storage'; +import { HttpsError } from 'firebase-functions/v2/https'; + +import { + DELETION_SETTLE_MS, + FRESH_SIGN_IN_S, + deleteAccount, + deleteAccountOf, + finishDeletions, +} from '../../src/account-deletion'; +import { stripeOf } from '../../src/stripe'; +import { + PRICE_ID, + StripeStandIn, + WEBHOOK_SECRET, + billingOff, + billingOn, + call, + clearAuth, + clearFirestore, + closeFirestore, + db, + list, + stripeRefusal, + subscription, +} from './harness'; + +/** + * Account deletion against the Auth, Firestore and Storage emulators and a stand-in for + * Stripe: that every place the project keeps something under an account is empty + * afterwards, that another account is untouched, and that the call can be made twice. + * + * "Every place" is checked by walking the whole database and the whole bucket for the + * account's id, not by naming collections, so a collection added later that is keyed by + * the account and missed by the purge fails here without anyone remembering to list it. + */ + +const UID = 'user_gone'; +const OTHER = 'user_staying'; +const CUSTOMER = 'cus_gone'; +const OTHER_CUSTOMER = 'cus_staying'; +const NOW = new Date('2026-11-12T12:00:00Z'); + +const stripe = new StripeStandIn(); +const bucket = () => getStorage().bucket(); + +beforeAll(() => stripe.start()); + +afterAll(async () => { + await stripe.stop(); + await clearBucket(); + await closeFirestore(); +}); + +beforeEach(async () => { + await Promise.all([clearFirestore(), clearAuth(), clearBucket()]); + stripe.reset(); + billingOn(stripe); +}); + +async function clearBucket(): Promise<void> { + const [files] = await bucket().getFiles(); + await Promise.all(files.map((file) => file.delete({ ignoreNotFound: true }))); +} + +/** Everything a real account accumulates, in every collection and in Storage. */ +async function seedAccount(uid: string, customerId: string): Promise<void> { + await getAuth().createUser({ uid, email: `${uid}@example.com`, password: 'a password for the emulator' }); + const firestore = db(); + const batch = firestore.batch(); + batch.set(firestore.collection('users').doc(uid), { + email: `${uid}@example.com`, + createdAt: Timestamp.fromDate(NOW), + fcmTokens: [`token-of-${uid}-phone`, `token-of-${uid}-browser`], + e2ee: { + v: 1, + kdf: 'pbkdf2-sha256-600000', + keyVersion: 1, + wraps: { + wrapPassword: { by: 'password', n: 'n'.repeat(16), ct: 'c'.repeat(64) }, + wrapRecovery: { by: 'recovery', n: 'n'.repeat(16), ct: 'c'.repeat(64) }, + }, + }, + usageRecords: { '2026-11': { url: 3, file: 1 } }, + subscription: { type: 'pro', status: 'active', expiresAt: Timestamp.fromMillis(NOW.getTime() + 1000) }, + billing: { customerId, subscriptionId: `sub_${uid}`, subscriptionStatus: 'active' }, + }); + // A subcollection the rules still name, though nothing writes it now. + batch.set(firestore.collection('users').doc(uid).collection('usage').doc('2026-10'), { url: 1 }); + batch.set(firestore.collection('stripeCustomers').doc(customerId), { userId: uid, createdAt: NOW }); + batch.set(firestore.collection('billingEvents').doc(`evt_${uid}`), { + eventId: `evt_${uid}`, + type: 'invoice.paid', + userId: uid, + receivedAt: NOW, + }); + const enc = { v: 1, n: 'n'.repeat(16), ct: 'ciphertext' }; + batch.set(firestore.collection('pushes').doc(), { userId: uid, type: 'url', createdAt: NOW, keyVersion: 1, enc }); + batch.set(firestore.collection('pushes').doc(), { + userId: uid, + type: 'file', + createdAt: NOW, + keyVersion: 1, + enc, + objectPath: `uploads/${uid}/RandomObjectId0000000A`, + }); + // A push from before encryption, which names its file by its title. + batch.set(firestore.collection('pushes').doc(), { + userId: uid, + type: 'file', + createdAt: NOW, + title: 'Report (final) v2.pdf', + }); + await batch.commit(); + await Promise.all( + [ + `uploads/${uid}/RandomObjectId0000000A`, + `uploads/${uid}/Report (final) v2.pdf`, + // An upload whose push never came: no document names it. + `uploads/${uid}/RandomObjectId0000000B`, + ].map((path) => bucket().file(path).save(Buffer.from('x'))) + ); +} + +/** Every document in the database, whatever collection it is in and however deep. */ +async function everyDocument(): Promise<{ path: string; data: unknown }[]> { + const found: { path: string; data: unknown }[] = []; + const walk = async (collections: FirebaseFirestore.CollectionReference[]): Promise<void> => { + for (const collection of collections) { + // `listDocuments` also gives a document that is only the parent of a subcollection. + for (const ref of await collection.listDocuments()) { + const snapshot = await ref.get(); + if (snapshot.exists) found.push({ path: ref.path, data: snapshot.data() }); + await walk(await ref.listCollections()); + } + } + }; + await walk(await db().listCollections()); + return found; +} + +/** Where `uid` still appears: a document's path or contents, a stored object, the Auth user. */ +async function tracesOf(uid: string): Promise<string[]> { + const traces = (await everyDocument()) + .filter((doc) => doc.path.includes(uid) || JSON.stringify(doc.data).includes(uid)) + .map((doc) => doc.path); + const [files] = await bucket().getFiles(); + traces.push(...files.map((file) => file.name).filter((name) => name.includes(uid))); + const user = await getAuth() + .getUser(uid) + .catch(() => null); + if (user) traces.push(`auth:${uid}`); + return traces.sort(); +} + +function stripeHasCustomer(customerId: string, subscriptionId: string, status = 'active'): void { + stripe.route('GET /v1/subscriptions', (req) => ({ + body: list( + req.query.get('customer') === customerId + ? [subscription(subscriptionId, { customer: customerId, status, created: 1 })] + : [] + ), + })); + stripe.route(`DELETE /v1/subscriptions/${subscriptionId}`, { id: subscriptionId, status: 'canceled' }); + stripe.route(`DELETE /v1/customers/${customerId}`, { id: customerId, deleted: true }); +} + +const config = () => + stripeOf({ + STRIPE_SECRET_KEY: 'sk_test_emulator', + STRIPE_WEBHOOK_SECRET: WEBHOOK_SECRET, + STRIPE_PRO_PRICE_ID: PRICE_ID, + STRIPE_API_URL: stripe.url, + }); + +const settled = (from: Date) => new Date(from.getTime() + DELETION_SETTLE_MS + 1000); + +describe('deleteAccountOf', () => { + test('leaves nothing under the account but the marker, and the second purge removes that', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + // The seed itself must be findable, or an empty result below would prove nothing. + expect((await tracesOf(UID)).length).toBeGreaterThan(8); + + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + // The marker holds two times and nothing about the person. + const marker = (await db().collection('accountDeletions').doc(UID).get()).data()!; + expect(Object.keys(marker).sort()).toEqual(['deletedAt', 'settleAt']); + + expect(await finishDeletions(settled(NOW))).toEqual([UID]); + expect(await tracesOf(UID)).toEqual([]); + }); + + test('another account keeps everything', async () => { + await seedAccount(UID, CUSTOMER); + await seedAccount(OTHER, OTHER_CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + const before = await tracesOf(OTHER); + + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + await finishDeletions(settled(NOW)); + + expect(await tracesOf(OTHER)).toEqual(before); + expect(stripe.made(`DELETE /v1/customers/${OTHER_CUSTOMER}`)).toHaveLength(0); + }); + + test('the subscription is cancelled and the customer deleted at Stripe', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + + expect(stripe.made(`DELETE /v1/subscriptions/sub_${UID}`)).toHaveLength(1); + expect(stripe.made(`DELETE /v1/customers/${CUSTOMER}`)).toHaveLength(1); + }); + + test('a subscription already over is not cancelled again', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`, 'canceled'); + + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + + expect(stripe.made(`DELETE /v1/subscriptions/sub_${UID}`)).toHaveLength(0); + expect(stripe.made(`DELETE /v1/customers/${CUSTOMER}`)).toHaveLength(1); + }); + + test('a subscription Stripe will not cancel stops the deletion with everything still there', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + stripe.route(`DELETE /v1/subscriptions/sub_${UID}`, () => stripeRefusal(500, 'try again')); + stripe.route(`GET /v1/subscriptions/sub_${UID}`, subscription(`sub_${UID}`, { customer: CUSTOMER, created: 1 })); + const before = await tracesOf(UID); + + await expect(deleteAccountOf(UID, { stripe: config(), now: NOW })).rejects.toThrow(/Stripe answered 500/); + + expect(await tracesOf(UID)).toEqual(before); + }); + + test('a customer Stripe will not delete does not stop it', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + stripe.route(`DELETE /v1/customers/${CUSTOMER}`, () => stripeRefusal(500, 'try again')); + + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + }); + + test('an account that never subscribed asks nothing of Stripe', async () => { + await getAuth().createUser({ uid: UID, email: `${UID}@example.com` }); + await db() + .collection('users') + .doc(UID) + .set({ email: `${UID}@example.com`, fcmTokens: ['t'] }); + + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + + expect(stripe.requests).toHaveLength(0); + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + }); + + test('a second call for the same account succeeds and changes nothing', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + const later = new Date(NOW.getTime() + 60_000); + + await expect(deleteAccountOf(UID, { stripe: config(), now: later })).resolves.toBeUndefined(); + + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + // The customer's row went with the first call, so Stripe is not asked a second time. + expect(stripe.made(`DELETE /v1/customers/${CUSTOMER}`)).toHaveLength(1); + }); + + test('a call that stopped halfway is finished by the next', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + const failing = { + revokeRefreshTokens: () => Promise.reject(new Error('the Auth service is down')), + deleteUser: () => Promise.resolve(), + }; + await expect(deleteAccountOf(UID, { stripe: config(), now: NOW, auth: failing })).rejects.toThrow(/is down/); + // Stopped after Stripe and before the data: the person can still sign in and ask again. + expect(await tracesOf(UID)).toContain(`auth:${UID}`); + + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + }); + + test('a retry after Stripe has already deleted the customer goes through', async () => { + await seedAccount(UID, CUSTOMER); + // What Stripe says of a customer it no longer has, for the lists and the delete alike. + const missing = () => ({ + status: 404, + body: { error: { type: 'invalid_request_error', code: 'resource_missing', message: 'No such customer' } }, + }); + stripe.route('GET /v1/checkout/sessions', missing); + stripe.route('GET /v1/subscriptions', missing); + stripe.route(`DELETE /v1/customers/${CUSTOMER}`, missing); + + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + }); + + test('every session is ended: the refresh tokens are revoked before the user is deleted', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + const calls: string[] = []; + const auth = { + revokeRefreshTokens: async (uid: string) => void calls.push(`revoke ${uid}`), + deleteUser: async (uid: string) => { + calls.push(`delete ${uid}`); + await getAuth().deleteUser(uid); + }, + }; + + await deleteAccountOf(UID, { stripe: config(), now: NOW, auth }); + + expect(calls).toEqual([`revoke ${UID}`, `delete ${UID}`]); + }); +}); + +describe('finishDeletions', () => { + test('removes what a device still signed in wrote after the deletion', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + // An ID token lives up to an hour past the deletion, and the rules still take it. + await db() + .collection('users') + .doc(UID) + .set({ fcmTokens: ['token-written-late'] }); + await db().collection('pushes').add({ userId: UID, type: 'url', createdAt: NOW }); + await bucket().file(`uploads/${UID}/late`).save(Buffer.from('x')); + + expect(await finishDeletions(settled(NOW))).toEqual([UID]); + + expect(await tracesOf(UID)).toEqual([]); + }); + + test('ends a subscription bought with a token that outlived the deletion', async () => { + await getAuth().createUser({ uid: UID, email: `${UID}@example.com` }); + await db() + .collection('users') + .doc(UID) + .set({ email: `${UID}@example.com` }); + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + // A checkout that slipped in and was paid: a customer row for the deleted account. + await db().collection('stripeCustomers').doc('cus_late').set({ userId: UID, createdAt: NOW }); + stripeHasCustomer('cus_late', 'sub_late'); + + expect(await finishDeletions(settled(NOW), { stripe: config() })).toEqual([UID]); + + expect(stripe.made('DELETE /v1/subscriptions/sub_late')).toHaveLength(1); + expect(stripe.made('DELETE /v1/customers/cus_late')).toHaveLength(1); + expect(await tracesOf(UID)).toEqual([]); + }); + + test('keeps the marker, to try again, when that subscription cannot be cancelled', async () => { + await getAuth().createUser({ uid: UID, email: `${UID}@example.com` }); + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + await db().collection('stripeCustomers').doc('cus_late').set({ userId: UID, createdAt: NOW }); + stripeHasCustomer('cus_late', 'sub_late'); + stripe.route('DELETE /v1/subscriptions/sub_late', () => stripeRefusal(500, 'try again')); + stripe.route('GET /v1/subscriptions/sub_late', subscription('sub_late', { customer: 'cus_late', created: 1 })); + + expect(await finishDeletions(settled(NOW), { stripe: config() })).toEqual([]); + + expect(await tracesOf(UID)).toContain(`accountDeletions/${UID}`); + }); + + test('deletes the Auth user when the first call stopped before it got there', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + const stopsAtTheUser = { + revokeRefreshTokens: (uid: string) => getAuth().revokeRefreshTokens(uid), + deleteUser: () => Promise.reject(new Error('the Auth service is down')), + }; + await expect(deleteAccountOf(UID, { stripe: config(), now: NOW, auth: stopsAtTheUser })).rejects.toThrow(/is down/); + // Nobody asks again: the marker alone is what gets the rest done. + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`, `auth:${UID}`]); + + expect(await finishDeletions(settled(NOW), { stripe: config() })).toEqual([UID]); + + expect(await tracesOf(UID)).toEqual([]); + }); + + test('leaves a deletion whose tokens may still be alive', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + + expect(await finishDeletions(new Date(NOW.getTime() + 30 * 60 * 1000))).toEqual([]); + + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + }); +}); + +describe('deleteAccount', () => { + const nowSeconds = () => Math.floor(Date.now() / 1000); + + /** The refusal `promise` ends in, as `code: message`. */ + async function refusal(promise: Promise<unknown>): Promise<string> { + try { + await promise; + } catch (err) { + if (err instanceof HttpsError) return `${err.code}: ${err.message}`; + throw err; + } + throw new Error('expected a refusal'); + } + + test('refuses a caller who is not signed in', async () => { + expect(await refusal(call(deleteAccount, null))).toMatch(/^unauthenticated/); + }); + + test('refuses a session whose sign-in is old, and deletes nothing', async () => { + await seedAccount(UID, CUSTOMER); + const before = await tracesOf(UID); + + const stale = { auth_time: nowSeconds() - FRESH_SIGN_IN_S - 60 }; + expect(await refusal(call(deleteAccount, UID, {}, stale))).toMatch(/^failed-precondition: Enter your password/); + // A token with no sign-in time at all is refused the same way. + expect(await refusal(call(deleteAccount, UID))).toMatch(/^failed-precondition/); + + expect(await tracesOf(UID)).toEqual(before); + expect(stripe.requests).toHaveLength(0); + }); + + test('deletes the account of a caller who has just signed in, and answers the same a second time', async () => { + await seedAccount(UID, CUSTOMER); + await seedAccount(OTHER, OTHER_CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + const fresh = { auth_time: nowSeconds() - 3 }; + + expect(await call(deleteAccount, UID, {}, fresh)).toEqual({ deleted: true }); + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + expect(await tracesOf(OTHER)).not.toEqual([]); + + // The deleted account's last ID token is good for up to an hour more. + expect(await call(deleteAccount, UID, {}, fresh)).toEqual({ deleted: true }); + }); + + test('says so, and deletes nothing, when the subscription cannot be cancelled', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + stripe.route(`DELETE /v1/subscriptions/sub_${UID}`, () => stripeRefusal(500, 'try again')); + stripe.route(`GET /v1/subscriptions/sub_${UID}`, subscription(`sub_${UID}`, { customer: CUSTOMER, created: 1 })); + const before = await tracesOf(UID); + + expect(await refusal(call(deleteAccount, UID, {}, { auth_time: nowSeconds() }))).toMatch( + /^unavailable: Your subscription could not be cancelled/ + ); + + expect(await tracesOf(UID)).toEqual(before); + }); + + describe('with billing off', () => { + const emulator = process.env.FUNCTIONS_EMULATOR; + afterEach(() => { + if (emulator === undefined) delete process.env.FUNCTIONS_EMULATOR; + else process.env.FUNCTIONS_EMULATOR = emulator; + }); + + test('on the emulator it deletes the account and asks nothing of Stripe', async () => { + billingOff(); + process.env.FUNCTIONS_EMULATOR = 'true'; + await seedAccount(UID, CUSTOMER); + + expect(await call(deleteAccount, UID, {}, { auth_time: nowSeconds() })).toEqual({ deleted: true }); + + expect(stripe.requests).toHaveLength(0); + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + }); + + test('deployed, it refuses an account with a customer, and deletes nothing', async () => { + billingOff(); + delete process.env.FUNCTIONS_EMULATOR; + await seedAccount(UID, CUSTOMER); + const before = await tracesOf(UID); + + expect(await refusal(call(deleteAccount, UID, {}, { auth_time: nowSeconds() }))).toMatch( + /^unavailable: Your subscription could not be cancelled/ + ); + + expect(await tracesOf(UID)).toEqual(before); + }); + + test('deployed, an account that never subscribed is still deleted', async () => { + billingOff(); + delete process.env.FUNCTIONS_EMULATOR; + await getAuth().createUser({ uid: UID, email: `${UID}@example.com` }); + await db() + .collection('users') + .doc(UID) + .set({ email: `${UID}@example.com` }); + + expect(await call(deleteAccount, UID, {}, { auth_time: nowSeconds() })).toEqual({ deleted: true }); + }); + }); +}); diff --git a/functions/test/emulator/check-ports.mjs b/functions/test/emulator/check-ports.mjs index e113105..351a454 100644 --- a/functions/test/emulator/check-ports.mjs +++ b/functions/test/emulator/check-ports.mjs @@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs'; import { createServer } from 'node:net'; const config = JSON.parse(readFileSync(new URL('../../../firebase.json', import.meta.url), 'utf8')); -const ports = ['firestore', 'storage'].map((name) => [name, config.emulators?.[name]?.port]).filter(([, p]) => p); +const ports = ['auth', 'firestore', 'storage'].map((name) => [name, config.emulators?.[name]?.port]).filter(([, p]) => p); const taken = []; for (const [name, port] of ports) { diff --git a/functions/test/emulator/harness.ts b/functions/test/emulator/harness.ts index 0685888..5ef6eb6 100644 --- a/functions/test/emulator/harness.ts +++ b/functions/test/emulator/harness.ts @@ -49,6 +49,17 @@ export async function clearFirestore(): Promise<void> { if (!res.ok) throw new Error(`clearing the emulator answered ${res.status}`); } +/** + * Deletes every user in the Auth emulator, which `test:emulator` starts beside the other + * two and the Admin SDK reaches through `FIREBASE_AUTH_EMULATOR_HOST`. + */ +export async function clearAuth(): Promise<void> { + const host = process.env.FIREBASE_AUTH_EMULATOR_HOST; + if (!host) throw new Error('FIREBASE_AUTH_EMULATOR_HOST is not set: run these with `npm run test:emulator`.'); + const res = await fetch(`http://${host}/emulator/v1/projects/${PROJECT}/accounts`, { method: 'DELETE' }); + if (!res.ok) throw new Error(`clearing the Auth emulator answered ${res.status}`); +} + /** Billing on, against `stripe`. */ export function billingOn(stripe: StripeStandIn): void { process.env.STRIPE_SECRET_KEY = 'sk_test_emulator'; @@ -231,13 +242,21 @@ export async function deliver( // --- Callables ------------------------------------------------------------------------------ -/** Calls an `onCall` function as the signed-in `uid`, or signed out with `uid` null. */ -export function call<T>(fn: CallableFunction<unknown, Promise<T>>, uid: string | null, data: unknown = {}): Promise<T> { +/** + * Calls an `onCall` function as the signed-in `uid`, or signed out with `uid` null. + * `claims` are further fields of the ID token, such as `auth_time`. + */ +export function call<T>( + fn: CallableFunction<unknown, Promise<T>>, + uid: string | null, + data: unknown = {}, + claims: Record<string, unknown> = {} +): Promise<T> { const request = { data, rawRequest: {}, acceptsStreaming: false, - ...(uid ? { auth: { uid, token: { uid, email: `${uid}@example.com` }, rawToken: 'token' } } : {}), + ...(uid ? { auth: { uid, token: { uid, email: `${uid}@example.com`, ...claims }, rawToken: 'token' } } : {}), } as unknown as CallableRequest<unknown>; return fn.run(request) as Promise<T>; } diff --git a/functions/test/emulator/stripe-callables.test.ts b/functions/test/emulator/stripe-callables.test.ts index ca501be..7c272e2 100644 --- a/functions/test/emulator/stripe-callables.test.ts +++ b/functions/test/emulator/stripe-callables.test.ts @@ -191,6 +191,13 @@ describe('refreshSubscription', () => { }); describe('createCheckoutSession', () => { + test('an account that has been deleted cannot open one, though its token still works', async () => { + await db().collection('accountDeletions').doc(UID).set({ deletedAt: new Date(), settleAt: new Date() }); + + expect(await refusal(call(createCheckoutSession, UID))).toBe('failed-precondition: This account has been deleted.'); + expect(stripe.requests).toHaveLength(0); + }); + const checkout = (uid: string | null = UID) => call(createCheckoutSession, uid) as Promise<{ url: string }>; /** Stripe as a new customer finds it: no subscriptions, and a session made on asking. */