import AppKit import Foundation import Security /// Magic-Session — Mac-App speichert nur den mgc_*-Token (kein Supabase-JWT mehr). /// Token wird beim Pairing in der Native-App generiert und hier 1:1 als /// `Authorization: Bearer mgc_...` an /api/magic/* gesendet. struct AuthSession: Codable { /// "mgc_" — wird als Bearer-Token verwendet. let accessToken: String /// Server-Session-ID (zur Anzeige + Revoke in Native-App). let sessionId: String /// User-ID nicht zwingend benötigt — wir holen Devices via Token. Display nur. let userId: String /// Zeitpunkt des Pairings (für Anzeige). let createdAt: Date /// Optionales Label (z.B. Mac-Hostname). Display nur. let label: String? // Backwards-compat mit altem Code (immer false — Magic-Tokens laufen nicht ab): var isExpired: Bool { false } var refreshToken: String { "" } var expiresAt: Date { Date.distantFuture } var email: String { label ?? "RebreakMagic" } } enum AuthError: Error, LocalizedError { case invalidCode case codeExpired case codeUsed case networkError(String) case configMissing(String) case keychainError(OSStatus) case tokenExpired case refreshFailed var errorDescription: String? { switch self { case .invalidCode: return "Code ungültig. Bitte in der Rebreak-App einen neuen Code generieren." case .codeExpired: return "Code abgelaufen. Bitte einen neuen Code generieren." case .codeUsed: return "Code wurde bereits verwendet." 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 pairen." case .refreshFailed: return "Session ungültig. Bitte neu pairen." } } } private struct PairRedeemResponseEnvelope: Codable { let success: Bool let data: PairRedeemResponseData } private struct PairRedeemResponseData: Codable { let token: String let sessionId: String let createdAt: String } /// AuthService — managt Pairing-Code-Login + Keychain-Persistence. /// /// Config aus ~/.config/rebreak-magic/config.json (optional): /// { "backendBaseUrl": "https://app.rebreak.org" } @MainActor final class AuthService { static let shared = AuthService() private let keychainService = "org.rebreak.magic" private let keychainAccount = "magic-session" private var cachedSession: AuthSession? private init() {} // MARK: - Config private struct Config: Codable { let backendBaseUrl: String? } private static let configPath: String = { let home = FileManager.default.homeDirectoryForCurrentUser.path return "\(home)/.config/rebreak-magic/config.json" }() private func loadBackendUrl() -> String { if let envUrl = ProcessInfo.processInfo.environment["REBREAK_BACKEND_URL"] { return envUrl } let url = URL(fileURLWithPath: Self.configPath) guard FileManager.default.fileExists(atPath: Self.configPath), let data = try? Data(contentsOf: url), let config = try? JSONDecoder().decode(Config.self, from: data), let base = config.backendBaseUrl else { return "https://staging.rebreak.org" } return base } // MARK: - Sign In via Pairing-Code /// Tauscht einen 6-stelligen Pairing-Code (aus der Native-App) gegen eine /// MagicSession. Speichert Token in macOS-Keychain. func signInWithPairingCode(_ code: String) async throws -> AuthSession { let trimmed = code.trimmingCharacters(in: .whitespaces) guard trimmed.range(of: "^\\d{6}$", options: .regularExpression) != nil else { throw AuthError.invalidCode } let base = loadBackendUrl() guard let url = URL(string: "\(base)/api/magic/pair/redeem") else { throw AuthError.configMissing("Ungültige backendBaseUrl") } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let hostname = Host.current().localizedName ?? ProcessInfo.processInfo.hostName let body: [String: String] = ["code": trimmed, "label": hostname] request.httpBody = try JSONEncoder().encode(body) let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse else { throw AuthError.networkError("Keine HTTP-Response") } switch http.statusCode { case 200: break case 400, 404: throw AuthError.invalidCode case 410: let bodyStr = String(data: data, encoding: .utf8) ?? "" if bodyStr.contains("verwendet") { throw AuthError.codeUsed } throw AuthError.codeExpired default: let bodyStr = String(data: data, encoding: .utf8) ?? "" throw AuthError.networkError("HTTP \(http.statusCode): \(bodyStr)") } let envelope = try JSONDecoder().decode(PairRedeemResponseEnvelope.self, from: data) let createdAt = ISO8601DateFormatter().date(from: envelope.data.createdAt) ?? Date() let session = AuthSession( accessToken: envelope.data.token, sessionId: envelope.data.sessionId, userId: "", createdAt: createdAt, label: hostname ) try saveToKeychain(session) cachedSession = session return session } // MARK: - Sign Out func signOut() async { cachedSession = nil deleteFromKeychain() } // MARK: - Current Session func currentSession() -> AuthSession? { if let cached = cachedSession { return cached } if let loaded = loadFromKeychain() { cachedSession = loaded return loaded } return nil } // MARK: - "Refresh" (no-op für Magic-Tokens) /// Magic-Tokens laufen nicht ab — diese Methode existiert für Backwards-Compat /// mit MagicAPIClient, der sie vor jedem Request aufruft. func refreshSessionIfNeeded() async throws -> AuthSession { guard let session = currentSession() else { throw AuthError.tokenExpired } return session } // 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: keychainAccount, kSecValueData as String: data ] SecItemDelete(query as CFDictionary) let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecSuccess else { throw AuthError.keychainError(status) } } private func loadFromKeychain() -> AuthSession? { 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() { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: keychainAccount ] SecItemDelete(query as CFDictionary) } }