- 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
273 lines
8.6 KiB
Swift
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)
|
|
}
|
|
}
|