74 lines
2.6 KiB
Swift
74 lines
2.6 KiB
Swift
import Foundation
|
|
import Security
|
|
|
|
/// Persists the session token issued by `POST /api/auth/login` /
|
|
/// `/api/auth/signup` in the iOS Keychain — never UserDefaults, which is
|
|
/// unencrypted on disk. This is the native counterpart to the web app's
|
|
/// httpOnly session cookie (see `src/lib/auth/session.ts`); the backend has
|
|
/// no idea which storage a given Bearer token came from.
|
|
final class KeychainTokenStore {
|
|
static let shared = KeychainTokenStore()
|
|
|
|
private let service = "app.scan-receipts.ios.session"
|
|
private let account = "session-token"
|
|
|
|
private init() {}
|
|
|
|
var token: String? {
|
|
get { read() }
|
|
set {
|
|
if let newValue {
|
|
save(newValue)
|
|
} else {
|
|
delete()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func query(extra: [String: Any] = [:]) -> [String: Any] {
|
|
var q: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
]
|
|
extra.forEach { q[$0] = $1 }
|
|
return q
|
|
}
|
|
|
|
private func read() -> String? {
|
|
var query = self.query()
|
|
query[kSecReturnData as String] = true
|
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
|
|
var result: AnyObject?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
guard status == errSecSuccess, let data = result as? Data else { return nil }
|
|
return String(data: data, encoding: .utf8)
|
|
}
|
|
|
|
private func save(_ token: String) {
|
|
let data = Data(token.utf8)
|
|
// Overwrite-or-insert: a plain SecItemAdd fails with errSecDuplicateItem
|
|
// on every login after the first, since the (service, account) pair is
|
|
// stable by design.
|
|
let attributes: [String: Any] = [
|
|
kSecValueData as String: data,
|
|
// Available as soon as the device is unlocked once after boot —
|
|
// matches what a background-launched app (e.g. from a push
|
|
// notification) needs, without requiring the passcode be entered
|
|
// in *this* unlock cycle the way `WhenUnlocked` would.
|
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
|
]
|
|
let updateStatus = SecItemUpdate(query() as CFDictionary, attributes as CFDictionary)
|
|
if updateStatus == errSecItemNotFound {
|
|
var insert = query()
|
|
attributes.forEach { insert[$0] = $1 }
|
|
SecItemAdd(insert as CFDictionary, nil)
|
|
}
|
|
}
|
|
|
|
private func delete() {
|
|
SecItemDelete(query() as CFDictionary)
|
|
}
|
|
}
|