Merge pull request #535 from barrydeen/fix/google-drive-401-refresh
fix(auth): refresh stale Drive access token on 401 and re-prompt for consent
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user