diff --git a/examples/demo/App/Models/AppModels.swift b/examples/demo/App/Models/AppModels.swift index 70ccd615a..8967a8304 100644 --- a/examples/demo/App/Models/AppModels.swift +++ b/examples/demo/App/Models/AppModels.swift @@ -80,6 +80,7 @@ enum AddItemType { case tag case trigger case externalUserId + case updateJwt var title: String { switch self { @@ -89,13 +90,23 @@ enum AddItemType { case .tag: return "Add Tag" case .trigger: return "Add Trigger" case .externalUserId: return "Login User" + case .updateJwt: return "Update JWT" } } var requiresKeyValue: Bool { switch self { - case .alias, .tag, .trigger: return true - case .email, .sms, .externalUserId: return false + case .alias, .tag, .trigger, .externalUserId, .updateJwt: return true + case .email, .sms: return false + } + } + + /// When true the second field may be left blank (confirm stays enabled). + /// Used by Login, where the JWT token is optional. + var optionalValue: Bool { + switch self { + case .externalUserId: return true + default: return false } } @@ -103,6 +114,7 @@ enum AddItemType { switch self { case .alias: return "Label" case .tag, .trigger: return "Key" + case .externalUserId, .updateJwt: return "External User Id" default: return "Key" } } @@ -113,7 +125,8 @@ enum AddItemType { case .email: return "Email Address" case .sms: return "Phone Number" case .tag, .trigger: return "Value" - case .externalUserId: return "External User Id" + case .externalUserId: return "JWT Token (optional)" + case .updateJwt: return "JWT Token" } } @@ -128,6 +141,7 @@ enum AddItemType { var confirmLabel: String { switch self { case .externalUserId: return "Login" + case .updateJwt: return "Update" default: return "Add" } } @@ -141,6 +155,7 @@ enum AddItemType { case .tag: return "tag" case .trigger: return "trigger" case .externalUserId: return "login_user_id" + case .updateJwt: return "update_jwt" } } @@ -152,6 +167,8 @@ enum AddItemType { case .alias: return "alias_label_input" case .tag: return "tag_key_input" case .trigger: return "trigger_key_input" + case .externalUserId: return "login_user_id_input" + case .updateJwt: return "update_jwt_external_id_input" default: return "\(accessibilityKey)_key_input" } } @@ -165,6 +182,8 @@ enum AddItemType { case .alias: return "alias_id_input" case .tag: return "tag_value_input" case .trigger: return "trigger_value_input" + case .externalUserId: return "login_user_jwt_input" + case .updateJwt: return "update_jwt_token_input" default: return "\(accessibilityKey)_input" } } diff --git a/examples/demo/App/Services/OneSignalService.swift b/examples/demo/App/Services/OneSignalService.swift index 598189ea0..db1f9c360 100644 --- a/examples/demo/App/Services/OneSignalService.swift +++ b/examples/demo/App/Services/OneSignalService.swift @@ -96,9 +96,13 @@ final class OneSignalService { // MARK: - User - func login(externalId: String) { + func login(externalId: String, token: String? = nil) { prefs.setExternalUserId(externalId) - OneSignal.login(externalId) + OneSignal.login(externalId: externalId, token: token) + } + + func updateUserJwt(externalId: String, token: String) { + OneSignal.updateUserJwt(externalId: externalId, token: token) } func logout() { diff --git a/examples/demo/App/ViewModels/OneSignalViewModel.swift b/examples/demo/App/ViewModels/OneSignalViewModel.swift index dfa23553a..01c70e792 100644 --- a/examples/demo/App/ViewModels/OneSignalViewModel.swift +++ b/examples/demo/App/ViewModels/OneSignalViewModel.swift @@ -67,8 +67,6 @@ final class OneSignalViewModel: ObservableObject { // MARK: - UI State - @Published var isLoading: Bool = false - @Published var activeTooltip: TooltipData? // MARK: - Computed @@ -129,7 +127,6 @@ final class OneSignalViewModel: ObservableObject { guard let onesignalId = service.onesignalId else { return } requestSequence &+= 1 let captured = requestSequence - isLoading = true let userData = await UserFetchService.shared.fetchUser(appId: appId, onesignalId: onesignalId) @@ -145,7 +142,6 @@ final class OneSignalViewModel: ObservableObject { externalUserId = extId } } - isLoading = false } // MARK: - Consent @@ -166,15 +162,22 @@ final class OneSignalViewModel: ObservableObject { // MARK: - User - func login(externalId: String) { + func login(externalId: String, token: String? = nil) { let trimmed = externalId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } - isLoading = true - service.login(externalId: trimmed) + let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) + service.login(externalId: trimmed, token: (trimmedToken?.isEmpty ?? true) ? nil : trimmedToken) externalUserId = trimmed clearUserData() } + func updateUserJwt(externalId: String, token: String) { + let trimmedId = externalId.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedId.isEmpty, !trimmedToken.isEmpty else { return } + service.updateUserJwt(externalId: trimmedId, token: trimmedToken) + } + func logout() { service.logout() externalUserId = nil diff --git a/examples/demo/App/Views/Components/AddItemDialog.swift b/examples/demo/App/Views/Components/AddItemDialog.swift index f6b099578..f8362379c 100644 --- a/examples/demo/App/Views/Components/AddItemDialog.swift +++ b/examples/demo/App/Views/Components/AddItemDialog.swift @@ -80,8 +80,9 @@ struct AddItemDialog: View { private var isValid: Bool { if itemType.requiresKeyValue { - return !keyText.trimmingCharacters(in: .whitespaces).isEmpty && - !valueText.trimmingCharacters(in: .whitespaces).isEmpty + let keyOK = !keyText.trimmingCharacters(in: .whitespaces).isEmpty + if itemType.optionalValue { return keyOK } + return keyOK && !valueText.trimmingCharacters(in: .whitespaces).isEmpty } return !valueText.trimmingCharacters(in: .whitespaces).isEmpty } diff --git a/examples/demo/App/Views/Sections/UserSection.swift b/examples/demo/App/Views/Sections/UserSection.swift index 0e0bb5067..80ab230e3 100644 --- a/examples/demo/App/Views/Sections/UserSection.swift +++ b/examples/demo/App/Views/Sections/UserSection.swift @@ -31,6 +31,7 @@ import SwiftUI struct UserSection: View { @EnvironmentObject var viewModel: OneSignalViewModel @State private var loginOpen = false + @State private var updateJwtOpen = false var body: some View { SectionCard(title: "USER", sectionKey: "user") { @@ -55,6 +56,14 @@ struct UserSection: View { loginOpen = true } + ActionButton( + "UPDATE JWT", + style: .outline, + accessibilityID: "update_jwt_button" + ) { + updateJwtOpen = true + } + if viewModel.isLoggedIn { ActionButton( "LOGOUT USER", @@ -68,12 +77,22 @@ struct UserSection: View { .osCenteredDialog(isPresented: $loginOpen) { AddItemDialog( itemType: .externalUserId, - onAdd: { _, value in - viewModel.login(externalId: value) + onAdd: { externalId, token in + viewModel.login(externalId: externalId, token: token.isEmpty ? nil : token) loginOpen = false }, onCancel: { loginOpen = false } ) } + .osCenteredDialog(isPresented: $updateJwtOpen) { + AddItemDialog( + itemType: .updateJwt, + onAdd: { externalId, token in + viewModel.updateUserJwt(externalId: externalId, token: token) + updateJwtOpen = false + }, + onCancel: { updateJwtOpen = false } + ) + } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 00fc4b56a..1f21e78f9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -185,6 +185,7 @@ 3CC063E02B6D7F2A002BB07F /* OneSignalUserMocks.h in Headers */ = {isa = PBXBuildFile; fileRef = 3CC063DF2B6D7F2A002BB07F /* OneSignalUserMocks.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3CC063E62B6D7F96002BB07F /* OneSignalUserMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063E52B6D7F96002BB07F /* OneSignalUserMocks.swift */; }; 3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */; }; + B91A66287DEA4026A4DC5952 /* OSIdentityModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1399651D1A401EB888DA77 /* OSIdentityModelTests.swift */; }; 3CC063EF2B6D7FE8002BB07F /* OneSignalUser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE69E19B282ED8060090BB3D /* OneSignalUser.framework */; }; 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */; }; 3CC9A6342AFA1FDE008F68FD /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3CC9A6332AFA1FDD008F68FD /* PrivacyInfo.xcprivacy */; }; @@ -1439,6 +1440,7 @@ 3CC063E52B6D7F96002BB07F /* OneSignalUserMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalUserMocks.swift; sourceTree = ""; }; 3CC063EB2B6D7FE8002BB07F /* OneSignalUserTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalUserTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalUserTests.swift; sourceTree = ""; }; + 6C1399651D1A401EB888DA77 /* OSIdentityModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIdentityModelTests.swift; sourceTree = ""; }; 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserConcurrencyTests.swift; sourceTree = ""; }; 3CC9A6332AFA1FDD008F68FD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 3CC9A6352AFA26E7008F68FD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -2422,6 +2424,7 @@ 3CDE664A2BFC2A55006DA114 /* OneSignalUserTests-Bridging-Header.h */, 3CF11E3E2C6D61AC002856F5 /* Executors */, 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */, + 6C1399651D1A401EB888DA77 /* OSIdentityModelTests.swift */, 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */, 3CB331672F281679000E1801 /* CustomEventsIntegrationTests.swift */, 3C67F7792BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift */, @@ -4539,6 +4542,7 @@ DE3568F22C8911EA00AF447C /* IdentityExecutorTests.swift in Sources */, 3C67F77A2BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift in Sources */, 3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */, + B91A66287DEA4026A4DC5952 /* OSIdentityModelTests.swift in Sources */, 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */, DE3568F02C89067400AF447C /* SubscriptionsExecutorTests.swift in Sources */, 3CB3316A2F281692000E1801 /* OSCustomEventsExecutorTests.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m index f9c457463..d101f9f73 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m @@ -1320,7 +1320,12 @@ - (void)onUserStateDidChangeWithState:(OSUserChangedState * _Nonnull)state { #pragma mark OSUserJwtConfigListener Methods - (void)onRequiresUserAuthChangedFrom:(enum OSRequiresUserAuth)from to:(enum OSRequiresUserAuth)to { - // This callback is unused, the controller will fetch when subscription ID changes + // Identity Verification was turned off: a fetch deferred waiting for a JWT may never be + // retried via onJwtUpdated once auth is off, so release it here + if (to == OSRequiresUserAuthOff && shouldRetryGetInAppMessagesOnJwtUpdated) { + shouldRetryGetInAppMessagesOnJwtUpdated = false; + [self getInAppMessagesFromServer]; + } } - (void)onJwtUpdatedWithExternalId:(NSString *)externalId token:(NSString *)token { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift index d5a3e52a4..de3647705 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift @@ -391,6 +391,9 @@ extension OSCustomEventsExecutor: OSUserJwtConfigListener { // If auth changed from false or unknown to true, drop invalid items if to == .on { removeInvalidDeltasAndRequests() + } else if to == .off { + // Identity Verification was turned off: release requests parked awaiting a JWT. + reQueueAllPendingRequests() } } @@ -398,6 +401,25 @@ extension OSCustomEventsExecutor: OSUserJwtConfigListener { reQueuePendingRequestsForExternalId(externalId: externalId) } + /// Identity Verification was turned off: move every auth-pended request, across all + /// external IDs, back into the request queue and flush. + private func reQueueAllPendingRequests() { + self.dispatchQueue.async { + guard !self.pendingAuthRequests.isEmpty else { + return + } + for (_, requests) in self.pendingAuthRequests { + for request in requests { + self.requestQueue.append(request) + } + } + self.pendingAuthRequests = [:] + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, withValue: self.requestQueue) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_PENDING_QUEUE_KEY, withValue: self.pendingAuthRequests) + self.processRequestQueue(inBackground: false) + } + } + private func reQueuePendingRequestsForExternalId(externalId: String) { self.dispatchQueue.async { guard let requests = self.pendingAuthRequests[externalId] else { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift index e667806e8..a8b209b85 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift @@ -397,9 +397,12 @@ class OSIdentityOperationExecutor: OSOperationExecutor { extension OSIdentityOperationExecutor: OSUserJwtConfigListener { func onRequiresUserAuthChanged(from: OSRequiresUserAuth, to: OSRequiresUserAuth) { - // If auth changed from false or unknown to true, process requests + // If auth changed from false or unknown to true, drop now-invalid requests if to == .on { removeInvalidDeltasAndRequests() + } else if to == .off { + // Identity Verification was turned off: release requests parked awaiting a JWT. + reQueueAllPendingRequests() } } @@ -407,6 +410,30 @@ extension OSIdentityOperationExecutor: OSUserJwtConfigListener { reQueuePendingRequestsForExternalId(externalId: externalId) } + /// Identity Verification was turned off: move every auth-pended request, across all + /// external IDs, back into the request queue and flush + private func reQueueAllPendingRequests() { + self.dispatchQueue.async { + guard !self.pendingAuthRequests.isEmpty else { + return + } + for (_, requests) in self.pendingAuthRequests { + for request in requests { + if let addRequest = request as? OSRequestAddAliases { + self.addRequestQueue.append(addRequest) + } else if let removeRequest = request as? OSRequestRemoveAlias { + self.removeRequestQueue.append(removeRequest) + } + } + } + self.pendingAuthRequests = [:] + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_PENDING_QUEUE_KEY, withValue: self.pendingAuthRequests) + self.processRequestQueue(inBackground: false) + } + } + private func reQueuePendingRequestsForExternalId(externalId: String) { self.dispatchQueue.async { guard let requests = self.pendingAuthRequests[externalId] else { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index d22ee4eea..4b0e6ff2f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -380,9 +380,12 @@ class OSPropertyOperationExecutor: OSOperationExecutor { extension OSPropertyOperationExecutor: OSUserJwtConfigListener { func onRequiresUserAuthChanged(from: OSRequiresUserAuth, to: OSRequiresUserAuth) { - // If auth changed from false or unknown to true, process requests + // If auth changed from false or unknown to true, drop now-invalid requests if to == .on { removeInvalidDeltasAndRequests() + } else if to == .off { + // Identity Verification was turned off: release requests parked awaiting a JWT. + reQueueAllPendingRequests() } } @@ -390,6 +393,25 @@ extension OSPropertyOperationExecutor: OSUserJwtConfigListener { reQueuePendingRequestsForExternalId(externalId: externalId) } + /// Identity Verification was turned off: move every auth-pended request, across all + /// external IDs, back into the update queue and flush. + private func reQueueAllPendingRequests() { + self.dispatchQueue.async { + guard !self.pendingAuthRequests.isEmpty else { + return + } + for (_, requests) in self.pendingAuthRequests { + for request in requests { + self.updateRequestQueue.append(request) + } + } + self.pendingAuthRequests = [:] + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_PENDING_QUEUE_KEY, withValue: self.pendingAuthRequests) + self.processRequestQueue(inBackground: false) + } + } + private func reQueuePendingRequestsForExternalId(externalId: String) { self.dispatchQueue.async { guard let requests = self.pendingAuthRequests[externalId] else { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index 919597285..0c8802097 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -541,6 +541,9 @@ extension OSSubscriptionOperationExecutor: OSUserJwtConfigListener { func onRequiresUserAuthChanged(from: OneSignalOSCore.OSRequiresUserAuth, to: OneSignalOSCore.OSRequiresUserAuth) { if to == .on { removeInvalidDeltasAndRequests() + } else if to == .off { + // Identity Verification was turned off: release requests parked awaiting a JWT. + reQueueAllPendingRequests() } } @@ -548,6 +551,30 @@ extension OSSubscriptionOperationExecutor: OSUserJwtConfigListener { reQueuePendingRequestsForExternalId(externalId: externalId) } + /// Identity Verification was turned off: move every auth-pended request, across all + /// external IDs, back into the add/remove queues and flush. + private func reQueueAllPendingRequests() { + self.dispatchQueue.async { + guard !self.pendingAuthRequests.isEmpty else { + return + } + for (_, requests) in self.pendingAuthRequests { + for request in requests { + if let addRequest = request as? OSRequestCreateSubscription { + self.addRequestQueue.append(addRequest) + } else if let removeRequest = request as? OSRequestDeleteSubscription { + self.removeRequestQueue.append(removeRequest) + } + } + } + self.pendingAuthRequests = [:] + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_PENDING_QUEUE_KEY, withValue: self.pendingAuthRequests) + self.processRequestQueue(inBackground: false) + } + } + func handleUnauthorizedError(externalId: String, request: OSUserRequest) { if jwtConfig.isRequired ?? false { self.pendRequestUntilAuthUpdated(request, externalId: externalId) @@ -615,7 +642,7 @@ extension OSSubscriptionOperationExecutor: OSUserJwtConfigListener { self.addRequestQueue.remove(at: index) } } - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) for (index, request) in self.removeRequestQueue.enumerated().reversed() { if request.identityModel.externalId == nil { @@ -623,7 +650,7 @@ extension OSSubscriptionOperationExecutor: OSUserJwtConfigListener { self.removeRequestQueue.remove(at: index) } } - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 3d83c66cd..1e90fce4f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -769,9 +769,12 @@ extension OSUserExecutor { extension OSUserExecutor: OSUserJwtConfigListener { func onRequiresUserAuthChanged(from: OSRequiresUserAuth, to: OSRequiresUserAuth) { - // If auth changed from false or unknown to true, process requests + // If auth changed from false or unknown to true, drop now-invalid requests if to == .on { removeInvalidRequests() + } else if to == .off { + // Identity Verification is turned off: release requests parked awaiting a JWT + reQueueAllPendingRequests() } self.executePendingRequests() } @@ -780,6 +783,25 @@ extension OSUserExecutor: OSUserJwtConfigListener { reQueuePendingRequestsForExternalId(externalId: externalId) } + /// Identity Verification was turned off: move every auth-pended request, across all + /// external IDs, back into the request queue and flush + private func reQueueAllPendingRequests() { + self.dispatchQueue.async { + guard !self.pendingAuthRequests.isEmpty else { + return + } + for (_, requests) in self.pendingAuthRequests { + for request in requests { + self.userRequestQueue.append(request) + } + } + self.pendingAuthRequests = [:] + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: self.userRequestQueue) + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_PENDING_QUEUE_KEY, withValue: self.pendingAuthRequests) + self.executePendingRequests(withDelay: true) + } + } + private func reQueuePendingRequestsForExternalId(externalId: String) { self.dispatchQueue.async { guard let requests = self.pendingAuthRequests[externalId] else { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift index dcbe778d7..2c5d158e0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift @@ -38,23 +38,55 @@ class OSIdentityModel: OSModel { return internalGetAlias(OS_EXTERNAL_ID) } - // All access to aliases should go through helper methods with locking + // All access to aliases and jwtBearerToken must go through the lock var aliases: [String: String] = [:] - private let aliasesLock = NSRecursiveLock() + private let lock = NSRecursiveLock() // MARK: - JWT + private var jwtBearerTokenLocked: String? // only read/write under self.lock public var jwtBearerToken: String? { - didSet { - guard jwtBearerToken != oldValue else { - return + get { + lock.withLock { jwtBearerTokenLocked } + } + set { + // Lock only the storage write. The change notifier fires synchronously + // to listeners that may take other locks + let changed = lock.withLock { + guard newValue != jwtBearerTokenLocked else { return false } + jwtBearerTokenLocked = newValue + return true + } + if changed { + self.set(property: OS_JWT_BEARER_TOKEN, newValue: newValue) } - self.set(property: OS_JWT_BEARER_TOKEN, newValue: jwtBearerToken) } } - func isJwtValid() -> Bool { - return jwtBearerToken != nil && jwtBearerToken != "" && jwtBearerToken != OS_JWT_TOKEN_INVALID + /// Returns the bearer token if it is valid, otherwise nil, snapshots once + func getValidJwt() -> String? { + let token = jwtBearerToken + guard let token = token, !token.isEmpty, token != OS_JWT_TOKEN_INVALID else { + return nil + } + return token + } + + /** + Atomically transition the JWT token to `OS_JWT_TOKEN_INVALID`. Returns + `true` if the transition occurred, `false` if the token was already invalid. + */ + @discardableResult + func invalidateJwtBearerToken() -> Bool { + let changed = lock.withLock { + guard jwtBearerTokenLocked != OS_JWT_TOKEN_INVALID else { return false } + jwtBearerTokenLocked = OS_JWT_TOKEN_INVALID + return true + } + if changed { + self.set(property: OS_JWT_BEARER_TOKEN, newValue: OS_JWT_TOKEN_INVALID) + } + return changed } // MARK: - Initialization @@ -66,10 +98,10 @@ class OSIdentityModel: OSModel { } override func encode(with coder: NSCoder) { - aliasesLock.withLock { + lock.withLock { super.encode(with: coder) coder.encode(aliases, forKey: "aliases") - coder.encode(jwtBearerToken, forKey: OS_JWT_BEARER_TOKEN) + coder.encode(jwtBearerTokenLocked, forKey: OS_JWT_BEARER_TOKEN) } } @@ -79,20 +111,20 @@ class OSIdentityModel: OSModel { // log error return nil } - self.jwtBearerToken = coder.decodeObject(forKey: OS_JWT_BEARER_TOKEN) as? String + self.jwtBearerTokenLocked = coder.decodeObject(forKey: OS_JWT_BEARER_TOKEN) as? String self.aliases = aliases } /** Threadsafe getter for an alias */ private func internalGetAlias(_ label: String) -> String? { - aliasesLock.withLock { + lock.withLock { return self.aliases[label] } } /** Threadsafe setter or removal for aliases */ private func internalAddAliases(_ aliases: [String: String]) { - aliasesLock.withLock { + lock.withLock { for (label, id) in aliases { // Remove the alias if the ID field is "" self.aliases[label] = id.isEmpty ? nil : id @@ -105,7 +137,7 @@ class OSIdentityModel: OSModel { Called to clear the model's data in preparation for hydration via a fetch user call. */ func clearData() { - aliasesLock.withLock { + lock.withLock { self.aliases = [:] } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift index ef82e264e..37e7e007e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift @@ -73,17 +73,19 @@ class OSIdentityModelRepo { This can be optimized in the future to re-use an Identity Model if multiple logins are made for the same user. */ func updateJwtToken(externalId: String, token: String) { - var found = false - lock.withLock { - for model in models.values { - if model.externalId == externalId { - model.jwtBearerToken = token - found = true - } - } + // Snapshot matching models under the repo lock, then mutate outside. + // Writing the token fires the model's change notifier synchronously + // (→ onModelUpdated → onJwtTokenChanged); doing that while holding the + // repo lock leaves a trap for future listeners to deadlock on. + let matchingModels: [OSIdentityModel] = lock.withLock { + models.values.filter { $0.externalId == externalId } } - if !found { + guard !matchingModels.isEmpty else { OneSignalLog.onesignalLog(ONE_S_LOG_LEVEL.LL_ERROR, message: "Update User JWT called for external ID \(externalId) that does not exist") + return + } + for model in matchingModels { + model.jwtBearerToken = token } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index e03fbfb14..dc0ebffd8 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -435,9 +435,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { // JWT is required - if _user.identityModel.isJwtValid(), - let token = _user.identityModel.jwtBearerToken - { + if let token = _user.identityModel.getValidJwt() { fullHeader["Authorization"] = "Bearer \(token)" return fullHeader } @@ -701,15 +699,12 @@ extension OneSignalUserManagerImpl { } /** - This is called when remote params does not return the property `IOS_JWT_REQUIRED`. - It is likely this feature is not enabled for the app, so we will assume it is off. - However, don't overwrite the value if this has already been set. + This is called when remote params does not return the property `IOS_JWT_REQUIRED`. A missing value means Identity Verification + is off for this app, so we set it to off unconditionally — including when it was previously on. The `requiresUserAuth` didSet only + fires listeners when the value actually changes, so re-confirming an existing off value is a no-op. */ @objc public func remoteParamsReturnedUnknownRequiresUserAuth() { - guard jwtConfig.isRequired == nil else { - return - } OneSignalLog.onesignalLog(.LL_DEBUG, message: "remoteParamsReturnedUnknownRequiresUserAuth called") jwtConfig.isRequired = false } @@ -739,14 +734,9 @@ extension OneSignalUserManagerImpl { return } - // Return, if the token has already been invalidated - guard identityModel.jwtBearerToken != OS_JWT_TOKEN_INVALID else { - return + if identityModel.invalidateJwtBearerToken() { + fireJwtExpired(externalId: externalId) } - - identityModel.jwtBearerToken = OS_JWT_TOKEN_INVALID - - fireJwtExpired(externalId: externalId) } private func fireJwtExpired(externalId: String) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift index 52bebf57e..fa435d421 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift @@ -70,12 +70,10 @@ internal extension OneSignalRequest { | --------------- | -------------- | ------- | ------- | */ func addJWTHeaderIsValid(identityModel: OSIdentityModel) -> Bool { - let tokenIsValid = identityModel.isJwtValid() + let validToken = identityModel.getValidJwt() let required = OneSignalUserManagerImpl.sharedInstance.jwtConfig.isRequired - let canBeSent = (required == false) || (required == true && tokenIsValid) - if canBeSent && tokenIsValid, - let token = identityModel.jwtBearerToken - { + let canBeSent = (required == false) || (required == true && validToken != nil) + if canBeSent, let token = validToken { // Add the JWT token if it is valid, regardless of requirements var additionalHeaders = self.additionalHeaders ?? [String: String]() additionalHeaders["Authorization"] = "Bearer \(token)" diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/IdentityExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/IdentityExecutorTests.swift index fdfe73f62..c67e289f6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/IdentityExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/IdentityExecutorTests.swift @@ -278,4 +278,41 @@ final class IdentityExecutorTests: XCTestCase { XCTAssertEqual(removeAliasRequests.count, 3) } + + /// When Identity Verification is turned off, alias requests parked awaiting a JWT must be + /// released and sent without a token (no JWT will ever arrive once auth is off). + func testReleasePendingRequests_OnIdentityVerificationTurnedOff() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + // No JWT token, so the request is parked awaiting a JWT while Identity Verification is on. + // Use the user manager's executor because the JWT-config callback fires only on subscribed + // executors; start() initializes and subscribes them. + OneSignalUserManagerImpl.sharedInstance.start() + let executor = OneSignalUserManagerImpl.sharedInstance.identityExecutor! + + let aliases = userA_Aliases + MockUserRequests.setAddAliasesResponse(with: mocks.client, aliases: aliases) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + executor.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: user.identityModel.modelId, model: user.identityModel, property: "aliases", value: aliases)) + + /* When: the request is parked because no token is present */ + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestAddAliases.self)) + + /* When: Identity Verification is turned off */ + mocks.setAuthRequired(false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then: the parked request is released and sent, with no JWT invalidation */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestAddAliases.self)) + XCTAssertFalse(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift index ba977e7b3..edf6cd0ba 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift @@ -428,4 +428,36 @@ final class OSCustomEventsExecutorTests: XCTestCase { // Verify user properties are still present XCTAssertEqual(payload["user_key"] as? String, "user_value") } + + // MARK: - Identity Verification turned off + + /// When Identity Verification is turned off, custom-event requests parked awaiting a JWT must + /// be released and sent without a token (no JWT will ever arrive once auth is off). + func testReleasePendingRequests_OnIdentityVerificationTurnedOff() { + /* Setup */ + let mocks = CustomEventsMocks() + // Turn Identity Verification on via the shared config, which the user manager's executor reads. + OneSignalUserManagerImpl.sharedInstance.jwtConfig.isRequired = true + + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + // start() initializes sharedInstance.customEventsExecutor and subscribes it as a JWT listener. + OneSignalUserManagerImpl.sharedInstance.start() + let executor = OneSignalUserManagerImpl.sharedInstance.customEventsExecutor! + + mocks.client.fireSuccessForAllRequests = true + let delta = createCustomEventDelta(name: "iv_off_event", properties: ["key": "value"], identityModel: user.identityModel) + executor.enqueueDelta(delta) + + /* When: the request is parked because no token is present */ + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) + + /* When: Identity Verification is turned off */ + OneSignalUserManagerImpl.sharedInstance.jwtConfig.isRequired = false + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then: the parked request is released and sent */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/PropertyExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/PropertyExecutorTests.swift index 93155b31a..e9dddfeca 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/PropertyExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/PropertyExecutorTests.swift @@ -213,4 +213,41 @@ final class PropertyExecutorTests: XCTestCase { } XCTAssertEqual(updateRequests.count, 3) } + + /// When Identity Verification is turned off, requests parked awaiting a JWT must be + /// released and sent without a token (no JWT will ever arrive once auth is off). + func testReleasePendingRequests_OnIdentityVerificationTurnedOff() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + // No JWT token, so the request is parked awaiting a JWT while Identity Verification is on. + // Use the user manager's executor because the JWT-config callback fires only on subscribed + // executors; start() initializes and subscribes them. + OneSignalUserManagerImpl.sharedInstance.start() + let executor = OneSignalUserManagerImpl.sharedInstance.propertyExecutor! + + let tags = ["testUserA": "true"] + MockUserRequests.setAddTagsResponse(with: mocks.client, tags: tags) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + executor.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: user.identityModel.modelId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "tags", value: tags)) + + /* When: the request is parked because no token is present */ + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestUpdateProperties.self)) + + /* When: Identity Verification is turned off */ + mocks.setAuthRequired(false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then: the parked request is released and sent, with no JWT invalidation */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestUpdateProperties.self)) + XCTAssertFalse(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionsExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionsExecutorTests.swift index 9a4b5a935..e180390e2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionsExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionsExecutorTests.swift @@ -277,4 +277,41 @@ final class SubscriptionExecutorTests: XCTestCase { XCTAssertEqual(deleteRequests.count, 3) } + + /// When Identity Verification is turned off, subscription requests parked awaiting a JWT must + /// be released and sent without a token (no JWT will ever arrive once auth is off). + func testReleasePendingRequests_OnIdentityVerificationTurnedOff() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + let user = mocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: userA_OSID) + // No JWT token, so the request is parked awaiting a JWT while Identity Verification is on. + // Use the user manager's executor because the JWT-config callback fires only on subscribed + // executors; start() initializes and subscribes them. + OneSignalUserManagerImpl.sharedInstance.start() + let executor = OneSignalUserManagerImpl.sharedInstance.subscriptionExecutor! + + let email = userA_email + MockUserRequests.setAddEmailResponse(with: mocks.client, email: email) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + executor.enqueueDelta(OSDelta(name: OS_ADD_SUBSCRIPTION_DELTA, identityModelId: user.identityModel.modelId, model: OSSubscriptionModel(type: .email, address: email, subscriptionId: nil, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: OSSubscriptionType.email.rawValue, value: email)) + + /* When: the request is parked because no token is present */ + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateSubscription.self)) + + /* When: Identity Verification is turned off */ + mocks.setAuthRequired(false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then: the parked request is released and sent, with no JWT invalidation */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateSubscription.self)) + XCTAssertFalse(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift index 97827f93e..68476abb1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift @@ -492,4 +492,107 @@ final class UserExecutorTests: XCTestCase { XCTAssertNil(currentUser.identityModel.aliases["stale_label"]) XCTAssertEqual(currentUser.identityModel.externalId, userA_EUID) } + + /// When Identity Verification is turned off, requests parked awaiting a JWT must be released + /// and sent without a token (no JWT will ever arrive once auth is off). + func testReleasePendingRequests_OnIdentityVerificationTurnedOff() { + /* Setup */ + let mocks = Mocks() + mocks.setAuthRequired(true) + + _ = mocks.setUserManagerInternalUser(externalId: userA_EUID) + // start() initializes sharedInstance.userExecutor and subscribes it as a JWT listener. + OneSignalUserManagerImpl.sharedInstance.start() + let executor = OneSignalUserManagerImpl.sharedInstance.userExecutor! + + // No JWT token, so the Fetch User request is parked awaiting a JWT while IV is on. + let newIdentityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID, OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + MockUserRequests.setDefaultFetchUserResponseForHydration(with: mocks.client, externalId: userA_EUID) + + let userJwtInvalidatedListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(userJwtInvalidatedListener) + + /* When: the request is parked because no token is present */ + executor.fetchUser(onesignalId: userA_OSID, identityModel: newIdentityModel) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestFetchUser.self)) + + /* When: Identity Verification is turned off */ + mocks.setAuthRequired(false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then: the parked request is released and sent, with no JWT invalidation */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestFetchUser.self)) + XCTAssertFalse(userJwtInvalidatedListener.invalidatedCallbackWasCalled) + } + + // MARK: - Identity Verification config: a missing remote-params field always means off + + /// The regression: a previously-ON app must turn OFF when remote params omit jwt_required. + func testRemoteParamsMissingJwtRequired_TurnsOff_WhenPreviouslyOn() { + /* Setup */ + OneSignalUserManagerImpl.sharedInstance.setRequiresUserAuth(true) + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance.jwtConfig.isRequired, true) + + let listener = MockJwtConfigListener() + OneSignalUserManagerImpl.sharedInstance.subscribeToJwtConfig(listener, key: "test_iv_off_previously_on") + + /* When: remote params come back without the jwt_required field */ + OneSignalUserManagerImpl.sharedInstance.remoteParamsReturnedUnknownRequiresUserAuth() + + /* Then: it flips to off and fires the transition exactly once */ + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance.jwtConfig.isRequired, false) + XCTAssertEqual(listener.changes.count, 1) + XCTAssertEqual(listener.changes.first?.from, OSRequiresUserAuth.on) + XCTAssertEqual(listener.changes.first?.to, OSRequiresUserAuth.off) + } + + /// An unknown (never-set) status resolves to off when remote params omit jwt_required. + func testRemoteParamsMissingJwtRequired_TurnsOff_WhenPreviouslyUnknown() { + /* Setup */ + OneSignalUserManagerImpl.sharedInstance.jwtConfig.isRequired = nil // force unknown + XCTAssertNil(OneSignalUserManagerImpl.sharedInstance.jwtConfig.isRequired) + + let listener = MockJwtConfigListener() + OneSignalUserManagerImpl.sharedInstance.subscribeToJwtConfig(listener, key: "test_iv_off_previously_unknown") + + /* When */ + OneSignalUserManagerImpl.sharedInstance.remoteParamsReturnedUnknownRequiresUserAuth() + + /* Then */ + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance.jwtConfig.isRequired, false) + XCTAssertEqual(listener.changes.count, 1) + XCTAssertEqual(listener.changes.first?.from, OSRequiresUserAuth.unknown) + XCTAssertEqual(listener.changes.first?.to, OSRequiresUserAuth.off) + } + + /// Re-confirming an already-off status must not re-fire listeners (didSet equality guard). + func testRemoteParamsMissingJwtRequired_DoesNotRefire_WhenAlreadyOff() { + /* Setup */ + OneSignalUserManagerImpl.sharedInstance.setRequiresUserAuth(false) + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance.jwtConfig.isRequired, false) + + let listener = MockJwtConfigListener() + OneSignalUserManagerImpl.sharedInstance.subscribeToJwtConfig(listener, key: "test_iv_off_already_off") + + /* When */ + OneSignalUserManagerImpl.sharedInstance.remoteParamsReturnedUnknownRequiresUserAuth() + + /* Then: value unchanged, no transition fired */ + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance.jwtConfig.isRequired, false) + XCTAssertEqual(listener.changes.count, 0) + } +} + +private class MockJwtConfigListener: NSObject, OSUserJwtConfigListener { + var changes: [(from: OSRequiresUserAuth, to: OSRequiresUserAuth)] = [] + var jwtUpdateCount = 0 + + func onRequiresUserAuthChanged(from: OSRequiresUserAuth, to: OSRequiresUserAuth) { + changes.append((from: from, to: to)) + } + + func onJwtUpdated(externalId: String, token: String?) { + jwtUpdateCount += 1 + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift new file mode 100644 index 000000000..2b8756ade --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift @@ -0,0 +1,90 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import XCTest +import OneSignalCore +@testable import OneSignalOSCore +@testable import OneSignalUser + +/// Tests for the two new JWT APIs added to `OSIdentityModel`: +/// - `getValidJwt()` snapshots and returns the bearer token only when it is +/// non-nil, non-empty, and not the `OS_JWT_TOKEN_INVALID` sentinel. +/// - `invalidateJwtBearerToken()` performs an atomic compare-and-set to +/// `OS_JWT_TOKEN_INVALID`, returning `true` only on the transition. +final class OSIdentityModelTests: XCTestCase { + + private func makeModel(token: String? = nil) -> OSIdentityModel { + let model = OSIdentityModel(aliases: [:], changeNotifier: OSEventProducer()) + model.jwtBearerToken = token + return model + } + + // MARK: - getValidJwt() + + func testGetValidJwt_returnsNil_whenTokenIsNil() { + XCTAssertNil(makeModel(token: nil).getValidJwt()) + } + + func testGetValidJwt_returnsNil_whenTokenIsEmptyString() { + XCTAssertNil(makeModel(token: "").getValidJwt()) + } + + func testGetValidJwt_returnsNil_whenTokenIsInvalidSentinel() { + XCTAssertNil(makeModel(token: OS_JWT_TOKEN_INVALID).getValidJwt()) + } + + func testGetValidJwt_returnsToken_whenTokenIsValid() { + let token = "eyJhbGciOiJFUzI1NiJ9.payload.sig" + XCTAssertEqual(makeModel(token: token).getValidJwt(), token) + } + + // MARK: - invalidateJwtBearerToken() + + func testInvalidate_returnsTrueOnFirstTransition_andSetsInvalidSentinel() { + let model = makeModel(token: "valid-token") + + XCTAssertTrue(model.invalidateJwtBearerToken()) + XCTAssertEqual(model.jwtBearerToken, OS_JWT_TOKEN_INVALID) + } + + func testInvalidate_returnsFalseWhenAlreadyInvalid() { + let model = makeModel(token: "valid-token") + _ = model.invalidateJwtBearerToken() + + XCTAssertFalse(model.invalidateJwtBearerToken()) + XCTAssertEqual(model.jwtBearerToken, OS_JWT_TOKEN_INVALID) + } + + func testInvalidate_returnsTrueWhenStartingFromNil() { + // Defensive: nil → INVALID is still a real transition, the model lands + // on the sentinel and the caller can fire fireJwtExpired once. + let model = makeModel(token: nil) + + XCTAssertTrue(model.invalidateJwtBearerToken()) + XCTAssertEqual(model.jwtBearerToken, OS_JWT_TOKEN_INVALID) + } +}