mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-09-08 19:01:50 +02:00
Refresh auth token on download 401
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user