Initial Commit
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
//
|
||||
// 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user