From d15ddbaa0ec2a3b24f28df0fb8627972a112c148 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 19 May 2026 09:20:45 -0700 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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 } + ) + } } }