Let a login fixed on one device lift the rejection on the other

This commit is contained in:
Arkadiusz Fal
2026-09-09 21:36:08 +02:00
parent bb6e98f9d2
commit 2e6a464575
3 changed files with 238 additions and 70 deletions

View File

@@ -1154,10 +1154,19 @@ final class AppEnvironment {
// A rejection belongs to the credential that earned it. A
// replacement has not been rejected by anyone.
if accountsManager.status(for: account.id) == .credentialRejected, !didReplaceCredential {
let wasRejected = accountsManager.status(for: account.id) == .credentialRejected
if wasRejected, !didReplaceCredential {
continue
}
if wasRejected {
LoggingService.shared.info(
"The rejected login was replaced — letting the account try again",
category: .keychain,
details: "accountID=\(account.id)"
)
}
accountsManager.setStatus(.signedIn, for: account.id)
}
@@ -1165,19 +1174,29 @@ final class AppEnvironment {
}
/// Re-reads the Keychain for a couple of minutes while any account is
/// missing its credential.
/// waiting on one.
///
/// iCloud Keychain delivers on its own schedule and announces nothing when
/// it does, so an account that syncs ahead of its credential — the ordinary
/// case on a new device — would sit at "sign in required", with reads
/// falling back to local, until something unrelated happened to recompute
/// statuses. Stops as soon as nothing is waiting, and gives up rather than
/// polling forever: on tvOS, where app Keychain items never sync at all,
/// the wait is permanent by design.
/// statuses.
///
/// A rejected account is waiting on the very same delivery: the user fixes
/// the login on one device, and this one goes on holding a secret the
/// server has already refused until the replacement lands. Nobody sitting
/// in front of that screen can hurry it along, and without this the account
/// stays red until something unrelated recomputes — which is how a
/// second device was measured still saying "sign in again" 41 minutes after
/// the login had been fixed.
///
/// Only the Keychain is re-read; the server is asked nothing. A secret that
/// has not changed therefore costs one local lookup a tick and no request.
/// Stops as soon as nothing is waiting, and gives up rather than polling
/// forever: on tvOS, where app Keychain items never sync at all, the wait
/// is permanent by design.
private func waitForArrivingCredentialsIfNeeded() {
let isWaiting = accountsManager.accounts.contains { accountsManager.status(for: $0.id) == .credentialMissing }
guard isWaiting else {
guard isWaitingForCredential else {
credentialArrivalTask?.cancel()
credentialArrivalTask = nil
return
@@ -1188,29 +1207,28 @@ final class AppEnvironment {
credentialArrivalTask = Task { [weak self] in
for _ in 0 ..< 8 {
try? await Task.sleep(for: .seconds(15))
guard !Task.isCancelled, let self else { return }
guard !Task.isCancelled, let self, self.isWaitingForCredential else { break }
let stillWaiting = self.accountsManager.accounts.contains {
self.accountsManager.status(for: $0.id) == .credentialMissing
}
guard stillWaiting else { break }
for account in self.accountsManager.accounts
where self.accountsManager.status(for: account.id) == .credentialMissing {
guard let credentials = self.accountCredentialsManager(for: account),
credentials.isScopeResolved else { continue }
credentials.refreshLoginStatus(for: account)
if credentials.isLoggedIn(for: account) {
self.accountsManager.setStatus(.signedIn, for: account.id)
}
}
// The whole verdict rather than a shortened version of it: a
// rejection lifts only when the stored secret is a different
// one, and that comparison lives in there.
self.refreshAccountStatuses()
}
self?.credentialArrivalTask = nil
}
}
/// Whether any account is holding out for a credential iCloud Keychain has
/// yet to deliver — either none at all, or one to replace what the server
/// refused.
private var isWaitingForCredential: Bool {
accountsManager.accounts.contains {
let status = accountsManager.status(for: $0.id)
return status == .credentialMissing || status == .credentialRejected
}
}
// MARK: - Credentials Management
/// Returns the appropriate credentials manager for an instance type.

View File

@@ -48,29 +48,50 @@ struct KeychainCredentialStore: Sendable {
// MARK: - Access
/// Reads a value, matching both synchronizable forms.
/// Reads a value, matching both synchronizable forms — the one that syncs
/// first.
///
/// `SynchronizableAny` is safe for scoped keys — the scope, not the sync
/// flag, is what provides identity isolation.
/// Matching both is safe for scoped keys: the scope, not the sync flag, is
/// what provides identity isolation. The *order* matters though, because
/// `kSecAttrSynchronizable` is part of a generic password's primary key, so
/// one key can hold two items at once. Legacy adoption writes a
/// device-local copy (`adoptLegacyCredential`), and a credential the user
/// signed in for on another device arrives beside it as a separate,
/// synchronizable item. A single `SynchronizableAny` match may answer with
/// either, and answering with the local copy is how a login already fixed
/// elsewhere stays invisible here — for as long as the copy exists.
///
/// The synced item is the one somebody proved they hold, so it is asked for
/// first, and the local copy only answers when nothing arrived.
func value(forKey key: String) -> String? {
let query: [String: Any] = [
for synchronizable in [true, false] {
var query = itemQuery(key: key, synchronizable: synchronizable)
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,
let value = String(data: data, encoding: .utf8)
else { continue }
return value
}
return nil
}
/// The attributes identifying one item: class, service, key and the
/// synchronizable form.
private func itemQuery(key: String, synchronizable: Any) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
kSecAttrSynchronizable as String: synchronizable
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let value = String(data: data, encoding: .utf8)
else { return nil }
return value
}
/// Writes a value **only if the key is currently empty**, returning
@@ -81,13 +102,8 @@ struct KeychainCredentialStore: Sendable {
/// replacing write would silently overwrite the newer secret.
@discardableResult
func addIfAbsent(_ value: String, forKey key: String, synchronizable: Bool) -> OSStatus {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecAttrSynchronizable as String: synchronizable,
kSecValueData as String: Data(value.utf8)
]
var query = itemQuery(key: key, synchronizable: synchronizable)
query[kSecValueData as String] = Data(value.utf8)
return SecItemAdd(query as CFDictionary, nil)
}
@@ -100,13 +116,8 @@ struct KeychainCredentialStore: Sendable {
/// account", which it cannot answer (measured).
func isSynchronizable(key: String) -> Bool? {
for synchronizable in [true, false] {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecAttrSynchronizable as String: synchronizable,
kSecMatchLimit as String: kSecMatchLimitOne
]
var query = itemQuery(key: key, synchronizable: synchronizable)
query[kSecMatchLimit as String] = kSecMatchLimitOne
if SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess {
return synchronizable
@@ -117,17 +128,20 @@ struct KeychainCredentialStore: Sendable {
}
/// Writes a value, replacing any existing item in either synchronizable form.
///
/// Deliberately delete-then-add, and **not** `SecItemUpdate` on the item
/// already there. Measured on two devices sharing one Apple account: a
/// value written this way reached the other device in 15 s, while a value
/// changed in place with `SecItemUpdate` had still not arrived 40 minutes
/// later. Replacing the item is what iCloud Keychain propagates, and a
/// sign-in the user performs on one device is worth nothing to their other
/// ones unless it gets there.
@discardableResult
func set(_ value: String, forKey key: String, synchronizable: Bool) -> OSStatus {
delete(key: key)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecAttrSynchronizable as String: synchronizable,
kSecValueData as String: Data(value.utf8)
]
var query = itemQuery(key: key, synchronizable: synchronizable)
query[kSecValueData as String] = Data(value.utf8)
return SecItemAdd(query as CFDictionary, nil)
}
@@ -139,14 +153,9 @@ struct KeychainCredentialStore: Sendable {
/// legacy item is never deleted as a side effect of adoption.
@discardableResult
func delete(key: String) -> OSStatus {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
return SecItemDelete(query as CFDictionary)
SecItemDelete(
itemQuery(key: key, synchronizable: kSecAttrSynchronizableAny) as CFDictionary
)
}
func describe(_ status: OSStatus) -> String {