// // MAAuthManager.swift // Mobile Music Assistant // // Created by Sven Hanold on 26.03.26. // import Foundation import Security import OSLog private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "Auth") /// Manages authentication with Music Assistant server @Observable final class MAAuthManager { enum AuthError: LocalizedError { case invalidCredentials case networkError(Error) case keychainError(OSStatus) case noStoredCredentials case domainNotFound case connectionTimeout case sslError var errorDescription: String? { switch self { case .invalidCredentials: return "Invalid username or password" case .networkError(let error): // Provide more specific error messages if let urlError = error as? URLError { switch urlError.code { case .notConnectedToInternet: return "No internet connection. Please check your network." case .cannotFindHost: return "Cannot find server. Check the URL: The domain might not exist or is unreachable." case .cannotConnectToHost: return "Cannot connect to server. The server might be offline or unreachable." case .networkConnectionLost: return "Network connection lost. Please try again." case .timedOut: return "Connection timed out. The server is taking too long to respond." case .dnsLookupFailed: return "DNS lookup failed. Cannot resolve domain name. Check the URL." case .secureConnectionFailed: return "SSL/TLS connection failed. Check server certificate or use HTTP." case .serverCertificateUntrusted: return "Server certificate is not trusted. Add ATS exception to Info.plist." case .badURL: return "Invalid URL format. Check the server URL." default: return "Network error: \(urlError.localizedDescription)" } } return "Network error: \(error.localizedDescription)" case .keychainError(let status): return "Keychain error: \(status)" case .noStoredCredentials: return "No stored credentials found" case .domainNotFound: return "Domain not found. Check the server URL." case .connectionTimeout: return "Connection timeout. Server is not responding." case .sslError: return "SSL certificate error. Try HTTP or add ATS exception." } } } // MARK: - Properties private(set) var isAuthenticated = false private(set) var currentToken: String? private(set) var serverURL: URL? private let keychainService = "com.musicassistant.mobile" private let tokenKey = "auth_token" private let serverURLKey = "server_url" // UserDefaults for server URL (not sensitive) private let defaults = UserDefaults.standard // MARK: - Initialization init() { // Try to load saved credentials loadSavedCredentials() } // MARK: - Authentication /// Login to Music Assistant server func login(serverURL: URL, username: String, password: String) async throws -> String { logger.info("Attempting login to \(serverURL.absoluteString)") // Build login URL var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)! components.path = "/api/auth/login" guard let loginURL = components.url else { throw AuthError.invalidCredentials } // Create request var request = URLRequest(url: loginURL) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.timeoutInterval = 30 let loginRequest = MALoginRequest(username: username, password: password) request.httpBody = try JSONEncoder().encode(loginRequest) // Send request with better error handling do { let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { logger.error("Invalid response type") throw AuthError.networkError(URLError(.badServerResponse)) } logger.info("Login response status: \(httpResponse.statusCode)") // Handle different status codes switch httpResponse.statusCode { case 200: // Success - decode response do { let loginResponse = try JSONDecoder().decode(MALoginResponse.self, from: data) logger.info("Login successful - received short-lived token") return loginResponse.accessToken } catch { logger.error("Failed to decode login response: \(error.localizedDescription)") throw AuthError.networkError(error) } case 401: logger.error("Login failed - invalid credentials") throw AuthError.invalidCredentials default: logger.error("Login failed with status \(httpResponse.statusCode)") if let errorString = String(data: data, encoding: .utf8) { logger.error("Error response: \(errorString)") } throw AuthError.networkError(URLError(.badServerResponse)) } } catch let error as AuthError { throw error } catch { logger.error("Login network error: \(error.localizedDescription)") throw AuthError.networkError(error) } } /// Save token directly (for pre-generated long-lived tokens) func saveToken(serverURL: URL, token: String) throws { logger.info("Saving long-lived token") print("🔵 MAAuthManager.saveToken: Saving token for \(serverURL.absoluteString)") try saveCredentials(serverURL: serverURL, token: token) self.serverURL = serverURL self.currentToken = token self.isAuthenticated = true print("✅ MAAuthManager.saveToken: Token saved successfully") logger.info("Long-lived token saved successfully") } /// Logout and clear credentials func logout() { logger.info("Logging out") deleteCredentials() self.currentToken = nil self.serverURL = nil self.isAuthenticated = false } // MARK: - Credential Storage private func loadSavedCredentials() { // Load server URL from UserDefaults if let urlString = defaults.string(forKey: serverURLKey), let url = URL(string: urlString) { self.serverURL = url } // Load token from Keychain if let token = loadTokenFromKeychain() { self.currentToken = token self.isAuthenticated = true logger.info("Loaded saved credentials") } } private func saveCredentials(serverURL: URL, token: String) throws { // Save server URL to UserDefaults defaults.set(serverURL.absoluteString, forKey: serverURLKey) // Save token to Keychain try saveTokenToKeychain(token) } private func deleteCredentials() { // Remove from UserDefaults defaults.removeObject(forKey: serverURLKey) // Remove from Keychain deleteTokenFromKeychain() } // MARK: - Keychain Operations private func saveTokenToKeychain(_ token: String) throws { guard let tokenData = token.data(using: .utf8) else { throw AuthError.keychainError(errSecParam) } // Delete existing item first deleteTokenFromKeychain() // Add new item let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: tokenKey, kSecValueData as String: tokenData, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock ] let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecSuccess else { logger.error("Failed to save token to Keychain: \(status)") throw AuthError.keychainError(status) } logger.debug("Token saved to Keychain") } private func loadTokenFromKeychain() -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: tokenKey, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess, let data = result as? Data, let token = String(data: data, encoding: .utf8) else { if status != errSecItemNotFound { logger.error("Failed to load token from Keychain: \(status)") } return nil } logger.debug("Token loaded from Keychain") return token } private func deleteTokenFromKeychain() { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: tokenKey ] let status = SecItemDelete(query as CFDictionary) if status == errSecSuccess { logger.debug("Token deleted from Keychain") } else if status != errSecItemNotFound { logger.error("Failed to delete token from Keychain: \(status)") } } }