Refresh auth token on download 401

This commit is contained in:
Nicholas Wallace
2026-09-02 10:39:15 -07:00
parent d230b16d5e
commit 4b8abe7deb
4 changed files with 173 additions and 97 deletions
@@ -39,6 +39,8 @@ class DownloadItemManager(
private val reservations = mutableMapOf<String, Long>()
private val lastPersistTime = mutableMapOf<String, Long>()
private val finalizingItems = mutableSetOf<String>()
private val refreshingServerIds = mutableSetOf<String>()
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
@@ -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")
@@ -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
) {
@@ -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)