import Foundation import Security /// Persists the session token issued by `POST /api/auth/login` / /// `/api/auth/signup` in the iOS Keychain — never UserDefaults, which is /// unencrypted on disk. This is the native counterpart to the web app's /// httpOnly session cookie (see `src/lib/auth/session.ts`); the backend has /// no idea which storage a given Bearer token came from. final class KeychainTokenStore { static let shared = KeychainTokenStore() private let service = "app.scan-receipts.ios.session" private let account = "session-token" private init() {} var token: String? { get { read() } set { if let newValue { save(newValue) } else { delete() } } } private func query(extra: [String: Any] = [:]) -> [String: Any] { var q: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, ] extra.forEach { q[$0] = $1 } return q } private func read() -> String? { var query = self.query() query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess, let data = result as? Data else { return nil } return String(data: data, encoding: .utf8) } private func save(_ token: String) { let data = Data(token.utf8) // Overwrite-or-insert: a plain SecItemAdd fails with errSecDuplicateItem // on every login after the first, since the (service, account) pair is // stable by design. let attributes: [String: Any] = [ kSecValueData as String: data, // Available as soon as the device is unlocked once after boot — // matches what a background-launched app (e.g. from a push // notification) needs, without requiring the passcode be entered // in *this* unlock cycle the way `WhenUnlocked` would. kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, ] let updateStatus = SecItemUpdate(query() as CFDictionary, attributes as CFDictionary) if updateStatus == errSecItemNotFound { var insert = query() attributes.forEach { insert[$0] = $1 } SecItemAdd(insert as CFDictionary, nil) } } private func delete() { SecItemDelete(query() as CFDictionary) } }