From 2f3cd34571ad325b8d5ab966e224e10f7624dd25 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 16:49:01 -0700 Subject: [PATCH 1/2] feat: [PR4] identity model JWT, the JWT repo, and the public API The surface an app talks to under Identity Verification: it hands the SDK a token for a user, and the SDK tells it when that token stopped being accepted. OSUserJwtRepo holds the token per external ID and remembers who has already been asked for one, so an app is asked once per user rather than once per rejected request. A listener registered after start or login still hears about an ask that already fired, since the alternative is an app that never learns it owes a token for the user it just logged in. Removes the beta JWT surface this replaces: requiresUserAuth, which nothing ever read, along with onJwtExpired and its handler typealiases. Anyone on the beta JWT API moves to addUserJwtInvalidatedListener and updateUserJwt. storeJwt lands here in the form the public API needs; the later PR that introduces the queues extends it to release the work held for want of a token. Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 32 +++ .../OneSignalOSCoreMocks/OSCoreMocks.swift | 10 + .../Source/OSIdentityModel.swift | 79 +++++-- .../Source/OSIdentityModelRepo.swift | 61 ++++++ .../Source/OSUserJwtInvalidatedEvent.swift | 48 +++++ .../OneSignalUser/Source/OSUserJwtRepo.swift | 128 +++++++++++ .../Source/OneSignalUserManagerImpl+Jwt.swift | 78 +++++++ .../Source/OneSignalUserManagerImpl.swift | 67 ++++-- .../MockUserJwtInvalidatedListener.swift | 48 +++++ .../OneSignalUserMocks.swift | 12 ++ .../OSIdentityModelRepoTests.swift | 198 ++++++++++++++++++ .../OSIdentityModelTests.swift | 146 +++++++++++++ .../OSUserJwtRepoTests.swift | 196 +++++++++++++++++ .../OneSignalUserTests/UserJwtApiTests.swift | 182 ++++++++++++++++ iOS_SDK/OneSignalSDK/Source/OneSignal.m | 12 ++ .../OneSignalSDK/Source/OneSignalFramework.h | 5 + .../Source/OneSignalSwiftInterface.swift | 8 + 17 files changed, 1273 insertions(+), 37 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtInvalidatedEvent.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserJwtInvalidatedListener.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelRepoTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift 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..bb5f0f67c 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) + } + } + } + + /// 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) + } + 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() } From 5aea6710152c2aefdf2697503f1ffe7c31baf58e Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 12 Aug 2026 10:16:29 -0700 Subject: [PATCH 2/2] fix: [PR4] mark JWT bearer updates as local-only Use preventServerUpdate so the token never becomes an identity delta by accident of the alias cast failing. Co-authored-by: Cursor --- .../OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift index bb5f0f67c..6df132faf 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift @@ -58,7 +58,7 @@ class OSIdentityModel: OSModel { return true } if changed { - self.set(property: OS_JWT_BEARER_TOKEN, newValue: newValue) + self.set(property: OS_JWT_BEARER_TOKEN, newValue: newValue, preventServerUpdate: true) } } } @@ -83,7 +83,7 @@ class OSIdentityModel: OSModel { return true } if changed { - self.set(property: OS_JWT_BEARER_TOKEN, newValue: OS_JWT_TOKEN_INVALID) + self.set(property: OS_JWT_BEARER_TOKEN, newValue: OS_JWT_TOKEN_INVALID, preventServerUpdate: true) } return changed }