chahinebrini 941dd60f36 feat(magic): pairing-code login flow
Backend:
- MagicPairingCode + MagicSession Prisma models
- /api/magic/pair/create (6-digit code, 10min TTL, single-use)
- /api/magic/pair/redeem (no auth, returns mgc_* token)
- /api/magic/info (public DMG metadata)
- requireUser() accepts mgc_* tokens

Mac-App (RebreakMagic):
- LoginView: 6-digit code input (OTP-style), real AppIcon, no signup
- AuthService: signInWithPairingCode() replaces email/pw flow

Native-App:
- MagicSheet (TrueSheet) in Settings: download + code generator + linked Macs
- AddMacSheet: subtle banner pointing to /settings
- de/en locales
2026-06-03 00:18:24 +02:00

242 lines
8.0 KiB
Swift

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_<base64url>" 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://app.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)
}
}