import Foundation import Security /// Access-token persistence. The scaffold/preview/unsigned-build path uses InMemoryTokenStore /// (Keychain on an unsigned binary can prompt or return errSecMissingEntitlement); the signed app /// uses KeychainStore. Only the ACCESS token is stored — refresh rides the HttpOnly cookie. public protocol TokenPersistence: Sendable { func read() -> String? func save(_ token: String) throws func delete() throws } public final class InMemoryTokenStore: TokenPersistence, @unchecked Sendable { private let lock = NSLock() private var token: String? public init() {} public func read() -> String? { lock.withLock { token } } public func save(_ token: String) throws { lock.withLock { self.token = token } } public func delete() throws { lock.withLock { token = nil } } } public struct KeychainStore: TokenPersistence, Sendable { private let service: String private let account: String public init(service: String = "net.hyungi.ds-app", account: String = "access_token") { self.service = service self.account = account } public func read() -> 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 item: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let data = item as? Data, let token = String(data: data, encoding: .utf8) else { return nil } return token } public func save(_ token: String) throws { let base: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, ] SecItemDelete(base as CFDictionary) // upsert var add = base add[kSecValueData as String] = Data(token.utf8) add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock let status = SecItemAdd(add as CFDictionary, nil) guard status == errSecSuccess else { throw DSError.transport(underlying: "keychain save failed (\(status))") } } public func delete() throws { let base: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, ] SecItemDelete(base as CFDictionary) } }