Files
MobileMusicAssistant/Mobile Music Assistant/ServicesMAAuthManager.swift
T
2026-03-27 09:21:41 +01:00

286 lines
10 KiB
Swift

//
// 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)")
}
}
}