mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-09-10 03:42:00 +02:00
Refactor token refresh to better handle types of failures and retries during download
This commit is contained in:
@@ -405,17 +405,26 @@ class DownloadItemManager(
|
|||||||
|
|
||||||
private fun refreshTokenThenResume(serverConnectionConfigId: String) {
|
private fun refreshTokenThenResume(serverConnectionConfigId: String) {
|
||||||
if (!refreshingServerIds.add(serverConnectionConfigId)) return
|
if (!refreshingServerIds.add(serverConnectionConfigId)) return
|
||||||
apiHandler.refreshAuthTokens(serverConnectionConfigId) { newAccessToken ->
|
apiHandler.refreshAuthTokens(serverConnectionConfigId) { result ->
|
||||||
synchronized(this@DownloadItemManager) {
|
synchronized(this@DownloadItemManager) {
|
||||||
refreshingServerIds.remove(serverConnectionConfigId)
|
refreshingServerIds.remove(serverConnectionConfigId)
|
||||||
if (newAccessToken.isNullOrEmpty()) {
|
when (result) {
|
||||||
failParkedAuthParts(serverConnectionConfigId)
|
is ApiHandler.RefreshResult.Success -> {
|
||||||
} else {
|
AbsLogger.info(
|
||||||
AbsLogger.info(
|
tag,
|
||||||
tag,
|
"Token refresh succeeded; resuming downloads for $serverConnectionConfigId"
|
||||||
"Token refresh succeeded; resuming downloads for $serverConnectionConfigId"
|
)
|
||||||
)
|
checkUpdateDownloadQueue()
|
||||||
checkUpdateDownloadQueue()
|
}
|
||||||
|
ApiHandler.RefreshResult.Rejected -> failParkedAuthParts(serverConnectionConfigId)
|
||||||
|
// Parked parts are still queued, so MAX_AUTH_RETRIES bounds the reattempts.
|
||||||
|
ApiHandler.RefreshResult.Transient -> {
|
||||||
|
AbsLogger.info(
|
||||||
|
tag,
|
||||||
|
"Token refresh could not be completed; retrying downloads for $serverConnectionConfigId"
|
||||||
|
)
|
||||||
|
checkUpdateDownloadQueue()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ class ApiHandler(var ctx:Context) {
|
|||||||
* 2. Make a request to /auth/refresh endpoint with the refresh token
|
* 2. Make a request to /auth/refresh endpoint with the refresh token
|
||||||
* 3. Update the stored tokens with the new access token
|
* 3. Update the stored tokens with the new access token
|
||||||
* 4. Retry the original request with the new access token
|
* 4. Retry the original request with the new access token
|
||||||
* 5. If refresh fails, handle logout
|
* 5. If refresh fails, fail the request ([refreshAuthTokens] owns clearing the session)
|
||||||
*
|
*
|
||||||
* @param originalRequest The original request that failed with 401
|
* @param originalRequest The original request that failed with 401
|
||||||
* @param httpClient The HTTP client to use for the request
|
* @param httpClient The HTTP client to use for the request
|
||||||
@@ -175,27 +175,37 @@ class ApiHandler(var ctx:Context) {
|
|||||||
*/
|
*/
|
||||||
private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
|
private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
|
||||||
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
||||||
refreshAuthTokens(serverConnectionConfigId, httpClient) { newAccessToken ->
|
refreshAuthTokens(serverConnectionConfigId, httpClient) { result ->
|
||||||
if (newAccessToken.isNullOrEmpty()) {
|
if (result is RefreshResult.Success) {
|
||||||
handleRefreshFailure(callback)
|
retryOriginalRequest(originalRequest, result.accessToken, httpClient, callback)
|
||||||
} else {
|
} else {
|
||||||
retryOriginalRequest(originalRequest, newAccessToken, httpClient, callback)
|
callback(JSObject().put("error", "Authentication failed - login again"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed interface RefreshResult {
|
||||||
|
data class Success(val accessToken: String) : RefreshResult
|
||||||
|
|
||||||
|
/** The server rejected the refresh token, so the session has already been cleared. */
|
||||||
|
data object Rejected : RefreshResult
|
||||||
|
|
||||||
|
/** The refresh could not be completed. Credentials are untouched and the caller may retry. */
|
||||||
|
data object Transient : RefreshResult
|
||||||
|
}
|
||||||
|
|
||||||
/** Refreshes tokens for a specific saved server, including downloads queued while another server is active. */
|
/** Refreshes tokens for a specific saved server, including downloads queued while another server is active. */
|
||||||
fun refreshAuthTokens(
|
fun refreshAuthTokens(
|
||||||
serverConnectionConfigId: String,
|
serverConnectionConfigId: String,
|
||||||
httpClient: OkHttpClient? = null,
|
httpClient: OkHttpClient? = null,
|
||||||
onResult: (String?) -> Unit
|
onResult: (RefreshResult) -> Unit
|
||||||
) {
|
) {
|
||||||
val config = DeviceManager.getServerConnectionConfig(serverConnectionConfigId)
|
val config = DeviceManager.getServerConnectionConfig(serverConnectionConfigId)
|
||||||
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
|
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
|
||||||
if (config == null || refreshToken.isNullOrEmpty()) {
|
if (config == null || refreshToken.isNullOrEmpty()) {
|
||||||
AbsLogger.error(tag, "No refresh token or server configuration for $serverConnectionConfigId")
|
AbsLogger.error(tag, "No refresh token or server configuration for $serverConnectionConfigId")
|
||||||
handleDownloadRefreshFailure(serverConnectionConfigId)
|
handleRefreshRejected(serverConnectionConfigId)
|
||||||
onResult(null)
|
onResult(RefreshResult.Rejected)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val request = try {
|
val request = try {
|
||||||
@@ -207,21 +217,25 @@ class ApiHandler(var ctx:Context) {
|
|||||||
.build()
|
.build()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
AbsLogger.error(tag, "Could not create refresh request for ${config.name}: ${e.message}")
|
AbsLogger.error(tag, "Could not create refresh request for ${config.name}: ${e.message}")
|
||||||
onResult(null)
|
onResult(RefreshResult.Transient)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
(httpClient ?: defaultClient).newCall(request).enqueue(object : Callback {
|
(httpClient ?: defaultClient).newCall(request).enqueue(object : Callback {
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
AbsLogger.error(tag, "Token refresh failed for ${config.name}: ${e.message}")
|
AbsLogger.error(tag, "Token refresh failed for ${config.name}: ${e.message}")
|
||||||
onResult(null)
|
onResult(RefreshResult.Transient)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
override fun onResponse(call: Call, response: Response) {
|
||||||
response.use {
|
response.use {
|
||||||
if (!it.isSuccessful) {
|
if (!it.isSuccessful) {
|
||||||
AbsLogger.error(tag, "Token refresh returned ${it.code} for ${config.name}")
|
AbsLogger.error(tag, "Token refresh returned ${it.code} for ${config.name}")
|
||||||
if (it.code == 401 || it.code == 403) handleDownloadRefreshFailure(serverConnectionConfigId)
|
if (it.code != 401 && it.code != 403) {
|
||||||
onResult(null)
|
onResult(RefreshResult.Transient)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleRefreshRejected(serverConnectionConfigId)
|
||||||
|
onResult(RefreshResult.Rejected)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -229,14 +243,14 @@ class ApiHandler(var ctx:Context) {
|
|||||||
val accessToken = user?.optString("accessToken").orEmpty()
|
val accessToken = user?.optString("accessToken").orEmpty()
|
||||||
if (accessToken.isEmpty()) {
|
if (accessToken.isEmpty()) {
|
||||||
AbsLogger.error(tag, "Refresh response had no access token for ${config.name}")
|
AbsLogger.error(tag, "Refresh response had no access token for ${config.name}")
|
||||||
onResult(null)
|
onResult(RefreshResult.Transient)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updateTokens(accessToken, user?.optString("refreshToken").orEmpty().ifEmpty { refreshToken }, serverConnectionConfigId)
|
updateTokens(accessToken, user?.optString("refreshToken").orEmpty().ifEmpty { refreshToken }, serverConnectionConfigId)
|
||||||
onResult(accessToken)
|
onResult(RefreshResult.Success(accessToken))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
AbsLogger.error(tag, "Could not parse refresh response for ${config.name}: ${e.message}")
|
AbsLogger.error(tag, "Could not parse refresh response for ${config.name}: ${e.message}")
|
||||||
onResult(null)
|
onResult(RefreshResult.Transient)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,20 +258,25 @@ class ApiHandler(var ctx:Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears only the server whose refresh token failed; queued downloads can target a non-active server.
|
* Clears only the server that rejected the refresh token; queued downloads can target a non-active server.
|
||||||
*
|
*
|
||||||
* Only call this when the server explicitly rejected the refresh token. Transient failures should not log the user out
|
* Only call this when the server explicitly rejected the refresh token. Transient failures should not log the user out
|
||||||
*/
|
*/
|
||||||
private fun handleDownloadRefreshFailure(serverConnectionConfigId: String) {
|
private fun handleRefreshRejected(serverConnectionConfigId: String) {
|
||||||
secureStorage.removeRefreshToken(serverConnectionConfigId)
|
// Must not throw: callers still have to report the refresh result to an in-flight request or download.
|
||||||
if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) return
|
try {
|
||||||
DeviceManager.serverConnectionConfig = null
|
secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) return
|
||||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
DeviceManager.serverConnectionConfig = null
|
||||||
if (checkAbsDatabaseNotifyListenersInitted()) {
|
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||||
absDatabaseNotifyListeners(
|
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||||
"onTokenRefreshFailure",
|
if (checkAbsDatabaseNotifyListenersInitted()) {
|
||||||
JSObject().put("error", "Token refresh failed").put("serverConnectionConfigId", serverConnectionConfigId))
|
absDatabaseNotifyListeners(
|
||||||
|
"onTokenRefreshFailure",
|
||||||
|
JSObject().put("error", "Token refresh failed").put("serverConnectionConfigId", serverConnectionConfigId))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
AbsLogger.error(tag, "Could not clear session for $serverConnectionConfigId: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,50 +390,6 @@ class ApiHandler(var ctx:Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the case when token refresh fails
|
|
||||||
* This will clear the current session and notify the callback
|
|
||||||
*
|
|
||||||
* @param callback The callback to return the error
|
|
||||||
*/
|
|
||||||
private fun handleRefreshFailure(callback: (JSObject) -> Unit) {
|
|
||||||
try {
|
|
||||||
Log.d(tag, "handleRefreshFailure: Token refresh failed, clearing session")
|
|
||||||
|
|
||||||
// Clear the current server connection
|
|
||||||
DeviceManager.serverConnectionConfig = null
|
|
||||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
|
||||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
|
||||||
|
|
||||||
// Remove refresh token from secure storage
|
|
||||||
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
|
||||||
if (serverConnectionConfigId.isNotEmpty()) {
|
|
||||||
secureStorage.removeRefreshToken(serverConnectionConfigId)
|
|
||||||
}
|
|
||||||
|
|
||||||
val errorObj = JSObject()
|
|
||||||
errorObj.put("error", "Authentication failed - please login again")
|
|
||||||
callback(errorObj)
|
|
||||||
|
|
||||||
if (checkAbsDatabaseNotifyListenersInitted()) {
|
|
||||||
val tokenJsObject = JSObject()
|
|
||||||
tokenJsObject.put("error", "Token refresh failed")
|
|
||||||
if (serverConnectionConfigId.isNotEmpty()) {
|
|
||||||
tokenJsObject.put("serverConnectionConfigId", serverConnectionConfigId)
|
|
||||||
}
|
|
||||||
absDatabaseNotifyListeners("onTokenRefreshFailure", tokenJsObject)
|
|
||||||
} else {
|
|
||||||
// Can happen if Webview is never run
|
|
||||||
Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send token refresh failure notification")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(tag, "handleRefreshFailure: Error during failure handling", e)
|
|
||||||
val errorObj = JSObject()
|
|
||||||
errorObj.put("error", "Authentication failed")
|
|
||||||
callback(errorObj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCurrentUser(cb: (User?) -> Unit) {
|
fun getCurrentUser(cb: (User?) -> Unit) {
|
||||||
getRequest("/api/me", null, null) {
|
getRequest("/api/me", null, null) {
|
||||||
if (it.has("error")) {
|
if (it.has("error")) {
|
||||||
|
|||||||
Reference in New Issue
Block a user