feat: add iOS support and harden receipt scanning
This commit is contained in:
73
app/ios/ScanReceipts/Networking/KeychainTokenStore.swift
Normal file
73
app/ios/ScanReceipts/Networking/KeychainTokenStore.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user