From 4b8abe7debbc925f30250e07497281a85486fe09 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Wed, 2 Sep 2026 10:39:15 -0700 Subject: [PATCH] Refresh auth token on download 401 --- .../app/managers/DownloadItemManager.kt | 98 +++++++++-- .../app/managers/InternalDownloadManager.kt | 5 + .../app/models/DownloadItemPart.kt | 1 + .../audiobookshelf/app/server/ApiHandler.kt | 166 +++++++++--------- 4 files changed, 173 insertions(+), 97 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt index cdf553f7..6203b443 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt @@ -39,6 +39,8 @@ class DownloadItemManager( private val reservations = mutableMapOf() private val lastPersistTime = mutableMapOf() private val finalizingItems = mutableSetOf() + private val refreshingServerIds = mutableSetOf() + private val apiHandler = ApiHandler(context) private var watcherRunning = false private val jacksonMapper = jacksonObjectMapper() @@ -59,6 +61,7 @@ class DownloadItemManager( interface InternalProgressCallback { fun onProgress(totalBytesWritten: Long, progress: Long) fun onComplete(failed: Boolean) + fun onAuthError() } @Synchronized @@ -280,6 +283,13 @@ class DownloadItemManager( persist(item, force = true) } } + + override fun onAuthError() { + synchronized(this@DownloadItemManager) { + if (part !in currentDownloadItemParts) return + handleAuthError(item, part) + } + } }, { hasAvailableSpace(part) } ) @@ -341,16 +351,7 @@ class DownloadItemManager( part.retryCount += 1 reservations.remove(part.destinationPath) if (part.retryCount > MAX_RETRIES) { - AbsLogger.error(tag, "$reason after $MAX_RETRIES retries: ${part.filename}") - part.failed = true - part.completed = false - part.downloadId = null - item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis() - item.stagingCleanupAt = null - persist(item, force = true) - IncompleteDownloadCleanup.schedule(context, item) - clientEventEmitter.onDownloadItemPartUpdate(part) - notifyQueueChanged() + markTerminalFailure(item, part, "$reason after $MAX_RETRIES retries") return } part.failed = false @@ -361,6 +362,81 @@ class DownloadItemManager( clientEventEmitter.onDownloadItemPartUpdate(part) } + /** A 401 refreshes the token for this queued item's server without consuming transfer retries. */ + @Synchronized + private fun handleAuthError(item: DownloadItem, part: DownloadItemPart) { + removeActivePart(part) + reservations.remove(part.destinationPath) + part.downloadId = null + part.isMoving = false + part.failed = false + part.completed = false + part.authRetryCount += 1 + part.lastUpdateTime = System.currentTimeMillis() + if (part.authRetryCount > MAX_AUTH_RETRIES) { + markTerminalFailure(item, part, "Unauthorized after $MAX_AUTH_RETRIES token refresh attempts") + return + } + + AbsLogger.info( + tag, + "Refreshing token after 401 for ${part.filename} (attempt ${part.authRetryCount})" + ) + persist(item, force = true) + clientEventEmitter.onDownloadItemPartUpdate(part) + refreshTokenThenResume(item.serverConnectionConfigId) + } + + private fun refreshTokenThenResume(serverConnectionConfigId: String) { + if (!refreshingServerIds.add(serverConnectionConfigId)) return + apiHandler.refreshAuthTokens(serverConnectionConfigId) { newAccessToken -> + synchronized(this@DownloadItemManager) { + refreshingServerIds.remove(serverConnectionConfigId) + if (newAccessToken.isNullOrEmpty()) { + failParkedAuthParts(serverConnectionConfigId) + } else { + AbsLogger.info(tag, "Token refresh succeeded; resuming downloads for $serverConnectionConfigId") + checkUpdateDownloadQueue() + } + } + } + } + + @Synchronized + private fun failParkedAuthParts(serverConnectionConfigId: String) { + downloadItemQueue.toList().forEach { item -> + if (item.serverConnectionConfigId != serverConnectionConfigId) return@forEach + item.downloadItemParts + .filter { + it.authRetryCount > 0 && + !it.completed && + !it.failed && + it.downloadId == null && + it !in currentDownloadItemParts + } + .forEach { part -> + markTerminalFailure(item, part, "Unable to refresh download authorization") + } + } + } + + @Synchronized + private fun markTerminalFailure(item: DownloadItem, part: DownloadItemPart, reason: String) { + AbsLogger.error(tag, "$reason: ${part.filename}") + removeActivePart(part) + reservations.remove(part.destinationPath) + part.failed = true + part.completed = false + part.downloadId = null + part.isMoving = false + item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis() + item.stagingCleanupAt = null + persist(item, force = true) + IncompleteDownloadCleanup.schedule(context, item) + clientEventEmitter.onDownloadItemPartUpdate(part) + notifyQueueChanged() + } + private fun finalizeInternalFile(item: DownloadItem, part: DownloadItemPart) { if (part.moved || part.isMoving) return part.isMoving = true @@ -634,6 +710,7 @@ class DownloadItemManager( part.isMoving = false part.downloadId = null part.retryCount = 0 + part.authRetryCount = 0 part.waitingForSpace = false part.reusedExistingFile = false return true @@ -673,6 +750,7 @@ class DownloadItemManager( const val WATCH_INTERVAL_MS = 1_000L const val STALL_TIMEOUT_MS = 60_000L const val MAX_RETRIES = 5 + const val MAX_AUTH_RETRIES = 2 const val PERSIST_INTERVAL_MS = 2_000L const val MIN_FREE_SPACE_BYTES = 100L * 1024L * 1024L const val UNKNOWN_PART_RESERVATION_BYTES = 100L * 1024L * 1024L diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt index a8994653..2fb47bee 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt @@ -97,6 +97,11 @@ class InternalDownloadManager( override fun onResponse(call: Call, response: Response) { response.use { try { + if (response.code == 401) { + AbsLogger.error(tag, "Download unauthorized (401) for ${destinationFile.name}") + progressCallback.onAuthError() + return + } if (response.code == 416) { val serverSize = response.header("Content-Range") diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt index 863a0192..737c81b3 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt @@ -38,6 +38,7 @@ data class DownloadItemPart( var progress: Long, var bytesDownloaded: Long, @JsonIgnore var retryCount: Int = 0, + @JsonIgnore var authRetryCount: Int = 0, @JsonIgnore var waitingForSpace: Boolean = false, @JsonIgnore var reusedExistingFile: Boolean = false ) { diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index 9e123d8f..7b29b4d2 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -174,98 +174,90 @@ class ApiHandler(var ctx:Context) { * @param callback The callback to return the response */ private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) { - try { - AbsLogger.info(tag, "handleTokenRefresh: Attempting to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString}") + val serverConnectionConfigId = DeviceManager.serverConnectionConfigId + refreshAuthTokens(serverConnectionConfigId, httpClient) { newAccessToken -> + if (newAccessToken.isNullOrEmpty()) { + handleRefreshFailure(callback) + } else { + retryOriginalRequest(originalRequest, newAccessToken, httpClient, callback) + } + } + } - // Get current server connection config ID - val serverConnectionConfigId = DeviceManager.serverConnectionConfigId - if (serverConnectionConfigId.isEmpty()) { - AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No server connection config ID") - val errorObj = JSObject() - errorObj.put("error", "No server connection available") - callback(errorObj) - return + /** Refreshes tokens for a specific saved server, including downloads queued while another server is active. */ + fun refreshAuthTokens( + serverConnectionConfigId: String, + httpClient: OkHttpClient? = null, + onResult: (String?) -> Unit + ) { + val config = DeviceManager.getServerConnectionConfig(serverConnectionConfigId) + val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId) + if (config == null || refreshToken.isNullOrEmpty()) { + AbsLogger.error(tag, "No refresh token or server configuration for $serverConnectionConfigId") + handleDownloadRefreshFailure(serverConnectionConfigId) + onResult(null) + return + } + val request = try { + Request.Builder() + .url("${config.address}/auth/refresh") + .addHeader("x-refresh-token", refreshToken) + .addHeader("Content-Type", "application/json") + .post(EMPTY_REQUEST) + .build() + } catch (e: Exception) { + AbsLogger.error(tag, "Could not create refresh request for ${config.name}: ${e.message}") + handleDownloadRefreshFailure(serverConnectionConfigId) + onResult(null) + return + } + (httpClient ?: defaultClient).newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + AbsLogger.error(tag, "Token refresh failed for ${config.name}: ${e.message}") + handleDownloadRefreshFailure(serverConnectionConfigId) + onResult(null) } - // Get refresh token from secure storage - val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId) - if (refreshToken.isNullOrEmpty()) { - AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No refresh token available for server ${DeviceManager.serverConnectionConfigString}") - val errorObj = JSObject() - errorObj.put("error", "No refresh token available") - callback(errorObj) - return - } - - Log.d(tag, "handleTokenRefresh: Retrieved refresh token, attempting to refresh access token") - - // Create refresh token request - val refreshEndpoint = "${DeviceManager.serverAddress}/auth/refresh" - val refreshRequest = Request.Builder() - .url(refreshEndpoint) - .addHeader("x-refresh-token", refreshToken) - .addHeader("Content-Type", "application/json") - .post(EMPTY_REQUEST) - .build() - - // Make the refresh request - val client = httpClient ?: defaultClient - client.newCall(refreshRequest).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - Log.e(tag, "handleTokenRefresh: Failed to connect to refresh endpoint", e) - AbsLogger.error(tag, "handleTokenRefresh: Failed to connect to refresh endpoint for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})") - handleRefreshFailure(callback) - } - - override fun onResponse(call: Call, response: Response) { - response.use { - if (!it.isSuccessful) { - AbsLogger.error(tag, "handleTokenRefresh: Refresh request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}") - handleRefreshFailure(callback) + override fun onResponse(call: Call, response: Response) { + response.use { + if (!it.isSuccessful) { + AbsLogger.error(tag, "Token refresh returned ${it.code} for ${config.name}") + handleDownloadRefreshFailure(serverConnectionConfigId) + onResult(null) + return + } + try { + val user = JSONObject(it.body!!.string()).optJSONObject("user") + val accessToken = user?.optString("accessToken").orEmpty() + if (accessToken.isEmpty()) { + AbsLogger.error(tag, "Refresh response had no access token for ${config.name}") + handleDownloadRefreshFailure(serverConnectionConfigId) + onResult(null) return } - - val bodyString = it.body!!.string() - try { - val responseJson = JSONObject(bodyString) - val userObj = responseJson.optJSONObject("user") - - if (userObj == null) { - AbsLogger.error(tag, "handleTokenRefresh: No user object in refresh response for server ${DeviceManager.serverConnectionConfigString}") - handleRefreshFailure(callback) - return - } - - val newAccessToken = userObj.optString("accessToken") - val newRefreshToken = userObj.optString("refreshToken") - - if (newAccessToken.isEmpty()) { - AbsLogger.error(tag, "handleTokenRefresh: No access token in refresh response for server ${DeviceManager.serverConnectionConfigString}") - handleRefreshFailure(callback) - return - } - - Log.d(tag, "handleTokenRefresh: Successfully obtained new access token") - - // Update tokens in secure storage and device manager - updateTokens(newAccessToken, newRefreshToken.ifEmpty { refreshToken }, serverConnectionConfigId) - - // Retry the original request with the new access token - Log.d(tag, "handleTokenRefresh: Retrying original request with new token") - retryOriginalRequest(originalRequest, newAccessToken, httpClient, callback) - - } catch (e: Exception) { - Log.e(tag, "handleTokenRefresh: Failed to parse refresh response", e) - AbsLogger.error(tag, "handleTokenRefresh: Failed to parse refresh response for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})") - handleRefreshFailure(callback) - } + updateTokens(accessToken, user?.optString("refreshToken").orEmpty().ifEmpty { refreshToken }, serverConnectionConfigId) + onResult(accessToken) + } catch (e: Exception) { + AbsLogger.error(tag, "Could not parse refresh response for ${config.name}: ${e.message}") + handleDownloadRefreshFailure(serverConnectionConfigId) + onResult(null) } } - }) + } + }) + } - } catch (e: Exception) { - Log.e(tag, "handleTokenRefresh: Unexpected error during token refresh", e) - handleRefreshFailure(callback) + /** Clears only the server whose refresh token failed; queued downloads can target a non-active server. */ + private fun handleDownloadRefreshFailure(serverConnectionConfigId: String) { + secureStorage.removeRefreshToken(serverConnectionConfigId) + if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) return + DeviceManager.serverConnectionConfig = null + DeviceManager.deviceData.lastServerConnectionConfigId = null + DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) + if (checkAbsDatabaseNotifyListenersInitted()) { + absDatabaseNotifyListeners( + "onTokenRefreshFailure", + JSObject().put("error", "Token refresh failed").put("serverConnectionConfigId", serverConnectionConfigId)) } } @@ -283,15 +275,15 @@ class ApiHandler(var ctx:Context) { Log.d(tag, "updateTokens: Updated refresh token in secure storage") } - // Update the access token in the current server connection config - DeviceManager.serverConnectionConfig?.let { config -> + // The refreshed connection may be queued in the downloader rather than currently active. + DeviceManager.getServerConnectionConfig(serverConnectionConfigId)?.let { config -> config.token = newAccessToken DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) Log.d(tag, "updateTokens: Updated access token in server connection config") } // Send access token to Webview frontend - if (checkAbsDatabaseNotifyListenersInitted()) { + if (DeviceManager.serverConnectionConfigId == serverConnectionConfigId && checkAbsDatabaseNotifyListenersInitted()) { val tokenJsObject = JSObject() tokenJsObject.put("accessToken", newAccessToken) absDatabaseNotifyListeners("onTokenRefresh", tokenJsObject)