From f74a5734618992723fa922ba1ab7e85db5bf03bf Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 16:56:33 -0700 Subject: [PATCH 1/3] feat: [PR7] in-app messages under Identity Verification The in-app message fetch is user-scoped, so under Identity Verification it has to name the user by external_id and sign the call. It asks the user manager how to address the current user rather than assembling that itself, which keeps the alias and the token consistent with every other user-scoped call. The fetch deliberately does not invalidate a JWT. Getting this request right means getting both the push subscription ID and the user in the URL right, so a 401 here is at least as likely to mean the request was mismatched as it is to mean the token is bad. Treating it as proof would let a malformed fetch invalidate a token that works everywhere else. It handles the rejection and stops; the request pipeline remains the only thing that decides a token is no longer good. This follows Android. A fetch that cannot yet be addressed waits rather than going out unsigned, and is reattempted when the requirement hydrates or the app supplies a token. Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../Controller/OSMessagingController.m | 166 ++++++++++- .../Requests/OSInAppMessagingRequests.h | 6 +- .../Requests/OSInAppMessagingRequests.m | 16 +- .../IamFetchIdentityVerificationTests.swift | 277 ++++++++++++++++++ .../OSMessagingControllerUserStateTests.swift | 36 ++- ...SignalInAppMessagesTests-Bridging-Header.h | 1 + .../Source/OneSignalUserManagerImpl+Jwt.swift | 20 ++ .../Source/OneSignalUserManagerImpl.swift | 3 + 9 files changed, 492 insertions(+), 37 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 0e4b40081..264c2e9cd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -298,6 +298,7 @@ 5BC1DE602C90B83900CA8807 /* OSConsistencyKeyEnum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC1DE5F2C90B83900CA8807 /* OSConsistencyKeyEnum.swift */; }; 5BC1DE622C90B85A00CA8807 /* OSIamFetchOffsetKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC1DE612C90B85A00CA8807 /* OSIamFetchOffsetKey.swift */; }; 5BC1DE642C90BB9000CA8807 /* OSIamFetchReadyCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC1DE632C90BB9000CA8807 /* OSIamFetchReadyCondition.swift */; }; + 6F894909CB6D09258DCCA1C9 /* IamFetchIdentityVerificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67ECA2928D863073B785F93F /* IamFetchIdentityVerificationTests.swift */; }; 7A123295235DFE3B002B6CE3 /* OutcomeTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 7A123294235DFE3B002B6CE3 /* OutcomeTests.m */; }; 7A1232A2235E1743002B6CE3 /* OneSignal.m in Sources */ = {isa = PBXBuildFile; fileRef = 912411F11E73342200E41FD7 /* OneSignal.m */; }; 7A2E90622460DA1500B3428C /* OutcomeIntegrationV2Tests.m in Sources */ = {isa = PBXBuildFile; fileRef = 7A2E90612460DA1500B3428C /* OutcomeIntegrationV2Tests.m */; }; @@ -1572,6 +1573,7 @@ 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSConsistencyManagerTests.swift; sourceTree = ""; }; 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtLifecycleTests.swift; sourceTree = ""; }; 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfig.swift; sourceTree = ""; }; + 67ECA2928D863073B785F93F /* IamFetchIdentityVerificationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IamFetchIdentityVerificationTests.swift; sourceTree = ""; }; 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtRepo.swift; sourceTree = ""; }; 7A123294235DFE3B002B6CE3 /* OutcomeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OutcomeTests.m; sourceTree = ""; }; 7A12EBD523060A6F005C4FA5 /* OSSessionManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OSSessionManager.m; sourceTree = ""; }; @@ -2292,6 +2294,7 @@ 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */, 3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */, 3C7021E72ECF0CF3001768C6 /* OneSignalInAppMessagesTests-Bridging-Header.h */, + 67ECA2928D863073B785F93F /* IamFetchIdentityVerificationTests.swift */, ); path = OneSignalInAppMessagesTests; sourceTree = ""; @@ -4521,6 +4524,7 @@ 3C7021E92ECF0CF4001768C6 /* IAMIntegrationTests.swift in Sources */, 3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */, 3CB35FCB2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift in Sources */, + 6F894909CB6D09258DCCA1C9 /* IamFetchIdentityVerificationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m index 067787d89..eaff70122 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m @@ -146,8 +146,16 @@ @interface OSMessagingController () @property (nonatomic) BOOL calledLoadTags; -/// set when we attempt getInAppMessagesFromServer and no onesignal ID is available yet -@property (strong, nonatomic, nullable) NSString *shouldFetchOnUserChangeWithSubscriptionID; +/** + Set when a fetch could not go out: no onesignal ID yet, or Identity Verification has not answered what + the call should be addressed and signed with. Read through `takeDeferredFetchSubscriptionId`, since a + user change, a hydration and a new token can all arrive at once and only one of them should refetch. + */ +@property (strong, nonatomic, nullable) NSString *deferredFetchSubscriptionId; + +/// Bumped on every login and logout. A fetch carries the value it started with, so a response that +/// arrives after the user changed is discarded instead of showing one user's messages to another. +@property (nonatomic) NSUInteger userGeneration; /// Tracks whether the first IAM fetch has completed since this cold start @property (nonatomic) BOOL hasCompletedFirstFetch; @@ -242,6 +250,11 @@ - (instancetype)init { _isInAppMessagingPaused = false; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleIAMPreview:) name:ONESIGNAL_POST_PREVIEW_IAM object:nil]; + // A deferred fetch waits on how to address it and what to sign it with; hydration answers the + // first, a supplied token the second. + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(retryDeferredFetch) name:OS_ON_JWT_CONFIG_HYDRATED object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(retryDeferredFetch) name:OS_ON_USER_JWT_UPDATED object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onUserWillChange) name:OS_ON_USER_WILL_CHANGE object:nil]; } return self; @@ -255,7 +268,15 @@ - (void)initializeTriggerController { dateFromString:timeSinceLastMessage]]; } +/** + Fetches in-app messages for `subscriptionId`, addressed as whoever is the current user. + + Nothing checks that the server has those two paired — the subscription comes from the caller and can + predate a login, while the alias is read here — so a well-formed fetch can still be refused. Pairing + them would mean tracking subscription ownership, which the SDK does not do. + */ - (void)getInAppMessagesFromServer:(NSString *)subscriptionId { + NSUInteger generation = [self currentUserGeneration]; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"getInAppMessagesFromServer"]; @@ -269,7 +290,16 @@ - (void)getInAppMessagesFromServer:(NSString *)subscriptionId { // NOTE: Check for subscription ID above first, before checking for OneSignal ID next if (!onesignalId) { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Failed to get in app messages due to no OneSignal ID, will reattempt"]; - self.shouldFetchOnUserChangeWithSubscriptionID = subscriptionId; + [self deferFetchWithSubscriptionId:subscriptionId]; + return; + } + + // Resolved before the read-your-write wait, which can hold this thread for as long as it takes + // the user requests to come back. + OSUserRequestAuthorization *authorization = [OneSignalUserManagerImpl.sharedInstance authorizationForCurrentUser]; + if (!authorization) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Failed to get in app messages due to Identity Verification, will reattempt"]; + [self deferFetchWithSubscriptionId:subscriptionId]; return; } @@ -288,24 +318,101 @@ - (void)getInAppMessagesFromServer:(NSString *)subscriptionId { // Initial request [self attemptFetchWithRetries:subscriptionId + authorization:authorization rywData:rywData attempts:@0 // Starting with 0 attempts - retryLimit:nil]; // Retry limit to be set dynamically on first failure + retryLimit:nil // Retry limit to be set dynamically on first failure + userGeneration:generation]; }); }); } +- (NSUInteger)currentUserGeneration { + @synchronized (self) { + return self.userGeneration; + } +} + +/// Whether a fetch that started in `generation` is still for the user it was addressed and signed for. +- (BOOL)isCurrentUserGeneration:(NSUInteger)generation { + @synchronized (self) { + return self.userGeneration == generation; + } +} + +/** + Drops the outgoing user's in-app messages and invalidates any fetch still in flight for them, then + queues one for whoever is signing in. The new user's fetch goes out from `onUserStateDidChange`, once + there is a `onesignal_id` to address it by. + */ +- (void)onUserWillChange { + @synchronized (self) { + self.userGeneration += 1; + } + [self deferFetchWithSubscriptionId:OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId]; + // On main, where every other write to `messages` happens. + dispatch_async(dispatch_get_main_queue(), ^{ + self.messages = @[]; + }); +} + +- (void)deferFetchWithSubscriptionId:(NSString *)subscriptionId { + @synchronized (self) { + self.deferredFetchSubscriptionId = subscriptionId; + } +} + +/// The deferred subscription ID, if there is one, taken so that two signals arriving together refetch once. +- (NSString * _Nullable)takeDeferredFetchSubscriptionId { + @synchronized (self) { + NSString *subscriptionId = self.deferredFetchSubscriptionId; + self.deferredFetchSubscriptionId = nil; + return subscriptionId; + } +} + +- (void)retryDeferredFetch { + NSString *subscriptionId = [self takeDeferredFetchSubscriptionId]; + if (subscriptionId) { + [self getInAppMessagesFromServer:subscriptionId]; + } +} + +/** + Parks the fetch for a later token to reattempt, without reporting the one it was signed with. + + A rejection here is ambiguous: the path names a user and a subscription the server may simply not have + paired, and a replacement token would not change that. Invalidating is left to the user requests, which + address a user alone; the token the app supplies after one of those releases what is parked here. + */ +- (void)handleUnauthorizedFetch:(OSUserRequestAuthorization *)authorization subscriptionId:(NSString *)subscriptionId { + // An unsigned fetch has no token to replace, so there would be nothing different to reattempt with. + if (!authorization.token) { + return; + } + [self deferFetchWithSubscriptionId:subscriptionId]; +} + - (void)attemptFetchWithRetries:(NSString *)subscriptionId + authorization:(OSUserRequestAuthorization *)authorization rywData:(OSReadYourWriteData *)rywData attempts:(NSNumber *)attempts - retryLimit:(NSNumber *)retryLimit { + retryLimit:(NSNumber *)retryLimit + userGeneration:(NSUInteger)generation { + if (![self isCurrentUserGeneration:generation]) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Abandoning an in app message fetch for a previous user"]; + return; + } + NSNumber *sessionDuration = @([OSSessionManager.sharedSessionManager getTimeFocusedElapsed]); NSString *rywToken = rywData.rywToken; NSNumber *rywDelay = rywData.rywDelay; // Create the request with the current attempt count OSRequestGetInAppMessages *request = [OSRequestGetInAppMessages withSubscriptionId:subscriptionId + withAlias:authorization.alias + withUserHeaders:authorization.headers withSessionDuration:sessionDuration withRetryCount:attempts withRywToken:rywToken]; @@ -316,6 +423,10 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId onSuccess:^(NSDictionary *result) { dispatch_async(dispatch_get_main_queue(), ^{ [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"getInAppMessagesFromServer success"]; + if (![self isCurrentUserGeneration:generation]) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Discarding in app messages fetched for a previous user"]; + return; + } if (result[@"in_app_messages"]) { NSMutableArray *messages = [NSMutableArray new]; @@ -335,7 +446,12 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId NSDictionary* responseHeaders = error.responseHeaders; [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"getInAppMessagesFromServer failure: %@", error.description]]; - + + // A retry, and the token this was signed with, both belong to the user it started for. + if (![self isCurrentUserGeneration:generation]) { + return; + } + if (error.code == 425 || error.code == 429) { // 425 Too Early or 429 Too Many Requests NSInteger retryAfter = [responseHeaders[@"Retry-After"] integerValue] ?: DEFAULT_RETRY_AFTER_SECONDS; @@ -348,13 +464,17 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId NSInteger nextAttempt = [attempts integerValue] + 1; // Increment attempts [self retryAfterDelay:retryAfter subscriptionId:subscriptionId + authorization:authorization rywData:rywData attempts:@(nextAttempt) - retryLimit:blockRetryLimit]; + retryLimit:blockRetryLimit + userGeneration:generation]; } else { // Final attempt without rywToken - [self fetchInAppMessagesWithoutToken:subscriptionId]; + [self fetchInAppMessagesWithoutToken:subscriptionId authorization:authorization userGeneration:generation]; } + } else if ([OSNetworkingUtils getResponseStatusType:error.code] == OSResponseStatusUnauthorized) { + [self handleUnauthorizedFetch:authorization subscriptionId:subscriptionId]; } else if (error.code >= 500 && error.code <= 599) { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Server error, skipping retries"]; } @@ -363,23 +483,31 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId - (void)retryAfterDelay:(NSInteger)retryAfter subscriptionId:(NSString *)subscriptionId + authorization:(OSUserRequestAuthorization *)authorization rywData:(OSReadYourWriteData *)rywData attempts:(NSNumber *)attempts - retryLimit:(NSNumber *)retryLimit { + retryLimit:(NSNumber *)retryLimit + userGeneration:(NSUInteger)generation { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(retryAfter * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [self attemptFetchWithRetries:subscriptionId + authorization:authorization rywData:rywData attempts:attempts - retryLimit:retryLimit]; + retryLimit:retryLimit + userGeneration:generation]; }); } -- (void)fetchInAppMessagesWithoutToken:(NSString *)subscriptionId { +- (void)fetchInAppMessagesWithoutToken:(NSString *)subscriptionId + authorization:(OSUserRequestAuthorization *)authorization + userGeneration:(NSUInteger)generation { NSNumber *sessionDuration = @([OSSessionManager.sharedSessionManager getTimeFocusedElapsed]); OSRequestGetInAppMessages *request = [OSRequestGetInAppMessages withSubscriptionId:subscriptionId + withAlias:authorization.alias + withUserHeaders:authorization.headers withSessionDuration:sessionDuration withRetryCount:nil withRywToken:nil]; // No retries for the final attempt @@ -388,6 +516,10 @@ - (void)fetchInAppMessagesWithoutToken:(NSString *)subscriptionId { onSuccess:^(NSDictionary *result) { dispatch_async(dispatch_get_main_queue(), ^{ [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Final attempt without token success"]; + if (![self isCurrentUserGeneration:generation]) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Discarding in app messages fetched for a previous user"]; + return; + } if (result[@"in_app_messages"]) { NSMutableArray *messages = [NSMutableArray new]; @@ -404,6 +536,12 @@ - (void)fetchInAppMessagesWithoutToken:(NSString *)subscriptionId { }); } onFailure:^(OneSignalClientError *error) { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"getInAppMessagesFromServer failure: %@", error.description]]; + if (![self isCurrentUserGeneration:generation]) { + return; + } + if ([OSNetworkingUtils getResponseStatusType:error.code] == OSResponseStatusUnauthorized) { + [self handleUnauthorizedFetch:authorization subscriptionId:subscriptionId]; + } }]; } @@ -1237,11 +1375,9 @@ - (void)onPushSubscriptionDidChangeWithState:(OSPushSubscriptionChangedState * _ } - (void)onUserStateDidChangeWithState:(OSUserChangedState * _Nonnull)state { - if (state.current.onesignalId != nil && self.shouldFetchOnUserChangeWithSubscriptionID) { + if (state.current.onesignalId != nil) { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"OSMessagingController onUserStateDidChangeWithState: changed to new valid onesignal id"]; - NSString *subscriptionID = self.shouldFetchOnUserChangeWithSubscriptionID; - self.shouldFetchOnUserChangeWithSubscriptionID = nil; - [self getInAppMessagesFromServer:subscriptionID]; + [self retryDeferredFetch]; } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h index 047592e63..6be5af702 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h @@ -28,8 +28,12 @@ #import #import "OSInAppMessageClickResult.h" +@class OSAliasPair; + @interface OSRequestGetInAppMessages : OneSignalRequest -+ (instancetype _Nonnull)withSubscriptionId:(NSString * _Nonnull)subscriptionId withSessionDuration:(NSNumber * _Nonnull)sessionDuration withRetryCount:(NSNumber *)retryCount withRywToken:(NSString *)rywToken; +/// A nil `alias` addresses the subscription on its own, which is how this was addressed before Identity +/// Verification; `userHeaders` carries the Bearer when the call is signed. ++ (instancetype _Nonnull)withSubscriptionId:(NSString * _Nonnull)subscriptionId withAlias:(OSAliasPair * _Nullable)alias withUserHeaders:(NSDictionary * _Nullable)userHeaders withSessionDuration:(NSNumber * _Nonnull)sessionDuration withRetryCount:(NSNumber *)retryCount withRywToken:(NSString *)rywToken; @end @interface OSRequestInAppMessageViewed : OneSignalRequest diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m index 6cf041dae..574b9854b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m @@ -35,13 +35,15 @@ - (NSString *)description { } + (instancetype _Nonnull) withSubscriptionId:(NSString * _Nonnull)subscriptionId + withAlias:(OSAliasPair * _Nullable)alias + withUserHeaders:(NSDictionary * _Nullable)userHeaders withSessionDuration:(NSNumber * _Nonnull)sessionDuration withRetryCount:(NSNumber *)retryCount withRywToken:(NSString *)rywToken { let request = [OSRequestGetInAppMessages new]; request.method = GET; - let headers = [NSMutableDictionary new]; + NSMutableDictionary *headers = userHeaders ? [userHeaders mutableCopy] : [NSMutableDictionary new]; if (sessionDuration != nil) { // convert to ms & round @@ -56,7 +58,17 @@ + (instancetype _Nonnull) withSubscriptionId:(NSString * _Nonnull)subscription request.additionalHeaders = headers; NSString *appId = OneSignalIdentifiers.currentAppId; - request.path = [NSString stringWithFormat:@"apps/%@/subscriptions/%@/iams", appId, subscriptionId]; + if (alias) { + // Encode so an app-chosen external_id cannot change which endpoint the path names. + NSString *encodedAliasId = [OSUrlPath segment:alias.id]; + if (!encodedAliasId) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:@"OSRequestGetInAppMessages: cannot encode alias id for path"]; + return request; + } + request.path = [NSString stringWithFormat:@"apps/%@/users/by/%@/%@/subscriptions/%@/iams", appId, alias.label, encodedAliasId, subscriptionId]; + } else { + request.path = [NSString stringWithFormat:@"apps/%@/subscriptions/%@/iams", appId, subscriptionId]; + } return request; } @end diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift new file mode 100644 index 000000000..659d90aba --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift @@ -0,0 +1,277 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import XCTest +import OneSignalCore +import OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +import OneSignalInAppMessagesMocks +@testable import OneSignalUser + +/** + How the in-app message fetch is addressed and signed. It does not travel through the Request queues, so + it makes the Identity Verification decision itself and holds the fetch until the answer arrives. + */ +final class IamFetchIdentityVerificationTests: XCTestCase { + private let appId = "test-app-id" + + private var client = MockOneSignalClient() + private var jwtListener = MockUserJwtInvalidatedListener() + + private var legacyPath: String { "apps/\(appId)/subscriptions/\(testPushSubId)/iams" } + private var anonymousUserPath: String { userPath(alias: OS_ONESIGNAL_ID, id: anonUserOSID) } + private var identifiedUserPath: String { userPath(alias: OS_EXTERNAL_ID, id: userA_EUID) } + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + ConsistencyManagerTestHelpers.reset() + OSMessagingController.removeInstance() + OneSignalIdentifiers.currentAppId = appId + + client = MockOneSignalClient() + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userA_EUID) + OneSignalCoreImpl.setSharedClient(client) + for path in [legacyPath, anonymousUserPath, identifiedUserPath] { + respondToFetch(from: path) + } + + // Held strongly for the test's lifetime: the observable keeps listeners weakly. + jwtListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(jwtListener) + + OSMessagingController.start() + } + + override func tearDownWithError() throws { + OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(jwtListener) + OSMessagingController.removeInstance() + OSFeatureManager.shared.setEnabledFeatureKeys([]) + OneSignalCoreMocks.clearUserDefaults() + } + + // MARK: - Setup helpers + + private func userPath(alias: String, id: String) -> String { + return "apps/\(appId)/users/by/\(alias)/\(id)/subscriptions/\(testPushSubId)/iams" + } + + private func respondToFetch(from path: String) { + client.setMockResponseForRequest( + request: "", + response: IAMTestHelpers.testFetchMessagesResponse(messages: [])) + } + + private func rejectFetch(from path: String) { + client.setMockFailureResponseForRequest( + request: "", + error: OneSignalClientError(code: 401, message: "unauthorized", responseHeaders: nil, response: nil, underlyingError: nil)) + } + + private func turnOnTheRolloutFlag() { + OSFeatureManager.shared.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + } + + /// An anonymous user with an `onesignal_id`, which the fetch needs before it does anything else. + private func startAnonymousUser() { + ConsistencyManagerTestHelpers.setDefaultRywToken(id: anonUserOSID) + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + } + + private func login(token: String?) { + ConsistencyManagerTestHelpers.setDefaultRywToken(id: userA_OSID) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: token) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + } + + private func fetch() { + OneSignalInAppMessages.getFromServer(testPushSubId) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + } + + // MARK: - Assertion helpers + + private func executedFetches() -> [OneSignalRequest] { + return client.executedRequests.filter { $0 is OSRequestGetInAppMessages } + } + + private func lastFetchPath() -> String? { + return executedFetches().last?.path + } + + private func lastFetchAuthorization() -> String? { + return executedFetches().last?.additionalHeaders?["Authorization"] + } + + private func deferredSubscriptionId() -> String? { + return OSMessagingController.sharedInstance().deferredFetchSubscriptionId + } + + // MARK: - How the fetch is addressed + + func testTheFetchAddressesTheSubscriptionAloneWhileTheNewCodePathsAreOff() { + startAnonymousUser() + + fetch() + + XCTAssertEqual(lastFetchPath(), legacyPath) + XCTAssertNil(lastFetchAuthorization()) + } + + func testTheFetchAddressesTheOnesignalIdWhileIdentityVerificationIsOff() { + turnOnTheRolloutFlag() + startAnonymousUser() + + fetch() + + XCTAssertEqual(lastFetchPath(), anonymousUserPath) + XCTAssertNil(lastFetchAuthorization()) + } + + func testTheFetchAddressesTheExternalIdAndIsSignedUnderIdentityVerification() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + + fetch() + + XCTAssertEqual(lastFetchPath(), identifiedUserPath) + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-a") + } + + // MARK: - Holding the fetch until Identity Verification answers + + /// An unsigned fetch on behalf of an app that turns out to require auth would be rejected, so a fetch + /// that runs before remote params answer waits for them — including when the rollout flag is off. + func testAFetchHeldForAnUnknownRequirementGoesOutOnHydration() { + startAnonymousUser() + let fetchesBefore = executedFetches().count + OSCoreMocks.resetSharedJwtConfig() + + fetch() + + XCTAssertEqual(executedFetches().count, fetchesBefore) + XCTAssertEqual(deferredSubscriptionId(), testPushSubId) + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(lastFetchPath(), legacyPath) + XCTAssertNil(deferredSubscriptionId()) + } + + /// A user whose token was rejected has none until the app supplies another, and the fetch has to wait + /// rather than fall back to sending unsigned. + func testAFetchIsHeldRatherThanSentUnsignedWhenTheUserHasNoToken() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + OneSignalUserManagerImpl.sharedInstance.userJwtRepo.invalidateJwt(externalId: userA_EUID, rejectedToken: "token-a") + let fetchesBefore = executedFetches().count + + fetch() + + XCTAssertEqual(executedFetches().count, fetchesBefore) + XCTAssertEqual(deferredSubscriptionId(), testPushSubId) + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "token-b") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(lastFetchPath(), identifiedUserPath) + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-b") + } + + // MARK: - A rejected fetch + + /// The fetch is not a source of truth for whether a token is good, because the server can refuse it + /// over a user and subscription it does not have paired. It parks, and the next token releases it. + func testARejectedFetchParksWithoutReportingTheTokenItUsed() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + rejectFetch(from: identifiedUserPath) + let fetchesBefore = executedFetches().count + + fetch() + + XCTAssertEqual(executedFetches().count, fetchesBefore + 1) + XCTAssertEqual(jwtListener.invalidatedExternalIds, []) + XCTAssertEqual(deferredSubscriptionId(), testPushSubId) + + // A token the app supplies for its own reasons, since the fetch never asked for one. + respondToFetch(from: identifiedUserPath) + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "token-b") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(executedFetches().count, fetchesBefore + 2) + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-b") + } + + /// The token the fetch was signed with stays usable, so everything else keeps going out signed. + func testARejectedFetchLeavesTheTokenInPlaceForTheRequests() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + rejectFetch(from: identifiedUserPath) + + fetch() + + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-a") + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance.user.identityModel.jwtBearerToken, "token-a") + } + + /// With the gate off a 401 stays as it was: logged and dropped, with no reattempt left pending. + func testARejectedFetchIsLeftAloneWhileTheNewCodePathsAreOff() { + startAnonymousUser() + rejectFetch(from: legacyPath) + let fetchesBefore = executedFetches().count + + fetch() + + XCTAssertEqual(executedFetches().count, fetchesBefore + 1) + XCTAssertNil(deferredSubscriptionId()) + } + + /// Under Identity Verification the alias id is an app-chosen external_id, so it has to be encoded. + func testTheFetchPathPercentEncodesTheAliasId() { + OneSignalIdentifiers.currentAppId = appId + let externalId = "us er/a?b#c%d" + let request = OSRequestGetInAppMessages.withSubscriptionId( + testPushSubId, + withAlias: OSAliasPair(OS_EXTERNAL_ID, externalId), + withUserHeaders: nil, + withSessionDuration: 0, + withRetryCount: 0, + withRywToken: nil + ) + + XCTAssertEqual( + request.path, + "apps/\(appId)/users/by/\(OS_EXTERNAL_ID)/us%20er%2Fa%3Fb%23c%25d/subscriptions/\(testPushSubId)/iams" + ) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift index 9c73924d6..3558b21a1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift @@ -71,7 +71,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { - IAM fetch should be deferred Expected: - - shouldFetchOnUserChangeWithSubscriptionID property is set with the subscription ID + - deferredFetchSubscriptionId property is set with the subscription ID - No IAM fetch actually occurs */ func testStoresSubscriptionIDWhenOneSignalIDUnavailable() throws { @@ -87,15 +87,14 @@ final class OSMessagingControllerUserStateTests: XCTestCase { /* Verify */ // The controller should have stored the subscription ID for retry - let shouldFetchOnUserChangeWithSubscriptionID = OSMessagingController.sharedInstance().value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") - XCTAssertEqual(shouldFetchOnUserChangeWithSubscriptionID as! String, testSubscriptionId) + XCTAssertEqual(OSMessagingController.sharedInstance().deferredFetchSubscriptionId, testSubscriptionId) // Verify no IAM request was actually made (since we don't have OneSignal ID) XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 0)) } /** - Test that when user state changes with a valid OneSignal ID and shouldFetchOnUserChangeWithSubscriptionID is set, it retries the fetch. + Test that when user state changes with a valid OneSignal ID and deferredFetchSubscriptionId is set, it retries the fetch. Scenario: - IAM fetch was previously deferred due to missing OneSignal ID @@ -104,7 +103,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { Expected: - IAM fetch is retried with the stored subscription ID - - shouldFetchOnUserChangeWithSubscriptionID is cleared + - deferredFetchSubscriptionId is cleared */ func testRetriesFetchWhenUserStateChangesWithValidOneSignalID() throws { /* Setup */ @@ -129,7 +128,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) // Verify the subscription ID was stored and no IAM fetch occurred - XCTAssertEqual(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") as! String, testSubscriptionId) + XCTAssertEqual(controller.deferredFetchSubscriptionId, testSubscriptionId) XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 0)) // Now let the login succeed, receive onesignal ID which fires user state observer @@ -143,21 +142,21 @@ final class OSMessagingControllerUserStateTests: XCTestCase { XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) // The stored subscription ID should be cleared after successful retry - XCTAssertNil(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID")) + XCTAssertNil(controller.deferredFetchSubscriptionId) } /** - Test that when user state changes but shouldFetchOnUserChangeWithSubscriptionID is not set, it does nothing. - + Test that logging in refetches in-app messages for the user signing in. + Scenario: - - Normal user state change occurs - - No deferred fetch was pending - + - A user's in-app messages have already been fetched + - The app logs in as someone else + Expected: - - No retry logic is triggered - - Normal operation continues + - A second fetch goes out once the new user has a OneSignal ID, so the previous user's messages are + not what gets evaluated */ - func testDoesNothingWhenNoRetryPending() throws { + func testLoginRefetchesInAppMessagesForTheIncomingUser() throws { /* Setup */ let client = MockOneSignalClient() OneSignalCoreImpl.setSharedClient(client) @@ -177,16 +176,15 @@ final class OSMessagingControllerUserStateTests: XCTestCase { /* Verify */ // IAM is fetched and no retry is pending XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) - XCTAssertNil(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID")) + XCTAssertNil(controller.deferredFetchSubscriptionId) /* Execute */ - // Trigger a normal user state change by login MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: testExternalId) OneSignalUserManagerImpl.sharedInstance.login(externalId: testExternalId, token: nil) OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Verify */ - // Does not fetch IAMs again - XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 2)) + XCTAssertNil(controller.deferredFetchSubscriptionId) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h index 813316635..8f671baae 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h @@ -14,6 +14,7 @@ @property (strong, nonatomic, nonnull) NSMutableDictionary *redisplayedInAppMessages; @property (strong, nonatomic, nonnull) NSMutableArray *messages; @property (strong, nonatomic, nonnull) OSTriggerController *triggerController; +@property (strong, nonatomic, nullable) NSString *deferredFetchSubscriptionId; + (void)start; + (void)removeInstance; - (void)presentInAppPreviewMessage:(OSInAppMessageInternal *)message; diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift index 859353d90..a4fb95fe0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -63,6 +63,7 @@ extension OneSignalUserManagerImpl { } operationRepo.addFlushDeltaQueueToDispatchQueue() userExecutor?.executePendingRequests() + NotificationCenter.default.post(name: Notification.Name(OS_ON_USER_JWT_UPDATED), object: nil) } /** @@ -107,4 +108,23 @@ extension OneSignalUserManagerImpl { storeJwt(externalId: externalId, token: token) } + + /** + How another module should address and sign a user-scoped call for the current user, decided in one + read so the alias and the token cannot come from different users. + + Returns nil when the call cannot be sent yet — the requirement is still unknown, nobody is logged in + under Identity Verification, or the app owes a token, which this asks for. Callers reattempt when + `OS_ON_JWT_CONFIG_HYDRATED` or `OS_ON_USER_JWT_UPDATED` is posted. + */ + @objc + public func authorizationForCurrentUser() -> OSUserRequestAuthorization? { + // `_user` rather than `user`, which would create a guest user for a caller that only reads. + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil), + let identityModel = _user?.identityModel + else { + return nil + } + return requestAuth.authorization(onesignalId: identityModel.onesignalId, externalId: identityModel.externalId) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 1cf2695f3..222137988 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -296,6 +296,9 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { app's own opt-in, which would make the silencing permanent. */ identityVerificationService.addOnJwtConfigHydratedHandler(for: .userManager) { [weak self] requirement in + // Tells work that does not travel through the Repo, such as the in-app message fetch, + // that how to address a user-scoped call is now decided. + NotificationCenter.default.post(name: Notification.Name(OS_ON_JWT_CONFIG_HYDRATED), object: nil) guard requirement == .off else { return } From 51718e0f5fbe27e71be81d49321e622ebe0a07e4 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 13 Aug 2026 09:28:59 -0700 Subject: [PATCH 2/3] fix: [PR7] reset in-app messages on user change only while newCodePathsRun Login/logout must not clear or dismiss IAMs for apps with Identity Verification off. When the code paths are on, also drop queued non-preview messages and dismiss a showing one so they cannot stay up under the next user. Co-authored-by: Cursor --- .../Controller/OSMessagingController.m | 40 +++++++++++++++++-- .../OSMessagingControllerUserStateTests.swift | 29 ++++++++++++++ .../Source/OneSignalUserManagerImpl+Jwt.swift | 5 +++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m index eaff70122..e456a4570 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m @@ -153,8 +153,8 @@ @interface OSMessagingController () */ @property (strong, nonatomic, nullable) NSString *deferredFetchSubscriptionId; -/// Bumped on every login and logout. A fetch carries the value it started with, so a response that -/// arrives after the user changed is discarded instead of showing one user's messages to another. +/// Bumped on login and logout while `newCodePathsRun`. A fetch carries the value it started with, so a +/// response that arrives after the user changed is discarded instead of showing one user's messages to another. @property (nonatomic) NSUInteger userGeneration; /// Tracks whether the first IAM fetch has completed since this cold start @@ -342,10 +342,13 @@ - (BOOL)isCurrentUserGeneration:(NSUInteger)generation { /** Drops the outgoing user's in-app messages and invalidates any fetch still in flight for them, then - queues one for whoever is signing in. The new user's fetch goes out from `onUserStateDidChange`, once - there is a `onesignal_id` to address it by. + queues one for whoever is signing in. Only while `newCodePathsRun`. The new user's fetch goes out + from `onUserStateDidChange`, once there is a `onesignal_id` to address it by. */ - (void)onUserWillChange { + if (!OneSignalUserManagerImpl.sharedInstance.newCodePathsRun) { + return; + } @synchronized (self) { self.userGeneration += 1; } @@ -353,9 +356,38 @@ - (void)onUserWillChange { // On main, where every other write to `messages` happens. dispatch_async(dispatch_get_main_queue(), ^{ self.messages = @[]; + [self dismissOutgoingUserInAppMessages]; }); } +/// Leaves preview IAMs; dismisses a showing non-preview so it cannot stay up under the next user. +- (void)dismissOutgoingUserInAppMessages { + BOOL shouldDismiss = NO; + @synchronized (self.messageDisplayQueue) { + OSInAppMessageInternal *showing = nil; + if (self.isInAppMessageShowing && self.messageDisplayQueue.count > 0) { + OSInAppMessageInternal *first = self.messageDisplayQueue.firstObject; + if (!first.isPreview) { + showing = first; + } + } + NSMutableArray *kept = [NSMutableArray new]; + if (showing) { + [kept addObject:showing]; + } + for (OSInAppMessageInternal *message in self.messageDisplayQueue) { + if (message.isPreview) { + [kept addObject:message]; + } + } + [self.messageDisplayQueue setArray:kept]; + shouldDismiss = showing != nil; + } + if (shouldDismiss) { + [self.viewController dismissCurrentInAppMessage]; + } +} + - (void)deferFetchWithSubscriptionId:(NSString *)subscriptionId { @synchronized (self) { self.deferredFetchSubscriptionId = subscriptionId; diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift index 3558b21a1..54155d657 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift @@ -59,6 +59,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { } override func tearDownWithError() throws { + OSFeatureManager.shared.setEnabledFeatureKeys([]) OSMessagingController.removeInstance() } @@ -149,6 +150,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { Test that logging in refetches in-app messages for the user signing in. Scenario: + - Identity Verification code paths are on - A user's in-app messages have already been fetched - The app logs in as someone else @@ -158,6 +160,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { */ func testLoginRefetchesInAppMessagesForTheIncomingUser() throws { /* Setup */ + OSFeatureManager.shared.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) let client = MockOneSignalClient() OneSignalCoreImpl.setSharedClient(client) OneSignalInAppMessages.start() @@ -187,4 +190,30 @@ final class OSMessagingControllerUserStateTests: XCTestCase { XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 2)) XCTAssertNil(controller.deferredFetchSubscriptionId) } + + /// Without the rollout flag or `jwt_required`, a login leaves the current user's messages as they were. + func testLoginDoesNotRefetchInAppMessagesWhenIdentityVerificationCodePathsAreOff() throws { + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + OneSignalInAppMessages.start() + let controller = OSMessagingController.sharedInstance() + + MockUserRequests.setDefaultCreateAnonUserResponses( + with: client, + onesignalId: testOneSignalId, + subscriptionId: testSubscriptionId + ) + ConsistencyManagerTestHelpers.setDefaultRywToken(id: testOneSignalId) + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) + + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: testExternalId) + OneSignalUserManagerImpl.sharedInstance.login(externalId: testExternalId, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) + XCTAssertNil(controller.deferredFetchSubscriptionId) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift index a4fb95fe0..5a424932f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -109,6 +109,11 @@ extension OneSignalUserManagerImpl { storeJwt(externalId: externalId, token: token) } + /// Rollout flag, or always when the app requires Identity Verification. + @objc public var newCodePathsRun: Bool { + identityVerificationService.newCodePathsRun + } + /** How another module should address and sign a user-scoped call for the current user, decided in one read so the alias and the token cannot come from different users. From e5884a7a4d03d4de99bb900a50cc436b0a41063c Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 13 Aug 2026 09:37:33 -0700 Subject: [PATCH 3/3] fix: [PR7] refetch IAMs on 401 when a replacement token already landed OS_ON_USER_JWT_UPDATED is a no-op while a fetch is in flight, so parking the 401 would wait for a wakeup that already fired. Co-authored-by: Cursor --- .../Controller/OSMessagingController.m | 53 ++++++++++++++----- .../IamFetchIdentityVerificationTests.swift | 32 +++++++++++ 2 files changed, 72 insertions(+), 13 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m index e456a4570..dc7cf17ac 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m @@ -294,12 +294,8 @@ - (void)getInAppMessagesFromServer:(NSString *)subscriptionId { return; } - // Resolved before the read-your-write wait, which can hold this thread for as long as it takes - // the user requests to come back. - OSUserRequestAuthorization *authorization = [OneSignalUserManagerImpl.sharedInstance authorizationForCurrentUser]; - if (!authorization) { - [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Failed to get in app messages due to Identity Verification, will reattempt"]; - [self deferFetchWithSubscriptionId:subscriptionId]; + // Park before the wait when the fetch cannot go out; attemptFetchWithRetries reads again after. + if (![self authorizationForFetchOrDefer:subscriptionId]) { return; } @@ -318,7 +314,6 @@ - (void)getInAppMessagesFromServer:(NSString *)subscriptionId { // Initial request [self attemptFetchWithRetries:subscriptionId - authorization:authorization rywData:rywData attempts:@0 // Starting with 0 attempts retryLimit:nil // Retry limit to be set dynamically on first failure @@ -410,6 +405,22 @@ - (void)retryDeferredFetch { } } +/// How to address and sign this fetch. Nil means it is parked until hydration or a token. +- (OSUserRequestAuthorization *)authorizationForFetchOrDefer:(NSString *)subscriptionId { + OSUserRequestAuthorization *authorization = [OneSignalUserManagerImpl.sharedInstance authorizationForCurrentUser]; + if (authorization) { + return authorization; + } + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Failed to get in app messages due to Identity Verification, will reattempt"]; + [self deferFetchWithSubscriptionId:subscriptionId]; + // OS_ON_USER_JWT_UPDATED can fire before deferredFetchSubscriptionId is set. + authorization = [OneSignalUserManagerImpl.sharedInstance authorizationForCurrentUser]; + if (authorization) { + [self takeDeferredFetchSubscriptionId]; + } + return authorization; +} + /** Parks the fetch for a later token to reattempt, without reporting the one it was signed with. @@ -422,12 +433,17 @@ - (void)handleUnauthorizedFetch:(OSUserRequestAuthorization *)authorization subs if (!authorization.token) { return; } + OSUserRequestAuthorization *current = [OneSignalUserManagerImpl.sharedInstance authorizationForCurrentUser]; + if (current.token.length && ![current.token isEqualToString:authorization.token]) { + // OS_ON_USER_JWT_UPDATED already fired with nothing parked. + [self getInAppMessagesFromServer:subscriptionId]; + return; + } [self deferFetchWithSubscriptionId:subscriptionId]; } - (void)attemptFetchWithRetries:(NSString *)subscriptionId - authorization:(OSUserRequestAuthorization *)authorization rywData:(OSReadYourWriteData *)rywData attempts:(NSNumber *)attempts retryLimit:(NSNumber *)retryLimit @@ -437,6 +453,11 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId return; } + OSUserRequestAuthorization *authorization = [self authorizationForFetchOrDefer:subscriptionId]; + if (!authorization) { + return; + } + NSNumber *sessionDuration = @([OSSessionManager.sharedSessionManager getTimeFocusedElapsed]); NSString *rywToken = rywData.rywToken; NSNumber *rywDelay = rywData.rywDelay; @@ -496,14 +517,13 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId NSInteger nextAttempt = [attempts integerValue] + 1; // Increment attempts [self retryAfterDelay:retryAfter subscriptionId:subscriptionId - authorization:authorization rywData:rywData attempts:@(nextAttempt) retryLimit:blockRetryLimit userGeneration:generation]; } else { // Final attempt without rywToken - [self fetchInAppMessagesWithoutToken:subscriptionId authorization:authorization userGeneration:generation]; + [self fetchInAppMessagesWithoutToken:subscriptionId userGeneration:generation]; } } else if ([OSNetworkingUtils getResponseStatusType:error.code] == OSResponseStatusUnauthorized) { [self handleUnauthorizedFetch:authorization subscriptionId:subscriptionId]; @@ -515,7 +535,6 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId - (void)retryAfterDelay:(NSInteger)retryAfter subscriptionId:(NSString *)subscriptionId - authorization:(OSUserRequestAuthorization *)authorization rywData:(OSReadYourWriteData *)rywData attempts:(NSNumber *)attempts retryLimit:(NSNumber *)retryLimit @@ -524,7 +543,6 @@ - (void)retryAfterDelay:(NSInteger)retryAfter dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(retryAfter * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [self attemptFetchWithRetries:subscriptionId - authorization:authorization rywData:rywData attempts:attempts retryLimit:retryLimit @@ -533,8 +551,17 @@ - (void)retryAfterDelay:(NSInteger)retryAfter } - (void)fetchInAppMessagesWithoutToken:(NSString *)subscriptionId - authorization:(OSUserRequestAuthorization *)authorization userGeneration:(NSUInteger)generation { + if (![self isCurrentUserGeneration:generation]) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Abandoning an in app message fetch for a previous user"]; + return; + } + + OSUserRequestAuthorization *authorization = [self authorizationForFetchOrDefer:subscriptionId]; + if (!authorization) { + return; + } + NSNumber *sessionDuration = @([OSSessionManager.sharedSessionManager getTimeFocusedElapsed]); OSRequestGetInAppMessages *request = [OSRequestGetInAppMessages withSubscriptionId:subscriptionId diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift index 659d90aba..52031a37a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift @@ -123,6 +123,10 @@ final class IamFetchIdentityVerificationTests: XCTestCase { return client.executedRequests.filter { $0 is OSRequestGetInAppMessages } } + private func startedFetches() -> [OneSignalRequest] { + return client.startedRequests.filter { $0 is OSRequestGetInAppMessages } + } + private func lastFetchPath() -> String? { return executedFetches().last?.path } @@ -232,6 +236,34 @@ final class IamFetchIdentityVerificationTests: XCTestCase { XCTAssertEqual(lastFetchAuthorization(), "Bearer token-b") } + /// A replacement that lands while the fetch is in flight is not parked: `OS_ON_USER_JWT_UPDATED` + /// already fired, and waiting for another would leave the fetch stuck. + func testARejectedFetchRefetchesWhenAReplacementTokenAlreadyLanded() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + rejectFetch(from: identifiedUserPath) + let startedBefore = startedFetches().count + let executedBefore = executedFetches().count + + client.holdResponses = true + OneSignalInAppMessages.getFromServer(testPushSubId) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(startedFetches().count, startedBefore + 1) + XCTAssertEqual(executedFetches().count, executedBefore) + + client.holdResponses = false + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "token-b") + client.releaseHeldResponses() + respondToFetch(from: identifiedUserPath) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(executedFetches().count, executedBefore + 2) + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-b") + XCTAssertNil(deferredSubscriptionId()) + XCTAssertEqual(jwtListener.invalidatedExternalIds, []) + } + /// The token the fetch was signed with stays usable, so everything else keeps going out signed. func testARejectedFetchLeavesTheTokenInPlaceForTheRequests() { OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true)