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
242 lines
8.0 KiB
Swift
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)
|
|
}
|
|
}
|