diff --git a/CHANGELOG.md b/CHANGELOG.md index 3baf045..6fada6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- We addressed a scenario where, in iOS, an Auth dialogue could continuously popup on app startup due to access of either large keychain blob items or blocked items. + ## [v0.5.1] - 2026-06-22 - We fixed an issue that could cause iOS apps to restart repeatedly after an OTA update. diff --git a/MendixNative.podspec b/MendixNative.podspec index c234757..d21b9a8 100644 --- a/MendixNative.podspec +++ b/MendixNative.podspec @@ -14,7 +14,7 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/mendix/mendix-native.git", :tag => "#{s.version}" } s.source_files = "ios/**/*.{h,m,mm,cpp,swift}" - s.public_header_files = "ios/Modules/Helper/ReactHostHelper.h" + s.public_header_files = ["ios/Modules/Helper/ReactHostHelper.h", "ios/Modules/NativeCookieModule/ObjCExceptionCatcher.h"] s.private_header_files = "ios/TurboModules/**/*.h" s.dependency "SSZipArchive" diff --git a/ios/Modules/Encryption/EncryptedStorage.swift b/ios/Modules/Encryption/EncryptedStorage.swift index 4ec5e89..c0c1bf7 100644 --- a/ios/Modules/Encryption/EncryptedStorage.swift +++ b/ios/Modules/Encryption/EncryptedStorage.swift @@ -33,11 +33,15 @@ import Foundation kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecReturnData: kCFBooleanTrue as Any, - kSecMatchLimit: kSecMatchLimitOne + kSecMatchLimit: kSecMatchLimitOne, + kSecUseAuthenticationUI: kSecUseAuthenticationUIFail ] as CFDictionary var dataRef: CFTypeRef? let status = SecItemCopyMatching(query, &dataRef) - if status == errSecSuccess { + if status == errSecInteractionNotAllowed { + // Device locked or item requires auth — don't delete, resolve as absent + promise.resolve(NSNull()) + } else if status == errSecSuccess { guard let data = dataRef as? Data, let value = String(data: data, encoding: .utf8) else { promise.reject("An error occured while retrieving value", errorCode: Int(status)) return diff --git a/ios/Modules/NativeCookieModule/NativeCookieModule.swift b/ios/Modules/NativeCookieModule/NativeCookieModule.swift index c7985e4..cadb00a 100644 --- a/ios/Modules/NativeCookieModule/NativeCookieModule.swift +++ b/ios/Modules/NativeCookieModule/NativeCookieModule.swift @@ -6,7 +6,7 @@ public class NativeCookieModule: NSObject { NativeCookieModule.clearAll() promise.resolve(nil) } - + static func clearAll() { let storage = HTTPCookieStorage.shared for cookie in (storage.cookies ?? []) { diff --git a/ios/Modules/NativeCookieModule/ObjCExceptionCatcher.h b/ios/Modules/NativeCookieModule/ObjCExceptionCatcher.h new file mode 100644 index 0000000..e93fd3d --- /dev/null +++ b/ios/Modules/NativeCookieModule/ObjCExceptionCatcher.h @@ -0,0 +1,13 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface ObjCExceptionCatcher : NSObject + +/// Executes the block and returns its result. If an NSException is raised, +/// catches it and returns nil instead of crashing. ++ (nullable id)catchException:(id _Nullable (NS_NOESCAPE ^)(void))block; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/Modules/NativeCookieModule/ObjCExceptionCatcher.m b/ios/Modules/NativeCookieModule/ObjCExceptionCatcher.m new file mode 100644 index 0000000..31e1811 --- /dev/null +++ b/ios/Modules/NativeCookieModule/ObjCExceptionCatcher.m @@ -0,0 +1,14 @@ +#import "ObjCExceptionCatcher.h" + +@implementation ObjCExceptionCatcher + ++ (nullable id)catchException:(id _Nullable (NS_NOESCAPE ^)(void))block { + @try { + return block(); + } @catch (NSException *exception) { + NSLog(@"ObjCExceptionCatcher: caught %@ — %@", exception.name, exception.reason); + return nil; + } +} + +@end diff --git a/ios/Modules/NativeCookieModule/SessionCookieStore.swift b/ios/Modules/NativeCookieModule/SessionCookieStore.swift index 9312e2c..ea2dd2d 100644 --- a/ios/Modules/NativeCookieModule/SessionCookieStore.swift +++ b/ios/Modules/NativeCookieModule/SessionCookieStore.swift @@ -1,27 +1,27 @@ import Foundation public class SessionCookieStore { - + // MARK: - Private properties private static let bundleIdentifier = Bundle.main.bundleIdentifier ?? "com.mendix.app" private static let storageKey = bundleIdentifier + "sessionCookies" private static let queue = DispatchQueue(label: bundleIdentifier + ".session-cookie-store", qos: .utility) - + // MARK: - Public API public static func restore() { - + guard let cookies = get(key: storageKey) else { NSLog("SessionCookieStore: No cookies to restore") return } - + let storage = HTTPCookieStorage.shared let existing = Set(storage.cookies ?? []) cookies.filter { !existing.contains($0) }.forEach { storage.setCookie($0) } - - clear() // Clear stored cookies after restoration to avoid any side effects + + clear() } - + public static func persist() { queue.async { let sessionCookies = HTTPCookieStorage.shared.cookies?.filter { isSessionCookie($0) } ?? [] @@ -33,21 +33,21 @@ public class SessionCookieStore { set(key: storageKey, cookies: sessionCookies) } } - + public static func clear() { clear(key: storageKey) } - + // MARK: - Private API private static func isSessionCookie(_ cookie: HTTPCookie) -> Bool { return cookie.expiresDate == nil } - + private static func set(key: String, cookies: [HTTPCookie]) { do { let data = try NSKeyedArchiver.archivedData(withRootObject: cookies, requiringSecureCoding: false) + clear(key: key) let storeQuery = [kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecValueData: data] as CFDictionary - SecItemDelete(storeQuery) let status = SecItemAdd(storeQuery, nil) if status != noErr { NSLog("SessionCookieStore: Failed to persist session cookies with status: \(status)") @@ -56,29 +56,46 @@ public class SessionCookieStore { NSLog("SessionCookieStore: Failed to persist session cookies: \(error.localizedDescription)") } } - + private static func get(key: String) -> [HTTPCookie]? { - do { - let query: [CFString: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecReturnData: true] - var item: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &item) - if status == errSecSuccess, let data = item as? Data { - let cookies = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSArray.self, HTTPCookie.self], from: data) as? [HTTPCookie] - return cookies - } else { - NSLog("SessionCookieStore: No session cookies found with status: \(status)") + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: key, + kSecReturnData: true, + kSecUseAuthenticationUI: kSecUseAuthenticationUIFail + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + if status == errSecInteractionNotAllowed { + NSLog("SessionCookieStore: Item inaccessible (device locked), skipping restore") + return nil + } else if status == errSecSuccess, let data = item as? Data { + let result = ObjCExceptionCatcher.catchException { + try? NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSArray.self, HTTPCookie.self], from: data) + } + + guard let cookies = result as? [HTTPCookie] else { + NSLog("SessionCookieStore: Failed to deserialize cookies, clearing") + clear(key: key) return nil } - } catch { - NSLog("SessionCookieStore: Failed to retrieve session cookies: \(error.localizedDescription)") + return cookies + } else if status != errSecItemNotFound { + // Any other unreadable state — delete to prevent repeated failures + NSLog("SessionCookieStore: Unreadable legacy item (status: \(status)), clearing") + clear(key: key) + return nil + } else { + NSLog("SessionCookieStore: No session cookies found") return nil } } - + private static func clear(key: String) { - let query = [kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecReturnData: true] as CFDictionary + let query = [kSecClass: kSecClassGenericPassword, kSecAttrAccount: key] as CFDictionary let status = SecItemDelete(query) - if status != errSecSuccess { + if status != errSecSuccess && status != errSecItemNotFound { NSLog("SessionCookieStore: Failed to clear cookies with status: \(status)") } }