import Foundation import Security /// Auth-Session mit Supabase JWT + Refresh-Token. /// Stored in macOS Keychain für persistence über App-Restarts. struct AuthSession: Codable { let accessToken: String let refreshToken: String let userId: String let expiresAt: Date let email: String var isExpired: Bool { Date().addingTimeInterval(60) > expiresAt } } enum AuthError: Error, LocalizedError { case invalidCredentials case networkError(String) case configMissing(String) case keychainError(OSStatus) case tokenExpired case refreshFailed var errorDescription: String? { switch self { case .invalidCredentials: return "Email oder Passwort falsch" case .networkError(let msg): return "Netzwerkfehler: \(msg)" case .configMissing(let msg): return "Konfiguration fehlt: \(msg)" case .keychainError(let status): return "Keychain-Fehler: \(status)" case .tokenExpired: return "Session abgelaufen. Bitte neu einloggen." case .refreshFailed: return "Token-Refresh fehlgeschlagen. Bitte neu einloggen." } } } /// Supabase Auth-Response Schemas private struct SupabaseAuthResponse: Codable { let access_token: String let refresh_token: String let expires_in: Int let user: SupabaseUser } private struct SupabaseUser: Codable { let id: String let email: String? } private struct SupabaseRefreshResponse: Codable { let access_token: String let refresh_token: String let expires_in: Int } /// AuthService — managt Supabase-Login + Keychain-Persistence. /// /// Config aus ~/.config/rebreak-magic/config.json: /// { "supabaseUrl": "https://xxx.supabase.co", "supabaseAnonKey": "..." } @MainActor final class AuthService { static let shared = AuthService() private let keychainService = "org.rebreak.magic" private var cachedSession: AuthSession? private init() {} // MARK: - Config private struct Config: Codable { let supabaseUrl: String let supabaseAnonKey: String let backendBaseUrl: String? } private static let configPath: String = { let home = FileManager.default.homeDirectoryForCurrentUser.path return "\(home)/.config/rebreak-magic/config.json" }() private func loadConfig() throws -> Config { let url = URL(fileURLWithPath: Self.configPath) guard FileManager.default.fileExists(atPath: Self.configPath) else { throw AuthError.configMissing("~/.config/rebreak-magic/config.json nicht gefunden. Bitte erstellen mit supabaseUrl + supabaseAnonKey.") } do { let data = try Data(contentsOf: url) return try JSONDecoder().decode(Config.self, from: data) } catch { throw AuthError.configMissing(error.localizedDescription) } } // MARK: - Sign In func signIn(email: String, password: String) async throws -> AuthSession { let config = try loadConfig() guard let url = URL(string: "\(config.supabaseUrl)/auth/v1/token?grant_type=password") else { throw AuthError.configMissing("Ungültige supabaseUrl") } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(config.supabaseAnonKey, forHTTPHeaderField: "apikey") let body = ["email": email, "password": password] request.httpBody = try JSONEncoder().encode(body) let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw AuthError.networkError("Keine HTTP-Response") } if httpResponse.statusCode == 400 { throw AuthError.invalidCredentials } guard httpResponse.statusCode == 200 else { let body = String(data: data, encoding: .utf8) ?? "" throw AuthError.networkError("HTTP \(httpResponse.statusCode): \(body)") } let authResponse = try JSONDecoder().decode(SupabaseAuthResponse.self, from: data) let session = AuthSession( accessToken: authResponse.access_token, refreshToken: authResponse.refresh_token, userId: authResponse.user.id, expiresAt: Date().addingTimeInterval(TimeInterval(authResponse.expires_in)), email: authResponse.user.email ?? email ) // Save to keychain try saveToKeychain(session) cachedSession = session return session } // MARK: - Sign Out func signOut() async { cachedSession = nil deleteFromKeychain(account: cachedSession?.email ?? "session") } // MARK: - Current Session func currentSession() -> AuthSession? { if let cached = cachedSession { return cached } // Load from keychain if let loaded = loadFromKeychain() { cachedSession = loaded return loaded } return nil } // MARK: - Refresh Token func refreshSessionIfNeeded() async throws -> AuthSession { guard let session = currentSession() else { throw AuthError.tokenExpired } if !session.isExpired { return session } // Refresh via Supabase let config = try loadConfig() guard let url = URL(string: "\(config.supabaseUrl)/auth/v1/token?grant_type=refresh_token") else { throw AuthError.configMissing("Ungültige supabaseUrl") } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(config.supabaseAnonKey, forHTTPHeaderField: "apikey") let body = ["refresh_token": session.refreshToken] request.httpBody = try JSONEncoder().encode(body) let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw AuthError.refreshFailed } let refreshResponse = try JSONDecoder().decode(SupabaseRefreshResponse.self, from: data) let newSession = AuthSession( accessToken: refreshResponse.access_token, refreshToken: refreshResponse.refresh_token, userId: session.userId, expiresAt: Date().addingTimeInterval(TimeInterval(refreshResponse.expires_in)), email: session.email ) try saveToKeychain(newSession) cachedSession = newSession return newSession } // MARK: - Keychain private func saveToKeychain(_ session: AuthSession) throws { let data = try JSONEncoder().encode(session) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: session.email, kSecValueData as String: data ] // Delete existing first SecItemDelete(query as CFDictionary) let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecSuccess else { throw AuthError.keychainError(status) } } private func loadFromKeychain() -> AuthSession? { // Try to load with a wildcard account search let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) guard status == errSecSuccess, let data = item as? Data, let session = try? JSONDecoder().decode(AuthSession.self, from: data) else { return nil } return session } private func deleteFromKeychain(account: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: account ] SecItemDelete(query as CFDictionary) } }