fix(auth): refresh stale Drive access token on 401 and re-prompt for consent

After a user revokes Wisp's authorization in their Google account
settings, Play Services may still hand back the previously-issued
access token from its local cache. The Drive API then 401s and sign-in
fails with no way for the user to recover from inside the app.

Detect the 401, clear the stale token from Play Services' cache via
GoogleAuthUtil.clearToken, and re-call AuthorizationClient.authorize().
With no cached token, authorize() contacts Google's servers, sees the
revoked consent, and returns a resolution PendingIntent — surfacing the
consent dialog so the user can re-grant the drive.appdata scope. Once
they consent, we retry listBackups with the fresh token.

  - DriveBackupService throws DriveAuthorizationExpiredException on 401
    (instead of a generic IOException) and exposes the stale token so
    callers can pass it to clearToken
  - GoogleSignInManager.refreshDriveAccessToken(activity, staleToken):
    clears via GoogleAuthUtil and re-runs getDriveAccessToken (which
    handles the resolution PendingIntent the same way as initial
    sign-in)
  - GoogleAuthViewModel.listBackupsWithRefresh wraps the initial list
    call with a single retry on the expired-auth exception
This commit is contained in:
Barry Deen
2026-05-15 15:28:34 -04:00
parent 58c08a0f9a
commit b999aebd42
3 changed files with 80 additions and 2 deletions
@@ -14,6 +14,19 @@ import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.UUID
/**
* Thrown when Drive returns 401 on a request authenticated with the supplied
* access token. Caller must clear the token from the Play Services cache (via
* `GoogleAuthUtil.clearToken`) and obtain a fresh one — most commonly because
* the user revoked Wisp's authorization from their Google account settings.
*
* The stale token is exposed so the caller can pass it to `clearToken`.
*/
class DriveAuthorizationExpiredException(
val staleToken: String,
message: String
) : IOException(message)
/**
* Minimal Drive REST v3 client targeted at the user's appDataFolder.
*
@@ -27,6 +40,15 @@ class DriveBackupService(
) {
private val json = Json { ignoreUnknownKeys = true }
private fun throwIfExpired(accessToken: String, response: okhttp3.Response, op: String) {
if (response.code == 401) {
throw DriveAuthorizationExpiredException(
staleToken = accessToken,
message = "Drive $op failed: 401 (authorization expired or revoked)"
)
}
}
data class BackupFile(val fileId: String, val name: String) {
/** `npub1…` parsed from the filename, or null for the legacy unnamed backup. */
val npubFromName: String?
@@ -54,6 +76,7 @@ class DriveBackupService(
.build()
httpClient.newCall(req).execute().use { response ->
throwIfExpired(accessToken, response, "list")
if (!response.isSuccessful) {
throw IOException("Drive list failed: ${response.code} ${response.message}")
}
@@ -78,6 +101,7 @@ class DriveBackupService(
.build()
httpClient.newCall(req).execute().use { response ->
throwIfExpired(accessToken, response, "download")
if (!response.isSuccessful) {
throw IOException("Drive download failed: ${response.code} ${response.message}")
}
@@ -124,6 +148,7 @@ class DriveBackupService(
.build()
httpClient.newCall(req).execute().use { response ->
throwIfExpired(accessToken, response, "upload")
if (!response.isSuccessful) {
throw IOException("Drive upload failed: ${response.code} ${response.message}")
}
@@ -2,6 +2,7 @@ package com.wisp.app.auth
import android.app.PendingIntent
import android.content.Context
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
@@ -9,13 +10,16 @@ import androidx.credentials.CredentialManager
import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialRequest
import androidx.credentials.exceptions.GetCredentialException
import com.google.android.gms.auth.GoogleAuthUtil
import com.google.android.gms.auth.api.identity.AuthorizationRequest
import com.google.android.gms.auth.api.identity.Identity
import com.google.android.gms.common.api.Scope
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
@@ -33,6 +37,7 @@ class GoogleSignInManager(
context: Context,
private val webClientId: String
) {
private val appContext = context.applicationContext
private val credentialManager = CredentialManager.create(context)
data class GoogleAuthResult(
@@ -83,6 +88,29 @@ class GoogleSignInManager(
return parsed.id
}
/**
* Clears the stale Drive access token from Play Services' local cache,
* then re-runs the authorization flow. When the user has revoked
* Wisp's consent in their Google account settings, the local cache may
* still hold a previously-issued token that Drive now rejects with 401.
* Clearing forces `authorize()` to contact Google, which returns a
* resolution PendingIntent so the user can re-consent.
*
* Best-effort: if `clearToken` itself fails (network, Play Services
* unavailable), we still re-call `authorize()` — Play Services may
* surface the resolution anyway once the upstream auth state is checked.
*/
suspend fun refreshDriveAccessToken(activity: ComponentActivity, staleToken: String): String {
withContext(Dispatchers.IO) {
try {
GoogleAuthUtil.clearToken(appContext, staleToken)
} catch (e: Exception) {
Log.w(TAG, "GoogleAuthUtil.clearToken failed; continuing with authorize() anyway", e)
}
}
return getDriveAccessToken(activity)
}
private suspend fun getDriveAccessToken(activity: ComponentActivity): String {
val authClient = Identity.getAuthorizationClient(activity)
val request = AuthorizationRequest.Builder()
@@ -131,6 +159,7 @@ class GoogleSignInManager(
}
companion object {
private const val TAG = "GoogleSignInManager"
private const val DRIVE_APPDATA_SCOPE = "https://www.googleapis.com/auth/drive.appdata"
}
}
@@ -6,6 +6,7 @@ import androidx.activity.ComponentActivity
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.wisp.app.auth.BackupCrypto
import com.wisp.app.auth.DriveAuthorizationExpiredException
import com.wisp.app.auth.DriveBackupService
import com.wisp.app.auth.GoogleSignInException
import com.wisp.app.auth.GoogleSignInManager
@@ -97,13 +98,14 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
_state.value = State.CheckingDrive
Log.d(TAG, "state -> CheckingDrive")
val files = driveService.listBackups(result.accessToken)
val files = listBackupsWithRefresh(manager, activity)
val activeToken = pendingAccessToken ?: result.accessToken
Log.d(TAG, "listBackups returned ${files.size} file(s)")
val summaries = files.mapNotNull { file ->
val npub = file.npubFromName ?: try {
// Legacy file with no npub in the filename — decrypt to learn it.
val payload = driveService.downloadBackup(result.accessToken, file.fileId)
val payload = driveService.downloadBackup(activeToken, file.fileId)
val nsec = BackupCrypto.decryptNsec(payload, backupKey)
Nip19.npubEncode(Keys.xOnlyPubkey(nsec))
} catch (e: Exception) {
@@ -136,6 +138,28 @@ class GoogleAuthViewModel(app: Application) : AndroidViewModel(app) {
}
}
/**
* Calls `listBackups` with the pending access token; if Drive returns 401,
* clears the stale token from Play Services' cache and re-runs the
* authorization flow (which surfaces a consent prompt when the user has
* revoked Wisp's authorization), then retries once.
*/
private suspend fun listBackupsWithRefresh(
manager: GoogleSignInManager,
activity: ComponentActivity
): List<DriveBackupService.BackupFile> {
val token = pendingAccessToken ?: error("no pending access token")
return try {
driveService.listBackups(token)
} catch (e: DriveAuthorizationExpiredException) {
Log.w(TAG, "Drive returned 401; clearing stale token and re-authorizing", e)
val fresh = manager.refreshDriveAccessToken(activity, e.staleToken)
pendingAccessToken = fresh
Log.d(TAG, "refresh complete; retrying listBackups")
driveService.listBackups(fresh)
}
}
fun restoreAccount(fileId: String) {
Log.d(TAG, "restoreAccount tapped, fileId=$fileId")
val key = pendingBackupKey ?: return