Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/Auth/KeychainStore.swift
T
weishu 1f0ec6184d feat(ios): HapiClient API transport + auth (A-M1b)
APIClient (final class, Sendable) with typed endpoints for the M2/M3 REST
surface behind an HTTPPerforming seam; actor AuthManager with single-flight
JWT refresh (POST /api/auth), proactive refresh 10 min before exp, terminal
authFailed state, and 401 -> refresh -> retry-once wiring per
docs/api/client-contract/auth.md; Keychain credential store (per-hub records
under run.hapi.companion) with CredentialStoring seam + in-memory double;
HubRegistry (ordered hubs + active hub in injectable UserDefaults, origin
normalization); payload-only JWT decoding; APIError {status, code, body}
parsing per errors.md; minimal multipart builder for M4c dictation; 256 MB
URLCache session for generated images.

Request/response wire models (SendMessageRequest, PermissionApproveRequest,
SpawnRequest/SpawnResponse, MessagesQuery, envelopes, RPC-wrapped shapes)
join HapiProtocol/Models mirroring shared/src/apiTypes.ts.

swift-testing coverage: JWT decode (padding/garbage/hostile exp), auth
single-flight (8 concurrent callers -> one exchange), 401 retry-once and
terminal paths, APIError body parsing, endpoint request construction
byte-checks (cursor queries, deliveryMode body, answers formats, explicit
null model reset) via a recording performer, hub URL normalization,
multipart bytes.
2026-08-17 15:20:03 +08:00

138 lines
5.1 KiB
Swift

import Foundation
import Security
/// The per-hub credential record (`docs/api/client-contract/auth.md`).
///
/// The access token is the durable secret; the JWT is a 4-hour cache
/// persisted only to save one `/api/auth` round-trip at cold start.
public struct HubCredentials: Codable, Equatable, Sendable {
/// Normalized hub origin (see ``HubURLNormalization``); also the storage key.
public var hubUrl: String
/// Long-lived pairing secret, passed verbatim to `POST /api/auth`.
public var accessToken: String
/// Last issued JWT, if any.
public var jwt: String?
/// When `jwt` was obtained, epoch ms.
public var jwtObtainedAt: Int?
public init(hubUrl: String, accessToken: String, jwt: String? = nil, jwtObtainedAt: Int? = nil) {
self.hubUrl = hubUrl
self.accessToken = accessToken
self.jwt = jwt
self.jwtObtainedAt = jwtObtainedAt
}
}
/// Storage seam so `AuthManager` and the pairing flow are testable without
/// touching the real Keychain.
public protocol CredentialStoring: Sendable {
/// Returns the record for a normalized hub origin, or `nil` when unpaired.
func credentials(forHub hubUrl: String) throws -> HubCredentials?
/// Inserts or replaces the record keyed by `credentials.hubUrl`.
func store(_ credentials: HubCredentials) throws
/// Removes the record; deleting a non-existent record is not an error.
func deleteCredentials(forHub hubUrl: String) throws
}
/// A Keychain operation failed with the given `SecItem` status.
public struct KeychainError: Error, Equatable, Sendable {
public let status: OSStatus
public init(status: OSStatus) {
self.status = status
}
}
/// Keychain-backed credential store: one generic-password item per hub under
/// service `run.hapi.companion`, account = normalized hub origin, value =
/// JSON-encoded ``HubCredentials``.
///
/// Items use `kSecAttrAccessibleAfterFirstUnlock` so a background refresh
/// after reboot (pre-unlock) fails gracefully instead of silently losing
/// credentials, and the data-protection keychain so behavior matches across
/// iOS and macOS (CI).
public struct KeychainCredentialStore: CredentialStoring {
public static let service = "run.hapi.companion"
public init() {}
public func credentials(forHub hubUrl: String) throws -> HubCredentials? {
var query = Self.baseQuery(account: hubUrl)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
switch status {
case errSecSuccess:
guard let data = item as? Data else {
throw KeychainError(status: errSecInternalError)
}
return try JSONDecoder().decode(HubCredentials.self, from: data)
case errSecItemNotFound:
return nil
default:
throw KeychainError(status: status)
}
}
public func store(_ credentials: HubCredentials) throws {
let data = try JSONEncoder().encode(credentials)
let query = Self.baseQuery(account: credentials.hubUrl)
let update: [String: Any] = [kSecValueData as String: data]
let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary)
if updateStatus == errSecSuccess { return }
guard updateStatus == errSecItemNotFound else {
throw KeychainError(status: updateStatus)
}
var add = query
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let addStatus = SecItemAdd(add as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw KeychainError(status: addStatus)
}
}
public func deleteCredentials(forHub hubUrl: String) throws {
let status = SecItemDelete(Self.baseQuery(account: hubUrl) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError(status: status)
}
}
private static func baseQuery(account: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecUseDataProtectionKeychain as String: true,
]
}
}
/// Dictionary-backed test double (also handy for previews).
public final class InMemoryCredentialStore: CredentialStoring, @unchecked Sendable {
private let lock = NSLock()
private var records: [String: HubCredentials] = [:]
public init() {}
public func credentials(forHub hubUrl: String) throws -> HubCredentials? {
lock.lock()
defer { lock.unlock() }
return records[hubUrl]
}
public func store(_ credentials: HubCredentials) throws {
lock.lock()
defer { lock.unlock() }
records[credentials.hubUrl] = credentials
}
public func deleteCredentials(forHub hubUrl: String) throws {
lock.lock()
defer { lock.unlock() }
records[hubUrl] = nil
}
}