3520c8f82a
- DSEndpoint: method/path/query/body single source (trailing slashes preserved, nil query skipped) - KeychainStore + InMemoryTokenStore (TokenPersistence); TokenProvider actor with single-flight refresh (Task handle, cleared on completion) - LiveDSClient: URLSession + shared cookie storage, Bearer injection, 401 -> single-flight refresh -> one retry (never on login/refresh/logout); same DTOs/decoder as fixtures - Tests: endpoint path/method/query/body + single-flight (fires once) + token cache/persist swift build + swift test green (25). Live HTTP path itself is FU-A (needs real backend). Sources/AI untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
72 lines
2.6 KiB
Swift
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)
|
|
}
|
|
}
|