Files
hyungi_document_server/Sources/DSKit/Auth/KeychainStore.swift
T
Hyungi 3520c8f82a feat(s3): LiveDSClient + Endpoint + Keychain/TokenProvider (FU-A plumbing)
- 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>
2026-06-05 06:38:07 +09:00

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