chahinebrini c1edef8abd feat(magic): RebreakMagic device-binding + DNS profile
- backend: /api/magic/{register,devices,profile,release} + AdGuard provisioning + 24h cooldown
- prisma: magic_binding_fields migration (additive on UserDevice)
- mac-app: Phase 2 - Login + MacRegistration + Profile install
- marketing: landing section + /download/rebreakmagic + DMG
- lyra: forbidden phrases + RebreakMagic coach guidance
2026-06-02 09:15:19 +02:00

273 lines
8.6 KiB
Swift

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