import Foundation import Security /// Legt Tokens im Schlüsselbund ab. /// /// Nicht in `UserDefaults`: ein Server-Token gibt vollen Zugriff auf die /// Mediathek und bleibt gültig, bis er am Server zurückgezogen wird. In den /// Voreinstellungen läge er als Klartext in einer Datei, die jedes Backup /// mitnimmt. /// /// Der Kontoname trennt die Server: `plex.server` und `jellyfin.server` /// liegen nebeneinander. Wer zwischen beiden wechselt, soll beim /// Zurückwechseln nicht neu anmelden müssen. public protocol TokenStoring: Sendable { func store(_ token: String, for account: String) throws func token(for account: String) -> String? func remove(for account: String) } public struct KeychainTokenStore: TokenStoring { private let service: String public init(service: String) { self.service = service } public enum Failure: Error, Equatable { case keychain(status: OSStatus) } public func store(_ token: String, for account: String) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, ] let attributes: [String: Any] = [ kSecValueData as String: Data(token.utf8), // Ohne Gerätesperre kein Zugriff, und nicht in Backups übertragbar. // Ein Token, der auf einem anderen Gerät wieder auftaucht, wäre // ein stiller Zweitzugang zur Mediathek. kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, ] let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) switch status { case errSecSuccess: return case errSecItemNotFound: let insert = query.merging(attributes) { current, _ in current } let addStatus = SecItemAdd(insert as CFDictionary, nil) guard addStatus == errSecSuccess else { throw Failure.keychain(status: addStatus) } default: throw Failure.keychain(status: status) } } public func token(for account: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, ] var result: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil } return String(data: data, encoding: .utf8) } public func remove(for account: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, ] SecItemDelete(query as CFDictionary) } } /// Ein Ablageort im Arbeitsspeicher — für Tests und SwiftUI-Vorschauen. public final class InMemoryTokenStore: TokenStoring, @unchecked Sendable { private let lock = NSLock() private var tokens: [String: String] = [:] public init() {} public func store(_ token: String, for account: String) throws { lock.withLock { tokens[account] = token } } public func token(for account: String) -> String? { lock.withLock { tokens[account] } } public func remove(for account: String) { _ = lock.withLock { tokens.removeValue(forKey: account) } } }