diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/AudioTrack.kt b/android/app/src/main/java/com/audiobookshelf/app/data/AudioTrack.kt index f77adbf6..086175d3 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/AudioTrack.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/AudioTrack.kt @@ -14,6 +14,7 @@ data class AudioTrack( var metadata: FileMetadata?, var isLocal: Boolean, var localFileId: String?, + // TODO: This should no longer be necessary var serverIndex: Int? // Need to know if server track index is different ) { diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt index 6358ea2c..76b8f4b4 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt @@ -32,11 +32,14 @@ enum class AndroidAutoBrowseSeriesSequenceOrderSetting { ASC, DESC } +@JsonIgnoreProperties(ignoreUnknown = true) data class ServerConnectionConfig( var id:String, var index:Int, var name:String, var address:String, + // version added after 0.9.81-beta + var version:String?, var userId:String, var username:String, var token:String, diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt index 7b2dcf70..0b08f1aa 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt @@ -53,6 +53,11 @@ class LibraryItem( return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon) } + // As of v2.17.0 token is not needed with cover image requests + if (DeviceManager.isServerVersionGreaterThanOrEqualTo("2.17.0")) { + return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover") + } + return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}") } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt index cde9318e..46ea3cdc 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt @@ -149,6 +149,16 @@ class PlaybackSession( return total } + @JsonIgnore + fun checkIsServerVersionGte(compareVersion: String): Boolean { + // Safety check this playback session is the same one currently connected (should always be) + if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) { + return false + } + + return DeviceManager.isServerVersionGreaterThanOrEqualTo(compareVersion) + } + @JsonIgnore fun getCoverUri(ctx: Context): Uri { if (localLibraryItem?.coverContentUrl != null) { @@ -168,12 +178,22 @@ class PlaybackSession( if (coverPath == null) return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon) + + // As of v2.17.0 token is not needed with cover image requests + if (checkIsServerVersionGte("2.17.0")) { + return Uri.parse("$serverAddress/api/items/$libraryItemId/cover") + } return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}") } @JsonIgnore fun getContentUri(audioTrack: AudioTrack): Uri { if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url + // As of v2.22.0 tracks use a different endpoint + // See: https://github.com/advplyr/audiobookshelf/pull/4263 + if (checkIsServerVersionGte("2.22.0")) { + return Uri.parse("$serverAddress/public/session/$id/track/${audioTrack.index}") + } return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}") } @@ -264,14 +284,16 @@ class PlaybackSession( com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER ) + // As of v2.17.0 token is not needed with cover image requests + val coverUri = if (checkIsServerVersionGte("2.17.0")) { + Uri.parse("$serverAddress/api/items/$libraryItemId/cover") + } else { + Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}") + } + + // Cast always uses server cover uri coverPath?.let { - castMetadata.addImage( - WebImage( - Uri.parse( - "$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}" - ) - ) - ) + castMetadata.addImage(WebImage(coverUri)) } castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "") diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt index d31891ff..de931ca1 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt @@ -41,6 +41,7 @@ object DeviceManager { get() = serverConnectionConfig?.userId ?: "" val token get() = serverConnectionConfig?.token ?: "" + val serverVersion get() = serverConnectionConfig?.version ?: "" val isConnectedToServer get() = serverConnectionConfig != null @@ -111,6 +112,41 @@ object DeviceManager { return id?.let { deviceData.serverConnectionConfigs.find { it.id == id } } } + /** + * Check if the currently connected server version is >= compareVersion + * Abs server only uses major.minor.patch + * Note: Version is returned in Abs auth payloads starting v2.6.0 + * Note: Version is saved with the server connection config starting after v0.9.81 + * + * @example + * serverVersion=2.25.1 + * isServerVersionGreaterThanOrEqualTo("2.26.0") = false + * + * serverVersion=2.26.1 + * isServerVersionGreaterThanOrEqualTo("2.26.0") = true + */ + fun isServerVersionGreaterThanOrEqualTo(compareVersion:String):Boolean { + if (serverVersion == "") return false + if (compareVersion == "") return true + + val serverVersionParts = serverVersion.split(".").map { it.toIntOrNull() ?: 0 } + val compareVersionParts = compareVersion.split(".").map { it.toIntOrNull() ?: 0 } + + // Compare major, minor, and patch components + for (i in 0 until maxOf(serverVersionParts.size, compareVersionParts.size)) { + val serverVersionComponent = serverVersionParts.getOrElse(i) { 0 } + val compareVersionComponent = compareVersionParts.getOrElse(i) { 0 } + + if (serverVersionComponent < compareVersionComponent) { + return false // Server version is less than compareVersion + } else if (serverVersionComponent > compareVersionComponent) { + return true // Server version is greater than compareVersion + } + } + + return true // versions are equal in major, minor, and patch + } + /** * Checks the network connectivity status. * @param ctx The context to use for checking connectivity. diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt new file mode 100644 index 00000000..c294f52e --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt @@ -0,0 +1,124 @@ +package com.audiobookshelf.app.managers + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import android.util.Log +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +class SecureStorage(private val context: Context) { + companion object { + private const val TAG = "SecureStorage" + private const val KEYSTORE_PROVIDER = "AndroidKeyStore" + private const val KEY_ALIAS = "AudiobookshelfRefreshTokens" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val IV_LENGTH = 12 + private const val TAG_LENGTH = 128 + } + + private val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { + load(null) + } + + /** + * Encrypts and stores a refresh token for a specific server connection + */ + fun storeRefreshToken(serverConnectionId: String, refreshToken: String): Boolean { + return try { + val key = getOrCreateKey() + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key) + + val encryptedBytes = cipher.doFinal(refreshToken.toByteArray(Charsets.UTF_8)) + val combined = cipher.iv + encryptedBytes + + val encoded = Base64.encodeToString(combined, Base64.DEFAULT) + + val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE) + sharedPrefs.edit().putString("refresh_token_$serverConnectionId", encoded).apply() + + Log.d(TAG, "Successfully stored encrypted refresh token for server: $serverConnectionId") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to store refresh token for server: $serverConnectionId", e) + false + } + } + + /** + * Retrieves and decrypts a refresh token for a specific server connection + */ + fun getRefreshToken(serverConnectionId: String): String? { + return try { + val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE) + val encoded = sharedPrefs.getString("refresh_token_$serverConnectionId", null) ?: return null + + val combined = Base64.decode(encoded, Base64.DEFAULT) + val iv = combined.copyOfRange(0, IV_LENGTH) + val encryptedBytes = combined.copyOfRange(IV_LENGTH, combined.size) + + val key = getOrCreateKey() + val cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(TAG_LENGTH, iv) + cipher.init(Cipher.DECRYPT_MODE, key, spec) + + val decryptedBytes = cipher.doFinal(encryptedBytes) + String(decryptedBytes, Charsets.UTF_8) + } catch (e: Exception) { + Log.e(TAG, "Failed to retrieve refresh token for server: $serverConnectionId", e) + null + } + } + + /** + * Removes a refresh token for a specific server connection + */ + fun removeRefreshToken(serverConnectionId: String): Boolean { + return try { + val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE) + sharedPrefs.edit().remove("refresh_token_$serverConnectionId").apply() + Log.d(TAG, "Successfully removed refresh token for server: $serverConnectionId") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to remove refresh token for server: $serverConnectionId", e) + false + } + } + + /** + * Checks if a refresh token exists for a specific server connection + */ + fun hasRefreshToken(serverConnectionId: String): Boolean { + val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE) + return sharedPrefs.contains("refresh_token_$serverConnectionId") + } + + private fun getOrCreateKey(): SecretKey { + return if (keyStore.containsAlias(KEY_ALIAS)) { + keyStore.getKey(KEY_ALIAS, null) as SecretKey + } else { + createKey() + } + } + + private fun createKey(): SecretKey { + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER) + val keyGenSpec = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setUserAuthenticationRequired(false) + .setRandomizedEncryptionRequired(true) + .build() + + keyGenerator.init(keyGenSpec) + return keyGenerator.generateKey() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt index bcf0e34d..bb96e554 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt @@ -8,6 +8,7 @@ import com.audiobookshelf.app.data.* import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.media.MediaEventManager import com.audiobookshelf.app.server.ApiHandler +import com.audiobookshelf.app.managers.SecureStorage import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue @@ -22,17 +23,21 @@ class AbsDatabase : Plugin() { val tag = "AbsDatabase" private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) - lateinit var mainActivity: MainActivity - lateinit var apiHandler: ApiHandler + private lateinit var mainActivity: MainActivity + private lateinit var apiHandler: ApiHandler + private lateinit var secureStorage: SecureStorage data class LocalMediaProgressPayload(val value:List) data class LocalLibraryItemsPayload(val value:List) data class LocalFoldersPayload(val value:List) - data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, val token:String, val address:String?, val customHeaders:Map?) + data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, var version:String, val token:String, val refreshToken:String?, val address:String?, val customHeaders:Map?) override fun load() { mainActivity = (activity as MainActivity) apiHandler = ApiHandler(mainActivity) + ApiHandler.absDatabaseNotifyListeners = ::notifyListeners + + secureStorage = SecureStorage(mainActivity) DeviceManager.dbManager.cleanLocalMediaProgress() DeviceManager.dbManager.cleanLocalLibraryItems() @@ -120,7 +125,9 @@ class AbsDatabase : Plugin() { val userId = serverConfigPayload.userId val username = serverConfigPayload.username - val token = serverConfigPayload.token + val serverVersion = serverConfigPayload.version + val accessToken = serverConfigPayload.token + val refreshToken = serverConfigPayload.refreshToken // Refresh only sent after login or refresh GlobalScope.launch(Dispatchers.IO) { if (serverConnectionConfig == null) { // New Server Connection @@ -129,7 +136,16 @@ class AbsDatabase : Plugin() { // Create new server connection config val sscId = DeviceManager.getBase64Id("$serverAddress@$username") val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size - serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token, serverConfigPayload.customHeaders) + + // Store refresh token securely if provided + val hasRefreshToken = if (!refreshToken.isNullOrEmpty()) { + secureStorage.storeRefreshToken(sscId, refreshToken) + } else { + false + } + Log.d(tag, "Refresh token secured = $hasRefreshToken") + + serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, serverVersion, userId, username, accessToken, serverConfigPayload.customHeaders) // Add and save DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!) @@ -137,14 +153,21 @@ class AbsDatabase : Plugin() { DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) } else { var shouldSave = false - if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != token) { + if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != accessToken || serverConnectionConfig?.version != serverVersion) { serverConnectionConfig?.userId = userId serverConnectionConfig?.username = username serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})" - serverConnectionConfig?.token = token + serverConnectionConfig?.version = serverVersion + serverConnectionConfig?.token = accessToken shouldSave = true } + // Update refresh token if provided + if (!refreshToken.isNullOrEmpty()) { + val stored = secureStorage.storeRefreshToken(serverConnectionConfig!!.id, refreshToken) + Log.d(tag, "Refresh token secured = $stored") + } + // Set last connection config if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) { DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id @@ -163,6 +186,10 @@ class AbsDatabase : Plugin() { fun removeServerConnectionConfig(call:PluginCall) { GlobalScope.launch(Dispatchers.IO) { val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString() + + // Remove refresh token if it exists + secureStorage.removeRefreshToken(serverConnectionConfigId) + DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) { DeviceManager.deviceData.lastServerConnectionConfigId = null @@ -175,6 +202,42 @@ class AbsDatabase : Plugin() { } } + @PluginMethod + fun getRefreshToken(call:PluginCall) { + val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString() + + GlobalScope.launch(Dispatchers.IO) { + val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId) + if (refreshToken != null) { + val result = JSObject() + result.put("refreshToken", refreshToken) + call.resolve(result) + } else { + call.resolve() + } + } + } + + @PluginMethod + fun clearRefreshToken(call:PluginCall) { + val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString() + + val refreshToken = secureStorage.removeRefreshToken(serverConnectionConfigId) + val result = JSObject() + result.put("success", refreshToken) + call.resolve(result) + } + + @PluginMethod + fun getAccessToken(call:PluginCall) { + val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString() + val serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId } + val token = serverConnectionConfig?.token ?: "" + val ret = JSObject() + ret.put("token", token) + call.resolve(ret) + } + @PluginMethod fun logout(call:PluginCall) { GlobalScope.launch(Dispatchers.IO) { 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 4086d6b7..c4caccb4 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 @@ -14,6 +14,7 @@ import com.audiobookshelf.app.media.SyncResult import com.audiobookshelf.app.models.User import com.audiobookshelf.app.BuildConfig import com.audiobookshelf.app.plugins.AbsLogger +import com.audiobookshelf.app.managers.SecureStorage import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper @@ -33,9 +34,19 @@ import java.util.concurrent.TimeUnit class ApiHandler(var ctx:Context) { val tag = "ApiHandler" + companion object { + // For sending data back to the Webview frontend + lateinit var absDatabaseNotifyListeners:(String, JSObject) -> Unit + + fun checkAbsDatabaseNotifyListenersInitted():Boolean { + return ::absDatabaseNotifyListeners.isInitialized + } + } + private var defaultClient = OkHttpClient() private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build() private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + private var secureStorage = SecureStorage(ctx) data class LocalSessionsSyncRequestPayload(val sessions:List, val deviceInfo:DeviceInfo) @JsonIgnoreProperties(ignoreUnknown = true) @@ -110,6 +121,13 @@ class ApiHandler(var ctx:Context) { override fun onResponse(call: Call, response: Response) { response.use { + if (it.code == 401) { + // Handle 401 Unauthorized by attempting token refresh + AbsLogger.info(tag, "makeRequest: 401 Unauthorized for request to \"${request.url}\" - attempt token refresh") + handleTokenRefresh(request, httpClient, cb) + return + } + if (!it.isSuccessful) { val jsobj = JSObject() jsobj.put("error", "Unexpected code $response") @@ -142,6 +160,266 @@ class ApiHandler(var ctx:Context) { }) } + /** + * Handles token refresh when a 401 Unauthorized response is received + * This function will: + * 1. Get the refresh token from secure storage for the current server connection + * 2. Make a request to /auth/refresh endpoint with the refresh token + * 3. Update the stored tokens with the new access token + * 4. Retry the original request with the new access token + * 5. If refresh fails, handle logout + * + * @param originalRequest The original request that failed with 401 + * @param httpClient The HTTP client to use for the request + * @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}") + + // 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 + } + + // 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) + 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) + } + } + } + }) + + } catch (e: Exception) { + Log.e(tag, "handleTokenRefresh: Unexpected error during token refresh", e) + handleRefreshFailure(callback) + } + } + + /** + * Updates the stored tokens with new access and refresh tokens + * + * @param newAccessToken The new access token + * @param newRefreshToken The new refresh token (or existing one if not provided) + */ + private fun updateTokens(newAccessToken: String, newRefreshToken: String, serverConnectionConfigId: String) { + try { + // Update the refresh token in secure storage if it's new + if (newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId)) { + secureStorage.storeRefreshToken(serverConnectionConfigId, newRefreshToken) + Log.d(tag, "updateTokens: Updated refresh token in secure storage") + } + + // Update the access token in the current server connection config + DeviceManager.serverConnectionConfig?.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()) { + val tokenJsObject = JSObject() + tokenJsObject.put("accessToken", newAccessToken) + absDatabaseNotifyListeners("onTokenRefresh", tokenJsObject) + } else { + // Can happen if Webview is never run + Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send new access token") + } + AbsLogger.info(tag, "updateTokens: Successfully refreshed auth tokens for server ${DeviceManager.serverConnectionConfigString}") + } catch (e: Exception) { + Log.e(tag, "updateTokens: Failed to update tokens", e) + AbsLogger.error(tag, "updateTokens: Failed to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})") + } + } + + /** + * Retries the original request with the new access token + * + * @param originalRequest The original request to retry + * @param newAccessToken The new access token to use + * @param httpClient The HTTP client to use + * @param callback The callback to return the response + */ + private fun retryOriginalRequest(originalRequest: Request, newAccessToken: String, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) { + try { + // Create a new request with the updated authorization header + val newRequest = originalRequest.newBuilder() + .removeHeader("Authorization") + .addHeader("Authorization", "Bearer $newAccessToken") + .build() + + Log.d(tag, "retryOriginalRequest: Retrying request to ${newRequest.url}") + + // Make the retry request + val client = httpClient ?: defaultClient + client.newCall(newRequest).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.e(tag, "retryOriginalRequest: Failed to retry request", e) + AbsLogger.error(tag, "retryOriginalRequest: Failed to retry request after token refresh for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})") + val errorObj = JSObject() + errorObj.put("error", "Failed to retry request after token refresh") + callback(errorObj) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (!it.isSuccessful) { + Log.e(tag, "retryOriginalRequest: Retry request failed with status ${it.code}") + AbsLogger.error(tag, "retryOriginalRequest: Retry request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}") + val errorObj = JSObject() + errorObj.put("error", "Retry request failed with status ${it.code}") + callback(errorObj) + return + } + + val bodyString = it.body!!.string() + if (bodyString == "OK") { + callback(JSObject()) + } else { + try { + var jsonObj = JSObject() + if (bodyString.startsWith("[")) { + val array = JSArray(bodyString) + jsonObj.put("value", array) + } else { + jsonObj = JSObject(bodyString) + } + callback(jsonObj) + } catch(je:JSONException) { + Log.e(tag, "retryOriginalRequest: Invalid JSON response ${je.localizedMessage} from body $bodyString") + val errorObj = JSObject() + errorObj.put("error", "Invalid response body") + callback(errorObj) + } + } + } + } + }) + + } catch (e: Exception) { + Log.e(tag, "retryOriginalRequest: Unexpected error during retry", e) + AbsLogger.error(tag, "retryOriginalRequest: Unexpected error during retry for server ${DeviceManager.serverConnectionConfigString}") + val errorObj = JSObject() + errorObj.put("error", "Failed to retry request") + callback(errorObj) + } + } + + /** + * 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") + 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) { getRequest("/api/me", null, null) { if (it.has("error")) { diff --git a/components/app/Appbar.vue b/components/app/Appbar.vue index 4f8bfb97..fd67744f 100644 --- a/components/app/Appbar.vue +++ b/components/app/Appbar.vue @@ -100,14 +100,14 @@ export default { this.isCastAvailable = data && data.value } }, - mounted() { + async mounted() { AbsAudioPlayer.getIsCastAvailable().then((data) => { this.isCastAvailable = data && data.value }) - this.onCastAvailableUpdateListener = AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate) + this.onCastAvailableUpdateListener = await AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate) }, beforeDestroy() { - if (this.onCastAvailableUpdateListener) this.onCastAvailableUpdateListener.remove() + this.onCastAvailableUpdateListener?.remove() } } diff --git a/components/app/AudioPlayer.vue b/components/app/AudioPlayer.vue index 7684fb90..ec129319 100644 --- a/components/app/AudioPlayer.vue +++ b/components/app/AudioPlayer.vue @@ -140,13 +140,6 @@ export default { readyTrackWidth: 0, seekedTime: 0, seekLoading: false, - onPlaybackSessionListener: null, - onPlaybackClosedListener: null, - onPlayingUpdateListener: null, - onMetadataListener: null, - onProgressSyncFailing: null, - onProgressSyncSuccess: null, - onPlaybackSpeedChangedListener: null, touchStartY: 0, touchStartTime: 0, playerSettings: { @@ -883,14 +876,14 @@ export default { async init() { await this.loadPlayerSettings() - this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession) - this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed) - this.onPlaybackFailedListener = AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed) - this.onPlayingUpdateListener = AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate) - this.onMetadataListener = AbsAudioPlayer.addListener('onMetadata', this.onMetadata) - this.onProgressSyncFailing = AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing) - this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess) - this.onPlaybackSpeedChangedListener = AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged) + AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession) + AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed) + AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed) + AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate) + AbsAudioPlayer.addListener('onMetadata', this.onMetadata) + AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing) + AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess) + AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged) }, async screenOrientationChange() { if (this.isRefreshingUI) return @@ -984,14 +977,9 @@ export default { document.body.removeEventListener('touchend', this.touchend) document.body.removeEventListener('touchmove', this.touchmove) - if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove() - if (this.onMetadataListener) this.onMetadataListener.remove() - if (this.onPlaybackSessionListener) this.onPlaybackSessionListener.remove() - if (this.onPlaybackClosedListener) this.onPlaybackClosedListener.remove() - if (this.onPlaybackFailedListener) this.onPlaybackFailedListener.remove() - if (this.onProgressSyncFailing) this.onProgressSyncFailing.remove() - if (this.onProgressSyncSuccess) this.onProgressSyncSuccess.remove() - if (this.onPlaybackSpeedChangedListener) this.onPlaybackSpeedChangedListener.remove() + if (AbsAudioPlayer.removeAllListeners) { + AbsAudioPlayer.removeAllListeners() + } clearInterval(this.playInterval) } } diff --git a/components/app/AudioPlayerContainer.vue b/components/app/AudioPlayerContainer.vue index 958fc968..1040e61b 100644 --- a/components/app/AudioPlayerContainer.vue +++ b/components/app/AudioPlayerContainer.vue @@ -351,11 +351,11 @@ export default { } } }, - mounted() { - this.onLocalMediaProgressUpdateListener = AbsAudioPlayer.addListener('onLocalMediaProgressUpdate', this.onLocalMediaProgressUpdate) - this.onSleepTimerEndedListener = AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded) - this.onSleepTimerSetListener = AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet) - this.onMediaPlayerChangedListener = AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged) + async mounted() { + this.onLocalMediaProgressUpdateListener = await AbsAudioPlayer.addListener('onLocalMediaProgressUpdate', this.onLocalMediaProgressUpdate) + this.onSleepTimerEndedListener = await AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded) + this.onSleepTimerSetListener = await AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet) + this.onMediaPlayerChangedListener = await AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged) this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate') console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`) @@ -370,10 +370,10 @@ export default { this.$eventBus.$on('device-focus-update', this.deviceFocused) }, beforeDestroy() { - if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove() - if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove() - if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove() - if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove() + this.onLocalMediaProgressUpdateListener?.remove() + this.onSleepTimerEndedListener?.remove() + this.onSleepTimerSetListener?.remove() + this.onMediaPlayerChangedListener?.remove() this.$eventBus.$off('abs-ui-ready', this.onReady) this.$eventBus.$off('play-item', this.playLibraryItem) diff --git a/components/app/SideDrawer.vue b/components/app/SideDrawer.vue index ee788317..f0dfed36 100644 --- a/components/app/SideDrawer.vue +++ b/components/app/SideDrawer.vue @@ -179,21 +179,7 @@ export default { this.show = false }, async logout() { - if (this.user) { - if (this.$store.getters['getIsPlayerOpen']) { - this.$eventBus.$emit('close-stream') - } - - await this.$nativeHttp.post('/logout').catch((error) => { - console.error('Failed to logout', error) - }) - } - - this.$socket.logout() - await this.$db.logout() - this.$localStore.removeLastLibraryId() - this.$store.commit('user/logout') - this.$store.commit('libraries/setCurrentLibrary', null) + await this.$store.dispatch('user/logout') }, async disconnect() { await this.$hapticsImpact() diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index 0c35b158..404f993d 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -17,6 +17,11 @@

{{ $strings.MessageOldServerConnectionWarning }}

{{ $strings.LabelMoreInfo }} + +
+

{{ $strings.MessageOldServerAuthWarning }}

+ {{ $strings.LabelMoreInfo }} +
{{ $strings.ButtonAddNewServer }} @@ -155,9 +160,23 @@ export default { cancelText: this.$strings.ButtonOk }) }, + async showOldAuthWarningDialog() { + const confirmResult = await Dialog.confirm({ + title: 'Old Server Auth Warning', + message: this.$strings.MessageOldServerAuthWarningHelp, + cancelButtonTitle: this.$strings.ButtonReadMore + }) + if (!confirmResult.value) { + window.open('https://github.com/advplyr/audiobookshelf/discussions/4460', '_blank') + } + }, checkIdUuid(userId) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId) }, + checkIsUsingOldAuth(config) { + if (!config.version) return true + return !this.$isValidVersion(config.version, '2.26.0') + }, /** * Initiates the login process using OpenID via OAuth2.0. * 1. Verifying the server's address @@ -374,15 +393,19 @@ export default { } catch (error) {} // No Error handling needed try { + // Returns the same user response payload as /login const response = await CapacitorHttp.get({ url: backendEndpoint }) - - if (!response.data || !response.data.user || !response.data.user.token) { - throw new Error('Token data is missing in the response.') + // v2.26.0+ returns accessToken and refreshToken on user object + if (!response.data?.user?.token && !response.data?.user?.accessToken) { + throw new Error('Token is missing in response.') } - this.serverConfig.token = response.data.user.token + const user = response.data.user + this.serverConfig.token = user.accessToken || user.token + + // TODO: Is it necessary to authenticate again? const payload = await this.authenticateToken() if (!payload) { @@ -394,6 +417,12 @@ export default { throw new Error('Config already exists for this address and username.') } + // For v2.26.0+ re-attach accessToken and refreshToken to user object because /authorize does not return them + if (user.accessToken) { + payload.user.accessToken = user.accessToken + payload.user.refreshToken = user.refreshToken + } + this.setUserAndConnection(payload) } catch (error) { console.error('[SSO] Error in exchangeCodeForToken: ', error) @@ -436,9 +465,10 @@ export default { } this.error = null - var payload = await this.authenticateToken() + const payload = await this.authenticateToken() if (payload) { + // Will NOT include access token and refresh token this.setUserAndConnection(payload) } else { this.showAuth = true @@ -504,7 +534,7 @@ export default { try { var urlObject = new URL(url) if (protocolOverride) urlObject.protocol = protocolOverride - return urlObject.href + return urlObject.href.replace(/\/$/, '') // Remove trailing slash } catch (error) { console.error('Invalid URL', error) return null @@ -597,7 +627,12 @@ export default { }) }, requestServerLogin() { - return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, this.serverConfig.customHeaders, 20000) + const headers = { + // Tells the Abs server to return the refresh token + 'x-return-tokens': 'true', + ...(this.serverConfig.customHeaders || {}) + } + return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, headers, 20000) .then((data) => { if (!data.user) { console.error(data.error) @@ -642,9 +677,13 @@ export default { if (statusData.data.authFormData?.authOpenIDAutoLaunch) { this.clickLoginWithOpenId() } + return true + } else { + return false } } catch (error) { this.handleLoginFormError(error) + return false } finally { this.processing = false } @@ -770,26 +809,6 @@ export default { prependProtocolIfNeeded(address) { return address.startsWith('http://') || address.startsWith('https://') ? address : `https://${address}` }, - /** - * Compares two semantic versioning strings to determine if the current version meets - * or exceeds the minimum version requirement. - * - * @param {string} currentVersion - The current version string to compare, e.g., "1.2.3". - * @param {string} minVersion - The minimum version string required, e.g., "1.0.0". - * @returns {boolean} - Returns true if the current version is greater than or equal - * to the minimum version, false otherwise. - */ - isValidVersion(currentVersion, minVersion) { - const currentParts = currentVersion.split('.').map(Number) - const minParts = minVersion.split('.').map(Number) - - for (let i = 0; i < minParts.length; i++) { - if (currentParts[i] > minParts[i]) return true - if (currentParts[i] < minParts[i]) return false - } - - return true - }, async submitAuth() { if (!this.networkConnected) return if (!this.serverConfig.username) { @@ -806,9 +825,10 @@ export default { this.error = null this.processing = true - var payload = await this.requestServerLogin() + const payload = await this.requestServerLogin() this.processing = false if (payload) { + // Will include access token and refresh token this.setUserAndConnection(payload) } }, @@ -821,18 +841,30 @@ export default { this.$store.commit('libraries/setEReaderDevices', ereaderDevices) this.$setServerLanguageCode(serverSettings.language) - // Set library - Use last library if set and available fallback to default user library - var lastLibraryId = await this.$localStore.getLastLibraryId() - if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) { - this.$store.commit('libraries/setCurrentLibrary', lastLibraryId) - } else if (userDefaultLibraryId) { - this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId) + this.serverConfig.userId = user.id + this.serverConfig.username = user.username + + if (this.$isValidVersion(serverSettings.version, '2.26.0')) { + // Tokens only returned from /login endpoint + if (user.accessToken) { + this.serverConfig.token = user.accessToken + this.serverConfig.refreshToken = user.refreshToken + } else { + // Detect if the connection config is using the old token. If so, force re-login + if (this.serverConfig.token === user.token || user.isOldToken) { + this.setForceReloginForNewAuth() + return + } + + // If the token was updated during a refresh (in nativeHttp.js) it gets updated in the store, so refetch + this.serverConfig.token = this.$store.getters['user/getToken'] || this.serverConfig.token + } + } else { + // Server version before new JWT auth, use old user.token + this.serverConfig.token = user.token } - this.serverConfig.userId = user.id - this.serverConfig.token = user.token - this.serverConfig.username = user.username - delete this.serverConfig.version + this.serverConfig.version = serverSettings.version var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig) @@ -849,7 +881,16 @@ export default { } } + // Set library - Use last library if set and available fallback to default user library + const lastLibraryId = await this.$localStore.getLastLibraryId() + if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) { + this.$store.commit('libraries/setCurrentLibrary', lastLibraryId) + } else if (userDefaultLibraryId) { + this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId) + } + this.$store.commit('user/setUser', user) + this.$store.commit('user/setAccessToken', serverConnectionConfig.token) this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig) this.$socket.connect(this.serverConfig.address, this.serverConfig.token) @@ -865,7 +906,13 @@ export default { this.error = null this.processing = true - const authRes = await this.postRequest(`${this.serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => { + const nativeHttpOptions = { + headers: { + Authorization: `Bearer ${this.serverConfig.token}` + }, + serverConnectionConfig: this.serverConfig + } + const authRes = await this.$nativeHttp.post(`${this.serverConfig.address}/api/authorize`, null, nativeHttpOptions).catch((error) => { console.error('[ServerConnectForm] Server auth failed', error) const errorMsg = error.message || error this.error = 'Failed to authorize' @@ -874,14 +921,30 @@ export default { } return false }) - console.log('[ServerConnectForm] authRes=', authRes) this.processing = false return authRes }, + async setForceReloginForNewAuth() { + // This calls /status on the server and sets the auth methods + const result = await this.submit() + if (result) { + this.error = this.$strings.MessageOldServerAuthReLoginRequired + } + }, init() { + // Handle force re-login for servers using new JWT auth but still using an old token in the server config + if (this.$route.query.error === 'oldAuthToken' && this.$route.query.serverConnectionConfigId) { + this.serverConfig = this.serverConnectionConfigs.find((scc) => scc.id === this.$route.query.serverConnectionConfigId) + if (this.serverConfig) { + this.setForceReloginForNewAuth() + return + } + } + if (this.lastServerConnectionConfig) { + console.log('[ServerConnectForm] init with lastServerConnectionConfig', this.lastServerConnectionConfig) this.connectToServer(this.lastServerConnectionConfig) } else { this.showForm = !this.serverConnectionConfigs.length diff --git a/components/readers/ComicReader.vue b/components/readers/ComicReader.vue index e18885e3..f62ea87d 100644 --- a/components/readers/ComicReader.vue +++ b/components/readers/ComicReader.vue @@ -77,9 +77,6 @@ export default { } }, computed: { - userToken() { - return this.$store.getters['user/getToken'] - }, libraryItemId() { return this.libraryItem?.id }, @@ -245,11 +242,9 @@ export default { async extract() { this.loading = true + // TODO: Handle JWT auth refresh const buff = await this.$axios.$get(this.url, { - responseType: 'blob', - headers: { - Authorization: `Bearer ${this.userToken}` - } + responseType: 'blob' }) const archive = await Archive.open(buff) diff --git a/components/readers/EpubReader.vue b/components/readers/EpubReader.vue index f40cf07c..28857a5d 100644 --- a/components/readers/EpubReader.vue +++ b/components/readers/EpubReader.vue @@ -49,9 +49,6 @@ export default { } }, computed: { - userToken() { - return this.$store.getters['user/getToken'] - }, /** @returns {string} */ libraryItemId() { return this.libraryItem?.id @@ -298,15 +295,26 @@ export default { /** @type {EpubReader} */ const reader = this + + // Use axios to make request because we have token refresh logic in interceptor + const customRequest = async (url) => { + try { + return this.$axios.$get(url, { + responseType: 'arraybuffer' + }) + } catch (error) { + console.error('EpubReader.initEpub customRequest failed:', error) + throw error + } + } + console.log('[EpubReader] initEpub', reader.url) /** @type {ePub.Book} */ reader.book = new ePub(reader.url, { width: window.innerWidth, height: window.innerHeight - this.readerHeightOffset, openAs: 'epub', - requestHeaders: { - Authorization: `Bearer ${this.userToken}` - } + requestMethod: this.isLocal ? null : customRequest }) /** @type {ePub.Rendition} */ diff --git a/components/readers/MobiReader.vue b/components/readers/MobiReader.vue index 556c145b..53de8ecd 100644 --- a/components/readers/MobiReader.vue +++ b/components/readers/MobiReader.vue @@ -22,11 +22,7 @@ export default { data() { return {} }, - computed: { - userToken() { - return this.$store.getters['user/getToken'] - } - }, + computed: {}, methods: { addHtmlCss() { let iframe = document.getElementsByTagName('iframe')[0] @@ -84,11 +80,9 @@ export default { }, async initMobi() { // Fetch mobi file as blob + // TODO: Handle JWT auth refresh var buff = await this.$axios.$get(this.url, { - responseType: 'blob', - headers: { - Authorization: `Bearer ${this.userToken}` - } + responseType: 'blob' }) var reader = new FileReader() reader.onload = async (event) => { @@ -133,4 +127,4 @@ export default { overflow-x: hidden; overflow-y: auto; } - \ No newline at end of file + diff --git a/components/readers/PdfReader.vue b/components/readers/PdfReader.vue index 8a163719..1446e396 100644 --- a/components/readers/PdfReader.vue +++ b/components/readers/PdfReader.vue @@ -49,7 +49,8 @@ export default { numPages: 0, windowWidth: 0, windowHeight: 0, - pdfDocInitParams: null + pdfDocInitParams: null, + isRefreshing: false } }, computed: { @@ -109,6 +110,10 @@ export default { }, isPlayerOpen() { return this.$store.getters['getIsPlayerOpen'] + }, + ebookUrl() { + const serverAddress = this.$store.getters['user/getServerAddress'] + return this.isLocal ? this.url : `${serverAddress}${this.url}` } }, methods: { @@ -164,7 +169,54 @@ export default { this.page++ this.updateProgress() }, - error(err) { + async handleRefreshFailure() { + try { + console.log('[PdfReader] Handling refresh failure - logging out user') + + // Clear store + await this.$store.dispatch('user/logout') + + if (this.$store.getters['user/getServerConnectionConfigId']) { + // Clear refresh token for server connection config + await this.$db.clearRefreshToken(this.$store.getters['user/getServerConnectionConfigId']) + } + + if (window.location.pathname !== '/connect') { + window.location.href = '/connect' + } + } catch (error) { + console.error('[PdfReader] Failed to handle refresh failure:', error) + } + }, + async refreshToken() { + if (this.isRefreshing) return + this.isRefreshing = true + // Cannot use axios with this pdf reader so we need to handle the refresh separately + // Should work on migrating to a different pdf reader in the future + const newAccessToken = await this.$store.dispatch('user/refreshToken').catch((error) => { + console.error('Failed to refresh token', error) + return null + }) + if (!newAccessToken) { + this.handleRefreshFailure() + return + } + + // Force Vue to re-render the PDF component by creating a new object + this.pdfDocInitParams = { + url: this.ebookUrl, + httpHeaders: { + Authorization: `Bearer ${newAccessToken}` + } + } + this.isRefreshing = false + }, + async error(err) { + if (err && err.status === 401) { + console.log('Received 401 error, refreshing token') + await this.refreshToken() + return + } console.error(err) }, screenOrientationChange() { @@ -173,7 +225,7 @@ export default { }, init() { this.pdfDocInitParams = { - url: this.url, + url: this.ebookUrl, httpHeaders: { Authorization: `Bearer ${this.userToken}` } diff --git a/components/readers/Reader.vue b/components/readers/Reader.vue index dda49acb..aa76df67 100644 --- a/components/readers/Reader.vue +++ b/components/readers/Reader.vue @@ -305,11 +305,11 @@ export default { if (this.localContentUrl) { return Capacitor.convertFileSrc(this.localContentUrl) } - const serverAddress = this.$store.getters['user/getServerAddress'] + if (this.ebookFileId) { - return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}` + return `/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}` } - return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook` + return `/api/items/${this.selectedLibraryItem.id}/ebook` }, isPlayerOpen() { return this.$store.getters['getIsPlayerOpen'] diff --git a/components/widgets/DownloadProgressIndicator.vue b/components/widgets/DownloadProgressIndicator.vue index 28bde581..8e389c7d 100644 --- a/components/widgets/DownloadProgressIndicator.vue +++ b/components/widgets/DownloadProgressIndicator.vue @@ -78,15 +78,15 @@ export default { this.$store.commit('globals/updateDownloadItemPart', itemPart) } }, - mounted() { - this.downloadItemListener = AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data)) - this.itemPartUpdateListener = AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data)) - this.completeListener = AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data)) + async mounted() { + this.downloadItemListener = await AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data)) + this.itemPartUpdateListener = await AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data)) + this.completeListener = await AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data)) }, beforeDestroy() { - if (this.downloadItemListener) this.downloadItemListener.remove() - if (this.completeListener) this.completeListener.remove() - if (this.itemPartUpdateListener) this.itemPartUpdateListener.remove() + this.downloadItemListener?.remove() + this.completeListener?.remove() + this.itemPartUpdateListener?.remove() } } \ No newline at end of file diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index c72a5f88..7fa9db20 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ 4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; }; 4D91EEC62A40F28D004807ED /* EBookFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D91EEC52A40F28D004807ED /* EBookFile.swift */; }; 4DABC04F2B0139CA000F6264 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DABC04E2B0139CA000F6264 /* User.swift */; }; + 4DB441E12E19B8BF0056C8F1 /* SecureStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */; }; 4DF6C7172DB58ABF004059F1 /* AbsLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF6C7162DB58ABF004059F1 /* AbsLogger.swift */; }; 4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF74911287105C600AC7814 /* DeviceSettings.swift */; }; 4DFE2DA32D345C390000B204 /* MyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFE2DA22D345C390000B204 /* MyViewController.swift */; }; @@ -98,6 +99,7 @@ 4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = ""; }; 4D91EEC52A40F28D004807ED /* EBookFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EBookFile.swift; sourceTree = ""; }; 4DABC04E2B0139CA000F6264 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; + 4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorage.swift; sourceTree = ""; }; 4DF6C7162DB58ABF004059F1 /* AbsLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsLogger.swift; sourceTree = ""; }; 4DF74911287105C600AC7814 /* DeviceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSettings.swift; sourceTree = ""; }; 4DFE2DA22D345C390000B204 /* MyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyViewController.swift; sourceTree = ""; }; @@ -239,6 +241,7 @@ 3AFCB5E627EA23F700ECCC05 /* util */ = { isa = PBXGroup; children = ( + 4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */, E9FA07E228C82848005520B0 /* Logger.swift */, 3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */, 3AD4FCEA280443DD006DB301 /* Database.swift */, @@ -550,6 +553,7 @@ E9D5505828AC1C1A00C746DD /* Library.swift in Sources */, 3AD4FCEB280443DD006DB301 /* Database.swift in Sources */, 3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */, + 4DB441E12E19B8BF0056C8F1 /* SecureStorage.swift in Sources */, 4DABC04F2B0139CA000F6264 /* User.swift in Sources */, E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */, EACB38162BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift in Sources */, diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index bd270f50..da344016 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -14,7 +14,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Override point for customization after application launch. let configuration = Realm.Configuration( - schemaVersion: 19, + schemaVersion: 20, migrationBlock: { [weak self] migration, oldSchemaVersion in if (oldSchemaVersion < 1) { self?.logger.log("Realm schema version was \(oldSchemaVersion)") @@ -65,9 +65,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding disableSleepTimerFadeOut settings") migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in newObject?["disableSleepTimerFadeOut"] = false - } - } - + } + } + if (oldSchemaVersion < 20) { + self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding version to ServerConnectionConfigs") + migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in + newObject?["version"] = "" + } + } } ) Realm.Configuration.defaultConfiguration = configuration diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index 8208be78..aa9dfc43 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -33,6 +33,8 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin { public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "setCurrentServerConnectionConfig", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "removeServerConnectionConfig", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getRefreshToken", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "clearRefreshToken", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "logout", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getDeviceData", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getLocalLibraryItems", returnType: CAPPluginReturnPromise), @@ -49,15 +51,33 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin { ] private let logger = AppLogger(category: "AbsDatabase") + private let secureStorage = SecureStorage() + + // Used to notify the webview frontend that the token has been refreshed + static var tokenRefreshCallback: ((String, [String: Any]) -> Void)? + + override public func load() { + AbsDatabase.tokenRefreshCallback = { [weak self] eventName, data in + self?.notifyListeners(eventName, data: data) + } + } @objc func setCurrentServerConnectionConfig(_ call: CAPPluginCall) { var id = call.getString("id") let address = call.getString("address", "") + let version = call.getString("version", "") let userId = call.getString("userId", "") let username = call.getString("username", "") let token = call.getString("token", "") + let refreshToken = call.getString("refreshToken", "") // Refresh only sent after login or refresh let name = "\(address) (\(username))" + + if (refreshToken != "") { + // Store refresh token securely if provided + let hasRefreshToken = secureStorage.storeRefreshToken(serverConnectionConfigId: id ?? "", refreshToken: refreshToken) + logger.log("Refresh token secured = \(hasRefreshToken)") + } if id == nil { id = "\(address)@\(username)".toBase64() @@ -68,6 +88,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin { config.index = 0 config.name = name config.address = address + config.version = version config.userId = userId config.username = username config.token = token @@ -76,12 +97,35 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin { let savedConfig = Store.serverConfig // Fetch the latest value call.resolve(convertServerConnectionConfigToJSON(config: savedConfig!)) } + @objc func removeServerConnectionConfig(_ call: CAPPluginCall) { let id = call.getString("serverConnectionConfigId", "") + + // Remove refresh token if it exists + _ = secureStorage.removeRefreshToken(serverConnectionConfigId: id) + Database.shared.deleteServerConnectionConfig(id: id) - call.resolve() } + + @objc func getRefreshToken(_ call: CAPPluginCall) { + let serverConnectionConfigId = call.getString("serverConnectionConfigId", "") + + let refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId: serverConnectionConfigId) + if let refreshToken = refreshToken { + call.resolve(["refreshToken": refreshToken]) + } else { + call.resolve() + } + } + + @objc func clearRefreshToken(_ call: CAPPluginCall) { + let serverConnectionConfigId = call.getString("serverConnectionConfigId", "") + + let success = secureStorage.removeRefreshToken(serverConnectionConfigId: serverConnectionConfigId) + call.resolve(["success": success]) + } + @objc func logout(_ call: CAPPluginCall) { Store.serverConfig = nil call.resolve() diff --git a/ios/App/Shared/models/ServerConnectionConfig.swift b/ios/App/Shared/models/ServerConnectionConfig.swift index be841da1..fea82e4b 100644 --- a/ios/App/Shared/models/ServerConnectionConfig.swift +++ b/ios/App/Shared/models/ServerConnectionConfig.swift @@ -13,6 +13,7 @@ class ServerConnectionConfig: Object { @Persisted(indexed: true) var index: Int = 1 @Persisted var name: String = "" @Persisted var address: String = "" + @Persisted var version: String = "" @Persisted var userId: String = "" @Persisted var username: String = "" @Persisted var token: String = "" @@ -29,6 +30,7 @@ func convertServerConnectionConfigToJSON(config: ServerConnectionConfig) -> Dict "name": config.name, "index": config.index, "address": config.address, + "version": config.version, "userId": config.userId, "username": config.username, "token": config.token, diff --git a/ios/App/Shared/player/AudioPlayer.swift b/ios/App/Shared/player/AudioPlayer.swift index e46b0da7..ae1e5db2 100644 --- a/ios/App/Shared/player/AudioPlayer.swift +++ b/ios/App/Shared/player/AudioPlayer.swift @@ -561,8 +561,15 @@ class AudioPlayer: NSObject { guard let playbackSession = self.getPlaybackSession() else { return nil } if (playbackSession.playMethod == PlayMethod.directplay.rawValue) { - let urlstr = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)" - let url = URL(string: urlstr)! + // As of v2.22.0 tracks use a different endpoint + // See: https://github.com/advplyr/audiobookshelf/pull/4263 + let contentUrl: String + if Store.isServerVersionGreaterThanOrEqualTo("2.22.0") { + contentUrl = "\(Store.serverConfig!.address)/public/session/\(playbackSession.id)/track/\(track.index ?? 1)" + } else { + contentUrl = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)" + } + let url = URL(string: contentUrl)! return AVURLAsset(url: url) } else if (playbackSession.playMethod == PlayMethod.local.rawValue) { guard let localFile = track.getLocalFile() else { @@ -577,7 +584,9 @@ class AudioPlayer: NSObject { let headers: [String: String] = [ "Authorization": "Bearer \(Store.serverConfig!.token)" ] - return AVURLAsset(url: URL(string: "\(Store.serverConfig!.address)\(track.contentUrl ?? "")")!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers]) + + let contentUrl = "\(Store.serverConfig!.address)\(track.contentUrl ?? "")" + return AVURLAsset(url: URL(string: contentUrl)!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers]) } } diff --git a/ios/App/Shared/util/ApiClient.swift b/ios/App/Shared/util/ApiClient.swift index 37e831e1..ab368988 100644 --- a/ios/App/Shared/util/ApiClient.swift +++ b/ios/App/Shared/util/ApiClient.swift @@ -10,6 +10,7 @@ import Alamofire class ApiClient { private static let logger = AppLogger(category: "ApiClient") + private static let secureStorage = SecureStorage() public static func getData(from url: URL, completion: @escaping (UIImage?) -> Void) { URLSession.shared.dataTask(with: url, completionHandler: {(data, response, error) in @@ -146,6 +147,318 @@ class ApiClient { } } + // MARK: - Token Refresh Handling + + /** + * Handles token refresh when a 401 Unauthorized response is received + * This function will: + * 1. Get the refresh token from secure storage for the current server connection + * 2. Make a request to /auth/refresh endpoint with the refresh token + * 3. Update the connection config with the new accessToken and put the refreshToken in secure storage + * 4. Retry the original request with the new access token + * 5. If refresh fails, handle logout + */ + private static func handleTokenRefresh(originalRequest: DataRequest, endpoint: String, method: HTTPMethod, parameters: Any?, decodable: T.Type, callback: ((_ param: T?) -> Void)?) { + guard let serverConfig = Store.serverConfig else { + logger.error("handleTokenRefresh: No server config available") + callback?(nil) + return + } + + logger.log("handleTokenRefresh: Attempting to refresh auth tokens for server \(serverConfig.name)") + + // Get refresh token from secure storage + guard let refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId: serverConfig.id) else { + logger.error("handleTokenRefresh: No refresh token available for server \(serverConfig.name)") + handleRefreshFailure() + callback?(nil) + return + } + + logger.log("handleTokenRefresh: Retrieved refresh token, attempting to refresh access token") + + // Create refresh token request + let refreshHeaders: HTTPHeaders = [ + "x-refresh-token": refreshToken, + "Content-Type": "application/json" + ] + + let refreshRequest = AF.request("\(serverConfig.address)/auth/refresh", method: .post, headers: refreshHeaders) + + refreshRequest.responseDecodable(of: RefreshResponse.self) { response in + switch response.result { + case .success(let refreshResponse): + guard let user = refreshResponse.user, + !user.accessToken.isEmpty else { + logger.error("handleTokenRefresh: No access token in refresh response for server \(serverConfig.name)") + handleRefreshFailure() + callback?(nil) + return + } + + logger.log("handleTokenRefresh: Successfully obtained new access token") + + // Update tokens in secure storage and store + updateTokens(newAccessToken: user.accessToken, newRefreshToken: user.refreshToken ?? refreshToken, serverConnectionConfigId: serverConfig.id) + + // Retry the original request with the new access token + logger.log("handleTokenRefresh: Retrying original request with new token") + retryOriginalRequest(endpoint: endpoint, method: method, parameters: parameters, decodable: decodable, newAccessToken: user.accessToken, callback: callback) + + case .failure(let error): + logger.error("handleTokenRefresh: Refresh request failed for server \(serverConfig.name): \(error)") + handleRefreshFailure() + callback?(nil) + } + } + } + + /** + * Updates the stored tokens with new access and refresh tokens + */ + private static func updateTokens(newAccessToken: String, newRefreshToken: String, serverConnectionConfigId: String) { + // Update the refresh token in secure storage if it's new + if newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId: serverConnectionConfigId) { + let hasStored = secureStorage.storeRefreshToken(serverConnectionConfigId: serverConnectionConfigId, refreshToken: newRefreshToken) + logger.log("updateTokens: Updated refresh token in secure storage. Stored=\(hasStored)") + } + + // Update access token on server connection config + Database.shared.updateServerConnectionConfigToken(newToken: newAccessToken) + logger.log("updateTokens: Updated access token in server connection config") + + logger.log("updateTokens: Successfully refreshed auth tokens for server \(Store.serverConfig?.name ?? "unknown")") + + // Notify webview frontend about token refresh + if let callback = AbsDatabase.tokenRefreshCallback { + let tokenData: [String: Any] = ["accessToken": newAccessToken] + callback("onTokenRefresh", tokenData) + } + } + + /** + * Retries the original request with the new access token + */ + private static func retryOriginalRequest(endpoint: String, method: HTTPMethod, parameters: Any?, decodable: T.Type, newAccessToken: String, callback: ((_ param: T?) -> Void)?) { + guard let serverConfig = Store.serverConfig else { + logger.error("retryOriginalRequest: No server config available") + callback?(nil) + return + } + + let headers: HTTPHeaders = [ + "Authorization": "Bearer \(newAccessToken)" + ] + + let retryRequest: DataRequest + + switch method { + case .get: + retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .get, headers: headers) + case .post: + if let parameters = parameters as? [String: Any] { + retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .post, parameters: parameters, encoding: JSONEncoding.default, headers: headers) + } else if let encodableParams = parameters as? Encodable { + retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .post, parameters: encodableParams, encoder: JSONParameterEncoder.default, headers: headers) + } else { + retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .post, headers: headers) + } + case .patch: + if let encodableParams = parameters as? Encodable { + retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .patch, parameters: encodableParams, encoder: JSONParameterEncoder.default, headers: headers) + } else { + retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .patch, headers: headers) + } + default: + logger.error("retryOriginalRequest: Unsupported method \(method)") + callback?(nil) + return + } + + // Handle the response + retryRequest.response { response in + if let statusCode = response.response?.statusCode, (200...299).contains(statusCode) { + // Check if response has data + if let data = response.data, !data.isEmpty { + // If it is a string return nil (e.g. express returns OK for 200 status codes) + if let responseString = String(data: data, encoding: .utf8) { + logger.log("retryOriginalRequest: Got string response '\(responseString)'") + callback?(nil) + return + } + + // If not a string, try JSON + do { + let decodedObject = try JSONDecoder().decode(decodable, from: data) + callback?(decodedObject) + } catch { + logger.error("retryOriginalRequest: JSON decode failed: \(error)") + callback?(nil) + } + } else { + // Empty response + logger.log("retryOriginalRequest: Empty response with success status \(statusCode)") + callback?(nil) + } + } else { + logger.error("retryOriginalRequest: Request failed with status \(response.response?.statusCode ?? 0)") + callback?(nil) + } + } + } + + /** + * Handles the case when token refresh fails + * This will clear the current server connection and notify webview + */ + private static func handleRefreshFailure() { + logger.log("handleRefreshFailure: Token refresh failed, clearing session") + + // Clear the current server connection + Store.serverConfig = nil + + // Remove refresh token from secure storage + if let serverConfig = Store.serverConfig { + _ = secureStorage.removeRefreshToken(serverConnectionConfigId: serverConfig.id) + } + + // Notify webview frontend about token refresh failure + if let callback = AbsDatabase.tokenRefreshCallback { + callback("onTokenRefreshFailure", ["error": "Token refresh failed"]) + } + } + + // MARK: - Enhanced API Methods with Token Refresh + + public static func getResourceWithTokenRefresh(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) { + if (Store.serverConfig == nil) { + logger.error("Server config not set") + callback?(nil) + return + } + + let headers: HTTPHeaders = [ + "Authorization": "Bearer \(Store.serverConfig!.token)" + ] + + let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .get, headers: headers) + + request.responseDecodable(of: decodable) { response in + if let statusCode = response.response?.statusCode, statusCode == 401 { + logger.log("getResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh") + handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .get, parameters: nil, decodable: decodable, callback: callback) + } else { + switch response.result { + case .success(let obj): + callback?(obj) + case .failure(let error): + logger.error("api request to \(endpoint) failed") + print(error) + callback?(nil) + } + } + } + } + + public static func postResourceWithTokenRefresh(endpoint: String, parameters: T, decodable: U.Type = U.self, callback: ((_ param: U?) -> Void)?) { + if (Store.serverConfig == nil) { + logger.error("Server config not set") + callback?(nil) + return + } + + let headers: HTTPHeaders = [ + "Authorization": "Bearer \(Store.serverConfig!.token)" + ] + + let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers) + + request.responseDecodable(of: decodable) { response in + if let statusCode = response.response?.statusCode, statusCode == 401 { + logger.log("postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh") + handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .post, parameters: parameters, decodable: decodable, callback: callback) + } else { + switch response.result { + case .success(let obj): + callback?(obj) + case .failure(let error): + logger.error("api request to \(endpoint) failed") + print(error) + callback?(nil) + } + } + } + } + + /** + * POST request for endpoints that only return success/failure + */ + public static func postResourceWithTokenRefresh(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) { + if (Store.serverConfig == nil) { + logger.error("Server config not set") + callback?(false) + return + } + + let headers: HTTPHeaders = [ + "Authorization": "Bearer \(Store.serverConfig!.token)" + ] + + let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers) + + request.response { response in + if let statusCode = response.response?.statusCode, statusCode == 401 { + logger.log("postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh") + handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .post, parameters: parameters, decodable: EmptyResponse.self) { result in + callback?(result != nil) + } + } else { + switch response.result { + case .success(_): + callback?(true) + case .failure(let error): + logger.error("api request to \(endpoint) failed") + print(error) + callback?(false) + } + } + } + } + + public static func patchResourceWithTokenRefresh(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) { + if (Store.serverConfig == nil) { + logger.error("Server config not set") + callback?(false) + return + } + + let headers: HTTPHeaders = [ + "Authorization": "Bearer \(Store.serverConfig!.token)" + ] + + let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .patch, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers) + + request.response { response in + if let statusCode = response.response?.statusCode, statusCode == 401 { + logger.log("patchResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh") + handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .patch, parameters: parameters, decodable: EmptyResponse.self) { result in + callback?(result != nil) + } + } else { + switch response.result { + case .success(_): + callback?(true) + case .failure(let error): + logger.error("api request to \(endpoint) failed") + print(error) + callback?(false) + } + } + } + } + + // MARK: - API Functions + public static func startPlaybackSession(libraryItemId: String, episodeId: String?, forceTranscode:Bool, callback: @escaping (_ param: PlaybackSession) -> Void) { var endpoint = "api/items/\(libraryItemId)/play" if episodeId != nil { @@ -160,20 +473,28 @@ class ApiClient { } } - let parameters: [String: Any] = [ - "forceDirectPlay": !forceTranscode ? "1" : "", - "forceTranscode": forceTranscode ? "1" : "", - "mediaPlayer": "AVPlayer", - "deviceInfo": [ - "deviceId": UIDevice.current.identifierForVendor?.uuidString, - "manufacturer": "Apple", - "model": modelCode, - "clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String - ] - ] - ApiClient.postResource(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { obj in - let session = obj + // Create an Encodable struct for the parameters + let parameters = PlaybackSessionRequest( + forceDirectPlay: !forceTranscode ? "1" : "", + forceTranscode: forceTranscode ? "1" : "", + mediaPlayer: "AVPlayer", + deviceInfo: DeviceInfo( + deviceId: UIDevice.current.identifierForVendor?.uuidString, + manufacturer: "Apple", + model: modelCode, + clientVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + ) + ) + + // Use the new token refresh-enabled method + postResourceWithTokenRefresh(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { session in + guard let session = session else { + logger.error("startPlaybackSession: Failed to create playback session") + callback(PlaybackSession()) // Return empty session on failure + return + } + // Set server connection info on the session session.serverConnectionConfigId = Store.serverConfig!.id session.serverAddress = Store.serverConfig!.address @@ -182,15 +503,28 @@ class ApiClient { } public static func reportPlaybackProgress(report: PlaybackReport, sessionId: String) async -> Bool { - return await postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report) + return await withCheckedContinuation { continuation in + postResourceWithTokenRefresh(endpoint: "api/session/\(sessionId)/sync", parameters: report) { success in + continuation.resume(returning: success) + } + } } public static func reportLocalPlaybackProgress(_ session: PlaybackSession) async -> Bool { - return await postResource(endpoint: "api/session/local", parameters: session) + return await withCheckedContinuation { continuation in + postResourceWithTokenRefresh(endpoint: "api/session/local", parameters: session) { success in + continuation.resume(returning: success) + } + } } public static func reportAllLocalPlaybackSessions(_ sessions: [PlaybackSession]) async -> Bool { - return await postResource(endpoint: "api/session/local-all", parameters: LocalPlaybackSessionSyncAllPayload(sessions: sessions, deviceInfo: sessions.first?.deviceInfo)) + return await withCheckedContinuation { continuation in + let payload = LocalPlaybackSessionSyncAllPayload(sessions: sessions, deviceInfo: sessions.first?.deviceInfo) + postResourceWithTokenRefresh(endpoint: "api/session/local-all", parameters: payload) { success in + continuation.resume(returning: success) + } + } } public static func syncLocalSessionsWithServer(isFirstSync: Bool) async { @@ -257,7 +591,7 @@ class ApiClient { public static func updateMediaProgress(libraryItemId: String, episodeId: String?, payload: T, callback: @escaping () -> Void) { logger.log("updateMediaProgress \(libraryItemId) \(episodeId ?? "NIL") \(payload)") let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")" - patchResource(endpoint: endpoint, parameters: payload) { success in + patchResourceWithTokenRefresh(endpoint: endpoint, parameters: payload) { _ in callback() } } @@ -265,21 +599,29 @@ class ApiClient { public static func getMediaProgress(libraryItemId: String, episodeId: String?) async -> MediaProgress? { logger.log("getMediaProgress \(libraryItemId) \(episodeId ?? "NIL")") let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")" - return await getResource(endpoint: endpoint, decodable: MediaProgress.self) + return await withCheckedContinuation { continuation in + getResourceWithTokenRefresh(endpoint: endpoint, decodable: MediaProgress.self) { result in + continuation.resume(returning: result) + } + } } public static func getCurrentUser() async -> User? { logger.log("getCurrentUser") - return await getResource(endpoint: "api/me", decodable: User.self) + return await withCheckedContinuation { continuation in + getResourceWithTokenRefresh(endpoint: "api/me", decodable: User.self) { result in + continuation.resume(returning: result) + } + } } - public static func getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, callback: @escaping (_ param: LibraryItem?) -> Void) { + public static func getLibraryItemWithProgress(libraryItemId: String, episodeId: String?, callback: @escaping (_ param: LibraryItem?) -> Void) { var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress" if episodeId != nil { endpoint += "&episodeId=\(episodeId!)" } - - ApiClient.getResource(endpoint: endpoint, decodable: LibraryItem.self) { obj in + + getResourceWithTokenRefresh(endpoint: endpoint, decodable: LibraryItem.self) { obj in callback(obj) } } @@ -338,3 +680,30 @@ struct Connectivity { return self.sharedInstance.isReachable } } + +// MARK: - Response Models + +struct RefreshResponse: Decodable { + let user: RefreshUser? +} + +struct RefreshUser: Decodable { + let accessToken: String + let refreshToken: String? +} + +struct EmptyResponse: Decodable {} + +struct PlaybackSessionRequest: Encodable { + let forceDirectPlay: String + let forceTranscode: String + let mediaPlayer: String + let deviceInfo: DeviceInfo +} + +struct DeviceInfo: Encodable { + let deviceId: String? + let manufacturer: String + let model: String? + let clientVersion: String? +} diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index c20a63b0..d87aac56 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -27,6 +27,7 @@ class Database { try existing.update { existing.name = config.name existing.address = config.address + existing.version = config.version existing.userId = config.userId existing.username = config.username existing.token = config.token @@ -60,6 +61,19 @@ class Database { setLastActiveConfigIndex(index: config.index) } } + + public func updateServerConnectionConfigToken(newToken: String) { + do { + let realm = try Realm() + if let config = realm.objects(ServerConnectionConfig.self).first(where: { $0.index == getLastActiveConfigIndex() }) { + try realm.write { + config.token = newToken + } + } + } catch { + debugPrint("Failed to update server connection config token: \(error)") + } + } public func deleteServerConnectionConfig(id: String) { let realm = try! Realm() diff --git a/ios/App/Shared/util/NowPlayingInfo.swift b/ios/App/Shared/util/NowPlayingInfo.swift index 69443fbe..5369cbca 100644 --- a/ios/App/Shared/util/NowPlayingInfo.swift +++ b/ios/App/Shared/util/NowPlayingInfo.swift @@ -22,8 +22,16 @@ struct NowPlayingMetadata { return item.coverUrl } else { guard let config = Store.serverConfig else { return nil } - guard let url = URL(string: "\(config.address)/api/items/\(itemId)/cover?token=\(config.token)") else { return nil } - return url + + // As of v2.17.0 token is not needed with cover image requests + let coverUrlString: String + if Store.isServerVersionGreaterThanOrEqualTo("2.17.0") { + coverUrlString = "\(config.address)/api/items/\(itemId)/cover" + } else { + coverUrlString = "\(config.address)/api/items/\(itemId)/cover?token=\(config.token)" + } + + return URL(string: coverUrlString) } } } diff --git a/ios/App/Shared/util/SecureStorage.swift b/ios/App/Shared/util/SecureStorage.swift new file mode 100644 index 00000000..babad6f6 --- /dev/null +++ b/ios/App/Shared/util/SecureStorage.swift @@ -0,0 +1,116 @@ +// +// SecureStorage.swift +// App +// +// Created by advplyr on 2025-07-05 +// + +import Foundation +import Security + +class SecureStorage { + private static let tag = "SecureStorage" + private static let serviceName = "AudiobookshelfRefreshTokens" + + /** + * Encrypts and stores a refresh token for a specific server connection + */ + func storeRefreshToken(serverConnectionConfigId: String, refreshToken: String) -> Bool { + let key = "refresh_token_\(serverConnectionConfigId)" + + guard let data = refreshToken.data(using: .utf8) else { + print("\(Self.tag): Failed to convert refresh token to data") + return false + } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.serviceName, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + // First, try to delete any existing item + SecItemDelete(query as CFDictionary) + + // Then add the new item + let status = SecItemAdd(query as CFDictionary, nil) + + if status == errSecSuccess { + print("\(Self.tag): Successfully stored encrypted refresh token for server: \(serverConnectionConfigId)") + return true + } else { + print("\(Self.tag): Failed to store refresh token for server: \(serverConnectionConfigId), status: \(status)") + return false + } + } + + /** + * Retrieves and decrypts a refresh token for a specific server connection + */ + func getRefreshToken(serverConnectionConfigId: String) -> String? { + let key = "refresh_token_\(serverConnectionConfigId)" + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.serviceName, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecSuccess, + let data = result as? Data, + let refreshToken = String(data: data, encoding: .utf8) { + return refreshToken + } else { + print("\(Self.tag): Failed to retrieve refresh token for server: \(serverConnectionConfigId), status: \(status)") + return nil + } + } + + /** + * Removes a refresh token for a specific server connection + */ + func removeRefreshToken(serverConnectionConfigId: String) -> Bool { + let key = "refresh_token_\(serverConnectionConfigId)" + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.serviceName, + kSecAttrAccount as String: key + ] + + let status = SecItemDelete(query as CFDictionary) + + if status == errSecSuccess || status == errSecItemNotFound { + print("\(Self.tag): Successfully removed refresh token for server: \(serverConnectionConfigId)") + return true + } else { + print("\(Self.tag): Failed to remove refresh token for server: \(serverConnectionConfigId), status: \(status)") + return false + } + } + + /** + * Checks if a refresh token exists for a specific server connection + */ + func hasRefreshToken(serverConnectionConfigId: String) -> Bool { + let key = "refresh_token_\(serverConnectionConfigId)" + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.serviceName, + kSecAttrAccount as String: key, + kSecReturnData as String: false, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + let status = SecItemCopyMatching(query as CFDictionary, nil) + return status == errSecSuccess + } +} \ No newline at end of file diff --git a/ios/App/Shared/util/Store.swift b/ios/App/Shared/util/Store.swift index 6073f2d7..b0b991d1 100644 --- a/ios/App/Shared/util/Store.swift +++ b/ios/App/Shared/util/Store.swift @@ -29,4 +29,46 @@ class Store { } } } + + /** + * Check if the currently connected server version is >= compareVersion + * Abs server only uses major.minor.patch + * Note: Version is returned in Abs auth payloads starting v2.6.0 + * Note: Version is saved with the server connection config starting after v0.9.81 + * + * @example + * serverVersion=2.25.1 + * isServerVersionGreaterThanOrEqualTo("2.26.0") = false + * + * serverVersion=2.26.1 + * isServerVersionGreaterThanOrEqualTo("2.26.0") = true + */ + public static func isServerVersionGreaterThanOrEqualTo(_ compareVersion: String) -> Bool { + guard let serverConfig = serverConfig, !serverConfig.version.isEmpty else { + return false + } + + if compareVersion.isEmpty { + return true + } + + let serverVersionParts = serverConfig.version.split(separator: ".").compactMap { Int($0) } + let compareVersionParts = compareVersion.split(separator: ".").compactMap { Int($0) } + + // Compare major, minor, and patch components + let maxLength = max(serverVersionParts.count, compareVersionParts.count) + + for i in 0.. compareVersionComponent { + return true // Server version is greater than compareVersion + } + } + + return true // versions are equal in major, minor, and patch + } } diff --git a/layouts/default.vue b/layouts/default.vue index 09a9355b..aa621721 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -99,21 +99,6 @@ export default { await this.$store.dispatch('user/loadUserSettings') }, - async postRequest(url, data, headers, connectTimeout = 30000) { - const options = { - url, - headers, - data, - connectTimeout - } - const response = await CapacitorHttp.post(options) - console.log('[default] POST request response', response) - if (response.status >= 400) { - throw new Error(response.data) - } else { - return response.data - } - }, async attemptConnection() { console.warn('[default] attemptConnection') if (!this.networkConnected) { @@ -145,10 +130,18 @@ export default { AbsLogger.info({ tag: 'default', message: `attemptConnection: Got server config, attempt authorize (${serverConfig.name})` }) - const authRes = await this.postRequest(`${serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${serverConfig.token}` }, 6000).catch((error) => { + const nativeHttpOptions = { + headers: { + Authorization: `Bearer ${serverConfig.token}` + }, + connectTimeout: 6000, + serverConnectionConfig: serverConfig + } + const authRes = await this.$nativeHttp.post(`${serverConfig.address}/api/authorize`, null, nativeHttpOptions).catch((error) => { AbsLogger.error({ tag: 'default', message: `attemptConnection: Server auth failed (${serverConfig.name})` }) return false }) + if (!authRes) { this.attemptingConnection = false return @@ -158,6 +151,22 @@ export default { this.$store.commit('setServerSettings', serverSettings) this.$store.commit('libraries/setEReaderDevices', ereaderDevices) + if (this.$isValidVersion(serverSettings.version, '2.26.0')) { + // Check if the server is using the new JWT auth and is still using an old token in the server config + // If so, redirect to /connect and request to re-login + if (serverConfig.token === user.token || user.isOldToken) { + this.attemptingConnection = false + AbsLogger.info({ tag: 'default', message: `attemptConnection: Server is using new JWT auth but config is still using an old token (server version: ${serverSettings.version}) (${serverConfig.name})` }) + // Clear last server config + await this.$store.dispatch('user/logout') + this.$router.push(`/connect?error=oldAuthToken&serverConnectionConfigId=${serverConfig.id}`) + return + } + + // Token may have been refreshed during the authorize call so refetch from store + serverConfig.token = this.$store.getters['user/getToken'] || serverConfig.token + } + // Set library - Use last library if set and available fallback to default user library const lastLibraryId = await this.$localStore.getLastLibraryId() if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) { @@ -165,9 +174,11 @@ export default { } else if (userDefaultLibraryId) { this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId) } + serverConfig.version = serverSettings.version const serverConnectionConfig = await this.$db.setServerConnectionConfig(serverConfig) this.$store.commit('user/setUser', user) + this.$store.commit('user/setAccessToken', serverConnectionConfig.token) this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig) this.$socket.connect(serverConnectionConfig.address, serverConnectionConfig.token) diff --git a/nuxt.config.js b/nuxt.config.js index 60b108a5..09cab389 100644 --- a/nuxt.config.js +++ b/nuxt.config.js @@ -30,7 +30,7 @@ export default { css: ['@/assets/tailwind.css', '@/assets/app.css'], - plugins: ['@/plugins/server.js', '@/plugins/db.js', '@/plugins/localStore.js', '@/plugins/init.client.js', '@/plugins/axios.js', '@/plugins/nativeHttp.js', '@/plugins/capacitor/index.js', '@/plugins/capacitor/AbsAudioPlayer.js', '@/plugins/toast.js', '@/plugins/constants.js', '@/plugins/haptics.js', '@/plugins/i18n.js'], + plugins: ['@/plugins/server.js', '@/plugins/db.js', '@/plugins/localStore.js', '@/plugins/init.client.js', '@/plugins/axios.js', '@/plugins/capacitor/index.js', '@/plugins/capacitor/AbsAudioPlayer.js', '@/plugins/nativeHttp.js', '@/plugins/toast.js', '@/plugins/constants.js', '@/plugins/haptics.js', '@/plugins/i18n.js'], components: true, diff --git a/pages/account.vue b/pages/account.vue index eb686e8e..dbcfc50c 100644 --- a/pages/account.vue +++ b/pages/account.vue @@ -4,6 +4,10 @@ +
+

Server version: v{{ serverVersion }}

+
+ {{ $strings.ButtonSwitchServerUser }}logout
@@ -43,22 +47,16 @@ export default { }, serverAddress() { return this.serverConnectionConfig.address + }, + serverVersion() { + // Saved in server connection config after 0.9.81 + return this.serverConnectionConfig.version } }, methods: { async logout() { await this.$hapticsImpact() - if (this.user) { - await this.$nativeHttp.post('/logout').catch((error) => { - console.error(error) - }) - } - - this.$socket.logout() - await this.$db.logout() - this.$localStore.removeLastLibraryId() - this.$store.commit('user/logout') - this.$store.commit('libraries/setCurrentLibrary', null) + await this.$store.dispatch('user/logout') this.$router.push('/connect') } }, diff --git a/pages/media/_id/history.vue b/pages/media/_id/history.vue index d63b7b66..f231aff5 100644 --- a/pages/media/_id/history.vue +++ b/pages/media/_id/history.vue @@ -200,7 +200,7 @@ export default { this.onMediaItemHistoryUpdatedListener = await AbsAudioPlayer.addListener('onMediaItemHistoryUpdated', this.onMediaItemHistoryUpdated) }, beforeDestroy() { - if (this.onMediaItemHistoryUpdatedListener) this.onMediaItemHistoryUpdatedListener.remove() + this.onMediaItemHistoryUpdatedListener?.remove() } } diff --git a/plugins/axios.js b/plugins/axios.js index 73b97238..6424446d 100644 --- a/plugins/axios.js +++ b/plugins/axios.js @@ -1,17 +1,50 @@ -export default function ({ $axios, store }) { - $axios.onRequest(config => { +export default function ({ $axios, store, $db }) { + // Track if we're currently refreshing to prevent multiple refresh attempts + let isRefreshing = false + let failedQueue = [] + + const processQueue = (error, token = null) => { + failedQueue.forEach(({ resolve, reject }) => { + if (error) { + reject(error) + } else { + resolve(token) + } + }) + failedQueue = [] + } + + /** + * Handles the case when token refresh fails + * @param {string} [serverConnectionConfigId] + * @returns {Promise} - Promise that resolves when logout is complete + */ + const handleRefreshFailure = async (serverConnectionConfigId) => { + try { + console.log('[axios] Handling refresh failure - logging out user') + + // Clear store + await store.dispatch('user/logout') + + if (serverConnectionConfigId) { + // Clear refresh token for server connection config + await $db.clearRefreshToken(serverConnectionConfigId) + } + + if (window.location.pathname !== '/connect') { + window.location.href = '/connect' + } + } catch (error) { + console.error('[axios] Failed to handle refresh failure:', error) + } + } + + $axios.onRequest((config) => { console.log('[Axios] Making request to ' + config.url) if (config.url.startsWith('http:') || config.url.startsWith('https:') || config.url.startsWith('capacitor:')) { return } - const customHeaders = store.getters['user/getCustomHeaders'] - if (customHeaders) { - for (const key in customHeaders) { - config.headers.common[key] = customHeaders[key] - } - } - const bearerToken = store.getters['user/getToken'] if (bearerToken) { config.headers.common['Authorization'] = `Bearer ${bearerToken}` @@ -26,7 +59,75 @@ export default function ({ $axios, store }) { console.log('[Axios] Request out', config.url) }) - $axios.onError(error => { - console.error('Axios error code', error) + $axios.onError(async (error) => { + const originalRequest = error.config + const code = parseInt(error.response && error.response.status) + const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error' + + console.error('Axios error', code, message) + + // Handle 401 Unauthorized (token expired) + if (code === 401 && !originalRequest._retry) { + // Skip refresh for auth endpoints to prevent infinite loops + if (originalRequest.url.endsWith('/auth/refresh') || originalRequest.url.endsWith('/login')) { + await handleRefreshFailure(store.getters['user/getServerConnectionConfigId']) + return Promise.reject(error) + } + + if (isRefreshing) { + // If already refreshing, queue this request + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }) + }) + .then((token) => { + if (!originalRequest.headers) { + originalRequest.headers = {} + } + originalRequest.headers['Authorization'] = `Bearer ${token}` + return $axios(originalRequest) + }) + .catch((err) => { + return Promise.reject(err) + }) + } + + originalRequest._retry = true + isRefreshing = true + + try { + // Attempt to refresh the token + // Updates store if successful, otherwise clears store and throw error + const newAccessToken = await store.dispatch('user/refreshToken') + if (!newAccessToken) { + console.error('No new access token received') + return Promise.reject(error) + } + + // Update the original request with new token + if (!originalRequest.headers) { + originalRequest.headers = {} + } + originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}` + + // Process any queued requests + processQueue(null, newAccessToken) + + // Retry the original request + return $axios(originalRequest) + } catch (refreshError) { + console.error('Token refresh failed:', refreshError) + + // Process queued requests with error + processQueue(refreshError, null) + + await handleRefreshFailure(store.getters['user/getServerConnectionConfigId']) + + return Promise.reject(refreshError) + } finally { + isRefreshing = false + } + } + + return Promise.reject(error) }) -} \ No newline at end of file +} diff --git a/plugins/capacitor/AbsAudioPlayer.js b/plugins/capacitor/AbsAudioPlayer.js index 43326165..f16b882b 100644 --- a/plugins/capacitor/AbsAudioPlayer.js +++ b/plugins/capacitor/AbsAudioPlayer.js @@ -70,7 +70,12 @@ class AbsAudioPlayerWeb extends WebPlugin { const deviceInfo = { deviceId: this.getDeviceId() } - const playbackSession = await $axios.$post(route, { deviceInfo, mediaPlayer: 'html5-mobile', forceDirectPlay: true }) + const reqBody = { + deviceInfo, + mediaPlayer: 'html5-mobile', + forceDirectPlay: true + } + const playbackSession = await $axios.$post(route, reqBody) if (playbackSession) { if (startTime !== undefined && startTime !== null) playbackSession.currentTime = startTime this.setAudioPlayer(playbackSession, playWhenReady) @@ -245,7 +250,15 @@ class AbsAudioPlayerWeb extends WebPlugin { this.trackStartTime = Math.max(0, this.startTime - (this.currentTrack.startOffset || 0)) const serverAddressUrl = new URL(vuexStore.getters['user/getServerAddress']) const serverHost = `${serverAddressUrl.protocol}//${serverAddressUrl.host}` - this.player.src = `${serverHost}${this.currentTrack.contentUrl}?token=${vuexStore.getters['user/getToken']}` + + let sessionTrackUrl = null + if (this.currentTrack.contentUrl?.startsWith('/hls')) { + sessionTrackUrl = this.currentTrack.contentUrl + } else { + sessionTrackUrl = `/public/session/${this.playbackSession.id}/track/${this.currentTrack.index}` + } + + this.player.src = `${serverHost}${sessionTrackUrl}` console.log(`[AbsAudioPlayer] Loading track src ${this.player.src}`) this.player.load() this.player.playbackRate = this.playbackRate diff --git a/plugins/capacitor/AbsDatabase.js b/plugins/capacitor/AbsDatabase.js index fc34a6fa..1c499251 100644 --- a/plugins/capacitor/AbsDatabase.js +++ b/plugins/capacitor/AbsDatabase.js @@ -1,4 +1,17 @@ -import { registerPlugin, Capacitor, WebPlugin } from '@capacitor/core'; +import { registerPlugin, Capacitor, WebPlugin } from '@capacitor/core' + +/** + * @typedef {Object} ServerConnectionConfig + * @property {string} id + * @property {number} index + * @property {string} name + * @property {string} address + * @property {string} version + * @property {string} userId + * @property {string} username + * @property {string} token + * @property {string} [refreshToken] - Only passed in when setting config, then stored in secure storage + */ class AbsDatabaseWeb extends WebPlugin { constructor() { @@ -19,17 +32,30 @@ class AbsDatabaseWeb extends WebPlugin { return deviceData } + /** + * + * @param {ServerConnectionConfig} serverConnectionConfig + * @returns {Promise} + */ async setCurrentServerConnectionConfig(serverConnectionConfig) { var deviceData = await this.getDeviceData() - var ssc = deviceData.serverConnectionConfigs.find(_ssc => _ssc.id == serverConnectionConfig.id) + var ssc = deviceData.serverConnectionConfigs.find((_ssc) => _ssc.id == serverConnectionConfig.id) if (ssc) { deviceData.lastServerConnectionConfigId = ssc.id ssc.name = `${ssc.address} (${serverConnectionConfig.username})` ssc.token = serverConnectionConfig.token ssc.userId = serverConnectionConfig.userId ssc.username = serverConnectionConfig.username + ssc.version = serverConnectionConfig.version ssc.customHeaders = serverConnectionConfig.customHeaders || {} + + if (serverConnectionConfig.refreshToken) { + console.log('[AbsDatabase] Updating refresh token...', serverConnectionConfig.refreshToken) + // Only using local storage for web version that is only used for testing + localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken) + } + localStorage.setItem('device', JSON.stringify(deviceData)) } else { ssc = { @@ -40,8 +66,16 @@ class AbsDatabaseWeb extends WebPlugin { username: serverConnectionConfig.username, address: serverConnectionConfig.address, token: serverConnectionConfig.token, + version: serverConnectionConfig.version, customHeaders: serverConnectionConfig.customHeaders || {} } + + if (serverConnectionConfig.refreshToken) { + console.log('[AbsDatabase] Setting refresh token...', serverConnectionConfig.refreshToken) + // Only using local storage for web version that is only used for testing + localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken) + } + deviceData.serverConnectionConfigs.push(ssc) deviceData.lastServerConnectionConfigId = ssc.id localStorage.setItem('device', JSON.stringify(deviceData)) @@ -49,14 +83,26 @@ class AbsDatabaseWeb extends WebPlugin { return ssc } + async getRefreshToken({ serverConnectionConfigId }) { + console.log('[AbsDatabase] Getting refresh token...', serverConnectionConfigId) + const refreshToken = localStorage.getItem(`refresh_token_${serverConnectionConfigId}`) + return refreshToken ? { refreshToken } : null + } + + async clearRefreshToken({ serverConnectionConfigId }) { + console.log('[AbsDatabase] Clearing refresh token...', serverConnectionConfigId) + localStorage.removeItem(`refresh_token_${serverConnectionConfigId}`) + } + async removeServerConnectionConfig(serverConnectionConfigCallObject) { var serverConnectionConfigId = serverConnectionConfigCallObject.serverConnectionConfigId var deviceData = await this.getDeviceData() - deviceData.serverConnectionConfigs = deviceData.serverConnectionConfigs.filter(ssc => ssc.id != serverConnectionConfigId) + deviceData.serverConnectionConfigs = deviceData.serverConnectionConfigs.filter((ssc) => ssc.id != serverConnectionConfigId) localStorage.setItem('device', JSON.stringify(deviceData)) } async logout() { + console.log('[AbsDatabase] Logging out...') var deviceData = await this.getDeviceData() deviceData.lastServerConnectionConfigId = null localStorage.setItem('device', JSON.stringify(deviceData)) @@ -85,79 +131,81 @@ class AbsDatabaseWeb extends WebPlugin { } async getLocalLibraryItems(payload) { return { - value: [{ - id: 'local_test', - libraryItemId: 'test34', - serverAddress: 'https://abs.test.com', - serverUserId: 'test56', - folderId: 'test1', - absolutePath: 'a', - contentUrl: 'c', - isInvalid: false, - mediaType: 'book', - media: { - metadata: { - title: 'Test Book', - authorName: 'Test Author Name' + value: [ + { + id: 'local_test', + libraryItemId: 'test34', + serverAddress: 'https://abs.test.com', + serverUserId: 'test56', + folderId: 'test1', + absolutePath: 'a', + contentUrl: 'c', + isInvalid: false, + mediaType: 'book', + media: { + metadata: { + title: 'Test Book', + authorName: 'Test Author Name' + }, + coverPath: null, + tags: [], + audioFiles: [], + chapters: [], + tracks: [ + { + index: 1, + startOffset: 0, + duration: 10000, + title: 'Track Title 1', + contentUrl: 'test', + mimeType: 'audio/mpeg', + metadata: null, + isLocal: true, + localFileId: 'lf1', + audioProbeResult: {} + }, + { + index: 2, + startOffset: 0, + duration: 15000, + title: 'Track Title 2', + contentUrl: 'test2', + mimeType: 'audio/mpeg', + metadata: null, + isLocal: true, + localFileId: 'lf2', + audioProbeResult: {} + }, + { + index: 3, + startOffset: 0, + duration: 20000, + title: 'Track Title 3', + contentUrl: 'test3', + mimeType: 'audio/mpeg', + metadata: null, + isLocal: true, + localFileId: 'lf3', + audioProbeResult: {} + } + ] }, - coverPath: null, - tags: [], - audioFiles: [], - chapters: [], - tracks: [ + localFiles: [ { - index: 1, - startOffset: 0, - duration: 10000, - title: 'Track Title 1', + id: 'lf1', + filename: 'lf1.mp3', contentUrl: 'test', + absolutePath: 'test', + simplePath: 'test', mimeType: 'audio/mpeg', - metadata: null, - isLocal: true, - localFileId: 'lf1', - audioProbeResult: {} - }, - { - index: 2, - startOffset: 0, - duration: 15000, - title: 'Track Title 2', - contentUrl: 'test2', - mimeType: 'audio/mpeg', - metadata: null, - isLocal: true, - localFileId: 'lf2', - audioProbeResult: {} - }, - { - index: 3, - startOffset: 0, - duration: 20000, - title: 'Track Title 3', - contentUrl: 'test3', - mimeType: 'audio/mpeg', - metadata: null, - isLocal: true, - localFileId: 'lf3', - audioProbeResult: {} + size: 39048290 } - ] - }, - localFiles: [ - { - id: 'lf1', - filename: 'lf1.mp3', - contentUrl: 'test', - absolutePath: 'test', - simplePath: 'test', - mimeType: 'audio/mpeg', - size: 39048290 - } - ], - coverContentUrl: null, - coverAbsolutePath: null, - isLocal: true - }] + ], + coverContentUrl: null, + coverAbsolutePath: null, + isLocal: true + } + ] } } async getLocalLibraryItemsInFolder({ folderId }) { @@ -167,7 +215,7 @@ class AbsDatabaseWeb extends WebPlugin { return this.getLocalLibraryItems().then((data) => data.value[0]) } async getLocalLibraryItemByLId({ libraryItemId }) { - return this.getLocalLibraryItems().then((data) => data.value.find(lli => lli.libraryItemId == libraryItemId)) + return this.getLocalLibraryItems().then((data) => data.value.find((lli) => lli.libraryItemId == libraryItemId)) } async getAllLocalMediaProgress() { return { @@ -182,7 +230,7 @@ class AbsDatabaseWeb extends WebPlugin { isFinished: false, lastUpdate: 394089090, startedAt: 239048209, - finishedAt: null, + finishedAt: null // For local lib items from server to support server sync // var serverConnectionConfigId:String?, // var serverAddress:String?, @@ -240,7 +288,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: true, serverSyncMessage: null, - timestamp: Date.now() - (1000 * 60 * 22) + 13000 // 22 mins ago + 13s + timestamp: Date.now() - 1000 * 60 * 22 + 13000 // 22 mins ago + 13s }, { name: 'Play', @@ -250,7 +298,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: false, serverSyncSuccess: null, serverSyncMessage: null, - timestamp: Date.now() - (1000 * 60 * 22) // 22 mins ago + timestamp: Date.now() - 1000 * 60 * 22 // 22 mins ago }, { name: 'Pause', @@ -260,7 +308,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: false, serverSyncMessage: null, - timestamp: Date.now() - (1000 * 60 * 60) + (58000) // 1 hour ago + 58s + timestamp: Date.now() - 1000 * 60 * 60 + 58000 // 1 hour ago + 58s }, { name: 'Save', @@ -270,7 +318,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: true, serverSyncMessage: null, - timestamp: Date.now() - (1000 * 60 * 60) + (45000) // 1 hour ago + 45s + timestamp: Date.now() - 1000 * 60 * 60 + 45000 // 1 hour ago + 45s }, { name: 'Save', @@ -280,7 +328,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: true, serverSyncMessage: null, - timestamp: Date.now() - (1000 * 60 * 60) + (30000) // 1 hour ago + 30s + timestamp: Date.now() - 1000 * 60 * 60 + 30000 // 1 hour ago + 30s }, { name: 'Save', @@ -290,7 +338,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: true, serverSyncMessage: null, - timestamp: Date.now() - (1000 * 60 * 60) + (15000) // 1 hour ago + 15s + timestamp: Date.now() - 1000 * 60 * 60 + 15000 // 1 hour ago + 15s }, { name: 'Play', @@ -300,7 +348,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: false, serverSyncSuccess: null, serverSyncMessage: null, - timestamp: Date.now() - (1000 * 60 * 60) // 1 hour ago + timestamp: Date.now() - 1000 * 60 * 60 // 1 hour ago }, { name: 'Stop', @@ -310,7 +358,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: true, serverSyncMessage: null, - timestamp: Date.now() - (1000 * 60 * 60 * 25) + 10000 // 25 hours ago + 10s + timestamp: Date.now() - 1000 * 60 * 60 * 25 + 10000 // 25 hours ago + 10s }, { name: 'Seek', @@ -320,7 +368,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: true, serverSyncMessage: null, - timestamp: Date.now() - (1000 * 60 * 60 * 25) + 2000 // 25 hours ago + 2s + timestamp: Date.now() - 1000 * 60 * 60 * 25 + 2000 // 25 hours ago + 2s }, { name: 'Play', @@ -330,7 +378,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: false, serverSyncSuccess: null, serverSyncMessage: null, - timestamp: Date.now() - (1000 * 60 * 60 * 25) // 25 hours ago + timestamp: Date.now() - 1000 * 60 * 60 * 25 // 25 hours ago }, { name: 'Play', @@ -340,7 +388,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: false, serverSyncSuccess: null, serverSyncMessage: null, - timestamp: Date.now() - (1000 * 60 * 60 * 50) // 50 hours ago + timestamp: Date.now() - 1000 * 60 * 60 * 50 // 50 hours ago } ] } @@ -351,4 +399,4 @@ const AbsDatabase = registerPlugin('AbsDatabase', { web: () => new AbsDatabaseWeb() }) -export { AbsDatabase } \ No newline at end of file +export { AbsDatabase } diff --git a/plugins/capacitor/index.js b/plugins/capacitor/index.js index 29b4ae87..1a0bf03f 100644 --- a/plugins/capacitor/index.js +++ b/plugins/capacitor/index.js @@ -2,9 +2,10 @@ import Vue from 'vue' import { AbsAudioPlayer } from './AbsAudioPlayer' import { AbsDownloader } from './AbsDownloader' import { AbsFileSystem } from './AbsFileSystem' +import { AbsDatabase } from './AbsDatabase' import { AbsLogger } from './AbsLogger' import { Capacitor } from '@capacitor/core' Vue.prototype.$platform = Capacitor.getPlatform() -export { AbsAudioPlayer, AbsDownloader, AbsFileSystem, AbsLogger } +export { AbsAudioPlayer, AbsDownloader, AbsFileSystem, AbsLogger, AbsDatabase } diff --git a/plugins/db.js b/plugins/db.js index 8819434f..f41be58f 100644 --- a/plugins/db.js +++ b/plugins/db.js @@ -1,7 +1,7 @@ import { AbsDatabase } from './capacitor/AbsDatabase' class DbService { - constructor() { } + constructor() {} getDeviceData() { return AbsDatabase.getDeviceData().then((data) => { @@ -10,6 +10,26 @@ class DbService { }) } + /** + * Retrieves refresh token from secure storage + * @param {string} serverConnectionConfigId + * @return {Promise} + */ + async getRefreshToken(serverConnectionConfigId) { + const refreshTokenData = await AbsDatabase.getRefreshToken({ serverConnectionConfigId }) + return refreshTokenData?.refreshToken + } + + /** + * Clears refresh token from secure storage + * @param {string} serverConnectionConfigId + * @returns {Promise} + */ + async clearRefreshToken(serverConnectionConfigId) { + const result = await AbsDatabase.clearRefreshToken({ serverConnectionConfigId }) + return !!result?.success + } + setServerConnectionConfig(serverConnectionConfig) { return AbsDatabase.setCurrentServerConnectionConfig(serverConnectionConfig).then((data) => { console.log('Set server connection config', JSON.stringify(data)) @@ -29,10 +49,12 @@ class DbService { } getLocalFolders() { - return AbsDatabase.getLocalFolders().then((data) => data.value).catch((error) => { - console.error('Failed to load', error) - return null - }) + return AbsDatabase.getLocalFolders() + .then((data) => data.value) + .catch((error) => { + console.error('Failed to load', error) + return null + }) } getLocalFolder(folderId) { @@ -103,4 +125,20 @@ class DbService { export default ({ app, store }, inject) => { inject('db', new DbService()) -} \ No newline at end of file + + // Listen for token refresh events from native app + AbsDatabase.addListener('onTokenRefresh', (data) => { + console.log('[db] onTokenRefresh', data) + store.commit('user/setAccessToken', data.accessToken) + }) + + // Listen for token refresh failure events from native app + AbsDatabase.addListener('onTokenRefreshFailure', async (data) => { + console.log('[db] onTokenRefreshFailure', data) + // Clear store and redirect to login page + await store.dispatch('user/logout') + if (window.location.pathname !== '/connect') { + window.location.href = '/connect' + } + }) +} diff --git a/plugins/init.client.js b/plugins/init.client.js index aa90dd4e..e54f85b2 100644 --- a/plugins/init.client.js +++ b/plugins/init.client.js @@ -245,10 +245,35 @@ Vue.prototype.$sanitizeSlug = (str) => { return str } +/** + * Compares two semantic versioning strings to determine if the current version meets + * or exceeds the minimum version requirement. + * Only supports 3 part versions, e.g. "1.2.3" + * + * @param {string} currentVersion - The current version string to compare, e.g., "1.2.3". + * @param {string} minVersion - The minimum version string required, e.g., "1.0.0". + * @returns {boolean} - Returns true if the current version is greater than or equal + * to the minimum version, false otherwise. + */ +function isValidVersion(currentVersion, minVersion) { + if (!currentVersion || !minVersion) return false + const currentParts = currentVersion.split('.').map(Number) + const minParts = minVersion.split('.').map(Number) + + for (let i = 0; i < minParts.length; i++) { + if (currentParts[i] > minParts[i]) return true + if (currentParts[i] < minParts[i]) return false + } + + return true +} + export default ({ store, app }, inject) => { const eventBus = new Vue() inject('eventBus', eventBus) + inject('isValidVersion', isValidVersion) + // Set theme app.$localStore?.getTheme()?.then((theme) => { if (theme) { diff --git a/plugins/nativeHttp.js b/plugins/nativeHttp.js index f2586dc2..f4089ebb 100644 --- a/plugins/nativeHttp.js +++ b/plugins/nativeHttp.js @@ -1,10 +1,14 @@ import { CapacitorHttp } from '@capacitor/core' -export default function ({ store }, inject) { +export default function ({ store, $db, $socket }, inject) { const nativeHttp = { - request(method, _url, data, options = {}) { + async request(method, _url, data, options = {}) { + // When authorizing before a config is set, server config gets passed in as an option + let serverConnectionConfig = options.serverConnectionConfig || store.state.user.serverConnectionConfig + delete options.serverConnectionConfig + let url = _url - const headers = {} + let headers = {} if (!url.startsWith('http') && !url.startsWith('capacitor')) { const bearerToken = store.getters['user/getToken'] if (bearerToken) { @@ -12,22 +16,31 @@ export default function ({ store }, inject) { } else { console.warn('[nativeHttp] No Bearer Token for request') } - const serverUrl = store.getters['user/getServerAddress'] - if (serverUrl) { - url = `${serverUrl}${url}` + if (serverConnectionConfig?.address) { + url = `${serverConnectionConfig.address}${url}` } } if (data) { headers['Content-Type'] = 'application/json' } + if (options.headers) { + headers = { ...headers, ...options.headers } + delete options.headers + } console.log(`[nativeHttp] Making ${method} request to ${url}`) + return CapacitorHttp.request({ method, url, data, headers, ...options - }).then(res => { + }).then((res) => { + if (res.status === 401) { + console.error(`[nativeHttp] 401 status for url "${url}"`) + // Handle refresh token automatically + return this.handleTokenRefresh(method, url, data, headers, options, serverConnectionConfig) + } if (res.status >= 400) { console.error(`[nativeHttp] ${res.status} status for url "${url}"`) throw new Error(res.data) @@ -35,6 +48,186 @@ export default function ({ store }, inject) { return res.data }) }, + + /** + * Handles token refresh when a 401 Unauthorized response is received + * @param {string} method - HTTP method + * @param {string} url - Full URL + * @param {*} data - Request data + * @param {Object} headers - Request headers + * @param {Object} options - Additional options + * @param {{ id: string, address: string, version: string }} serverConnectionConfig + * @returns {Promise} - Promise that resolves with the response data + */ + async handleTokenRefresh(method, url, data, headers, options, serverConnectionConfig) { + try { + console.log('[nativeHttp] Attempting to refresh token...') + + if (!serverConnectionConfig?.id) { + console.error('[nativeHttp] No server connection config ID available for token refresh') + throw new Error('No server connection available') + } + + // Get refresh token from secure storage + const refreshToken = await $db.getRefreshToken(serverConnectionConfig.id) + if (!refreshToken) { + console.error('[nativeHttp] No refresh token available') + throw new Error('No refresh token available') + } + + // Attempt to refresh the token + const newTokens = await this.refreshAccessToken(refreshToken, serverConnectionConfig.address) + if (!newTokens?.accessToken) { + console.error('[nativeHttp] Failed to refresh access token') + throw new Error('Failed to refresh access token') + } + + // Update the store with new tokens + await this.updateTokens(newTokens, serverConnectionConfig) + + // Retry the original request with the new token + console.log('[nativeHttp] Retrying original request with new token...') + const retryResponse = await CapacitorHttp.request({ + method, + url, + data, + headers: { + ...headers, + Authorization: `Bearer ${newTokens.accessToken}` + }, + ...options + }) + + if (retryResponse.status >= 400) { + console.error(`[nativeHttp] Retry request failed with status ${retryResponse.status}`) + throw new Error(retryResponse.data) + } + + return retryResponse.data + } catch (error) { + console.error('[nativeHttp] Token refresh failed:', error) + + // If refresh fails, redirect to login + await this.handleRefreshFailure(serverConnectionConfig?.id) + throw error + } + }, + + /** + * Refreshes the access token using the refresh token + * @param {string} refreshToken - The refresh token + * @param {string} serverAddress - The server address + * @returns {Promise} - Promise that resolves with new tokens or null + */ + async refreshAccessToken(refreshToken, serverAddress) { + try { + if (!serverAddress) { + throw new Error('No server address available') + } + + console.log('[nativeHttp] Refreshing access token...') + + const response = await CapacitorHttp.post({ + url: `${serverAddress}/auth/refresh`, + headers: { + 'Content-Type': 'application/json', + 'x-refresh-token': refreshToken + }, + data: {} + }) + + if (response.status !== 200) { + console.error('[nativeHttp] Token refresh request failed:', response.status) + return null + } + + const userResponseData = response.data + if (!userResponseData.user?.accessToken) { + console.error('[nativeHttp] No access token in refresh response') + return null + } + + console.log('[nativeHttp] Successfully refreshed access token') + return { + accessToken: userResponseData.user.accessToken, + // Refresh token gets returned when refresh token is sent in x-refresh-token header + refreshToken: userResponseData.user.refreshToken + } + } catch (error) { + console.error('[nativeHttp] Failed to refresh access token:', error) + return null + } + }, + + /** + * Updates the store and secure storage with new tokens + * @param {Object} tokens - Object containing accessToken and refreshToken + * @param {{ id: string, address: string, version: string }} serverConnectionConfig + * @returns {Promise} - Promise that resolves when tokens are updated + */ + async updateTokens(tokens, serverConnectionConfig) { + try { + if (!serverConnectionConfig?.id) { + throw new Error('No server connection config ID available') + } + + // Update the config with new tokens + const updatedConfig = { + ...serverConnectionConfig, + token: tokens.accessToken, + refreshToken: tokens.refreshToken + } + + // Save updated config to secure storage, persists refresh token in secure storage + const savedConfig = await $db.setServerConnectionConfig(updatedConfig) + + // Update the store + store.commit('user/setAccessToken', tokens.accessToken) + + // Re-authenticate socket if necessary + if ($socket?.connected && !$socket.isAuthenticated) { + $socket.sendAuthenticate() + } else if (!$socket) { + console.warn('[nativeHttp] Socket not available, cannot re-authenticate') + } + + if (savedConfig) { + store.commit('user/setServerConnectionConfig', savedConfig) + } + + console.log('[nativeHttp] Successfully updated tokens in store and secure storage') + } catch (error) { + console.error('[nativeHttp] Failed to update tokens:', error) + throw error + } + }, + + /** + * Handles the case when token refresh fails + * @param {string} [serverConnectionConfigId] + * @returns {Promise} - Promise that resolves when logout is complete + */ + async handleRefreshFailure(serverConnectionConfigId) { + try { + console.log('[nativeHttp] Handling refresh failure - logging out user') + + // Clear store + await store.dispatch('user/logout') + + if (serverConnectionConfigId) { + // Clear refresh token for server connection config + await $db.clearRefreshToken(serverConnectionConfigId) + } + + // Redirect to login page + if (window.location.pathname !== '/connect') { + window.location.href = '/connect' + } + } catch (error) { + console.error('[nativeHttp] Failed to handle refresh failure:', error) + } + }, + get(url, options = {}) { return this.request('GET', url, undefined, options) }, @@ -49,4 +242,4 @@ export default function ({ store }, inject) { } } inject('nativeHttp', nativeHttp) -} \ No newline at end of file +} diff --git a/plugins/server.js b/plugins/server.js index 409ebe2d..90f6f715 100644 --- a/plugins/server.js +++ b/plugins/server.js @@ -9,7 +9,7 @@ class ServerSocket extends EventEmitter { this.socket = null this.connected = false this.serverAddress = null - this.token = null + this.isAuthenticated = false this.lastReconnectAttemptTime = 0 } @@ -26,7 +26,6 @@ class ServerSocket extends EventEmitter { connect(serverAddress, token) { this.serverAddress = serverAddress - this.token = token const serverUrl = new URL(serverAddress) const serverHost = `${serverUrl.protocol}//${serverUrl.host}` @@ -53,6 +52,7 @@ class ServerSocket extends EventEmitter { this.socket.on('connect', this.onConnect.bind(this)) this.socket.on('disconnect', this.onDisconnect.bind(this)) this.socket.on('init', this.onInit.bind(this)) + this.socket.on('auth_failed', this.onAuthFailed.bind(this)) this.socket.on('user_updated', this.onUserUpdated.bind(this)) this.socket.on('user_item_progress_updated', this.onUserItemProgressUpdated.bind(this)) this.socket.on('playlist_added', this.onPlaylistAdded.bind(this)) @@ -61,6 +61,11 @@ class ServerSocket extends EventEmitter { this.socket.io.on('reconnect_failed', this.onReconnectFailed.bind(this)) } + sendAuthenticate() { + // Required to connect a socket to a user + this.socket.emit('auth', this.$store.getters['user/getToken']) + } + removeListeners() { if (!this.socket) return this.socket.removeAllListeners() @@ -74,7 +79,7 @@ class ServerSocket extends EventEmitter { this.connected = true this.$store.commit('setSocketConnected', true) this.emit('connection-update', true) - this.socket.emit('auth', this.token) // Required to connect a user with their socket + this.sendAuthenticate() } onReconnectAttempt(attemptNumber) { @@ -101,6 +106,12 @@ class ServerSocket extends EventEmitter { onInit(data) { console.log('[SOCKET] Initial socket data received', data) this.emit('initialized', true) + this.isAuthenticated = true + } + + onAuthFailed(data) { + console.log('[SOCKET] Auth failed', data) + this.isAuthenticated = false } onUserUpdated(data) { diff --git a/store/user.js b/store/user.js index f0c23eb7..042eb55c 100644 --- a/store/user.js +++ b/store/user.js @@ -1,7 +1,10 @@ import { Browser } from '@capacitor/browser' +import { AbsLogger } from '@/plugins/capacitor' +import { CapacitorHttp } from '@capacitor/core' export const state = () => ({ user: null, + accessToken: null, serverConnectionConfig: null, settings: { mobileOrderBy: 'addedAt', @@ -17,7 +20,7 @@ export const getters = { getIsRoot: (state) => state.user && state.user.type === 'root', getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'), getToken: (state) => { - return state.user?.token || null + return state.accessToken || null }, getServerConnectionConfigId: (state) => { return state.serverConnectionConfig?.id || null @@ -28,9 +31,6 @@ export const getters = { getServerConfigName: (state) => { return state.serverConnectionConfig?.name || null }, - getCustomHeaders: (state) => { - return state.serverConnectionConfig?.customHeaders || null - }, getUserMediaProgress: (state) => (libraryItemId, episodeId = null) => { @@ -137,17 +137,103 @@ export const actions = { } catch (error) { console.error('Error opening browser', error) } + }, + async logout({ state, commit }, logoutFromServer = false) { + // Logging out from server deletes the session so the refresh token is no longer valid + // Currently this is not being used to support switching servers without logging back in (assuming refresh token is still valid) + // We may want to make this change in the future + if (state.serverConnectionConfig && logoutFromServer) { + const refreshToken = await this.$db.getRefreshToken(state.serverConnectionConfig.id) + const options = {} + if (refreshToken) { + // Refresh token is used to delete the session on the server + options.headers = { + 'x-refresh-token': refreshToken + } + } + // Logout from server + await this.$nativeHttp.post('/logout', null, options).catch((error) => { + console.error('Failed to logout', error) + }) + } + + await this.$db.logout() + this.$socket.logout() + this.$localStore.removeLastLibraryId() + commit('logout') + commit('libraries/setCurrentLibrary', null, { root: true }) + await AbsLogger.info({ tag: 'user', message: `Logged out from server ${state.serverConnectionConfig?.name || 'Not connected'}` }) + }, + async refreshToken({ getters, commit, state }) { + const refreshToken = await this.$db.getRefreshToken(getters.getServerConnectionConfigId) + if (!refreshToken) { + console.error('No refresh token found') + return null + } + + const serverAddress = getters.getServerAddress + + const response = await CapacitorHttp.post({ + url: `${serverAddress}/auth/refresh`, + headers: { + 'Content-Type': 'application/json', + 'x-refresh-token': refreshToken + }, + data: {} + }) + + if (response.status !== 200) { + console.error('[user] Token refresh request failed:', response.status) + return null + } + + const userResponseData = response.data + if (!userResponseData.user?.accessToken) { + console.error('[user] No access token in refresh response') + return null + } + + // Update the config with new tokens + const updatedConfig = { + ...state.serverConnectionConfig, + token: userResponseData.user.accessToken, + refreshToken: userResponseData.user.refreshToken + } + + // Save updated config to secure storage, persists refresh token in secure storage + const savedConfig = await this.$db.setServerConnectionConfig(updatedConfig) + + // Update the store + commit('setAccessToken', userResponseData.user.accessToken) + + // Re-authenticate socket if necessary + if (this.$socket?.connected && !this.$socket.isAuthenticated) { + this.$socket.sendAuthenticate() + } else if (!this.$socket) { + console.warn('[user] Socket not available, cannot re-authenticate') + } + + if (savedConfig) { + commit('setServerConnectionConfig', savedConfig) + } + + return userResponseData.user.accessToken } } export const mutations = { logout(state) { state.user = null + state.accessToken = null state.serverConnectionConfig = null }, setUser(state, user) { state.user = user }, + setAccessToken(state, accessToken) { + console.log('[user] setAccessToken', accessToken) + state.accessToken = accessToken + }, removeMediaProgress(state, id) { if (!state.user) return state.user.mediaProgress = state.user.mediaProgress.filter((mp) => mp.id != id) diff --git a/strings/en-us.json b/strings/en-us.json index 8ee7a956..ef8fb515 100644 --- a/strings/en-us.json +++ b/strings/en-us.json @@ -325,6 +325,9 @@ "MessageNoSeries": "No series", "MessageNoUpdatesWereNecessary": "No updates were necessary", "MessageNoUserPlaylists": "You have no playlists", + "MessageOldServerAuthReLoginRequired": "Authentication has been improved for security in server v2.26.0. All users are required to re-login.", + "MessageOldServerAuthWarning": "Server is using out-dated authentication", + "MessageOldServerAuthWarningHelp": "This server is running a version older than v2.26.0. A more secure authentication system was added in v2.26.0. It is strongly recommended to update the server to the latest version. If you have already updated the server, log in again to use the new authentication.", "MessageOldServerConnectionWarning": "Server connection config is using an old user ID. Please delete and re-add this server connection.", "MessageOldServerConnectionWarningHelp": "You originally set up the connection to this server prior to the database migration in 2.3.0, released June 2023. A future server update will remove the ability to sign in with this old connection. Please delete the existing server connection and connect again (using the same server address and credentials). If you have any downloaded media on this device, the media will need to be downloaded again to sync with the server.", "MessagePodcastSearchField": "Enter search term or RSS feed URL",