From 467fedbfe7051fd216b462b36e150d4f357b01c3 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 4 Jul 2025 17:41:19 -0500 Subject: [PATCH] Update to use x-refresh-token header, update logout to clear refresh token, add AbsLogger logs for android refresh --- .../audiobookshelf/app/plugins/AbsDatabase.kt | 10 +++ .../audiobookshelf/app/server/ApiHandler.kt | 25 +++--- components/app/SideDrawer.vue | 16 +--- components/readers/Reader.vue | 1 + pages/account.vue | 12 +-- plugins/capacitor/AbsAudioPlayer.js | 17 +++- plugins/capacitor/AbsDatabase.js | 6 ++ plugins/db.js | 20 +++++ plugins/nativeHttp.js | 77 ++++++------------- store/user.js | 29 +++++++ 10 files changed, 123 insertions(+), 90 deletions(-) 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 39a0b650..861ae692 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 @@ -216,6 +216,16 @@ class AbsDatabase : Plugin() { } } + @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() 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 64841c0b..a3a9b550 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 @@ -123,7 +123,7 @@ class ApiHandler(var ctx:Context) { response.use { if (it.code == 401) { // Handle 401 Unauthorized by attempting token refresh - Log.d(tag, "Received 401, attempting token refresh") + AbsLogger.info(tag, "makeRequest: 401 Unauthorized for request to \"${request.url}\" - attempt token refresh") handleTokenRefresh(request, httpClient, cb) return } @@ -175,12 +175,12 @@ class ApiHandler(var ctx:Context) { */ private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) { try { - Log.d(tag, "handleTokenRefresh: Starting token refresh process") + 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()) { - Log.e(tag, "handleTokenRefresh: No server connection config ID available") + 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) @@ -190,7 +190,7 @@ class ApiHandler(var ctx:Context) { // Get refresh token from secure storage val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId) if (refreshToken.isNullOrEmpty()) { - Log.e(tag, "handleTokenRefresh: No refresh token available for server $serverConnectionConfigId") + 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) @@ -203,7 +203,7 @@ class ApiHandler(var ctx:Context) { val refreshEndpoint = "${DeviceManager.serverAddress}/auth/refresh" val refreshRequest = Request.Builder() .url(refreshEndpoint) - .addHeader("Authorization", "Bearer $refreshToken") + .addHeader("x-refresh-token", refreshToken) .addHeader("Content-Type", "application/json") .post(EMPTY_REQUEST) .build() @@ -213,13 +213,14 @@ class ApiHandler(var ctx:Context) { 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) { - Log.e(tag, "handleTokenRefresh: Refresh request failed with status ${it.code}") + AbsLogger.error(tag, "handleTokenRefresh: Refresh request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}") handleRefreshFailure(callback) return } @@ -230,7 +231,7 @@ class ApiHandler(var ctx:Context) { val userObj = responseJson.optJSONObject("user") if (userObj == null) { - Log.e(tag, "handleTokenRefresh: No user object in refresh response") + AbsLogger.error(tag, "handleTokenRefresh: No user object in refresh response for server ${DeviceManager.serverConnectionConfigString}") handleRefreshFailure(callback) return } @@ -239,7 +240,7 @@ class ApiHandler(var ctx:Context) { val newRefreshToken = userObj.optString("refreshToken") if (newAccessToken.isEmpty()) { - Log.e(tag, "handleTokenRefresh: No access token in refresh response") + AbsLogger.error(tag, "handleTokenRefresh: No access token in refresh response for server ${DeviceManager.serverConnectionConfigString}") handleRefreshFailure(callback) return } @@ -255,6 +256,7 @@ class ApiHandler(var ctx:Context) { } 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) } } @@ -297,8 +299,10 @@ class ApiHandler(var ctx:Context) { // 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})") } } @@ -325,6 +329,7 @@ class ApiHandler(var ctx:Context) { 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) @@ -334,6 +339,7 @@ class ApiHandler(var ctx:Context) { 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) @@ -366,6 +372,7 @@ class ApiHandler(var ctx:Context) { } 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) @@ -389,7 +396,7 @@ class ApiHandler(var ctx:Context) { // Remove refresh token from secure storage val serverConnectionConfigId = DeviceManager.serverConnectionConfigId - if (!serverConnectionConfigId.isNullOrEmpty()) { + if (serverConnectionConfigId.isNotEmpty()) { secureStorage.removeRefreshToken(serverConnectionConfigId) } diff --git a/components/app/SideDrawer.vue b/components/app/SideDrawer.vue index ee788317..7173f790 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/readers/Reader.vue b/components/readers/Reader.vue index 137457a4..b1318617 100644 --- a/components/readers/Reader.vue +++ b/components/readers/Reader.vue @@ -260,6 +260,7 @@ export default { return null }, ebookFile() { + if (!this.media) return null // ebook file id is passed when reading a supplementary ebook if (this.ebookFileId) { return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId) diff --git a/pages/account.vue b/pages/account.vue index eb686e8e..78c16b8b 100644 --- a/pages/account.vue +++ b/pages/account.vue @@ -48,17 +48,7 @@ export default { 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/plugins/capacitor/AbsAudioPlayer.js b/plugins/capacitor/AbsAudioPlayer.js index b00dde3f..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}` + + 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 971f7ee0..059abe30 100644 --- a/plugins/capacitor/AbsDatabase.js +++ b/plugins/capacitor/AbsDatabase.js @@ -69,6 +69,11 @@ class AbsDatabaseWeb extends WebPlugin { 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() @@ -77,6 +82,7 @@ class AbsDatabaseWeb extends WebPlugin { } async logout() { + console.log('[AbsDatabase] Logging out...') var deviceData = await this.getDeviceData() deviceData.lastServerConnectionConfigId = null localStorage.setItem('device', JSON.stringify(deviceData)) diff --git a/plugins/db.js b/plugins/db.js index 747e6e56..7b7740e1 100644 --- a/plugins/db.js +++ b/plugins/db.js @@ -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)) diff --git a/plugins/nativeHttp.js b/plugins/nativeHttp.js index 2d4bc908..68b8bc6c 100644 --- a/plugins/nativeHttp.js +++ b/plugins/nativeHttp.js @@ -1,7 +1,6 @@ import { CapacitorHttp } from '@capacitor/core' -import { AbsDatabase } from '@/plugins/capacitor' -export default function ({ store }, inject) { +export default function ({ store, $db }, inject) { const nativeHttp = { async request(method, _url, data, options = {}) { // When authorizing before a config is set, server config gets passed in as an option @@ -9,7 +8,7 @@ export default function ({ store }, inject) { 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) { @@ -24,6 +23,10 @@ export default function ({ store }, inject) { 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, @@ -65,15 +68,15 @@ export default function ({ store }, inject) { } // Get refresh token from secure storage - const refreshTokenData = await this.getRefreshToken(serverConnectionConfig.id) - if (!refreshTokenData || !refreshTokenData.refreshToken) { + 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(refreshTokenData.refreshToken, serverConnectionConfig.address) - if (!newTokens || !newTokens.accessToken) { + 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') } @@ -83,15 +86,15 @@ export default function ({ store }, inject) { // Retry the original request with the new token console.log('[nativeHttp] Retrying original request with new token...') - const newHeaders = options?.headers ? { ...options.headers } : { ...headers } - newHeaders['Authorization'] = `Bearer ${newTokens.accessToken}` - const retryResponse = await CapacitorHttp.request({ method, url, data, - ...options, - headers: newHeaders + headers: { + ...headers, + Authorization: `Bearer ${newTokens.accessToken}` + }, + ...options }) if (retryResponse.status >= 400) { @@ -104,26 +107,11 @@ export default function ({ store }, inject) { console.error('[nativeHttp] Token refresh failed:', error) // If refresh fails, redirect to login - await this.handleRefreshFailure() + await this.handleRefreshFailure(serverConnectionConfig?.id) throw error } }, - /** - * Retrieves refresh token from secure storage - * @param {string} serverConnectionConfigId - Server connection config ID - * @returns {Promise} - Promise that resolves with refresh token data or null - */ - async getRefreshToken(serverConnectionConfigId) { - try { - console.log('[nativeHttp] Getting refresh token...') - return await AbsDatabase.getRefreshToken({ serverConnectionConfigId }) - } catch (error) { - console.error('[nativeHttp] Failed to get refresh token:', error) - return null - } - }, - /** * Refreshes the access token using the refresh token * @param {string} refreshToken - The refresh token @@ -142,8 +130,7 @@ export default function ({ store }, inject) { url: `${serverAddress}/auth/refresh`, headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${refreshToken}`, - 'X-Return-Tokens': 'true' + 'x-refresh-token': refreshToken }, data: {} }) @@ -162,7 +149,8 @@ export default function ({ store }, inject) { console.log('[nativeHttp] Successfully refreshed access token') return { accessToken: userResponseData.user.accessToken, - refreshToken: userResponseData.user.refreshToken || refreshToken // Use new refresh token if provided, otherwise keep the old one + // 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) @@ -190,7 +178,7 @@ export default function ({ store }, inject) { } // Save updated config to secure storage - const savedConfig = await AbsDatabase.setCurrentServerConnectionConfig(updatedConfig) + const savedConfig = await $db.setServerConnectionConfig(updatedConfig) // Update the store store.commit('user/setAccessToken', tokens.accessToken) @@ -208,19 +196,15 @@ export default function ({ store }, inject) { /** * Handles the case when token refresh fails + * @param {string} [serverConnectionConfigId] * @returns {Promise} - Promise that resolves when logout is complete */ - async handleRefreshFailure() { + async handleRefreshFailure(serverConnectionConfigId) { try { console.log('[nativeHttp] Handling refresh failure - logging out user') - // Clear the store - store.commit('user/setUser', null) - store.commit('user/setAccessToken', null) - store.commit('user/setServerConnectionConfig', null) - - // Logout from database - await AbsDatabase.logout() + // Logout from server and clear store + await store.dispatch('user/logout', { serverConnectionConfigId }) // Redirect to login page if (window.location.pathname !== '/connect') { @@ -231,19 +215,6 @@ export default function ({ store }, inject) { } }, - /** - * Gets device data from the database - * @returns {Promise} - Promise that resolves with device data - */ - async getDeviceData() { - try { - return await AbsDatabase.getDeviceData() - } catch (error) { - console.error('[nativeHttp] Failed to get device data:', error) - return { serverConnectionConfigs: [] } - } - }, - get(url, options = {}) { return this.request('GET', url, undefined, options) }, diff --git a/store/user.js b/store/user.js index 120702ad..4b013157 100644 --- a/store/user.js +++ b/store/user.js @@ -1,4 +1,5 @@ import { Browser } from '@capacitor/browser' +import { AbsLogger } from '@/plugins/capacitor' export const state = () => ({ user: null, @@ -135,12 +136,40 @@ export const actions = { } catch (error) { console.error('Error opening browser', error) } + }, + async logout({ state, commit }, { serverConnectionConfigId }) { + if (state.serverConnectionConfig) { + 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.clearRefreshToken(state.serverConnectionConfig.id) + } else if (serverConnectionConfigId) { + // When refresh fails before a server connection config is set, clear refresh token for server connection config + await this.$db.clearRefreshToken(serverConnectionConfigId) + } + + 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'}` }) } } export const mutations = { logout(state) { state.user = null + state.accessToken = null state.serverConnectionConfig = null }, setUser(state, user) {