From 5766c49f6186187cd921a8db40a411984aa4256a Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 5 Jul 2025 16:46:37 -0500 Subject: [PATCH] Update iOS ApiClient to handle token refresh --- .../audiobookshelf/app/server/ApiHandler.kt | 1 + ios/App/App/plugins/AbsDatabase.swift | 9 + ios/App/Shared/util/ApiClient.swift | 392 +++++++++++++++++- ios/App/Shared/util/Database.swift | 13 + 4 files changed, 393 insertions(+), 22 deletions(-) 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 a3a9b550..c45a7e5a 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 @@ -404,6 +404,7 @@ class ApiHandler(var ctx:Context) { errorObj.put("error", "Authentication failed - please login again") callback(errorObj) + // TODO: Notify webview frontend } catch (e: Exception) { Log.e(tag, "handleRefreshFailure: Error during failure handling", e) val errorObj = JSObject() diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index ac8d5457..2ba5a1c1 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -53,6 +53,15 @@ 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", "") diff --git a/ios/App/Shared/util/ApiClient.swift b/ios/App/Shared/util/ApiClient.swift index 37e831e1..9b1804e6 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,297 @@ 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 + } + + retryRequest.responseDecodable(of: decodable) { response in + switch response.result { + case .success(let obj): + callback?(obj) + case .failure(let error): + logger.error("retryOriginalRequest: Retry request failed: \(error)") + 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 +452,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 +482,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 +570,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 +578,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 +659,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 f40a57fe..d87aac56 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -61,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()