mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-09-10 03:42:00 +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 reservations = mutableMapOf<String, Long>()
|
||||||
private val lastPersistTime = mutableMapOf<String, Long>()
|
private val lastPersistTime = mutableMapOf<String, Long>()
|
||||||
private val finalizingItems = mutableSetOf<String>()
|
private val finalizingItems = mutableSetOf<String>()
|
||||||
|
private val refreshingServerIds = mutableSetOf<String>()
|
||||||
|
private val apiHandler = ApiHandler(context)
|
||||||
private var watcherRunning = false
|
private var watcherRunning = false
|
||||||
private val jacksonMapper =
|
private val jacksonMapper =
|
||||||
jacksonObjectMapper()
|
jacksonObjectMapper()
|
||||||
@@ -59,6 +61,7 @@ class DownloadItemManager(
|
|||||||
interface InternalProgressCallback {
|
interface InternalProgressCallback {
|
||||||
fun onProgress(totalBytesWritten: Long, progress: Long)
|
fun onProgress(totalBytesWritten: Long, progress: Long)
|
||||||
fun onComplete(failed: Boolean)
|
fun onComplete(failed: Boolean)
|
||||||
|
fun onAuthError()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@@ -280,6 +283,13 @@ class DownloadItemManager(
|
|||||||
persist(item, force = true)
|
persist(item, force = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onAuthError() {
|
||||||
|
synchronized(this@DownloadItemManager) {
|
||||||
|
if (part !in currentDownloadItemParts) return
|
||||||
|
handleAuthError(item, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ hasAvailableSpace(part) }
|
{ hasAvailableSpace(part) }
|
||||||
)
|
)
|
||||||
@@ -341,16 +351,7 @@ class DownloadItemManager(
|
|||||||
part.retryCount += 1
|
part.retryCount += 1
|
||||||
reservations.remove(part.destinationPath)
|
reservations.remove(part.destinationPath)
|
||||||
if (part.retryCount > MAX_RETRIES) {
|
if (part.retryCount > MAX_RETRIES) {
|
||||||
AbsLogger.error(tag, "$reason after $MAX_RETRIES retries: ${part.filename}")
|
markTerminalFailure(item, part, "$reason after $MAX_RETRIES retries")
|
||||||
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()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
part.failed = false
|
part.failed = false
|
||||||
@@ -361,6 +362,81 @@ class DownloadItemManager(
|
|||||||
clientEventEmitter.onDownloadItemPartUpdate(part)
|
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) {
|
private fun finalizeInternalFile(item: DownloadItem, part: DownloadItemPart) {
|
||||||
if (part.moved || part.isMoving) return
|
if (part.moved || part.isMoving) return
|
||||||
part.isMoving = true
|
part.isMoving = true
|
||||||
@@ -634,6 +710,7 @@ class DownloadItemManager(
|
|||||||
part.isMoving = false
|
part.isMoving = false
|
||||||
part.downloadId = null
|
part.downloadId = null
|
||||||
part.retryCount = 0
|
part.retryCount = 0
|
||||||
|
part.authRetryCount = 0
|
||||||
part.waitingForSpace = false
|
part.waitingForSpace = false
|
||||||
part.reusedExistingFile = false
|
part.reusedExistingFile = false
|
||||||
return true
|
return true
|
||||||
@@ -673,6 +750,7 @@ class DownloadItemManager(
|
|||||||
const val WATCH_INTERVAL_MS = 1_000L
|
const val WATCH_INTERVAL_MS = 1_000L
|
||||||
const val STALL_TIMEOUT_MS = 60_000L
|
const val STALL_TIMEOUT_MS = 60_000L
|
||||||
const val MAX_RETRIES = 5
|
const val MAX_RETRIES = 5
|
||||||
|
const val MAX_AUTH_RETRIES = 2
|
||||||
const val PERSIST_INTERVAL_MS = 2_000L
|
const val PERSIST_INTERVAL_MS = 2_000L
|
||||||
const val MIN_FREE_SPACE_BYTES = 100L * 1024L * 1024L
|
const val MIN_FREE_SPACE_BYTES = 100L * 1024L * 1024L
|
||||||
const val UNKNOWN_PART_RESERVATION_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) {
|
override fun onResponse(call: Call, response: Response) {
|
||||||
response.use {
|
response.use {
|
||||||
try {
|
try {
|
||||||
|
if (response.code == 401) {
|
||||||
|
AbsLogger.error(tag, "Download unauthorized (401) for ${destinationFile.name}")
|
||||||
|
progressCallback.onAuthError()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (response.code == 416) {
|
if (response.code == 416) {
|
||||||
val serverSize =
|
val serverSize =
|
||||||
response.header("Content-Range")
|
response.header("Content-Range")
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ data class DownloadItemPart(
|
|||||||
var progress: Long,
|
var progress: Long,
|
||||||
var bytesDownloaded: Long,
|
var bytesDownloaded: Long,
|
||||||
@JsonIgnore var retryCount: Int = 0,
|
@JsonIgnore var retryCount: Int = 0,
|
||||||
|
@JsonIgnore var authRetryCount: Int = 0,
|
||||||
@JsonIgnore var waitingForSpace: Boolean = false,
|
@JsonIgnore var waitingForSpace: Boolean = false,
|
||||||
@JsonIgnore var reusedExistingFile: Boolean = false
|
@JsonIgnore var reusedExistingFile: Boolean = false
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -174,98 +174,90 @@ class ApiHandler(var ctx:Context) {
|
|||||||
* @param callback The callback to return the response
|
* @param callback The callback to return the response
|
||||||
*/
|
*/
|
||||||
private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
|
private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
|
||||||
try {
|
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
||||||
AbsLogger.info(tag, "handleTokenRefresh: Attempting to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString}")
|
refreshAuthTokens(serverConnectionConfigId, httpClient) { newAccessToken ->
|
||||||
|
if (newAccessToken.isNullOrEmpty()) {
|
||||||
|
handleRefreshFailure(callback)
|
||||||
|
} else {
|
||||||
|
retryOriginalRequest(originalRequest, newAccessToken, httpClient, callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get current server connection config ID
|
/** Refreshes tokens for a specific saved server, including downloads queued while another server is active. */
|
||||||
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
fun refreshAuthTokens(
|
||||||
if (serverConnectionConfigId.isEmpty()) {
|
serverConnectionConfigId: String,
|
||||||
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No server connection config ID")
|
httpClient: OkHttpClient? = null,
|
||||||
val errorObj = JSObject()
|
onResult: (String?) -> Unit
|
||||||
errorObj.put("error", "No server connection available")
|
) {
|
||||||
callback(errorObj)
|
val config = DeviceManager.getServerConnectionConfig(serverConnectionConfigId)
|
||||||
return
|
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
|
override fun onResponse(call: Call, response: Response) {
|
||||||
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
|
response.use {
|
||||||
if (refreshToken.isNullOrEmpty()) {
|
if (!it.isSuccessful) {
|
||||||
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No refresh token available for server ${DeviceManager.serverConnectionConfigString}")
|
AbsLogger.error(tag, "Token refresh returned ${it.code} for ${config.name}")
|
||||||
val errorObj = JSObject()
|
handleDownloadRefreshFailure(serverConnectionConfigId)
|
||||||
errorObj.put("error", "No refresh token available")
|
onResult(null)
|
||||||
callback(errorObj)
|
return
|
||||||
return
|
}
|
||||||
}
|
try {
|
||||||
|
val user = JSONObject(it.body!!.string()).optJSONObject("user")
|
||||||
Log.d(tag, "handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
|
val accessToken = user?.optString("accessToken").orEmpty()
|
||||||
|
if (accessToken.isEmpty()) {
|
||||||
// Create refresh token request
|
AbsLogger.error(tag, "Refresh response had no access token for ${config.name}")
|
||||||
val refreshEndpoint = "${DeviceManager.serverAddress}/auth/refresh"
|
handleDownloadRefreshFailure(serverConnectionConfigId)
|
||||||
val refreshRequest = Request.Builder()
|
onResult(null)
|
||||||
.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)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
updateTokens(accessToken, user?.optString("refreshToken").orEmpty().ifEmpty { refreshToken }, serverConnectionConfigId)
|
||||||
val bodyString = it.body!!.string()
|
onResult(accessToken)
|
||||||
try {
|
} catch (e: Exception) {
|
||||||
val responseJson = JSONObject(bodyString)
|
AbsLogger.error(tag, "Could not parse refresh response for ${config.name}: ${e.message}")
|
||||||
val userObj = responseJson.optJSONObject("user")
|
handleDownloadRefreshFailure(serverConnectionConfigId)
|
||||||
|
onResult(null)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
/** Clears only the server whose refresh token failed; queued downloads can target a non-active server. */
|
||||||
Log.e(tag, "handleTokenRefresh: Unexpected error during token refresh", e)
|
private fun handleDownloadRefreshFailure(serverConnectionConfigId: String) {
|
||||||
handleRefreshFailure(callback)
|
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")
|
Log.d(tag, "updateTokens: Updated refresh token in secure storage")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the access token in the current server connection config
|
// The refreshed connection may be queued in the downloader rather than currently active.
|
||||||
DeviceManager.serverConnectionConfig?.let { config ->
|
DeviceManager.getServerConnectionConfig(serverConnectionConfigId)?.let { config ->
|
||||||
config.token = newAccessToken
|
config.token = newAccessToken
|
||||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||||
Log.d(tag, "updateTokens: Updated access token in server connection config")
|
Log.d(tag, "updateTokens: Updated access token in server connection config")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send access token to Webview frontend
|
// Send access token to Webview frontend
|
||||||
if (checkAbsDatabaseNotifyListenersInitted()) {
|
if (DeviceManager.serverConnectionConfigId == serverConnectionConfigId && checkAbsDatabaseNotifyListenersInitted()) {
|
||||||
val tokenJsObject = JSObject()
|
val tokenJsObject = JSObject()
|
||||||
tokenJsObject.put("accessToken", newAccessToken)
|
tokenJsObject.put("accessToken", newAccessToken)
|
||||||
absDatabaseNotifyListeners("onTokenRefresh", tokenJsObject)
|
absDatabaseNotifyListeners("onTokenRefresh", tokenJsObject)
|
||||||
|
|||||||
Reference in New Issue
Block a user