Make Keychain and shared-container state correct before the Mac port
Two failures here are silent rather than loud, which is why they have gone unnoticed on iOS and would have been much harder to diagnose on a Mac. The old `enum Keychain` discarded all four OSStatus results. On Mac Catalyst a missing `keychain-access-groups` entitlement makes SecItem calls fail with errSecMissingEntitlement, and because nothing checked, that was indistinguishable from "no token stored" — the user would be signed out on every launch, with no error anywhere. KeychainStore now checks every status, distinguishes errSecItemNotFound from real failures, and sets kSecUseDataProtectionKeychain so macOS selects the modern entitlement-gated keychain instead of the legacy login keychain. Adding the entitlement moves the default access group to the first array entry, so that entry is deliberately the app's own group: existing tokens keep resolving with an unqualified query and nobody is signed out. loadToken() then migrates forward in three steps — shared group, own default group, and the pre-Keychain UserDefaults copy. If the entitlement is not provisioned yet, KeychainStore falls back to the default group rather than throwing. A hard failure would make the app unusable for everyone whose provisioning lags; the fallback asserts in DEBUG instead, so a misconfiguration is loud in development and survivable in production. Separately, logout() left widget-cache.json in the App Group container, so widgets kept rendering the signed-out user's events indefinitely. That is an existing iOS bug, and it would have leaked the same data to any other app reading the container. WidgetStore.clear() now removes both cache files and reloads the timelines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import SwiftUI
|
||||
import Security
|
||||
|
||||
@main
|
||||
struct CalendarrApp: App {
|
||||
@@ -25,17 +24,45 @@ class AppState {
|
||||
|
||||
init() {
|
||||
serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? ""
|
||||
// Migrate a token previously kept in UserDefaults into the Keychain once,
|
||||
// so existing logins survive the change without re-authenticating.
|
||||
if let legacy = UserDefaults.standard.string(forKey: "authToken"), !legacy.isEmpty {
|
||||
Keychain.set(legacy, for: "authToken")
|
||||
UserDefaults.standard.removeObject(forKey: "authToken")
|
||||
}
|
||||
authToken = Keychain.get("authToken") ?? ""
|
||||
authToken = Self.loadToken()
|
||||
username = UserDefaults.standard.string(forKey: "username") ?? ""
|
||||
isAdmin = UserDefaults.standard.bool(forKey: "isAdmin")
|
||||
}
|
||||
|
||||
/// Find the stored token, migrating it forward from wherever an older build
|
||||
/// left it. Runs on every launch but does real work only once.
|
||||
///
|
||||
/// Step 2 is the one that keeps existing users signed in. Before the
|
||||
/// `keychain-access-groups` entitlement existed, items landed in the app's
|
||||
/// own default group. Adding the entitlement makes the *first* array entry
|
||||
/// the new default — and that entry is deliberately the app's own group, so
|
||||
/// an unqualified query still resolves those items and we can copy them
|
||||
/// across instead of stranding them.
|
||||
private static func loadToken() -> String {
|
||||
let key = "authToken"
|
||||
|
||||
// 1. Already in the shared group — the steady state.
|
||||
if let token = try? KeychainStore.get(key), !token.isEmpty {
|
||||
return token
|
||||
}
|
||||
|
||||
// 2. In the app's own default group, written before the entitlement.
|
||||
if let token = try? KeychainStore.get(key, accessGroup: nil), !token.isEmpty {
|
||||
try? KeychainStore.set(token, for: key)
|
||||
try? KeychainStore.set(nil, for: key, accessGroup: nil)
|
||||
return token
|
||||
}
|
||||
|
||||
// 3. In UserDefaults, written before secrets moved to the Keychain.
|
||||
if let legacy = UserDefaults.standard.string(forKey: key), !legacy.isEmpty {
|
||||
try? KeychainStore.set(legacy, for: key)
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
return legacy
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func saveServer(url: String) {
|
||||
serverURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if serverURL.hasSuffix("/") { serverURL = String(serverURL.dropLast()) }
|
||||
@@ -46,7 +73,7 @@ class AppState {
|
||||
authToken = token
|
||||
username = user
|
||||
isAdmin = admin
|
||||
Keychain.set(token, for: "authToken") // secret → Keychain, not UserDefaults
|
||||
try? KeychainStore.set(token, for: "authToken") // secret → Keychain, not UserDefaults
|
||||
UserDefaults.standard.set(user, forKey: "username")
|
||||
UserDefaults.standard.set(admin, forKey: "isAdmin")
|
||||
}
|
||||
@@ -55,10 +82,14 @@ class AppState {
|
||||
authToken = ""
|
||||
username = ""
|
||||
isAdmin = false
|
||||
Keychain.set(nil, for: "authToken")
|
||||
UserDefaults.standard.removeObject(forKey: "authToken") // clear any legacy copy
|
||||
try? KeychainStore.set(nil, for: "authToken")
|
||||
try? KeychainStore.set(nil, for: "authToken", accessGroup: nil) // pre-entitlement copy
|
||||
UserDefaults.standard.removeObject(forKey: "authToken") // pre-Keychain copy
|
||||
UserDefaults.standard.removeObject(forKey: "username")
|
||||
UserDefaults.standard.removeObject(forKey: "isAdmin")
|
||||
// The shared container outlives the session, so it has to be cleared
|
||||
// explicitly — otherwise widgets keep showing the signed-out user's data.
|
||||
WidgetStore.clear()
|
||||
}
|
||||
|
||||
func resetServer() {
|
||||
@@ -67,37 +98,3 @@ class AppState {
|
||||
UserDefaults.standard.removeObject(forKey: "serverURL")
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal Keychain wrapper for secrets (the auth bearer token). Values are
|
||||
/// stored as generic passwords, accessible after first unlock.
|
||||
enum Keychain {
|
||||
private static let service = "Calendarr"
|
||||
|
||||
static func set(_ value: String?, for key: String) {
|
||||
let base: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
]
|
||||
SecItemDelete(base as CFDictionary)
|
||||
guard let value, let data = value.data(using: .utf8) else { return }
|
||||
var add = base
|
||||
add[kSecValueData as String] = data
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
SecItemAdd(add as CFDictionary, nil)
|
||||
}
|
||||
|
||||
static func get(_ key: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var out: AnyObject?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess,
|
||||
let data = out as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user