diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 33a035cce..50c5ad2c3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -53,6 +53,8 @@ 03E56DD328405F4A006AA1DA /* OneSignalAppDelegateOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 03E56DD228405F4A006AA1DA /* OneSignalAppDelegateOverrider.m */; }; 0AA11438FBF3A82D13824467 /* OSFeatureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DA9EEED644F1160CAD9A38 /* OSFeatureManager.swift */; }; 16664C4C25DDB195003B8A14 /* NSTimeZoneOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 16664C4B25DDB195003B8A14 /* NSTimeZoneOverrider.m */; }; + 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */; }; + 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */; }; 2F32272222E88DF0C2C18B53 /* OSFeatureManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E4A83C6C0D0DF28CDECA90 /* OSFeatureManagerTests.swift */; }; 37E6B2BB19D9CAF300D0C601 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 3C0151922C2E298F0079E076 /* OneSignalInAppMessages.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DEBAAE282A4211D900BF2C1C /* OneSignalInAppMessages.framework */; }; @@ -364,6 +366,8 @@ 918CB0301E73388E0067130F /* OneSignalFramework.h in CopyFiles */ = {isa = PBXBuildFile; fileRef = 912411F01E73342200E41FD7 /* OneSignalFramework.h */; }; 91C7725E1E7CCE1000D612D0 /* OneSignalInternal.h in Headers */ = {isa = PBXBuildFile; fileRef = 91C7725D1E7CCE1000D612D0 /* OneSignalInternal.h */; }; 91F60F7D1E80E4E400706E60 /* UncaughtExceptionHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 91F60F7C1E80E4E400706E60 /* UncaughtExceptionHandler.m */; }; + 9224347AAE3E092B5743380D /* UserJwtApiTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */; }; + 94D9F5AFE86B16453A36C9A2 /* MockUserJwtInvalidatedListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F214EE6C5FE133672D6622F /* MockUserJwtInvalidatedListener.swift */; }; 9D3300FA23149DAE000F0A83 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D3300F923149DAE000F0A83 /* CoreGraphics.framework */; }; 9D348537233C669E00EB81C9 /* CoreLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D348536233C669E00EB81C9 /* CoreLocation.framework */; }; 9D34853A233D2E3600EB81C9 /* OneSignalLocationOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 9D348539233D2E3600EB81C9 /* OneSignalLocationOverrider.m */; }; @@ -372,6 +376,7 @@ 9FF50E2A40C88E4533033A38 /* OSIdentityVerificationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */; }; A662399326850DDE00D52FD8 /* LanguageTest.m in Sources */ = {isa = PBXBuildFile; fileRef = A662399026850DDE00D52FD8 /* LanguageTest.m */; }; A66239952686612F00D52FD8 /* OneSignalFramework.h in Headers */ = {isa = PBXBuildFile; fileRef = 912411F01E73342200E41FD7 /* OneSignalFramework.h */; settings = {ATTRIBUTES = (Public, ); }; }; + B5FBED8247288744EB484CB5 /* OSIdentityModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */; }; BE737361D82E74544B7A1996 /* OSUserJwtConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */; }; CA08FC871FE99BB4004C445F /* OneSignalClientOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = CA08FC831FE99BB4004C445F /* OneSignalClientOverrider.m */; }; CA1A6E6920DC2E31001C41B9 /* OneSignalDialogController.h in Headers */ = {isa = PBXBuildFile; fileRef = CA1A6E6720DC2E31001C41B9 /* OneSignalDialogController.h */; }; @@ -400,6 +405,8 @@ CACBAAAA218A65AE000ACAA5 /* InAppMessagingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CACBAAA9218A65AE000ACAA5 /* InAppMessagingTests.m */; }; CACBAAAC218A662B000ACAA5 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; CACBAAB4218A7113000ACAA5 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; + D465D9B81F58B242ADF14874 /* OSIdentityModelRepoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DE5BB0EDD3964C20C5169F /* OSIdentityModelRepoTests.swift */; }; + DAF9C81134248FCDB0C12E5B /* OSUserJwtInvalidatedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */; }; DD2A89A8052E2D1912B0038B /* OSIamFetchReadyConditionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */; }; DE16C14424D3724700670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; DE16C14524D3724700670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; @@ -658,6 +665,7 @@ DEFB3E652BB7346D00E65DAD /* OSLiveActivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */; }; DEFB3E672BB735B500E65DAD /* OSStubLiveActivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */; }; ECD9DF65FB98056806A43541 /* OSIdentityVerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */; }; + FD1F1FCA05D555623DD53B54 /* OSUserJwtRepo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1339,6 +1347,8 @@ 1AF75EAC1E8567FD0097B315 /* NSString+OneSignal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSString+OneSignal.h"; sourceTree = ""; }; 1AF75EAD1E8567FD0097B315 /* NSString+OneSignal.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSString+OneSignal.m"; sourceTree = ""; }; 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityVerificationService.swift; sourceTree = ""; }; + 1F214EE6C5FE133672D6622F /* MockUserJwtInvalidatedListener.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MockUserJwtInvalidatedListener.swift; sourceTree = ""; }; + 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtInvalidatedEvent.swift; sourceTree = ""; }; 37747F9319147D6500558FAD /* libOneSignal.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libOneSignal.a; sourceTree = BUILT_PRODUCTS_DIR; }; 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; 3C01518E2C2E298E0079E076 /* OneSignalInAppMessagesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalInAppMessagesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1531,6 +1541,8 @@ 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OneSignalLiveActivities.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 475F47202B8E398E00EC05B3 /* OneSignalLiveActivities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLiveActivities.h; sourceTree = ""; }; 475F47482B8E3A4400EC05B3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "OneSignalUserManagerImpl+Jwt.swift"; sourceTree = ""; }; + 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityModelTests.swift; sourceTree = ""; }; 5B053FB82CAE07EB002F30C4 /* OneSignalOSCoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalOSCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5B58E4F3237CE7B3009401E0 /* UIDeviceOverrider.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UIDeviceOverrider.h; sourceTree = ""; }; 5B58E4F6237CE7B4009401E0 /* UIDeviceOverrider.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = UIDeviceOverrider.m; sourceTree = ""; }; @@ -1542,6 +1554,7 @@ 5BC1DE632C90BB9000CA8807 /* OSIamFetchReadyCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIamFetchReadyCondition.swift; sourceTree = ""; }; 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSConsistencyManagerTests.swift; sourceTree = ""; }; 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfig.swift; sourceTree = ""; }; + 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtRepo.swift; sourceTree = ""; }; 7A123294235DFE3B002B6CE3 /* OutcomeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OutcomeTests.m; sourceTree = ""; }; 7A12EBD523060A6F005C4FA5 /* OSSessionManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OSSessionManager.m; sourceTree = ""; }; 7A12EBD623060A6F005C4FA5 /* OSSessionManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OSSessionManager.h; sourceTree = ""; }; @@ -1615,6 +1628,8 @@ 7AF98691244A567B00C36EAE /* OSOutcomeEventsCache.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSOutcomeEventsCache.m; sourceTree = ""; }; 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSFocusCallParams.m; sourceTree = ""; }; 7AFE856E2368DDC50091D6A5 /* OSFocusCallParams.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSFocusCallParams.h; sourceTree = ""; }; + 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtApiTests.swift; sourceTree = ""; }; + 89DE5BB0EDD3964C20C5169F /* OSIdentityModelRepoTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityModelRepoTests.swift; sourceTree = ""; }; 911E2CBA1E398AB3003112A4 /* UnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 911E2CBC1E398AB3003112A4 /* UnitTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UnitTests.m; sourceTree = ""; }; 911E2CBE1E398AB3003112A4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -1648,6 +1663,7 @@ 9D348538233D2DCF00EB81C9 /* OneSignalLocationOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLocationOverrider.h; sourceTree = ""; }; 9D348539233D2E3600EB81C9 /* OneSignalLocationOverrider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalLocationOverrider.m; sourceTree = ""; }; A662399026850DDE00D52FD8 /* LanguageTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = LanguageTest.m; sourceTree = ""; }; + B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtRepoTests.swift; sourceTree = ""; }; C7DA9EEED644F1160CAD9A38 /* OSFeatureManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSFeatureManager.swift; sourceTree = ""; }; CA08FC821FE99BB4004C445F /* OneSignalClientOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalClientOverrider.h; sourceTree = ""; }; CA08FC831FE99BB4004C445F /* OneSignalClientOverrider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalClientOverrider.m; sourceTree = ""; }; @@ -2398,6 +2414,7 @@ 3C87066F2BDE0957000D8CD2 /* MockUserRequests.swift */, 3C8706712BDEE076000D8CD2 /* MockUserDefines.swift */, 3CC063E52B6D7F96002BB07F /* OneSignalUserMocks.swift */, + 1F214EE6C5FE133672D6622F /* MockUserJwtInvalidatedListener.swift */, ); path = OneSignalUserMocks; sourceTree = ""; @@ -2413,6 +2430,10 @@ 3CB331672F281679000E1801 /* CustomEventsIntegrationTests.swift */, 3C67F7792BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift */, 3CDE664B2BFC2A56006DA114 /* OneSignalUserObjcTests.m */, + 89DE5BB0EDD3964C20C5169F /* OSIdentityModelRepoTests.swift */, + 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */, + B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */, + 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */, ); path = OneSignalUserTests; sourceTree = ""; @@ -2803,6 +2824,9 @@ 3CF8629F28A1964F00776CA4 /* OSPropertiesModel.swift */, 3CE795F828DB99B500736BD4 /* OSSubscriptionModelStoreListener.swift */, 3C5117162B15C31E00563465 /* OSUserState.swift */, + 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */, + 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */, + 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */, ); path = Source; sourceTree = ""; @@ -4550,6 +4574,7 @@ 3C8706702BDE0957000D8CD2 /* MockUserRequests.swift in Sources */, 3C8706722BDEE076000D8CD2 /* MockUserDefines.swift in Sources */, 3CC063E62B6D7F96002BB07F /* OneSignalUserMocks.swift in Sources */, + 94D9F5AFE86B16453A36C9A2 /* MockUserJwtInvalidatedListener.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4566,6 +4591,10 @@ 3CB3316A2F281692000E1801 /* OSCustomEventsExecutorTests.swift in Sources */, 3CA93BC4300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift in Sources */, 3CDE664C2BFC2A56006DA114 /* OneSignalUserObjcTests.m in Sources */, + D465D9B81F58B242ADF14874 /* OSIdentityModelRepoTests.swift in Sources */, + B5FBED8247288744EB484CB5 /* OSIdentityModelTests.swift in Sources */, + 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */, + 9224347AAE3E092B5743380D /* UserJwtApiTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4765,6 +4794,9 @@ 3CE9227A289FA88B001B1062 /* OSIdentityModelStoreListener.swift in Sources */, DE69E19F282ED8060090BB3D /* OneSignalUser.docc in Sources */, 3CA6CE0A28E4F19B00CA0585 /* OSUserRequest.swift in Sources */, + DAF9C81134248FCDB0C12E5B /* OSUserJwtInvalidatedEvent.swift in Sources */, + FD1F1FCA05D555623DD53B54 /* OSUserJwtRepo.swift in Sources */, + 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift index 6a144eef7..1f8cc7a27 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift @@ -34,6 +34,16 @@ public class OSCoreMocks: NSObject { public static func resetOperationRepo() { OSOperationRepo.sharedInstance.reset() } + + /// Puts the shared JWT config back to unhydrated. + public static func resetSharedJwtConfig() { + OSUserJwtConfig.shared.resetRequirementToUnknownForTests() + } + + /// Hydrates the shared JWT config. Non-IV tests hydrate `false` so the Operation Repo will flush. + public static func hydrateSharedJwtConfig(requiresUserAuth: Bool) { + OSUserJwtConfig.shared.hydrate(requiresUserAuth: requiresUserAuth) + } } extension OSOperationRepo { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift index 6e70b5057..6df132faf 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift @@ -38,12 +38,55 @@ class OSIdentityModel: OSModel { return internalGetAlias(OS_EXTERNAL_ID) } - // All access to aliases should go through helper methods with locking + // All access to aliases and the JWT bearer token must go through the lock var aliases: [String: String] = [:] - private let aliasesLock = NSRecursiveLock() + private let lock = NSRecursiveLock() - // TODO: We need to make this token secure - public var jwtBearerToken: String? + // MARK: - JWT + + private var jwtBearerTokenLocked: String? + public var jwtBearerToken: String? { + get { + lock.withLock { jwtBearerTokenLocked } + } + set { + // Notify outside the lock: the change notifier fires synchronously into listeners that + // take locks of their own. + let changed = lock.withLock { + guard newValue != jwtBearerTokenLocked else { return false } + jwtBearerTokenLocked = newValue + return true + } + if changed { + self.set(property: OS_JWT_BEARER_TOKEN, newValue: newValue, preventServerUpdate: true) + } + } + } + + /// Returns the bearer token if it is valid, otherwise nil, snapshots once + func getValidJwt() -> String? { + let token = jwtBearerToken + guard let token = token, !token.isEmpty, token != OS_JWT_TOKEN_INVALID else { + return nil + } + return token + } + + /// Returns `true` if the transition occurred, `false` if `rejectedToken` is no longer the stored + /// token. Comparing against the rejected token rather than the sentinel is what keeps a failure + /// response that was already in flight from parking the replacement supplied after it left. + @discardableResult + func invalidateJwtBearerToken(rejectedToken: String) -> Bool { + let changed = lock.withLock { + guard jwtBearerTokenLocked == rejectedToken else { return false } + jwtBearerTokenLocked = OS_JWT_TOKEN_INVALID + return true + } + if changed { + self.set(property: OS_JWT_BEARER_TOKEN, newValue: OS_JWT_TOKEN_INVALID, preventServerUpdate: true) + } + return changed + } // MARK: - Initialization @@ -54,9 +97,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(jwtBearerTokenLocked, forKey: OS_JWT_BEARER_TOKEN) } } @@ -66,19 +110,20 @@ class OSIdentityModel: OSModel { // log error return nil } + self.jwtBearerTokenLocked = coder.decodeObject(forKey: OS_JWT_BEARER_TOKEN) as? String self.aliases = aliases } /** Threadsafe getter for an alias */ private func internalGetAlias(_ label: String) -> String? { - aliasesLock.withLock { + lock.withLock { return self.aliases[label] } } /** Threadsafe setter or removal for aliases */ private func internalAddAliases(_ aliases: [String: String]) { - aliasesLock.withLock { + lock.withLock { for (label, id) in aliases { // Remove the alias if the ID field is "" self.aliases[label] = id.isEmpty ? nil : id @@ -91,7 +136,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 = [:] } } @@ -120,14 +165,20 @@ class OSIdentityModel: OSModel { let newExternalId = remoteAliases[OS_EXTERNAL_ID] internalAddAliases(remoteAliases) - fireUserStateChanged(newOnesignalId: newOnesignalId, newExternalId: newExternalId) + OSUserStateSnapshot.fireUserStateChanged(newOnesignalId: newOnesignalId, newExternalId: newExternalId) } +} - /** - Fires the user observer if `onesignal_id` OR `external_id` has changed from the previous snapshot (previous hydration). - */ - private func fireUserStateChanged(newOnesignalId: String?, newExternalId: String?) { - let prevOnesignalId = OneSignalUserDefaults.initShared().getSavedString(forKey: OS_SNAPSHOT_ONESIGNAL_ID, defaultValue: nil) +/** + Owns the last user state the app was told about, so the observer only hears real changes. + + Hydration is the usual source, but `logout` under Identity Verification also reports here: it creates + no user on the server, so there is no hydration to carry the news that nobody is signed in. + */ +enum OSUserStateSnapshot { + /// Fires the user observer if `onesignal_id` OR `external_id` differs from the last reported pair. + static func fireUserStateChanged(newOnesignalId: String?, newExternalId: String?) { + let prevOnesignalId = OneSignalUserDefaults.initShared().getSavedString(forKey: OS_SNAPSHOT_ONESIGNAL_ID, defaultValue: nil) let prevExternalId = OneSignalUserDefaults.initShared().getSavedString(forKey: OS_SNAPSHOT_EXTERNAL_ID, defaultValue: nil) guard prevOnesignalId != newOnesignalId || prevExternalId != newExternalId else { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift index 781fe4e8f..c1468632e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift @@ -26,6 +26,7 @@ */ import Foundation +import OneSignalCore /** This class stores all Identity Models that are being used during an app session. @@ -52,4 +53,64 @@ class OSIdentityModelRepo { return models[modelId] } } + + func get(externalId: String) -> OSIdentityModel? { + lock.withLock { + return models.values.first { $0.externalId == externalId } + } + } + + /** + Repeated logins as the same user each create an Identity Model, so update them all. + This can be optimized in the future to re-use an Identity Model if multiple logins are made for the same user. + + Returns `false` if no Identity Model carries this external ID, in which case the token was not stored + anywhere and nothing can sign with it. + */ + @discardableResult + func updateJwtToken(externalId: String, token: String) -> Bool { + let matchingModels = modelsMatching(externalId: externalId) + guard !matchingModels.isEmpty else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityModelRepo.updateJwtToken called for unknown external ID \(externalId)") + return false + } + for model in matchingModels { + model.jwtBearerToken = token + } + return true + } + + /// The token this user can currently sign with, or nil if there is none. + func validJwt(externalId: String) -> String? { + return modelsMatching(externalId: externalId).lazy.compactMap { $0.getValidJwt() }.first + } + + /** + Invalidates the token on every Identity Model with this external ID, since repeated logins as the + same user each create one. Only the models still holding `rejectedToken` transition, so a + replacement that landed while the rejected request was in flight survives. + + Returns `false` if no Identity Model carries this external ID, in which case there was nothing + to park and nothing to tell the app about. + */ + @discardableResult + func invalidateJwtToken(externalId: String, rejectedToken: String) -> Bool { + let matchingModels = modelsMatching(externalId: externalId) + guard !matchingModels.isEmpty else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityModelRepo.invalidateJwtToken called for unknown external ID \(externalId)") + return false + } + for model in matchingModels { + model.invalidateJwtBearerToken(rejectedToken: rejectedToken) + } + return true + } + + /// Snapshot before touching the tokens: writing one fires the model's change notifier + /// synchronously into listeners that take locks of their own. + private func modelsMatching(externalId: String) -> [OSIdentityModel] { + lock.withLock { + models.values.filter { $0.externalId == externalId } + } + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtInvalidatedEvent.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtInvalidatedEvent.swift new file mode 100644 index 000000000..35d7a7e33 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtInvalidatedEvent.swift @@ -0,0 +1,48 @@ +/* + 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. + */ + +/** + Tells the app that the JWT it supplied for `externalId` is no longer accepted, so it should mint a + fresh one and hand it back through `OneSignal.updateUserJwt(externalId:token:)`. + */ +@objc public class OSUserJwtInvalidatedEvent: NSObject { + @objc public let externalId: String + + init(externalId: String) { + self.externalId = externalId + } + + @objc public func jsonRepresentation() -> NSDictionary { + return [ + "externalId": externalId + ] + } +} + +@objc public protocol OSUserJwtInvalidatedListener { + @objc func onUserJwtInvalidated(event: OSUserJwtInvalidatedEvent) +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift new file mode 100644 index 000000000..8adf8f164 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift @@ -0,0 +1,128 @@ +/* + 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 Foundation +import OneSignalCore + +/** + Identity Verification token access, keyed by `external_id`. Executors depend on this rather than on + the User Manager so that no JWT lookup reaches for a singleton. + */ +protocol OSUserJwtProviding: AnyObject { + /// The token this user can sign with, or nil if the SDK holds none. + func validJwt(externalId: String) -> String? + + /** + Asks the app for a token for `externalId`. + + Returns `true` if this call is the one that asked, which happens at most once per external ID + per session so a burst of concurrent callers does not fire the event repeatedly. + */ + @discardableResult + func askForToken(externalId: String) -> Bool + + /// Parks the rejected token and asks the app for a replacement. Returns `true` if this call asked. + @discardableResult + func invalidateJwt(externalId: String, rejectedToken: String) -> Bool +} + +final class OSUserJwtRepo: OSUserJwtProviding { + private let identityModelRepo: OSIdentityModelRepo + private let notifyInvalidated: (String) -> Void + + let lock = NSLock() + /** + External IDs the app has already been asked to re-sign. + + In memory only. A model decoded at launch can already hold the invalid sentinel, leaving nothing + to transition, so a fresh session has to be able to ask again — otherwise an app that was asked + in a previous run is never told it still owes a token. + */ + var askedForToken: Set = [] + + init(identityModelRepo: OSIdentityModelRepo, notifyInvalidated: @escaping (String) -> Void) { + self.identityModelRepo = identityModelRepo + self.notifyInvalidated = notifyInvalidated + } + + func validJwt(externalId: String) -> String? { + return identityModelRepo.validJwt(externalId: externalId) + } + + /** + Stores a token supplied by the app, and lets this user be asked again if it is ever rejected. + Returns `false` for a token that was not stored, so callers do not go looking for held work to release. + + An unusable token is ignored rather than stored: it would replace a good token with nothing to sign + with, and clearing the ask for it would have the SDK and the app trade asks and replies on every flush. + A token for an external ID the SDK has no Identity Model for lands nowhere, so it is not treated as an + answer either. + */ + @discardableResult + func updateJwt(externalId: String, token: String) -> Bool { + guard !token.isEmpty, token != OS_JWT_TOKEN_INVALID else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserJwtRepo.updateJwt ignored an unusable token for \(externalId)") + return false + } + guard identityModelRepo.updateJwtToken(externalId: externalId, token: token) else { + return false + } + lock.withLock { _ = askedForToken.remove(externalId) } + return true + } + + @discardableResult + func askForToken(externalId: String) -> Bool { + guard lock.withLock({ askedForToken.insert(externalId).inserted }) else { + return false + } + notifyInvalidated(externalId) + return true + } + + /// External IDs already asked this session; cleared when a usable token is stored. + func pendingTokenAsks() -> [String] { + return lock.withLock { Array(askedForToken) } + } + + @discardableResult + func invalidateJwt(externalId: String, rejectedToken: String) -> Bool { + // No model for this user means the token could not have come from here. A Request stamped + // with an owner whose model was cleared for hydration lands here, and it retries once the + // aliases come back. + guard identityModelRepo.invalidateJwtToken(externalId: externalId, rejectedToken: rejectedToken) else { + return false + } + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserJwtRepo invalidated JWT for externalId: \(externalId)") + // A replacement that landed while the rejected Request was in flight leaves the token above + // untouched, and the retry signs with it, so there is nothing to ask the app for. + guard identityModelRepo.validJwt(externalId: externalId) == nil else { + return false + } + return askForToken(externalId: externalId) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift new file mode 100644 index 000000000..eaf4c7784 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -0,0 +1,78 @@ +/* + 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 OneSignalCore +import OneSignalOSCore + +/** + The Identity Verification surface the app talks to: it hands the SDK a token for a user, and the SDK + tells it when that token stopped being accepted. + */ +extension OneSignalUserManagerImpl { + /** + Replays any ask that already fired this session, so a listener registered after `start` or `login` + still hears who currently owes a token. + */ + @objc + public func addUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + self.userJwtInvalidatedObserver.addObserver(listener) + let pending = userJwtRepo.pendingTokenAsks() + guard !pending.isEmpty else { + return + } + // Same queue as OSObservable; skip any ask answered between registration and delivery. + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + let stillPending = Set(self.userJwtRepo.pendingTokenAsks()) + for externalId in pending where stillPending.contains(externalId) { + listener.onUserJwtInvalidated(event: OSUserJwtInvalidatedEvent(externalId: externalId)) + } + } + } + + @objc + public func removeUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + self.userJwtInvalidatedObserver.removeObserver(listener) + } + + @objc + public func updateUserJwt(externalId: String, token: String) { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "updateUserJwt") else { + return + } + guard !externalId.isEmpty, !token.isEmpty, token != OS_JWT_TOKEN_INVALID else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OneSignal.updateUserJwt called with empty externalId or an unusable token. This is not allowed.") + return + } + // TODO: omit the token from this log before shipping — keep for testing. + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignal.updateUserJwt called for externalId: \(externalId) with token: \(token)") + + storeJwt(externalId: externalId, token: token) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index eb7608b2d..ca4457d62 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -37,6 +37,7 @@ import OneSignalNotifications var User: OSUser { get } func login(externalId: String, token: String?) func logout() + func updateUserJwt(externalId: String, token: String) // Location func setLocation(latitude: Float, longitude: Float) // Purchase Tracking @@ -84,10 +85,6 @@ import OneSignalNotifications */ func trackEvent(name: String, properties: [String: Any]?) // ^ TODO: After alpha feedback, confirm value type for properties dict - // JWT Token Expire - typealias OSJwtCompletionBlock = (_ newJwtToken: String) -> Void - typealias OSJwtExpiredHandler = (_ externalId: String, _ completion: OSJwtCompletionBlock) -> Void - func onJwtExpired(expiredHandler: @escaping OSJwtExpiredHandler) } /** @@ -131,11 +128,15 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let newRecordsState = OSNewRecordsState() + // Shared instances: remote params hydrate them before this class is started, and a + // fresh instance here would read none of it. + let featureManager = OSFeatureManager.shared + let jwtConfig = OSUserJwtConfig.shared + let identityVerificationService: OSIdentityVerificationService + private let startQueue = DispatchQueue(label: "com.onesignal.user.start") var hasCalledStart = false - private var jwtExpiredHandler: OSJwtExpiredHandler? - var user: OSUserInternal { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { return _mockUser @@ -167,8 +168,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { propertiesModel: OSPropertiesModel(changeNotifier: OSEventProducer()), pushSubscriptionModel: OSSubscriptionModel(type: .push, address: nil, subscriptionId: nil, reachable: false, isDisabled: true, changeNotifier: OSEventProducer())) - @objc public var requiresUserAuth = false - // User State Observer private var _userStateChangesObserver: OSObservable? var userStateChangesObserver: OSObservable { @@ -181,6 +180,18 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { return userStateChangesObserver } + // JWT Invalidated Observer + private var _userJwtInvalidatedObserver: OSObservable? + var userJwtInvalidatedObserver: OSObservable { + if let observer = _userJwtInvalidatedObserver { + return observer + } + let userJwtInvalidatedObserver = OSObservable(change: #selector(OSUserJwtInvalidatedListener.onUserJwtInvalidated(event:))) + _userJwtInvalidatedObserver = userJwtInvalidatedObserver + + return userJwtInvalidatedObserver + } + // Model Stores let identityModelStore = OSModelStore(changeSubscription: OSEventProducer(), storeKey: OS_IDENTITY_MODEL_STORE_KEY).registerAsUserObserver() let propertiesModelStore = OSModelStore(changeSubscription: OSEventProducer(), storeKey: OS_PROPERTIES_MODEL_STORE_KEY).registerAsUserObserver() @@ -190,6 +201,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let pushSubscriptionModelStore = OSModelStore(changeSubscription: OSEventProducer(), storeKey: OS_PUSH_SUBSCRIPTION_MODEL_STORE_KEY) // These must be initialized in init() + let userJwtRepo: OSUserJwtRepo let identityModelStoreListener: OSIdentityModelStoreListener let propertiesModelStoreListener: OSPropertiesModelStoreListener let subscriptionModelStoreListener: OSSubscriptionModelStoreListener @@ -203,6 +215,14 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { var customEventsExecutor: OSCustomEventsExecutor? private override init() { + let identityVerificationService = OSIdentityVerificationService(featureManager: featureManager, jwtConfig: jwtConfig) + // Goes through `sharedInstance` rather than capturing self: the observer it notifies is created + // lazily and must not be touched during init. + let userJwtRepo = OSUserJwtRepo(identityModelRepo: identityModelRepo) { externalId in + OneSignalUserManagerImpl.sharedInstance.userJwtInvalidatedObserver.notifyChange(OSUserJwtInvalidatedEvent(externalId: externalId)) + } + self.identityVerificationService = identityVerificationService + self.userJwtRepo = userJwtRepo self.identityModelStoreListener = OSIdentityModelStoreListener(store: identityModelStore) self.propertiesModelStoreListener = OSPropertiesModelStoreListener(store: propertiesModelStore) self.subscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: subscriptionModelStore) @@ -233,6 +253,9 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { propertiesModelStore.refresh() subscriptionModelStore.refresh() pushSubscriptionModelStore.refresh() + // Same prewarm gap as the stores: init may have read UserDefaults while it was locked. + jwtConfig.refreshIfUnknown() + featureManager.refreshIfEmpty() OSNotificationsManager.delegate = self @@ -468,6 +491,19 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { createUserIfNil() } + /** + Stores a token for `externalId`, so that the pending ask for this user is cleared and a later + rejection can ask again. + + Every app-supplied token arrives here, from `login` as well as `updateUserJwt`. + */ + func storeJwt(externalId: String, token: String) { + guard userJwtRepo.updateJwt(externalId: externalId, token: token) else { + return + } + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager stored a JWT for externalId: \(externalId)") + } + @objc public func clearAllModelsFromStores() { prepareForNewUser() @@ -579,17 +615,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { updatePropertiesDeltas(property: .purchases, value: purchases) } - private func fireJwtExpired() { - guard let externalId = user.identityModel.externalId, let jwtExpiredHandler = self.jwtExpiredHandler else { - return - } - jwtExpiredHandler(externalId) { [self] (newToken) in - guard user.identityModel.externalId == externalId else { - return - } - user.identityModel.jwtBearerToken = newToken - } - } } // MARK: - Sessions @@ -665,10 +690,6 @@ extension OneSignalUserManagerImpl { } extension OneSignalUserManagerImpl: OSUser { - public func onJwtExpired(expiredHandler: @escaping OSJwtExpiredHandler) { - jwtExpiredHandler = expiredHandler - } - public var User: OSUser { start() return self diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserJwtInvalidatedListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserJwtInvalidatedListener.swift new file mode 100644 index 000000000..bc8b3207f --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserJwtInvalidatedListener.swift @@ -0,0 +1,48 @@ +/* + 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 OneSignalUser + +public class MockUserJwtInvalidatedListener: NSObject, OSUserJwtInvalidatedListener { + public private(set) var invalidatedExternalIds: [String] = [] + public var invalidatedCallbackWasCalled: Bool { + return !invalidatedExternalIds.isEmpty + } + + private var callback: (() -> Void)? + + public override init() { } + + public func setCallback(_ callback: @escaping () -> Void) { + self.callback = callback + } + + public func onUserJwtInvalidated(event: OSUserJwtInvalidatedEvent) { + invalidatedExternalIds.append(event.externalId) + callback?() + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift index da94af841..e626f423a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift @@ -38,6 +38,9 @@ public class OneSignalUserMocks: NSObject { @objc public static func reset() { OSCoreMocks.resetOperationRepo() + OSCoreMocks.resetSharedJwtConfig() + // Hydrate `off` so the Operation Repo's unknown-requirement deferral does not stall non-IV tests. + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) OneSignalUserManagerImpl.sharedInstance.reset() } @@ -59,6 +62,14 @@ extension OSIdentityModelRepo { } } +extension OSUserJwtRepo { + /// Clears which external IDs have already been asked for a token, which lives for a session in + /// production and would otherwise silence the invalidated event in the next test. + func reset() { + lock.withLock { askedForToken.removeAll() } + } +} + extension OneSignalUserManagerImpl { /** User Manager needs to reset between tests until we dependency inject the User Manager. @@ -67,6 +78,7 @@ extension OneSignalUserManagerImpl { */ func reset() { identityModelRepo.reset() + userJwtRepo.reset() // Model store listeners unsubscribe to their models // User Manager start() will subscribe them diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelRepoTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelRepoTests.swift new file mode 100644 index 000000000..40bf57230 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelRepoTests.swift @@ -0,0 +1,198 @@ +/* + 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 +import OneSignalCoreMocks +import OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +/// Covers the repo's external-ID lookups. The repo is constructible, so these build their own +/// instance rather than reaching through the User Manager's shared one. +final class OSIdentityModelRepoTests: XCTestCase { + + private var repo = OSIdentityModelRepo() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + repo = OSIdentityModelRepo() + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + @discardableResult + private func addModel(externalId: String?) -> OSIdentityModel { + let aliases = externalId.map { [OS_EXTERNAL_ID: $0] } + let model = OSIdentityModel(aliases: aliases, changeNotifier: OSEventProducer()) + repo.add(model: model) + return model + } + + // MARK: - get(externalId:) + + func testGetByExternalIdFindsTheMatchingModel() { + addModel(externalId: "user-a") + let userB = addModel(externalId: "user-b") + + XCTAssertIdentical(repo.get(externalId: "user-b"), userB) + } + + func testGetByExternalIdReturnsNilWhenNoModelMatches() { + addModel(externalId: "user-a") + + XCTAssertNil(repo.get(externalId: "user-b")) + } + + /// Anonymous users have no external ID, and must not be matched by one. + func testGetByExternalIdSkipsAnonymousModels() { + addModel(externalId: nil) + + XCTAssertNil(repo.get(externalId: "user-a")) + } + + // MARK: - updateJwtToken + + func testUpdateJwtTokenWritesTheTokenOntoTheMatchingModel() { + let userA = addModel(externalId: "user-a") + + repo.updateJwtToken(externalId: "user-a", token: "token-a") + + XCTAssertEqual(userA.jwtBearerToken, "token-a") + } + + /// Repeated logins as the same user can leave more than one model carrying that external ID; + /// a token that only reaches one of them would leave the others stuck on a rejected token. + func testUpdateJwtTokenWritesToEveryModelWithThatExternalId() { + let first = addModel(externalId: "user-a") + let second = addModel(externalId: "user-a") + + repo.updateJwtToken(externalId: "user-a", token: "token-a") + + XCTAssertEqual(first.jwtBearerToken, "token-a") + XCTAssertEqual(second.jwtBearerToken, "token-a") + } + + func testUpdateJwtTokenLeavesOtherUsersAlone() { + let userA = addModel(externalId: "user-a") + let userB = addModel(externalId: "user-b") + + repo.updateJwtToken(externalId: "user-a", token: "token-a") + + XCTAssertEqual(userA.jwtBearerToken, "token-a") + XCTAssertNil(userB.jwtBearerToken) + } + + /// A token supplied for a user the SDK has never seen is dropped rather than applied to whoever + /// happens to be current. + func testUpdateJwtTokenForAnUnknownExternalIdChangesNothing() { + let userA = addModel(externalId: "user-a") + + repo.updateJwtToken(externalId: "user-b", token: "token-b") + + XCTAssertNil(userA.jwtBearerToken) + } + + func testUpdateJwtTokenReplacesTheInvalidSentinel() { + let userA = addModel(externalId: "user-a") + userA.invalidateJwtBearerToken(rejectedToken: "token-a") + + repo.updateJwtToken(externalId: "user-a", token: "fresh-token") + + XCTAssertEqual(userA.getValidJwt(), "fresh-token") + } + + // MARK: - validJwt + + func testValidJwtReturnsTheStoredToken() { + let userA = addModel(externalId: "user-a") + userA.jwtBearerToken = "token-a" + + XCTAssertEqual(repo.validJwt(externalId: "user-a"), "token-a") + } + + func testValidJwtIsNilForAnUnknownExternalIdAndForAParkedToken() { + let userA = addModel(externalId: "user-a") + userA.jwtBearerToken = "token-a" + + XCTAssertNil(repo.validJwt(externalId: "user-b")) + + repo.invalidateJwtToken(externalId: "user-a", rejectedToken: "token-a") + XCTAssertNil(repo.validJwt(externalId: "user-a")) + } + + // MARK: - invalidateJwtToken + + /// A model left unparked would keep signing requests with a token the server already rejected. + func testInvalidateJwtTokenParksEveryModelWithThatExternalId() { + let first = addModel(externalId: "user-a") + let second = addModel(externalId: "user-a") + first.jwtBearerToken = "token-a" + second.jwtBearerToken = "token-a" + + repo.invalidateJwtToken(externalId: "user-a", rejectedToken: "token-a") + + XCTAssertNil(first.getValidJwt()) + XCTAssertNil(second.getValidJwt()) + } + + /// A login that landed while the rejected request was in flight leaves a newer token behind. + func testInvalidateJwtTokenLeavesAModelHoldingADifferentToken() { + let stale = addModel(externalId: "user-a") + let fresh = addModel(externalId: "user-a") + stale.jwtBearerToken = "token-a" + fresh.jwtBearerToken = "token-b" + + repo.invalidateJwtToken(externalId: "user-a", rejectedToken: "token-a") + + XCTAssertNil(stale.getValidJwt()) + XCTAssertEqual(fresh.getValidJwt(), "token-b") + } + + func testInvalidateJwtTokenLeavesOtherUsersAlone() { + let userA = addModel(externalId: "user-a") + let userB = addModel(externalId: "user-b") + userA.jwtBearerToken = "token-a" + userB.jwtBearerToken = "token-a" + + repo.invalidateJwtToken(externalId: "user-a", rejectedToken: "token-a") + + XCTAssertEqual(userB.getValidJwt(), "token-a") + } + + func testInvalidateJwtTokenForAnUnknownExternalIdChangesNothing() { + let userA = addModel(externalId: "user-a") + userA.jwtBearerToken = "token-a" + + repo.invalidateJwtToken(externalId: "user-b", rejectedToken: "token-a") + + XCTAssertEqual(userA.getValidJwt(), "token-a") + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift new file mode 100644 index 000000000..a29434e21 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift @@ -0,0 +1,146 @@ +/* + 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 +import OneSignalCoreMocks +import OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +/// Covers the JWT bearer token on `OSIdentityModel`: which tokens count as usable, the +/// compare-and-set on invalidation, and what survives an archive round trip. +final class OSIdentityModelTests: XCTestCase { + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + private func makeModel(token: String? = nil) -> OSIdentityModel { + let model = OSIdentityModel(aliases: [:], changeNotifier: OSEventProducer()) + model.jwtBearerToken = token + return model + } + + private func archiveThenUnarchive(_ model: OSIdentityModel) throws -> OSIdentityModel { + let data = try NSKeyedArchiver.archivedData(withRootObject: model, requiringSecureCoding: false) + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + return try XCTUnwrap(unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? OSIdentityModel) + } + + // MARK: - getValidJwt() + + func testGetValidJwtReturnsNilWhenTokenIsNil() { + XCTAssertNil(makeModel(token: nil).getValidJwt()) + } + + func testGetValidJwtReturnsNilWhenTokenIsEmptyString() { + XCTAssertNil(makeModel(token: "").getValidJwt()) + } + + func testGetValidJwtReturnsNilWhenTokenIsInvalidSentinel() { + XCTAssertNil(makeModel(token: OS_JWT_TOKEN_INVALID).getValidJwt()) + } + + func testGetValidJwtReturnsTokenWhenTokenIsValid() { + let token = "eyJhbGciOiJFUzI1NiJ9.payload.sig" + XCTAssertEqual(makeModel(token: token).getValidJwt(), token) + } + + // MARK: - invalidateJwtBearerToken(rejectedToken:) + + func testInvalidateReturnsTrueOnFirstTransitionAndSetsInvalidSentinel() { + let model = makeModel(token: "valid-token") + + XCTAssertTrue(model.invalidateJwtBearerToken(rejectedToken: "valid-token")) + XCTAssertEqual(model.jwtBearerToken, OS_JWT_TOKEN_INVALID) + } + + /// Two requests can be rejected at once; only one of them should tell the app to mint a token. + func testInvalidateReturnsFalseWhenAlreadyInvalid() { + let model = makeModel(token: "valid-token") + _ = model.invalidateJwtBearerToken(rejectedToken: "valid-token") + + XCTAssertFalse(model.invalidateJwtBearerToken(rejectedToken: "valid-token")) + XCTAssertEqual(model.jwtBearerToken, OS_JWT_TOKEN_INVALID) + } + + /// The app can supply a replacement while the rejected request is still in flight. + func testInvalidateLeavesAReplacementTokenAlone() { + let model = makeModel(token: "replacement-token") + + XCTAssertFalse(model.invalidateJwtBearerToken(rejectedToken: "stale-token")) + XCTAssertEqual(model.jwtBearerToken, "replacement-token") + } + + func testInvalidateDoesNothingWhenThereIsNoToken() { + let model = makeModel(token: nil) + + XCTAssertFalse(model.invalidateJwtBearerToken(rejectedToken: "stale-token")) + XCTAssertNil(model.jwtBearerToken) + } + + // MARK: - Persistence + + func testTokenSurvivesAnArchiveRoundTrip() throws { + let model = makeModel(token: "cached-token") + model.addAliases([OS_EXTERNAL_ID: "user-a"]) + + let decoded = try archiveThenUnarchive(model) + + XCTAssertEqual(decoded.jwtBearerToken, "cached-token") + XCTAssertEqual(decoded.externalId, "user-a") + } + + /// A model archived by a build that never encoded a token has no value under the key. + func testAModelArchivedWithoutATokenDecodesWithANilToken() throws { + let model = makeModel(token: nil) + model.addAliases([OS_ONESIGNAL_ID: "osid-a"]) + + let decoded = try archiveThenUnarchive(model) + + XCTAssertNil(decoded.jwtBearerToken) + XCTAssertEqual(decoded.onesignalId, "osid-a") + } + + func testTheInvalidSentinelIsWhatPersists() throws { + let model = makeModel(token: "valid-token") + model.invalidateJwtBearerToken(rejectedToken: "valid-token") + + let decoded = try archiveThenUnarchive(model) + + XCTAssertEqual(decoded.jwtBearerToken, OS_JWT_TOKEN_INVALID) + XCTAssertNil(decoded.getValidJwt()) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift new file mode 100644 index 000000000..dc492a9f4 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift @@ -0,0 +1,196 @@ +/* + 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 +import OneSignalOSCore +@testable import OneSignalUser + +/// Covers when the app is asked for a replacement token. Parking of the models themselves is +/// `OSIdentityModelRepoTests`. +final class OSUserJwtRepoTests: XCTestCase { + private var identityModelRepo: OSIdentityModelRepo! + private var repo: OSUserJwtRepo! + private var asked: [String] = [] + + override func setUp() { + super.setUp() + identityModelRepo = OSIdentityModelRepo() + asked = [] + repo = OSUserJwtRepo(identityModelRepo: identityModelRepo) { [weak self] externalId in + self?.asked.append(externalId) + } + } + + @discardableResult + private func addModel(externalId: String, token: String?) -> OSIdentityModel { + let model = OSIdentityModel(aliases: [OS_EXTERNAL_ID: externalId], changeNotifier: OSEventProducer()) + model.jwtBearerToken = token + identityModelRepo.add(model: model) + return model + } + + // MARK: - validJwt + + func testValidJwtReadsThroughToTheIdentityModel() { + addModel(externalId: "user-a", token: "token-a") + + XCTAssertEqual(repo.validJwt(externalId: "user-a"), "token-a") + XCTAssertNil(repo.validJwt(externalId: "user-b")) + } + + // MARK: - askForToken + + /// A Request that parks for want of a token has nothing to reject, so it asks directly. + func testAskingForATokenAsksOncePerExternalId() { + XCTAssertTrue(repo.askForToken(externalId: "user-a")) + XCTAssertFalse(repo.askForToken(externalId: "user-a")) + XCTAssertTrue(repo.askForToken(externalId: "user-b")) + + XCTAssertEqual(asked, ["user-a", "user-b"]) + } + + /// Whichever path asks first, the other stays quiet for the rest of the session. + func testAParkedRequestAndARejectedTokenShareTheOneAsk() { + addModel(externalId: "user-a", token: "token-a") + + XCTAssertTrue(repo.askForToken(externalId: "user-a")) + XCTAssertFalse(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a")) + + XCTAssertEqual(asked, ["user-a"]) + } + + // MARK: - invalidateJwt + + func testInvalidatingParksTheTokenAndAsksTheApp() { + let model = addModel(externalId: "user-a", token: "token-a") + + XCTAssertTrue(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a")) + XCTAssertNil(model.getValidJwt()) + XCTAssertEqual(asked, ["user-a"]) + } + + /// Several Requests can be rejected before the app answers; asking once is enough. + func testInvalidatingTwiceAsksOnce() { + addModel(externalId: "user-a", token: "token-a") + + XCTAssertTrue(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a")) + XCTAssertFalse(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a")) + XCTAssertEqual(asked, ["user-a"]) + } + + /// The replacement arrived while the rejected Request was in flight, so the retry can use it. + func testInvalidatingAStaleTokenLeavesTheReplacementAndDoesNotAsk() { + let model = addModel(externalId: "user-a", token: "token-b") + + XCTAssertFalse(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a")) + XCTAssertEqual(model.getValidJwt(), "token-b") + XCTAssertTrue(asked.isEmpty) + } + + func testInvalidatingAnUnknownExternalIdAsksNobody() { + addModel(externalId: "user-a", token: "token-a") + + XCTAssertFalse(repo.invalidateJwt(externalId: "user-b", rejectedToken: "token-a")) + XCTAssertTrue(asked.isEmpty) + } + + /// A model restored from cache already holds the sentinel, so there is no transition left to make. + /// The app still has to be told once this session that it owes a token. + func testInvalidatingAnAlreadyParkedTokenStillAsksOnceThisSession() { + addModel(externalId: "user-a", token: OS_JWT_TOKEN_INVALID) + + XCTAssertTrue(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a")) + XCTAssertEqual(asked, ["user-a"]) + } + + // MARK: - updateJwt + + func testANewTokenRearmsTheRequestForAnother() { + addModel(externalId: "user-a", token: "token-a") + _ = repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a") + + repo.updateJwt(externalId: "user-a", token: "token-b") + XCTAssertEqual(repo.validJwt(externalId: "user-a"), "token-b") + + XCTAssertTrue(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-b")) + XCTAssertEqual(asked, ["user-a", "user-a"]) + } + + /// An app answering with nothing usable must not be able to trade asks and replies with the SDK. + func testAnEmptyTokenDoesNotRearmTheRequestForAnother() { + addModel(externalId: "user-a", token: "token-a") + _ = repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a") + + XCTAssertFalse(repo.updateJwt(externalId: "user-a", token: "")) + + XCTAssertFalse(repo.askForToken(externalId: "user-a")) + XCTAssertEqual(asked, ["user-a"]) + } + + /// An app answering for an external ID the SDK has no model for stores nothing, so treating it as an + /// answer would have the SDK and the app trade asks and replies on every flush. + func testATokenForAnUnknownExternalIdIsNotStoredAndDoesNotRearmTheRequestForAnother() { + addModel(externalId: "user-a", token: "token-a") + _ = repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a") + + XCTAssertFalse(repo.updateJwt(externalId: "typo-id", token: "token-b")) + + XCTAssertNil(repo.validJwt(externalId: "user-a")) + XCTAssertFalse(repo.askForToken(externalId: "user-a")) + XCTAssertEqual(asked, ["user-a"]) + } + + /// The sentinel is what a rejection writes, so storing it as an update would park a working token. + func testTheInvalidSentinelIsIgnored() { + let model = addModel(externalId: "user-a", token: "token-a") + + XCTAssertFalse(repo.updateJwt(externalId: "user-a", token: OS_JWT_TOKEN_INVALID)) + + XCTAssertEqual(model.getValidJwt(), "token-a") + } + + /// A usable replacement must reach a user whose token was already rejected. + func testAReplacementTokenOverwritesTheParkedSentinel() { + addModel(externalId: "user-a", token: "token-a") + _ = repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a") + + repo.updateJwt(externalId: "user-a", token: "token-b") + + XCTAssertEqual(repo.validJwt(externalId: "user-a"), "token-b") + } + + func testUsersAreAskedForIndependently() { + addModel(externalId: "user-a", token: "token-a") + addModel(externalId: "user-b", token: "token-b") + + _ = repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a") + _ = repo.invalidateJwt(externalId: "user-b", rejectedToken: "token-b") + + XCTAssertEqual(asked, ["user-a", "user-b"]) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift new file mode 100644 index 000000000..f24b44667 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift @@ -0,0 +1,182 @@ +/* + 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 +import OneSignalCoreMocks +import OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +/** + Public JWT surface on the User Manager: store a token, and tell the app when it stopped being accepted. + Goes through `sharedInstance` because the JWT config and IV service are shared. + + Asks are driven through `userJwtRepo` the way a rejected Request is: there is no public invalidate API. + */ +final class UserJwtApiTests: XCTestCase { + + private var listener = MockUserJwtInvalidatedListener() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + OneSignalCoreImpl.setSharedClient(MockOneSignalClient()) + + // Held strongly for the test's lifetime: OSObservable keeps observers weakly. + listener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(listener) + } + + override func tearDownWithError() throws { + OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(listener) + OneSignalCoreMocks.clearUserDefaults() + } + + /// `OSObservable` delivers on the main queue, so a block enqueued after the notification runs + /// once the notification has — deterministic, rather than waiting out a timeout. + private func drainMainQueue() { + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + waitForExpectations(timeout: 5) + } + + /// Parks the token and asks the app the way a 401 on a user Request does. + @discardableResult + private func invalidate(externalId: String, rejectedToken: String) -> Bool { + return OneSignalUserManagerImpl.sharedInstance.userJwtRepo.invalidateJwt( + externalId: externalId, + rejectedToken: rejectedToken + ) + } + + // MARK: - updateUserJwt + + func testUpdateUserJwtStoresTheTokenOnThatUsersIdentityModel() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: "user-a", token: "token-a") + + XCTAssertEqual(user.identityModel.getValidJwt(), "token-a") + } + + func testUpdateUserJwtForAnUnknownExternalIdLeavesTheCurrentUserAlone() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: "user-b", token: "token-b") + + XCTAssertNil(user.identityModel.jwtBearerToken) + } + + /// An empty token reads as no token at all, so it would leave the user unable to sign a request + /// and unable to be asked for another one. + func testUpdateUserJwtWithAnEmptyTokenLeavesThePreviousTokenInPlace() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + user.identityModel.jwtBearerToken = "token-a" + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: "user-a", token: "") + + XCTAssertEqual(user.identityModel.getValidJwt(), "token-a") + } + + /// Storing the sentinel would look like an already-invalidated token, so the app is never asked again. + func testUpdateUserJwtWithTheInvalidSentinelIsRejected() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: "user-a", token: OS_JWT_TOKEN_INVALID) + + XCTAssertNil(user.identityModel.jwtBearerToken) + } + + // MARK: - Invalidated listener + + func testInvalidatingAJwtParksTheTokenAndNotifiesTheApp() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + user.identityModel.jwtBearerToken = "token-a" + + invalidate(externalId: "user-a", rejectedToken: "token-a") + drainMainQueue() + + XCTAssertNil(user.identityModel.getValidJwt()) + XCTAssertEqual(listener.invalidatedExternalIds, ["user-a"]) + } + + /// Several requests can be rejected before the app supplies a new token; asking it once is enough. + func testInvalidatingAJwtTwiceNotifiesOnce() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + user.identityModel.jwtBearerToken = "token-a" + + invalidate(externalId: "user-a", rejectedToken: "token-a") + invalidate(externalId: "user-a", rejectedToken: "token-a") + drainMainQueue() + + XCTAssertEqual(listener.invalidatedExternalIds, ["user-a"]) + } + + func testARemovedListenerIsNotNotified() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + user.identityModel.jwtBearerToken = "token-a" + OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(listener) + + invalidate(externalId: "user-a", rejectedToken: "token-a") + drainMainQueue() + + XCTAssertTrue(listener.invalidatedExternalIds.isEmpty) + } + + /// An ask that fires before the app registers still has to reach a late listener. + func testAListenerAddedAfterAnAskStillHearsWhoOwesAToken() { + OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(listener) + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + user.identityModel.jwtBearerToken = "token-a" + + invalidate(externalId: "user-a", rejectedToken: "token-a") + drainMainQueue() + XCTAssertTrue(listener.invalidatedExternalIds.isEmpty) + + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(listener) + drainMainQueue() + + XCTAssertEqual(listener.invalidatedExternalIds, ["user-a"]) + } + + /// A token that lands before the late listener is delivered must not be asked for again. + func testALateListenerIsNotToldAboutAnAskThatWasAlreadyAnswered() { + OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(listener) + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + user.identityModel.jwtBearerToken = "token-a" + + invalidate(externalId: "user-a", rejectedToken: "token-a") + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: "user-a", token: "token-b") + + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(listener) + drainMainQueue() + + XCTAssertTrue(listener.invalidatedExternalIds.isEmpty) + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 552daf87a..437aa2550 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -190,6 +190,18 @@ + (void)login:(NSString * _Nonnull)externalId withToken:(NSString * _Nullable)to [OneSignalUserManagerImpl.sharedInstance loginWithExternalId:externalId token:token]; } ++ (void)addUserJwtInvalidatedListener:(id _Nonnull)listener { + [OneSignalUserManagerImpl.sharedInstance addUserJwtInvalidatedListener:listener]; +} + ++ (void)removeUserJwtInvalidatedListener:(id _Nonnull)listener { + [OneSignalUserManagerImpl.sharedInstance removeUserJwtInvalidatedListener:listener]; +} + ++ (void)updateUserJwt:(NSString * _Nonnull)externalId withToken:(NSString * _Nonnull)token { + [OneSignalUserManagerImpl.sharedInstance updateUserJwtWithExternalId:externalId token:token]; +} + + (void)logout { [OneSignalUserManagerImpl.sharedInstance logout]; } diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h b/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h index 90b8b0d8a..59c5d71c2 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h +++ b/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h @@ -52,6 +52,7 @@ // Forward declarations for Objective-C++ compatibility @protocol OSUser; @protocol OSLiveActivities; +@protocol OSUserJwtInvalidatedListener; #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wstrict-prototypes" @@ -71,6 +72,10 @@ typedef void (^OSFailureBlock)(NSError* error); + (void)login:(NSString * _Nonnull)externalId; + (void)login:(NSString * _Nonnull)externalId withToken:(NSString * _Nullable)token NS_SWIFT_NAME(login(externalId:token:)); ++ (void)addUserJwtInvalidatedListener:(id _Nonnull)listener NS_REFINED_FOR_SWIFT; ++ (void)removeUserJwtInvalidatedListener:(id _Nonnull)listener NS_REFINED_FOR_SWIFT; ++ (void)updateUserJwt:(NSString * _Nonnull)externalId withToken:(NSString * _Nonnull)token +NS_SWIFT_NAME(updateUserJwt(externalId:token:)); + (void)logout; #pragma mark Notifications diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift b/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift index bf399005c..756ec78ac 100644 --- a/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift +++ b/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift @@ -33,6 +33,14 @@ import OneSignalNotifications import OneSignalCore public extension OneSignal { + static func addUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + __add(listener) + } + + static func removeUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + __remove(listener) + } + static var User: OSUser { return __user() }