Adding to the above:
Since the app can trigger API calls in the background, I updated the Keychain access control to kSecAttrAccessibleAfterFirstUnlock. After making this change, the number of TOKEN_MISSING incidents has significantly decreased over the past week. However, I can still occasionally see errSecItemNotFound errors in the logs.
Pasting my code for saving & retrieving data from the keychain:
public enum KeychainError: Error {
case noPassword
case unexpectedPasswordData
case unexpectedItemData
case unhandledError(status: OSStatus)
}
public let service: String = "AppUser"
public func readItem(from account: String, shouldAccessFromFirstUnlock: Bool = false) throws -> String {
/*
Build a query to find the item that matches the service, account and
access group.
*/
var query = KeychainPasswordItem.keychainQuery(withService: service, account: account, accessGroup: accessGroup, shouldAccessFromFirstUnlock: shouldAccessFromFirstUnlock)
query[kSecMatchLimit as String] = kSecMatchLimitOne
query[kSecReturnAttributes as String] = kCFBooleanTrue
query[kSecReturnData as String] = kCFBooleanTrue
// Try to fetch the existing keychain item that matches the query.
var queryResult: AnyObject?
let status = withUnsafeMutablePointer(to: &queryResult) {
SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0))
}
// Check the return status and throw an error if appropriate.
guard status != errSecItemNotFound else { throw KeychainError.noPassword }
guard status == noErr else { throw KeychainError.unhandledError(status: status) }
// Parse the password string from the query result.
guard let existingItem = queryResult as? [String: AnyObject],
let passwordData = existingItem[kSecValueData as String] as? Data,
let password = String(data: passwordData, encoding: String.Encoding.utf8)
else {
throw KeychainError.unexpectedPasswordData
}
return password
}
public func saveItem(_ item: String, account: String, shouldAccessFromFirstUnlock: Bool = false) throws {
// Encode the password into an Data object.
guard let encodedPassword = item.data(using: String.Encoding.utf8) else { return }
do {
// Check for an existing item in the keychain.
try _ = readItem(from: account, shouldAccessFromFirstUnlock: shouldAccessFromFirstUnlock)
// Update the existing item with the new password.
var attributesToUpdate = [String: AnyObject]()
attributesToUpdate[kSecValueData as String] = encodedPassword as AnyObject?
let query = KeychainPasswordItem.keychainQuery(withService: service, account: account, accessGroup: accessGroup, shouldAccessFromFirstUnlock: shouldAccessFromFirstUnlock)
let status = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary)
// Throw an error if an unexpected status was returned.
guard status == noErr else { throw KeychainError.unhandledError(status: status) }
} catch KeychainError.noPassword {
/*
No password was found in the keychain. Create a dictionary to save
as a new keychain item.
*/
var newItem = KeychainPasswordItem.keychainQuery(withService: service, account: account, accessGroup: accessGroup, shouldAccessFromFirstUnlock: shouldAccessFromFirstUnlock)
newItem[kSecValueData as String] = encodedPassword as AnyObject?
// Add a the new item to the keychain.
let status = SecItemAdd(newItem as CFDictionary, nil)
// Throw an error if an unexpected status was returned.
guard status == noErr else { throw KeychainError.unhandledError(status: status) }
}
}
private static func keychainQuery(withService service: String, account: String? = nil, accessGroup: String? = nil, shouldAccessFromFirstUnlock: Bool = false) -> [String: AnyObject] {
var query = [String: AnyObject]()
query[kSecClass as String] = kSecClassGenericPassword
query[kSecAttrService as String] = service as AnyObject?
if shouldAccessFromFirstUnlock {
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
}
if let account = account {
query[kSecAttrAccount as String] = account as AnyObject?
}
if let accessGroup = accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup as AnyObject?
}
return query
}