From d15ddbaa0ec2a3b24f28df0fb8627972a112c148 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 19 May 2026 09:20:45 -0700 Subject: [PATCH 1/9] fix(jwt): make OSIdentityModel.jwtBearerToken thread-safe fix unsynchronized reads of OSIdentityModel state (jwtBearerToken in particular) --- .../Source/Modeling/OSIdentityModel.swift | 47 +++++++++++++------ .../Source/OneSignalUserManagerImpl.swift | 4 +- .../Source/Requests/OSUserRequest.swift | 10 ++-- 3 files changed, 39 insertions(+), 22 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift index dcbe778d7..b59334d0b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift @@ -38,23 +38,42 @@ 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 _jwtBearerToken: String? public var jwtBearerToken: String? { - didSet { - guard jwtBearerToken != oldValue else { - return + get { + lock.withLock { _jwtBearerToken } + } + set { + // Lock only the storage write. The change notifier fires synchronously + // to listeners that may take other locks; firing under our lock would + // risk deadlock (NSRecursiveLock only saves same-thread re-entry). + let changed: Bool = lock.withLock { + guard newValue != _jwtBearerToken else { return false } + _jwtBearerToken = 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 non-nil, non-empty, and not the + /// `OS_JWT_TOKEN_INVALID` sentinel — otherwise nil. Snapshots once so the + /// caller cannot split a read-then-check across two reads of a property + /// that other threads can mutate. + func getValidJwt() -> String? { + let token = jwtBearerToken + guard let token = token, !token.isEmpty, token != OS_JWT_TOKEN_INVALID else { + return nil + } + return token } // MARK: - Initialization @@ -66,10 +85,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(_jwtBearerToken, forKey: OS_JWT_BEARER_TOKEN) } } @@ -79,20 +98,20 @@ class OSIdentityModel: OSModel { // log error return nil } - self.jwtBearerToken = coder.decodeObject(forKey: OS_JWT_BEARER_TOKEN) as? String + self._jwtBearerToken = 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 +124,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/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index e03fbfb14..9739055f1 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 } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift index 52bebf57e..d0a696133 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift @@ -70,12 +70,12 @@ internal extension OneSignalRequest { | --------------- | -------------- | ------- | ------- | */ func addJWTHeaderIsValid(identityModel: OSIdentityModel) -> Bool { - let tokenIsValid = identityModel.isJwtValid() + // Snapshot once via getValidJwt() to avoid split read-then-check races + // between concurrent writers (login/setUserJwtToken/invalidate). + 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)" From d1b065b17e87e8d7d86309e6a6ac0f95c29221e9 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 19 May 2026 09:21:34 -0700 Subject: [PATCH 2/9] fix(jwt): remove notifier-under-lock and TOCTOU in JWT invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-on JWT concurrency issues exposed while reviewing the prior fix. 1. OSIdentityModelRepo.updateJwtToken fired the model's change notifier synchronously (→ onModelUpdated → onJwtTokenChanged → executor listeners) while still holding the repo's NSLock. Today nothing re-enters the repo lock so it doesn't deadlock by luck, but it's a trap for any future listener. The fix collects matching models under the lock and mutates them outside, so the notifier fires lock-free. 2. invalidateJwtForExternalId had a TOCTOU between its "is it already invalid?" read and the "set to invalid" write. A concurrent valid-token write landing between them would be overwritten with INVALID and trigger a needless re-auth. The transition is now an atomic compare-and-set on the model (invalidateJwtBearerToken); only the thread that wins the transition fires fireJwtExpired. Co-Authored-By: Claude Opus 4.7 --- .../Source/Modeling/OSIdentityModel.swift | 19 ++++++++++++++++++ .../Source/OSIdentityModelRepo.swift | 20 ++++++++++--------- .../Source/OneSignalUserManagerImpl.swift | 13 ++++++------ 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift index b59334d0b..26e25c567 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift @@ -76,6 +76,25 @@ class OSIdentityModel: OSModel { 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. Used by `invalidateJwtForExternalId` so only the thread that + actually invalidated fires `fireJwtExpired`. + */ + @discardableResult + func invalidateJwtBearerToken() -> Bool { + let changed: Bool = lock.withLock { + guard _jwtBearerToken != OS_JWT_TOKEN_INVALID else { return false } + _jwtBearerToken = OS_JWT_TOKEN_INVALID + return true + } + if changed { + self.set(property: OS_JWT_BEARER_TOKEN, newValue: OS_JWT_TOKEN_INVALID) + } + return changed + } + // MARK: - Initialization // Initialize with aliases, if any 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 9739055f1..357adf4d8 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -737,14 +737,13 @@ extension OneSignalUserManagerImpl { return } - // Return, if the token has already been invalidated - guard identityModel.jwtBearerToken != OS_JWT_TOKEN_INVALID else { - return + // Atomic compare-and-set on the model. Only the thread that actually + // transitioned the token to INVALID fires the expired event — avoids + // a needless re-auth round trip if a concurrent valid-token write + // landed between a TOCTOU read/write pair. + if identityModel.invalidateJwtBearerToken() { + fireJwtExpired(externalId: externalId) } - - identityModel.jwtBearerToken = OS_JWT_TOKEN_INVALID - - fireJwtExpired(externalId: externalId) } private func fireJwtExpired(externalId: String) { From a8eb5db36bf6687115d5635b5a9157f57f86f751 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 28 May 2026 10:31:50 -0700 Subject: [PATCH 3/9] cleanup renaming a variable and cleaning up comments --- .../Source/Modeling/OSIdentityModel.swift | 32 ++++++++----------- .../Source/OneSignalUserManagerImpl.swift | 4 --- .../Source/Requests/OSUserRequest.swift | 2 -- 3 files changed, 13 insertions(+), 25 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift index 26e25c567..2c5d158e0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift @@ -44,18 +44,17 @@ class OSIdentityModel: OSModel { // MARK: - JWT - private var _jwtBearerToken: String? + private var jwtBearerTokenLocked: String? // only read/write under self.lock public var jwtBearerToken: String? { get { - lock.withLock { _jwtBearerToken } + lock.withLock { jwtBearerTokenLocked } } set { // Lock only the storage write. The change notifier fires synchronously - // to listeners that may take other locks; firing under our lock would - // risk deadlock (NSRecursiveLock only saves same-thread re-entry). - let changed: Bool = lock.withLock { - guard newValue != _jwtBearerToken else { return false } - _jwtBearerToken = newValue + // to listeners that may take other locks + let changed = lock.withLock { + guard newValue != jwtBearerTokenLocked else { return false } + jwtBearerTokenLocked = newValue return true } if changed { @@ -64,10 +63,7 @@ class OSIdentityModel: OSModel { } } - /// Returns the bearer token if it is non-nil, non-empty, and not the - /// `OS_JWT_TOKEN_INVALID` sentinel — otherwise nil. Snapshots once so the - /// caller cannot split a read-then-check across two reads of a property - /// that other threads can mutate. + /// 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 { @@ -78,15 +74,13 @@ class OSIdentityModel: OSModel { /** Atomically transition the JWT token to `OS_JWT_TOKEN_INVALID`. Returns - `true` if the transition occurred, `false` if the token was already - invalid. Used by `invalidateJwtForExternalId` so only the thread that - actually invalidated fires `fireJwtExpired`. + `true` if the transition occurred, `false` if the token was already invalid. */ @discardableResult func invalidateJwtBearerToken() -> Bool { - let changed: Bool = lock.withLock { - guard _jwtBearerToken != OS_JWT_TOKEN_INVALID else { return false } - _jwtBearerToken = OS_JWT_TOKEN_INVALID + let changed = lock.withLock { + guard jwtBearerTokenLocked != OS_JWT_TOKEN_INVALID else { return false } + jwtBearerTokenLocked = OS_JWT_TOKEN_INVALID return true } if changed { @@ -107,7 +101,7 @@ class OSIdentityModel: OSModel { 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) } } @@ -117,7 +111,7 @@ 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 } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 357adf4d8..9de6ccd1c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -737,10 +737,6 @@ extension OneSignalUserManagerImpl { return } - // Atomic compare-and-set on the model. Only the thread that actually - // transitioned the token to INVALID fires the expired event — avoids - // a needless re-auth round trip if a concurrent valid-token write - // landed between a TOCTOU read/write pair. if identityModel.invalidateJwtBearerToken() { fireJwtExpired(externalId: externalId) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift index d0a696133..fa435d421 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift @@ -70,8 +70,6 @@ internal extension OneSignalRequest { | --------------- | -------------- | ------- | ------- | */ func addJWTHeaderIsValid(identityModel: OSIdentityModel) -> Bool { - // Snapshot once via getValidJwt() to avoid split read-then-check races - // between concurrent writers (login/setUserJwtToken/invalidate). let validToken = identityModel.getValidJwt() let required = OneSignalUserManagerImpl.sharedInstance.jwtConfig.isRequired let canBeSent = (required == false) || (required == true && validToken != nil) From 37899799a86a4d12c18a084656922983ddc45de5 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 4 Jun 2026 09:09:15 -0700 Subject: [PATCH 4/9] add tests --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../OSIdentityModelTests.swift | 90 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift 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/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) + } +} From 7699bbd0c68c7d3f214875fcc35b194205137c73 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 8 Jun 2026 11:58:40 -0700 Subject: [PATCH 5/9] demo app: add jwt buttons to login, updatetoken --- examples/demo/App/Models/AppModels.swift | 25 ++++++++++++++++--- .../demo/App/Services/OneSignalService.swift | 8 ++++-- .../App/ViewModels/OneSignalViewModel.swift | 17 +++++++------ .../App/Views/Components/AddItemDialog.swift | 5 ++-- .../demo/App/Views/Sections/UserSection.swift | 23 +++++++++++++++-- 5 files changed, 62 insertions(+), 16 deletions(-) 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 } + ) + } } } From 552890b1b6ae1981b0587b9828d654db52b243dd Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 10 Jun 2026 18:09:52 -0700 Subject: [PATCH 6/9] fix: turn Identity Verification off when remote params omit jwt_required A missing jwt_required field previously set the flag off only when it was still unknown, so an app that had Identity Verification on stayed on after it was disabled remotely. Treat a missing field as off unconditionally so the on->off transition is actually detected. Co-Authored-By: Claude Opus 4.8 --- .../OneSignalUser/Source/OneSignalUserManagerImpl.swift | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 9de6ccd1c..dc0ebffd8 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -699,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 } From 531632983fdfed2fe8171d463459fc207913c7c7 Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 10 Jun 2026 18:15:30 -0700 Subject: [PATCH 7/9] fix: release auth-pended requests when Identity Verification is turned off While auth is required, requests without a valid JWT are parked per external ID awaiting a token. Once Identity Verification is turned off no token will arrive, so each JWT listener (User, Identity, Subscription, Property, and CustomEvents executors, plus the IAM controller) now releases the parked work and flushes it on the off transition. Co-Authored-By: Claude Opus 4.8 --- .../Controller/OSMessagingController.m | 7 ++++- .../Executors/OSCustomEventsExecutor.swift | 22 ++++++++++++++ .../OSIdentityOperationExecutor.swift | 29 ++++++++++++++++++- .../OSPropertyOperationExecutor.swift | 24 ++++++++++++++- .../OSSubscriptionOperationExecutor.swift | 27 +++++++++++++++++ .../Source/Executors/OSUserExecutor.swift | 24 ++++++++++++++- 6 files changed, 129 insertions(+), 4 deletions(-) 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..8fb43cbd5 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) 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 { From 1de9ff2d7833c9eac03897b24fb4eeb3228b17c4 Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 10 Jun 2026 18:16:00 -0700 Subject: [PATCH 8/9] fix: persist correct add/remove subscription queues on JWT cleanup removeInvalidDeltasAndRequests saved updateRequestQueue under both the add and remove request queue keys, corrupting those caches when Identity Verification is enabled. Persist the matching addRequestQueue and removeRequestQueue instead. Co-Authored-By: Claude Opus 4.8 --- .../Source/Executors/OSSubscriptionOperationExecutor.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index 8fb43cbd5..0c8802097 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -642,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 { @@ -650,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) } } } From d4d113ebe30a23f25b363276a9dfa5efd85acd97 Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 10 Jun 2026 18:16:08 -0700 Subject: [PATCH 9/9] test: cover Identity Verification turned-off transition Add per-executor tests asserting parked requests are released and sent without auth once Identity Verification is turned off, plus OSUserJwtConfig tests verifying a missing jwt_required remote param resolves to off (including the previously-on case) and that an unchanged value does not re-fire listeners. Co-Authored-By: Claude Opus 4.8 --- .../Executors/IdentityExecutorTests.swift | 37 +++++++ .../OSCustomEventsExecutorTests.swift | 32 ++++++ .../Executors/PropertyExecutorTests.swift | 37 +++++++ .../SubscriptionsExecutorTests.swift | 37 +++++++ .../Executors/UserExecutorTests.swift | 103 ++++++++++++++++++ 5 files changed, 246 insertions(+) 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 + } }