286 lines
10 KiB
Swift
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)")
|
|
}
|
|
}
|
|
}
|