Files

72 lines
2.6 KiB
Swift

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)
}
}