From 1b80146883ad4cb6539989b63e53afe3e40742ad Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 16:44:43 -0700 Subject: [PATCH 1/2] feat: [PR3] feature flags, JWT config, and the Identity Verification gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The foundation the rest of Identity Verification is built on. Nothing consumes the gate yet, so this changes no behavior beyond the params handling below. OSUserJwtConfig holds whether the app requires a token as a three-state answer — unknown until remote params say — because "not yet known" and "not required" have to be told apart before anything can be sent. OSFeatureManager carries the flags remote params enable, and OSIdentityVerificationService is the single place that answers whether Identity Verification is on. Remote params now hydrate that requirement from jwt_required. A response that omits the key means Identity Verification is off for the app, while an empty response answers nothing and leaves the cached requirement standing. Because the operation repo will hold every queued operation until the requirement is known, a params request that failed was going to cost a first-launch app every tag, session count and event for the rest of the session: nothing retried it. It now retries with a backoff over five attempts and, failing that, tries again on the next session. Replaces the unread require_user_id_auth key with jwt_required. The requiresUserAuth property it wrote is removed in the next PR. Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 51 ++++ .../Source/OneSignalCommonDefines.h | 13 +- .../Jwt/OSIdentityVerificationService.swift | 115 ++++++++ .../Source/Jwt/OSUserJwtConfig.swift | 134 ++++++++++ .../Source/OSFeatureManager.swift | 102 ++++++++ .../Feature/OSFeatureManagerTests.swift | 116 +++++++++ .../OSIdentityVerificationServiceTests.swift | 245 ++++++++++++++++++ .../Jwt/OSUserJwtConfigTests.swift | 95 +++++++ iOS_SDK/OneSignalSDK/Source/OneSignal.m | 52 +++- 9 files changed, 919 insertions(+), 4 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSFeatureManager.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Feature/OSFeatureManagerTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSIdentityVerificationServiceTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 658d2d80d..33a035cce 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -51,7 +51,9 @@ 03CCCC832835D90F004BF794 /* OneSignalUNUserNotificationCenterHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = 03CCCC812835D90F004BF794 /* OneSignalUNUserNotificationCenterHelper.m */; }; 03CCCC852835F291004BF794 /* UIApplicationDelegateSwizzlingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 03CCCC842835F291004BF794 /* UIApplicationDelegateSwizzlingTests.m */; }; 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 */; }; + 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 */; }; 3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C01519B2C2E29F90079E076 /* IAMRequestTests.m */; }; @@ -331,6 +333,7 @@ 7AFE856B2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; }; 7AFE856C2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; }; 7AFE856D2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; }; + 8E949FF4C7A7A2C7182E53EA /* OSUserJwtConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */; }; 911E2CBD1E398AB3003112A4 /* UnitTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 911E2CBC1E398AB3003112A4 /* UnitTests.m */; }; 911E2CC51E398B53003112A4 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E08E2701D49A5C8002176DE /* SystemConfiguration.framework */; }; 911E2CC61E398B97003112A4 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */; }; @@ -366,8 +369,10 @@ 9D34853A233D2E3600EB81C9 /* OneSignalLocationOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 9D348539233D2E3600EB81C9 /* OneSignalLocationOverrider.m */; }; 9D59C2F82321C7720008ECCF /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; 9D59C2F92321C7780008ECCF /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D3300F923149DAE000F0A83 /* CoreGraphics.framework */; }; + 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, ); }; }; + 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 */; }; CA1A6E6A20DC2E31001C41B9 /* OneSignalDialogController.m in Sources */ = {isa = PBXBuildFile; fileRef = CA1A6E6820DC2E31001C41B9 /* OneSignalDialogController.m */; }; @@ -652,6 +657,7 @@ DEFB3E632BB731BD00E65DAD /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DEFB3E622BB731BD00E65DAD /* ActivityKit.framework */; platformFilter = ios; }; 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 */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1332,6 +1338,7 @@ 16664C5425DDB2CB003B8A14 /* NSTimeZoneOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NSTimeZoneOverrider.h; sourceTree = ""; }; 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 = ""; }; 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; }; @@ -1483,6 +1490,7 @@ 3E2400381D4FFC31008BDE70 /* OneSignalFramework.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OneSignalFramework.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3E24003B1D4FFC31008BDE70 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 3E464ED91D88EE6A00DCF7E9 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; + 42E4A83C6C0D0DF28CDECA90 /* OSFeatureManagerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSFeatureManagerTests.swift; sourceTree = ""; }; 4529DED01FA81EA800CEAB1D /* NSObjectOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NSObjectOverrider.h; sourceTree = ""; }; 4529DED11FA81EA800CEAB1D /* NSObjectOverrider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NSObjectOverrider.m; sourceTree = ""; }; 4529DED31FA823B900CEAB1D /* TestHelperFunctions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TestHelperFunctions.h; sourceTree = ""; }; @@ -1533,6 +1541,7 @@ 5BC1DE612C90B85A00CA8807 /* OSIamFetchOffsetKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIamFetchOffsetKey.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -1639,6 +1648,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 = ""; }; + 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 = ""; }; CA1A6E6720DC2E31001C41B9 /* OneSignalDialogController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalDialogController.h; sourceTree = ""; }; @@ -1837,6 +1847,8 @@ DEFB3E622BB731BD00E65DAD /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivities.swift; sourceTree = ""; }; DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSStubLiveActivities.swift; sourceTree = ""; }; + E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfigTests.swift; sourceTree = ""; }; + F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityVerificationServiceTests.swift; sourceTree = ""; }; FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIamFetchReadyConditionTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -2277,6 +2289,8 @@ 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, + A843B922174496E99F2D00A8 /* Jwt */, + C7DA9EEED644F1160CAD9A38 /* OSFeatureManager.swift */, ); path = Source; sourceTree = ""; @@ -2555,6 +2569,15 @@ path = OneSignalLiveActivitiesFramework; sourceTree = ""; }; + 4795885CE6CFFB1998AC7D09 /* Feature */ = { + isa = PBXGroup; + children = ( + 42E4A83C6C0D0DF28CDECA90 /* OSFeatureManagerTests.swift */, + ); + name = Feature; + path = Feature; + sourceTree = ""; + }; 5B053FB92CAE07EB002F30C4 /* OneSignalOSCoreTests */ = { isa = PBXGroup; children = ( @@ -2566,10 +2589,22 @@ 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */, FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */, 03A81CCA8DFA43C8AEA738B8 /* XCTestCase+WaitUntil.swift */, + 4795885CE6CFFB1998AC7D09 /* Feature */, + 5B47CE0CE255AC7128442FFF /* Jwt */, ); path = OneSignalOSCoreTests; sourceTree = ""; }; + 5B47CE0CE255AC7128442FFF /* Jwt */ = { + isa = PBXGroup; + children = ( + F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */, + E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */, + ); + name = Jwt; + path = Jwt; + sourceTree = ""; + }; 5BC1DE652C90BC9F00CA8807 /* Consistency */ = { isa = PBXGroup; children = ( @@ -2721,6 +2756,16 @@ name = Categories; sourceTree = ""; }; + A843B922174496E99F2D00A8 /* Jwt */ = { + isa = PBXGroup; + children = ( + 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */, + 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */, + ); + name = Jwt; + path = Jwt; + sourceTree = ""; + }; DE51DDE2294262670073D5C4 /* RemoteParameters */ = { isa = PBXGroup; children = ( @@ -4452,6 +4497,9 @@ 3C11518D289AF5E800565C41 /* OSModelChangedHandler.swift in Sources */, 3C14E3B32FAE54C006ED053 /* OSLoggerPlatformProvider.swift in Sources */, 3C8E6DF928A6D89E0031E48A /* OSOperationExecutor.swift in Sources */, + ECD9DF65FB98056806A43541 /* OSIdentityVerificationService.swift in Sources */, + BE737361D82E74544B7A1996 /* OSUserJwtConfig.swift in Sources */, + 0AA11438FBF3A82D13824467 /* OSFeatureManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4593,6 +4641,9 @@ 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, DD2A89A8052E2D1912B0038B /* OSIamFetchReadyConditionTests.swift in Sources */, 50C4F7CFEC46836131011004 /* XCTestCase+WaitUntil.swift in Sources */, + 2F32272222E88DF0C2C18B53 /* OSFeatureManagerTests.swift in Sources */, + 9FF50E2A40C88E4533033A38 /* OSIdentityVerificationServiceTests.swift in Sources */, + 8E949FF4C7A7A2C7182E53EA /* OSUserJwtConfigTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index 6b2c48a24..3dc2fab3e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -77,6 +77,8 @@ // Remote Params #define OSUD_LOCATION_ENABLED @"OSUD_LOCATION_ENABLED" #define OSUD_REQUIRES_USER_PRIVACY_CONSENT @"OSUD_REQUIRES_USER_PRIVACY_CONSENT" +#define OSUD_USE_IDENTITY_VERIFICATION @"OSUD_USE_IDENTITY_VERIFICATION" +#define OSUD_SDK_FEATURE_FLAGS @"OSUD_SDK_FEATURE_FLAGS" // Remote Params - Receive Receipts #define OSUD_RECEIVE_RECEIPTS_ENABLED @"OS_ENABLE_RECEIVE_RECEIPTS" // * OSUD_RECEIVE_RECEIPTS_ENABLED // Outcomes @@ -135,7 +137,7 @@ #define IOS_USES_PROVISIONAL_AUTHORIZATION @"uses_provisional_auth" #define IOS_REQUIRES_EMAIL_AUTHENTICATION @"require_email_auth" #define IOS_REQUIRES_SMS_AUTHENTICATION @"require_sms_auth" -#define IOS_REQUIRES_USER_ID_AUTHENTICATION @"require_user_id_auth" +#define IOS_JWT_REQUIRED @"jwt_required" #define IOS_RECEIVE_RECEIPTS_ENABLE @"receive_receipts_enable" #define IOS_OUTCOMES_V2_SERVICE_ENABLE @"v2_enabled" #define IOS_LOCATION_SHARED @"location_shared" @@ -324,6 +326,15 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #define OS_SNAPSHOT_ONESIGNAL_ID @"OS_SNAPSHOT_ONESIGNAL_ID" #define OS_SNAPSHOT_EXTERNAL_ID @"OS_SNAPSHOT_EXTERNAL_ID" +/* Identity Verification */ +#define OS_JWT_BEARER_TOKEN @"OS_JWT_BEARER_TOKEN" +#define OS_JWT_TOKEN_INVALID @"OS_JWT_TOKEN_INVALID" + +// Posted when work held for want of an answer can be reattempted: the requirement hydrated, or the app +// supplied a token. Listeners re-read the current state rather than take anything from the post. +#define OS_ON_JWT_CONFIG_HYDRATED @"OS_ON_JWT_CONFIG_HYDRATED" +#define OS_ON_USER_JWT_UPDATED @"OS_ON_USER_JWT_UPDATED" + // Models and Model Stores #define OS_IDENTITY_MODEL_KEY @"OS_IDENTITY_MODEL_KEY" #define OS_IDENTITY_MODEL_STORE_KEY @"OS_IDENTITY_MODEL_STORE_KEY" diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift new file mode 100644 index 000000000..6faffc2e2 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift @@ -0,0 +1,115 @@ +/* + 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 + +/// Who is waiting on hydration. Keyed so re-registering replaces rather than stacking a duplicate. +public enum OSJwtConfigHydratedObserver { + case userExecutor + case operationRepo + case userManager +} + +/** + Decides Identity Verification gating from the rollout feature flag and the app's `jwt_required` + setting. + + - `ivBehaviorActive`: whether IV behavior is in effect (JWT on requests, `external_id` alias, 401 + handling). + - `newCodePathsRun`: whether IV code paths should run at all (feature flag, or always when the app + requires auth). + - `requirement`: use when you must tell `unknown` apart from `off` — both booleans are `false` while + the requirement is unknown, which is fine for apps that do not require Identity Verification, but + callers that must not send an unsigned request should wait until it is known. + */ +public final class OSIdentityVerificationService { + private let featureManager: OSFeatureManagerProtocol + private let jwtConfig: OSUserJwtConfig + + private let handlerLock = NSLock() + // Ordered by registration: User executor's held Create User before Deltas that need its onesignal_id. + private var jwtConfigHydratedHandlers: [(observer: OSJwtConfigHydratedObserver, handler: (OSRequiresUserAuth) -> Void)] = [] + + /// The raw `jwt_required` value, including `unknown` before remote params arrive. + public var requirement: OSRequiresUserAuth { + return jwtConfig.requirement + } + + /// Whether Identity Verification behavior applies: JWT on requests, `external_id` alias, 401 handling. + public var ivBehaviorActive: Bool { + return jwtConfig.requirement == .on + } + + /// Whether the new Identity Verification code paths run at all. An app that requires auth is always in, + /// no matter how the rollout flag is set. + public var newCodePathsRun: Bool { + return featureManager.isEnabled(.identityVerification) || ivBehaviorActive + } + + public init(featureManager: OSFeatureManagerProtocol, jwtConfig: OSUserJwtConfig) { + self.featureManager = featureManager + self.jwtConfig = jwtConfig + jwtConfig.setOnHydratedHandler { [weak self] requirement in + self?.fireJwtConfigHydrated(requirement) + } + } + + /** + Fires on every hydration, including an unchanged value — deferred work waits on that. A handler + registered after `requirement` is already known runs immediately, since that hydration is not repeated. + */ + public func addOnJwtConfigHydratedHandler(for observer: OSJwtConfigHydratedObserver, _ handler: @escaping (OSRequiresUserAuth) -> Void) { + handlerLock.withLock { + if let index = jwtConfigHydratedHandlers.firstIndex(where: { $0.observer == observer }) { + jwtConfigHydratedHandlers[index] = (observer, handler) + } else { + jwtConfigHydratedHandlers.append((observer, handler)) + } + } + + let alreadyKnown = jwtConfig.requirement + guard alreadyKnown != .unknown else { + return + } + handler(alreadyKnown) + } + + public func removeOnJwtConfigHydratedHandler(for observer: OSJwtConfigHydratedObserver) { + handlerLock.withLock { + jwtConfigHydratedHandlers.removeAll { $0.observer == observer } + } + } + + private func fireJwtConfigHydrated(_ requirement: OSRequiresUserAuth) { + // Snapshot: a handler can register another, and handlers take locks of their own. + let handlers = handlerLock.withLock { jwtConfigHydratedHandlers } + for entry in handlers { + entry.handler(requirement) + } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift new file mode 100644 index 000000000..c4ad37f66 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift @@ -0,0 +1,134 @@ +/* + 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 + +/** + Whether the app requires Identity Verification, mirroring the `jwt_required` remote param. + `unknown` has to stay distinguishable from `off` so nothing is sent unsigned on behalf of an app + that turns out to require auth. + */ +public enum OSRequiresUserAuth: Int { + // Raw values are cached, and 0 is what UserDefaults returns for a missing integer, so `unknown` owns it. + case on = 1 + case off = -1 + case unknown = 0 +} + +/** + Holds the Identity Verification requirement and caches it across launches. + Deliberately knows nothing about gating: `OSIdentityVerificationService` makes every such decision + and is the only observer here. + */ +@objc(OSUserJwtConfig) +public final class OSUserJwtConfig: NSObject { + /** + Remote params hydrate the requirement from `OneSignal.m`, which runs before the User Manager is + started, and keeps running in sessions where it never starts at all because consent is pending. + Reaching the requirement through a shared instance keeps that path from constructing the User + Manager just to hand over a boolean. + + Only `OneSignal.m` and `OneSignalUserManagerImpl` should reference this. Everything below them — + the operation repo, the executors, the request layer — is handed the config when it is created, + which keeps the shared instance contained to the two places that cannot avoid it. + */ + @objc public static let shared = OSUserJwtConfig() + + private let lock = NSLock() + private var _requirement: OSRequiresUserAuth + private var onHydrated: ((OSRequiresUserAuth) -> Void)? + + public var requirement: OSRequiresUserAuth { + return lock.withLock { _requirement } + } + + public override init() { + _requirement = OSUserJwtConfig.cachedRequirement() + super.init() + } + + /** + Applies the requirement carried by a successful remote params response. A response that omits + `jwt_required` means the app has Identity Verification off, so callers pass `false` for it rather + than leaving the requirement unknown. A response with no body at all answers nothing, so callers + skip this and leave the cached requirement in place. + */ + @objc + public func hydrate(requiresUserAuth: Bool) { + let hydrated: OSRequiresUserAuth = requiresUserAuth ? .on : .off + // Keep the log and the handler out of the lock; either can re-enter and read the requirement. + let (previous, handler) = lock.withLock { () -> (OSRequiresUserAuth, ((OSRequiresUserAuth) -> Void)?) in + let previous = _requirement + _requirement = hydrated + // Written even when the value is unchanged, so a launch whose write was dropped by locked + // storage still ends up with the requirement on disk. + OneSignalUserDefaults.initShared().saveInteger(forKey: OSUD_USE_IDENTITY_VERIFICATION, withValue: hydrated.rawValue) + return (previous, onHydrated) + } + if previous != hydrated { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSUserJwtConfig requirement changed from \(previous) to \(hydrated)") + } + // Fires even when the value is unchanged, because work deferred while the requirement was + // unknown is waiting on this to run. + handler?(hydrated) + } + + /** + Re-reads the cached requirement while it is still unknown. The read in `init` can land during an + app prewarm, before first unlock, when UserDefaults silently returns nothing. + */ + public func refreshIfUnknown() { + lock.withLock { + guard _requirement == .unknown else { + return + } + _requirement = OSUserJwtConfig.cachedRequirement() + } + } + + /// The Identity Verification service is the sole observer, so a second registration replaces the first. + func setOnHydratedHandler(_ handler: ((OSRequiresUserAuth) -> Void)?) { + lock.withLock { onHydrated = handler } + } + + /** + Test seam. The shared instance outlives a test case, so a test + that hydrates the requirement has to hand the next one an unhydrated config, and `hydrate` can + only reach `on` or `off`. Leaves the hydrated handler alone: the Identity Verification service + that registered it is built once and lives as long as the process. + */ + func resetRequirementToUnknownForTests() { + lock.withLock { _requirement = .unknown } + } + + private static func cachedRequirement() -> OSRequiresUserAuth { + let rawValue = OneSignalUserDefaults.initShared().getSavedInteger(forKey: OSUD_USE_IDENTITY_VERIFICATION, + defaultValue: OSRequiresUserAuth.unknown.rawValue) + return OSRequiresUserAuth(rawValue: rawValue) ?? .unknown + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSFeatureManager.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSFeatureManager.swift new file mode 100644 index 000000000..1283f7fd4 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSFeatureManager.swift @@ -0,0 +1,102 @@ +/* + 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 + +/// Backend-driven feature switches keyed by remote-config identifiers. +public enum OSFeatureFlag: String { + case identityVerification = "sdk_identity_verification" +} + +public protocol OSFeatureManagerProtocol: AnyObject { + func isEnabled(_ feature: OSFeatureFlag) -> Bool +} + +/** + Resolves which features are enabled for this run. + Flags take effect as soon as they are set so a kill switch does not need a cold start; the set is + cached so a launch that has not yet fetched remote config keeps the last known values. + */ +public final class OSFeatureManager: OSFeatureManagerProtocol { + /// Shared for the same reason as `OSUserJwtConfig.shared`, and under the same rule: only + /// `OneSignalUserManagerImpl` references it today, and `OneSignal.m` will once remote params + /// deliver flag keys. Everything below them is handed the feature manager when it is created. + public static let shared = OSFeatureManager() + + private let lock = NSLock() + private var enabledKeys: Set + + public init() { + enabledKeys = OSFeatureManager.cachedKeys() + } + + /// Bypasses the cache so tests can force flags on. + public init(enabledKeys: Set) { + self.enabledKeys = Set(enabledKeys.map(OSFeatureManager.canonicalize)) + } + + public func isEnabled(_ feature: OSFeatureFlag) -> Bool { + return lock.withLock { + enabledKeys.contains(feature.rawValue) + } + } + + public func setEnabledFeatureKeys(_ keys: [String]) { + let canonical = Set(keys.map(OSFeatureManager.canonicalize)) + lock.withLock { + enabledKeys = canonical + OneSignalUserDefaults.initShared().saveObject(forKey: OSUD_SDK_FEATURE_FLAGS, withValue: Array(canonical)) + } + } + + /** + Re-reads the cached keys while none are known, closing the same prewarm gap as + `OSUserJwtConfig.refreshIfUnknown`. An app with every flag off reads the cache a second time, which + is cheaper than keeping enough state to tell that case apart from a read that came back empty. + */ + public func refreshIfEmpty() { + lock.withLock { + guard enabledKeys.isEmpty else { + return + } + enabledKeys = OSFeatureManager.cachedKeys() + } + } + + // Fold case so a differently cased remote key still matches a flag. + private static func canonicalize(_ key: String) -> String { + return key.lowercased() + } + + private static func cachedKeys() -> Set { + guard let cached = OneSignalUserDefaults.initShared().getSavedObject(forKey: OSUD_SDK_FEATURE_FLAGS, defaultValue: nil) as? [String] else { + return [] + } + return Set(cached.map(canonicalize)) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Feature/OSFeatureManagerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Feature/OSFeatureManagerTests.swift new file mode 100644 index 000000000..c469749e8 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Feature/OSFeatureManagerTests.swift @@ -0,0 +1,116 @@ +/* + 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 XCTest +import OneSignalCore +@testable import OneSignalOSCore + +final class OSFeatureManagerTests: XCTestCase { + + override func setUp() { + super.setUp() + clearCachedKeys() + } + + override func tearDown() { + clearCachedKeys() + super.tearDown() + } + + private func clearCachedKeys() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_FEATURE_FLAGS) + } + + func testFlagsAreOffWithoutRemoteConfig() { + XCTAssertFalse(OSFeatureManager().isEnabled(.identityVerification)) + } + + func testSettingTheKeyEnablesTheFlag() { + let featureManager = OSFeatureManager() + featureManager.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + + XCTAssertTrue(featureManager.isEnabled(.identityVerification)) + } + + func testEnabledKeysAreReadBackOnTheNextLaunch() { + OSFeatureManager().setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + + XCTAssertTrue(OSFeatureManager().isEnabled(.identityVerification)) + } + + func testUnrecognizedKeysDontEnableAnything() { + let featureManager = OSFeatureManager() + featureManager.setEnabledFeatureKeys(["sdk_some_future_feature"]) + + XCTAssertFalse(featureManager.isEnabled(.identityVerification)) + } + + func testKeysMatchRegardlessOfCase() { + let featureManager = OSFeatureManager() + featureManager.setEnabledFeatureKeys(["SDK_Identity_Verification"]) + + XCTAssertTrue(featureManager.isEnabled(.identityVerification)) + } + + func testDroppingTheKeyTurnsTheFlagOffWithoutARelaunch() { + let featureManager = OSFeatureManager() + featureManager.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + + featureManager.setEnabledFeatureKeys([]) + + // The kill switch has to land in the current run, and it can't come back on the next one + XCTAssertFalse(featureManager.isEnabled(.identityVerification)) + XCTAssertFalse(OSFeatureManager().isEnabled(.identityVerification)) + } + + func testRefreshAdoptsCachedKeysThatTheInitialReadMissed() { + let featureManager = OSFeatureManager() + OSFeatureManager().setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + + featureManager.refreshIfEmpty() + + XCTAssertTrue(featureManager.isEnabled(.identityVerification)) + } + + func testRefreshLeavesKeysThatAreAlreadyKnownAlone() { + let featureManager = OSFeatureManager(enabledKeys: [OSFeatureFlag.identityVerification.rawValue]) + OSFeatureManager().setEnabledFeatureKeys([]) + + featureManager.refreshIfEmpty() + + XCTAssertTrue(featureManager.isEnabled(.identityVerification)) + } + + func testKeysPassedToTheInitializerBypassTheCache() { + OSFeatureManager().setEnabledFeatureKeys([]) + + let featureManager = OSFeatureManager(enabledKeys: [OSFeatureFlag.identityVerification.rawValue]) + + XCTAssertTrue(featureManager.isEnabled(.identityVerification)) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSIdentityVerificationServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSIdentityVerificationServiceTests.swift new file mode 100644 index 000000000..1e9bc5a31 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSIdentityVerificationServiceTests.swift @@ -0,0 +1,245 @@ +/* + 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 XCTest +import OneSignalCore +@testable import OneSignalOSCore + +final class OSIdentityVerificationServiceTests: XCTestCase { + + private var jwtConfig = OSUserJwtConfig() + private var featureManager = OSFeatureManager(enabledKeys: []) + + override func setUp() { + super.setUp() + clearCache() + jwtConfig = OSUserJwtConfig() + featureManager = OSFeatureManager(enabledKeys: []) + } + + override func tearDown() { + clearCache() + super.tearDown() + } + + private func clearCache() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_FEATURE_FLAGS) + } + + private func makeService() -> OSIdentityVerificationService { + return OSIdentityVerificationService(featureManager: featureManager, jwtConfig: jwtConfig) + } + + // MARK: - Gates + + func testBothGatesAreOffForAnAppWithoutTheFlagOrTheRequirement() { + let service = makeService() + jwtConfig.hydrate(requiresUserAuth: false) + + XCTAssertFalse(service.newCodePathsRun) + XCTAssertFalse(service.ivBehaviorActive) + } + + func testTheFlagAloneRunsTheNewCodePathsWithoutTurningOnTheBehavior() { + featureManager = OSFeatureManager(enabledKeys: [OSFeatureFlag.identityVerification.rawValue]) + let service = makeService() + jwtConfig.hydrate(requiresUserAuth: false) + + XCTAssertTrue(service.newCodePathsRun) + XCTAssertFalse(service.ivBehaviorActive) + } + + func testAnAppThatRequiresAuthIsGatedInRegardlessOfTheFlag() { + let service = makeService() + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertTrue(service.newCodePathsRun) + XCTAssertTrue(service.ivBehaviorActive) + } + + func testBothGatesAreOffBeforeRemoteParamsAreRead() { + let service = makeService() + + XCTAssertFalse(service.newCodePathsRun) + XCTAssertFalse(service.ivBehaviorActive) + } + + func testAnUnknownRequirementStaysVisibleWhileTheFlagRunsTheNewCodePaths() { + featureManager = OSFeatureManager(enabledKeys: [OSFeatureFlag.identityVerification.rawValue]) + let service = makeService() + + XCTAssertTrue(service.newCodePathsRun) + // Neither gate can tell unknown from off, so callers about to send unsigned work read this instead + XCTAssertFalse(service.ivBehaviorActive) + XCTAssertEqual(service.requirement, .unknown) + } + + func testGatesFollowTheFlagWithinTheSameRun() { + let realFeatureManager = OSFeatureManager() + let service = OSIdentityVerificationService(featureManager: realFeatureManager, jwtConfig: jwtConfig) + jwtConfig.hydrate(requiresUserAuth: false) + + realFeatureManager.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + XCTAssertTrue(service.newCodePathsRun) + + realFeatureManager.setEnabledFeatureKeys([]) + XCTAssertFalse(service.newCodePathsRun) + } + + // MARK: - Hydration handler + + func testTheHandlerRunsForEveryHydration() { + let service = makeService() + var requirements: [OSRequiresUserAuth] = [] + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { requirements.append($0) } + + jwtConfig.hydrate(requiresUserAuth: true) + jwtConfig.hydrate(requiresUserAuth: true) + + // Work deferred while the requirement was unknown needs the repeat too, not just the change + XCTAssertEqual(requirements, [.on, .on]) + } + + func testTheHandlerReceivesTheHydratedRequirement() { + let service = makeService() + var requirement: OSRequiresUserAuth? + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { requirement = $0 } + + jwtConfig.hydrate(requiresUserAuth: false) + + XCTAssertEqual(requirement, .off) + } + + func testRemovingTheHandlerStopsTheCallbacks() { + let service = makeService() + var callCount = 0 + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in callCount += 1 } + service.removeOnJwtConfigHydratedHandler(for: .operationRepo) + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(callCount, 0) + } + + func testAHandlerRegisteredAfterHydrationRunsImmediately() { + let service = makeService() + jwtConfig.hydrate(requiresUserAuth: true) + + var requirement: OSRequiresUserAuth? + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { requirement = $0 } + + // Remote params can return before the repo subscribes, and that hydration does not come again + XCTAssertEqual(requirement, .on) + } + + func testAHandlerRegisteredBeforeRemoteParamsWaitsForThem() { + let service = makeService() + var callCount = 0 + + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in callCount += 1 } + + XCTAssertEqual(callCount, 0) + } + + func testHydratingAfterTheServiceIsReleasedIsANoOp() { + var service: OSIdentityVerificationService? = makeService() + var callCount = 0 + service?.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in callCount += 1 } + service = nil + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(callCount, 0) + } + + // MARK: - Multiple observers + + /// The User executor and the operation repo both wait on hydration; neither may displace the other. + func testEveryObserverIsNotified() { + let service = makeService() + var notified: [OSJwtConfigHydratedObserver] = [] + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in notified.append(.userExecutor) } + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in notified.append(.operationRepo) } + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(notified, [.userExecutor, .operationRepo]) + } + + /// Registration order, so a held Create User goes out before Deltas that need its `onesignal_id`. + func testObserversAreNotifiedInRegistrationOrder() { + let service = makeService() + var notified: [OSJwtConfigHydratedObserver] = [] + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in notified.append(.operationRepo) } + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in notified.append(.userExecutor) } + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(notified, [.operationRepo, .userExecutor]) + } + + /// A rebuilt observer replaces its own registration rather than leaving the old closure behind. + func testReRegisteringTheSameObserverReplacesIt() { + let service = makeService() + var firstCallCount = 0 + var secondCallCount = 0 + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in firstCallCount += 1 } + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in secondCallCount += 1 } + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(firstCallCount, 0) + XCTAssertEqual(secondCallCount, 1) + } + + /// Replacing keeps the original position, so ordering does not shift under a rebuild. + func testReplacingAnObserverKeepsItsPosition() { + let service = makeService() + var notified: [String] = [] + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in notified.append("user-executor-original") } + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in notified.append("operation-repo") } + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in notified.append("user-executor-replacement") } + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(notified, ["user-executor-replacement", "operation-repo"]) + } + + func testRemovingOneObserverLeavesTheOther() { + let service = makeService() + var notified: [OSJwtConfigHydratedObserver] = [] + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in notified.append(.userExecutor) } + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in notified.append(.operationRepo) } + service.removeOnJwtConfigHydratedHandler(for: .userExecutor) + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(notified, [.operationRepo]) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift new file mode 100644 index 000000000..1d536c65a --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift @@ -0,0 +1,95 @@ +/* + 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 XCTest +import OneSignalCore +@testable import OneSignalOSCore + +final class OSUserJwtConfigTests: XCTestCase { + + override func setUp() { + super.setUp() + clearCachedRequirement() + } + + override func tearDown() { + clearCachedRequirement() + super.tearDown() + } + + private func clearCachedRequirement() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + } + + private func cacheRequirement(_ requirement: OSRequiresUserAuth) { + OneSignalUserDefaults.initShared().saveInteger(forKey: OSUD_USE_IDENTITY_VERIFICATION, withValue: requirement.rawValue) + } + + func testRequirementIsUnknownUntilRemoteParamsAreRead() { + XCTAssertEqual(OSUserJwtConfig().requirement, .unknown) + } + + func testHydratedRequirementIsReadBackOnTheNextLaunch() { + OSUserJwtConfig().hydrate(requiresUserAuth: true) + XCTAssertEqual(OSUserJwtConfig().requirement, .on) + + // `off` has to be cached as its own value, not as the absence of one + OSUserJwtConfig().hydrate(requiresUserAuth: false) + XCTAssertEqual(OSUserJwtConfig().requirement, .off) + } + + func testRefreshAdoptsACachedRequirementThatTheInitialReadMissed() { + let jwtConfig = OSUserJwtConfig() + cacheRequirement(.on) + + jwtConfig.refreshIfUnknown() + + XCTAssertEqual(jwtConfig.requirement, .on) + } + + func testRefreshLeavesAKnownRequirementAlone() { + let jwtConfig = OSUserJwtConfig() + jwtConfig.hydrate(requiresUserAuth: false) + cacheRequirement(.on) + + jwtConfig.refreshIfUnknown() + + XCTAssertEqual(jwtConfig.requirement, .off) + } + + func testHydrateCachesTheRequirementEvenWhenTheValueIsUnchanged() { + let jwtConfig = OSUserJwtConfig() + jwtConfig.hydrate(requiresUserAuth: true) + // Stands in for a write that locked storage dropped, leaving memory and disk out of step + clearCachedRequirement() + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(OSUserJwtConfig().requirement, .on) + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 7a128f170..4e8be121d 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -127,6 +127,16 @@ + (BOOL)downloadedParameters { return _downloadedParameters; } +/* + Remote params carry `jwt_required`, and the operation repo holds every queued operation until it knows + that value. A first launch has no cached answer to fall back on, so a params request that fails and is + never retried costs the app every tag, session count and event for the rest of the session. Retry with + a backoff, then leave it to the next session. + */ +static int _downloadParametersAttempts = 0; +static const int MAX_DOWNLOAD_PARAMETERS_ATTEMPTS = 5; +static const NSTimeInterval DOWNLOAD_PARAMETERS_RETRY_BASE_SECONDS = 5.0; + static OneSignalReceiveReceiptsController* _receiveReceiptsController; + (OneSignalReceiveReceiptsController*)receiveReceiptsController { if (!_receiveReceiptsController) @@ -147,7 +157,8 @@ + (void)clearStatics { _downloadedParameters = false; _didCallDownloadParameters = false; - + _downloadParametersAttempts = 0; + // sessionLaunchTime = [NSDate date]; [OSOutcomes clearStatics]; @@ -359,6 +370,12 @@ + (void)startNewSessionInternal { if ([OneSignalConfig shouldAwaitAppIdAndLogMissingPrivacyConsentForMethod:nil]) return; + // A new session is a fresh chance at params after the launch attempt exhausted its retries + if (!_downloadedParameters && !_didCallDownloadParameters && OneSignalIdentifiers.currentAppId) { + _downloadParametersAttempts = 0; + [self downloadIOSParamsWithAppId:OneSignalIdentifiers.currentAppId]; + } + [OSOutcomes.sharedController clearOutcomes]; [[OSSessionManager sharedSessionManager] restartSessionIfNeeded]; @@ -654,6 +671,7 @@ + (void)handleAppIdChange:(NSString*)appId { initDone = false; _downloadedParameters = false; _didCallDownloadParameters = false; + _downloadParametersAttempts = 0; let sharedUserDefaults = OneSignalUserDefaults.initShared; @@ -665,6 +683,9 @@ + (void)handleAppIdChange:(NSString*)appId { [sharedUserDefaults removeValueForKey:OSUD_RECEIVE_RECEIPTS_ENABLED]; [sharedUserDefaults removeValueForKey:OS_PUSH_SUBSCRIPTION_MODEL_STORE_KEY]; + [sharedUserDefaults removeValueForKey:OSUD_USE_IDENTITY_VERIFICATION]; + [sharedUserDefaults removeValueForKey:OSUD_SDK_FEATURE_FLAGS]; + // Drop cached identifiers — a real app-id change invalidates them. [OSResilientStorage setStrings:@{ OSResilientStorage.keySubscriptionId: @"", @@ -710,6 +731,7 @@ + (void)setConsentGiven:(BOOL)granted { + (void)downloadIOSParamsWithAppId:(NSString *)appId { [OneSignalLog onesignalLog:ONE_S_LL_DEBUG message:@"Downloading iOS parameters for this application"]; _didCallDownloadParameters = true; + _downloadParametersAttempts++; // This will be nil unless we have a cached user // TODO: Commented out. This will init the User Manager too early, and userId is not needed anyway. // NSString *userId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId; @@ -717,8 +739,12 @@ + (void)downloadIOSParamsWithAppId:(NSString *)appId { [OneSignalCoreImpl.sharedClient executeRequest:[OSRequestGetIosParams withUserId:userId appId:appId] onSuccess:^(NSDictionary *result) { - if (result[IOS_REQUIRES_USER_ID_AUTHENTICATION]) { - OneSignalUserManagerImpl.sharedInstance.requiresUserAuth = [result[IOS_REQUIRES_USER_ID_AUTHENTICATION] boolValue]; + // A response that omits the key means Identity Verification is off for this app; an empty + // response answers nothing, so the cached requirement stands + if (result != nil) { + id jwtRequired = result[IOS_JWT_REQUIRED]; + BOOL requiresUserAuth = jwtRequired != (id)[NSNull null] && [jwtRequired boolValue]; + [OSUserJwtConfig.shared hydrateWithRequiresUserAuth:requiresUserAuth]; } if (result[IOS_USES_PROVISIONAL_AUTHORIZATION] != (id)[NSNull null]) { @@ -759,9 +785,29 @@ + (void)downloadIOSParamsWithAppId:(NSString *)appId { } onFailure:^(OneSignalClientError *error) { _didCallDownloadParameters = false; + [self scheduleDownloadIOSParamsRetryWithAppId:appId]; }]; } ++ (void)scheduleDownloadIOSParamsRetryWithAppId:(NSString *)appId { + if (_downloadParametersAttempts >= MAX_DOWNLOAD_PARAMETERS_ATTEMPTS) { + [OneSignalLog onesignalLog:ONE_S_LL_WARN message:@"Could not download iOS parameters; retrying on the next session. Operations that wait on the Identity Verification setting stay queued until then."]; + return; + } + + // 5s, 10s, 20s, 40s across the remaining attempts + NSTimeInterval delay = DOWNLOAD_PARAMETERS_RETRY_BASE_SECONDS * (1 << MAX(_downloadParametersAttempts - 1, 0)); + [OneSignalLog onesignalLog:ONE_S_LL_DEBUG message:[NSString stringWithFormat:@"Retrying the iOS parameters download in %.0f seconds", delay]]; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + // A session start or a re-init may have already succeeded or have one in flight + if (_downloadedParameters || _didCallDownloadParameters) + return; + + [self downloadIOSParamsWithAppId:appId]; + }); +} + //TODO: consolidate in one place. Where??? + (void)launchWebURL:(NSString*)openUrl { From 9ea39abde6173acf5104ebdc2d7bf6787e19368c Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 12 Aug 2026 09:50:19 -0700 Subject: [PATCH 2/2] fix: [PR3] tighten params retry class and hydration handler race Outer iOS-params retries now follow OSResponseStatusRetryable so classified 4xx are not re-armed for the session. Hydration handlers snapshot requirement under the same lock as registration. Co-authored-by: Cursor --- .../Source/Jwt/OSIdentityVerificationService.swift | 4 ++-- iOS_SDK/OneSignalSDK/Source/OneSignal.m | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift index 6faffc2e2..d649c342c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift @@ -84,15 +84,15 @@ public final class OSIdentityVerificationService { registered after `requirement` is already known runs immediately, since that hydration is not repeated. */ public func addOnJwtConfigHydratedHandler(for observer: OSJwtConfigHydratedObserver, _ handler: @escaping (OSRequiresUserAuth) -> Void) { - handlerLock.withLock { + let alreadyKnown = handlerLock.withLock { () -> OSRequiresUserAuth in if let index = jwtConfigHydratedHandlers.firstIndex(where: { $0.observer == observer }) { jwtConfigHydratedHandlers[index] = (observer, handler) } else { jwtConfigHydratedHandlers.append((observer, handler)) } + return jwtConfig.requirement } - let alreadyKnown = jwtConfig.requirement guard alreadyKnown != .unknown else { return } diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 4e8be121d..552daf87a 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -785,7 +785,11 @@ + (void)downloadIOSParamsWithAppId:(NSString *)appId { } onFailure:^(OneSignalClientError *error) { _didCallDownloadParameters = false; - [self scheduleDownloadIOSParamsRetryWithAppId:appId]; + if ([OSNetworkingUtils getResponseStatusType:error.code] == OSResponseStatusRetryable) { + [self scheduleDownloadIOSParamsRetryWithAppId:appId]; + } else { + [OneSignalLog onesignalLog:ONE_S_LL_WARN message:[NSString stringWithFormat:@"Could not download iOS parameters (HTTP %ld); not retrying this session.", (long)error.code]]; + } }]; }