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..d649c342c --- /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) { + 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 + } + + 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..552daf87a 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,33 @@ + (void)downloadIOSParamsWithAppId:(NSString *)appId { } onFailure:^(OneSignalClientError *error) { _didCallDownloadParameters = false; + 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]]; + } }]; } ++ (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 {